Merge pull request #833 from vector-im/feature/typing

Send and render typing events (#564)
This commit is contained in:
Benoit Marty 2020-01-13 15:17:43 +01:00 committed by GitHub
commit b5fead18fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 656 additions and 141 deletions

View File

@ -2,7 +2,7 @@ Changes in RiotX 0.13.0 (2020-XX-XX)
===================================================
Features ✨:
-
- Send and render typing events (#564)
Improvements 🙌:
- Render events m.room.encryption and m.room.guest_access in the timeline

View File

@ -22,12 +22,13 @@ 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.relation.RelationService
import im.vector.matrix.android.api.session.room.notification.RoomPushRuleService
import im.vector.matrix.android.api.session.room.reporting.ReportingService
import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.reporting.ReportingService
import im.vector.matrix.android.api.session.room.send.DraftService
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.timeline.TimelineService
import im.vector.matrix.android.api.session.room.typing.TypingService
import im.vector.matrix.android.api.util.Optional
/**
@ -38,6 +39,7 @@ interface Room :
SendService,
DraftService,
ReadService,
TypingService,
MembershipService,
StateService,
ReportingService,

View File

@ -42,7 +42,8 @@ data class RoomSummary(
val versioningState: VersioningState = VersioningState.NONE,
val readMarkerId: String? = null,
val userDrafts: List<UserDraft> = emptyList(),
var isEncrypted: Boolean
var isEncrypted: Boolean,
val typingRoomMemberIds: List<String> = emptyList()
) {
val isVersioned: Boolean

View File

@ -0,0 +1,38 @@
/*
* Copyright 2020 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.typing
/**
* This interface defines methods to handle typing data. It's implemented at the room level.
*/
interface TypingService {
/**
* To call when user is typing a message in the room
* The SDK will handle the requests scheduling to the homeserver:
* - No more than one typing request per 10s
* - If not called after 10s, the SDK will notify the homeserver that the user is not typing anymore
*/
fun userIsTyping()
/**
* To call when user stops typing in the room
* Notify immediately the homeserver that the user is not typing anymore in the room, for
* instance when user has emptied the composer, or when the user quits the timeline screen.
*/
fun userStopsTyping()
}

View File

@ -22,7 +22,7 @@ import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.tag.RoomTag
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import java.util.UUID
import java.util.*
import javax.inject.Inject
internal class RoomSummaryMapper @Inject constructor(
@ -71,7 +71,8 @@ internal class RoomSummaryMapper @Inject constructor(
userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) } ?: emptyList(),
canonicalAlias = roomSummaryEntity.canonicalAlias,
aliases = roomSummaryEntity.aliases.toList(),
isEncrypted = roomSummaryEntity.isEncrypted
isEncrypted = roomSummaryEntity.isEncrypted,
typingRoomMemberIds = roomSummaryEntity.typingUserIds.toList()
)
}
}

View File

@ -44,7 +44,8 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "",
var aliases: RealmList<String> = RealmList(),
// this is required for querying
var flatAliases: String = "",
var isEncrypted: Boolean = false
var isEncrypted: Boolean = false,
var typingUserIds: RealmList<String> = RealmList()
) : RealmObject() {
private var membershipStr: String = Membership.NONE.name

View File

@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.room.send.DraftService
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.timeline.TimelineService
import im.vector.matrix.android.api.session.room.typing.TypingService
import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.android.api.util.toOptional
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper
@ -49,6 +50,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
private val stateService: StateService,
private val reportingService: ReportingService,
private val readService: ReadService,
private val typingService: TypingService,
private val cryptoService: CryptoService,
private val relationService: RelationService,
private val roomMembersService: MembershipService,
@ -60,6 +62,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
StateService by stateService,
ReportingService by reportingService,
ReadService by readService,
TypingService by typingService,
RelationService by relationService,
MembershipService by roomMembersService,
RoomPushRuleService by roomPushRuleService {

View File

@ -18,13 +18,13 @@ package im.vector.matrix.android.internal.session.room
import im.vector.matrix.android.api.session.events.model.Content
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.internal.session.room.alias.RoomAliasDescription
import im.vector.matrix.android.api.session.room.model.create.CreateRoomParams
import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsParams
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse
import im.vector.matrix.android.api.session.room.model.thirdparty.ThirdPartyProtocol
import im.vector.matrix.android.internal.network.NetworkConstants
import im.vector.matrix.android.internal.session.room.alias.RoomAliasDescription
import im.vector.matrix.android.internal.session.room.membership.RoomMembersResponse
import im.vector.matrix.android.internal.session.room.membership.joining.InviteBody
import im.vector.matrix.android.internal.session.room.relation.RelationsResponse
@ -32,6 +32,7 @@ import im.vector.matrix.android.internal.session.room.reporting.ReportContentBod
import im.vector.matrix.android.internal.session.room.send.SendResponse
import im.vector.matrix.android.internal.session.room.timeline.EventContextResponse
import im.vector.matrix.android.internal.session.room.timeline.PaginationResponse
import im.vector.matrix.android.internal.session.room.typing.TypingBody
import retrofit2.Call
import retrofit2.http.*
@ -268,4 +269,12 @@ internal interface RoomAPI {
*/
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "directory/room/{roomAlias}")
fun getRoomIdByAlias(@Path("roomAlias") roomAlias: String): Call<RoomAliasDescription>
/**
* Inform that the user is starting to type or has stopped typing
*/
@PUT(NetworkConstants.URI_API_PREFIX_PATH_R0 + "rooms/{roomId}/typing/{userId}")
fun sendTypingState(@Path("roomId") roomId: String,
@Path("userId") userId: String,
@Body body: TypingBody): Call<Unit>
}

View File

@ -30,6 +30,7 @@ import im.vector.matrix.android.internal.session.room.reporting.DefaultReporting
import im.vector.matrix.android.internal.session.room.send.DefaultSendService
import im.vector.matrix.android.internal.session.room.state.DefaultStateService
import im.vector.matrix.android.internal.session.room.timeline.DefaultTimelineService
import im.vector.matrix.android.internal.session.room.typing.DefaultTypingService
import javax.inject.Inject
internal interface RoomFactory {
@ -46,6 +47,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
private val stateServiceFactory: DefaultStateService.Factory,
private val reportingServiceFactory: DefaultReportingService.Factory,
private val readServiceFactory: DefaultReadService.Factory,
private val typingServiceFactory: DefaultTypingService.Factory,
private val relationServiceFactory: DefaultRelationService.Factory,
private val membershipServiceFactory: DefaultMembershipService.Factory,
private val roomPushRuleServiceFactory: DefaultRoomPushRuleService.Factory) :
@ -62,6 +64,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
stateServiceFactory.create(roomId),
reportingServiceFactory.create(roomId),
readServiceFactory.create(roomId),
typingServiceFactory.create(roomId),
cryptoService,
relationServiceFactory.create(roomId),
membershipServiceFactory.create(roomId),

View File

@ -52,6 +52,8 @@ import im.vector.matrix.android.internal.session.room.reporting.ReportContentTas
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.timeline.*
import im.vector.matrix.android.internal.session.room.typing.DefaultSendTypingTask
import im.vector.matrix.android.internal.session.room.typing.SendTypingTask
import retrofit2.Retrofit
@Module
@ -68,74 +70,77 @@ internal abstract class RoomModule {
}
@Binds
abstract fun bindRoomFactory(roomFactory: DefaultRoomFactory): RoomFactory
abstract fun bindRoomFactory(factory: DefaultRoomFactory): RoomFactory
@Binds
abstract fun bindRoomService(roomService: DefaultRoomService): RoomService
abstract fun bindRoomService(service: DefaultRoomService): RoomService
@Binds
abstract fun bindRoomDirectoryService(roomDirectoryService: DefaultRoomDirectoryService): RoomDirectoryService
abstract fun bindRoomDirectoryService(service: DefaultRoomDirectoryService): RoomDirectoryService
@Binds
abstract fun bindEventRelationsAggregationTask(eventRelationsAggregationTask: DefaultEventRelationsAggregationTask): EventRelationsAggregationTask
abstract fun bindFileService(service: DefaultFileService): FileService
@Binds
abstract fun bindCreateRoomTask(createRoomTask: DefaultCreateRoomTask): CreateRoomTask
abstract fun bindEventRelationsAggregationTask(task: DefaultEventRelationsAggregationTask): EventRelationsAggregationTask
@Binds
abstract fun bindGetPublicRoomTask(getPublicRoomTask: DefaultGetPublicRoomTask): GetPublicRoomTask
abstract fun bindCreateRoomTask(task: DefaultCreateRoomTask): CreateRoomTask
@Binds
abstract fun bindGetThirdPartyProtocolsTask(getThirdPartyProtocolsTask: DefaultGetThirdPartyProtocolsTask): GetThirdPartyProtocolsTask
abstract fun bindGetPublicRoomTask(task: DefaultGetPublicRoomTask): GetPublicRoomTask
@Binds
abstract fun bindInviteTask(inviteTask: DefaultInviteTask): InviteTask
abstract fun bindGetThirdPartyProtocolsTask(task: DefaultGetThirdPartyProtocolsTask): GetThirdPartyProtocolsTask
@Binds
abstract fun bindJoinRoomTask(joinRoomTask: DefaultJoinRoomTask): JoinRoomTask
abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask
@Binds
abstract fun bindLeaveRoomTask(leaveRoomTask: DefaultLeaveRoomTask): LeaveRoomTask
abstract fun bindJoinRoomTask(task: DefaultJoinRoomTask): JoinRoomTask
@Binds
abstract fun bindLoadRoomMembersTask(loadRoomMembersTask: DefaultLoadRoomMembersTask): LoadRoomMembersTask
abstract fun bindLeaveRoomTask(task: DefaultLeaveRoomTask): LeaveRoomTask
@Binds
abstract fun bindPruneEventTask(pruneEventTask: DefaultPruneEventTask): PruneEventTask
abstract fun bindLoadRoomMembersTask(task: DefaultLoadRoomMembersTask): LoadRoomMembersTask
@Binds
abstract fun bindSetReadMarkersTask(setReadMarkersTask: DefaultSetReadMarkersTask): SetReadMarkersTask
abstract fun bindPruneEventTask(task: DefaultPruneEventTask): PruneEventTask
@Binds
abstract fun bindMarkAllRoomsReadTask(markAllRoomsReadTask: DefaultMarkAllRoomsReadTask): MarkAllRoomsReadTask
abstract fun bindSetReadMarkersTask(task: DefaultSetReadMarkersTask): SetReadMarkersTask
@Binds
abstract fun bindFindReactionEventForUndoTask(findReactionEventForUndoTask: DefaultFindReactionEventForUndoTask): FindReactionEventForUndoTask
abstract fun bindMarkAllRoomsReadTask(task: DefaultMarkAllRoomsReadTask): MarkAllRoomsReadTask
@Binds
abstract fun bindUpdateQuickReactionTask(updateQuickReactionTask: DefaultUpdateQuickReactionTask): UpdateQuickReactionTask
abstract fun bindFindReactionEventForUndoTask(task: DefaultFindReactionEventForUndoTask): FindReactionEventForUndoTask
@Binds
abstract fun bindSendStateTask(sendStateTask: DefaultSendStateTask): SendStateTask
abstract fun bindUpdateQuickReactionTask(task: DefaultUpdateQuickReactionTask): UpdateQuickReactionTask
@Binds
abstract fun bindReportContentTask(reportContentTask: DefaultReportContentTask): ReportContentTask
abstract fun bindSendStateTask(task: DefaultSendStateTask): SendStateTask
@Binds
abstract fun bindGetContextOfEventTask(getContextOfEventTask: DefaultGetContextOfEventTask): GetContextOfEventTask
abstract fun bindReportContentTask(task: DefaultReportContentTask): ReportContentTask
@Binds
abstract fun bindClearUnlinkedEventsTask(clearUnlinkedEventsTask: DefaultClearUnlinkedEventsTask): ClearUnlinkedEventsTask
abstract fun bindGetContextOfEventTask(task: DefaultGetContextOfEventTask): GetContextOfEventTask
@Binds
abstract fun bindPaginationTask(paginationTask: DefaultPaginationTask): PaginationTask
abstract fun bindClearUnlinkedEventsTask(task: DefaultClearUnlinkedEventsTask): ClearUnlinkedEventsTask
@Binds
abstract fun bindFileService(fileService: DefaultFileService): FileService
abstract fun bindPaginationTask(task: DefaultPaginationTask): PaginationTask
@Binds
abstract fun bindFetchEditHistoryTask(fetchEditHistoryTask: DefaultFetchEditHistoryTask): FetchEditHistoryTask
abstract fun bindFetchEditHistoryTask(task: DefaultFetchEditHistoryTask): FetchEditHistoryTask
@Binds
abstract fun bindGetRoomIdByAliasTask(getRoomIdByAliasTask: DefaultGetRoomIdByAliasTask): GetRoomIdByAliasTask
abstract fun bindGetRoomIdByAliasTask(task: DefaultGetRoomIdByAliasTask): GetRoomIdByAliasTask
@Binds
abstract fun bindSendTypingTask(task: DefaultSendTypingTask): SendTypingTask
}

View File

@ -24,14 +24,15 @@ import im.vector.matrix.android.api.session.room.model.RoomAliasesContent
import im.vector.matrix.android.api.session.room.model.RoomCanonicalAliasContent
import im.vector.matrix.android.api.session.room.model.RoomTopicContent
import im.vector.matrix.android.internal.database.mapper.ContentMapper
import im.vector.matrix.android.internal.database.model.*
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.RoomMemberEntityFields
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.query.*
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.session.room.membership.RoomDisplayNameResolver
import im.vector.matrix.android.internal.session.room.membership.RoomMembers
import im.vector.matrix.android.internal.session.sync.RoomSyncHandler
import im.vector.matrix.android.internal.session.sync.model.RoomSyncSummary
import im.vector.matrix.android.internal.session.sync.model.RoomSyncUnreadNotifications
import io.realm.Realm
@ -65,7 +66,8 @@ internal class RoomSummaryUpdater @Inject constructor(
membership: Membership? = null,
roomSummary: RoomSyncSummary? = null,
unreadNotifications: RoomSyncUnreadNotifications? = null,
updateMembers: Boolean = false) {
updateMembers: Boolean = false,
ephemeralResult: RoomSyncHandler.EphemeralResult? = null) {
val roomSummaryEntity = RoomSummaryEntity.getOrCreate(realm, roomId)
if (roomSummary != null) {
if (roomSummary.heroes.isNotEmpty()) {
@ -93,8 +95,8 @@ internal class RoomSummaryUpdater @Inject constructor(
val encryptionEvent = EventEntity.where(realm, roomId, EventType.STATE_ROOM_ENCRYPTION).prev()
roomSummaryEntity.hasUnreadMessages = roomSummaryEntity.notificationCount > 0
// avoid this call if we are sure there are unread events
|| !isEventRead(monarchy, userId, roomId, latestPreviewableEvent?.eventId)
// avoid this call if we are sure there are unread events
|| !isEventRead(monarchy, userId, roomId, latestPreviewableEvent?.eventId)
roomSummaryEntity.displayName = roomDisplayNameResolver.resolve(roomId).toString()
roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(roomId)
@ -104,11 +106,13 @@ internal class RoomSummaryUpdater @Inject constructor(
?.canonicalAlias
val roomAliases = ContentMapper.map(lastAliasesEvent?.content).toModel<RoomAliasesContent>()?.aliases
?: emptyList()
?: emptyList()
roomSummaryEntity.aliases.clear()
roomSummaryEntity.aliases.addAll(roomAliases)
roomSummaryEntity.flatAliases = roomAliases.joinToString(separator = "|", prefix = "|")
roomSummaryEntity.isEncrypted = encryptionEvent != null
roomSummaryEntity.typingUserIds.clear()
roomSummaryEntity.typingUserIds.addAll(ephemeralResult?.typingUserIds.orEmpty())
if (updateMembers) {
val otherRoomMembers = RoomMembers(realm, roomId)

View File

@ -0,0 +1,118 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.room.typing
import android.os.SystemClock
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.room.typing.TypingService
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import timber.log.Timber
/**
* Rules:
* - user is typing: notify the homeserver (true), at least once every 10s
* - user stop typing: after 10s delay: notify the homeserver (false)
* - user empty the text composer or quit the timeline screen: notify the homeserver (false)
*/
internal class DefaultTypingService @AssistedInject constructor(
@Assisted private val roomId: String,
private val taskExecutor: TaskExecutor,
private val sendTypingTask: SendTypingTask
) : TypingService {
@AssistedInject.Factory
interface Factory {
fun create(roomId: String): TypingService
}
private var currentTask: Cancelable? = null
private var currentAutoStopTask: Cancelable? = null
// What the homeserver knows
private var userIsTyping = false
// Last time the user is typing event has been sent
private var lastRequestTimestamp: Long = 0
override fun userIsTyping() {
scheduleAutoStop()
val now = SystemClock.elapsedRealtime()
if (userIsTyping && now < lastRequestTimestamp + MIN_DELAY_BETWEEN_TWO_USER_IS_TYPING_REQUESTS_MILLIS) {
Timber.d("Typing: Skip start request")
return
}
Timber.d("Typing: Send start request")
userIsTyping = true
lastRequestTimestamp = now
currentTask?.cancel()
val params = SendTypingTask.Params(roomId, true)
currentTask = sendTypingTask
.configureWith(params)
.executeBy(taskExecutor)
}
override fun userStopsTyping() {
if (!userIsTyping) {
Timber.d("Typing: Skip stop request")
return
}
Timber.d("Typing: Send stop request")
userIsTyping = false
lastRequestTimestamp = 0
currentAutoStopTask?.cancel()
currentTask?.cancel()
val params = SendTypingTask.Params(roomId, false)
currentTask = sendTypingTask
.configureWith(params)
.executeBy(taskExecutor)
}
private fun scheduleAutoStop() {
Timber.d("Typing: Schedule auto stop")
currentAutoStopTask?.cancel()
val params = SendTypingTask.Params(
roomId,
false,
delay = MIN_DELAY_TO_SEND_STOP_TYPING_REQUEST_WHEN_NO_USER_ACTIVITY_MILLIS)
currentAutoStopTask = sendTypingTask
.configureWith(params) {
callback = object : MatrixCallback<Unit> {
override fun onSuccess(data: Unit) {
userIsTyping = false
}
}
}
.executeBy(taskExecutor)
}
companion object {
private const val MIN_DELAY_BETWEEN_TWO_USER_IS_TYPING_REQUESTS_MILLIS = 10_000L
private const val MIN_DELAY_TO_SEND_STOP_TYPING_REQUEST_WHEN_NO_USER_ACTIVITY_MILLIS = 10_000L
}
}

View File

@ -0,0 +1,55 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.room.typing
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.network.executeRequest
import im.vector.matrix.android.internal.session.room.RoomAPI
import im.vector.matrix.android.internal.task.Task
import kotlinx.coroutines.delay
import org.greenrobot.eventbus.EventBus
import javax.inject.Inject
internal interface SendTypingTask : Task<SendTypingTask.Params, Unit> {
data class Params(
val roomId: String,
val isTyping: Boolean,
val typingTimeoutMillis: Int? = 30_000,
// Optional delay before sending the request to the homeserver
val delay: Long? = null
)
}
internal class DefaultSendTypingTask @Inject constructor(
private val roomAPI: RoomAPI,
@UserId private val userId: String,
private val eventBus: EventBus
) : SendTypingTask {
override suspend fun execute(params: SendTypingTask.Params) {
delay(params.delay ?: -1)
executeRequest<Unit>(eventBus) {
apiCall = roomAPI.sendTypingState(
params.roomId,
userId,
TypingBody(params.isTyping, params.typingTimeoutMillis?.takeIf { params.isTyping })
)
}
}
}

View File

@ -0,0 +1,30 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.room.typing
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class TypingBody(
// Required. Whether the user is typing or not. If false, the timeout key can be omitted.
@Json(name = "typing")
val typing: Boolean,
// The length of time in milliseconds to mark this user as typing.
@Json(name = "timeout")
val timeout: Int?
)

View File

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

View File

@ -37,6 +37,7 @@ import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater
import im.vector.matrix.android.internal.session.room.membership.RoomMemberEventHandler
import im.vector.matrix.android.internal.session.room.read.FullyReadContent
import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection
import im.vector.matrix.android.internal.session.room.typing.TypingEventContent
import im.vector.matrix.android.internal.session.sync.model.*
import io.realm.Realm
import io.realm.kotlin.createObject
@ -97,11 +98,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
isInitialSync: Boolean): RoomEntity {
Timber.v("Handle join sync for room $roomId")
if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) {
handleEphemeral(realm, roomId, roomSync.ephemeral, isInitialSync)
var ephemeralResult: EphemeralResult? = null
if (roomSync.ephemeral?.events?.isNotEmpty() == true) {
ephemeralResult = handleEphemeral(realm, roomId, roomSync.ephemeral, isInitialSync)
}
if (roomSync.accountData != null && roomSync.accountData.events.isNullOrEmpty().not()) {
if (roomSync.accountData?.events?.isNotEmpty() == true) {
handleRoomAccountDataEvents(realm, roomId, roomSync.accountData)
}
@ -114,7 +116,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
// State event
if (roomSync.state != null && roomSync.state.events.isNotEmpty()) {
if (roomSync.state?.events?.isNotEmpty() == true) {
val minStateIndex = roomEntity.untimelinedStateEvents.where().min(EventEntityFields.STATE_INDEX)?.toInt()
?: Int.MIN_VALUE
val untimelinedStateIndex = minStateIndex + 1
@ -125,7 +127,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
roomMemberEventHandler.handle(realm, roomId, event)
}
}
if (roomSync.timeline != null && roomSync.timeline.events.isNotEmpty()) {
if (roomSync.timeline?.events?.isNotEmpty() == true) {
val chunkEntity = handleTimelineEvents(
realm,
roomEntity,
@ -141,7 +143,14 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
it.type == EventType.STATE_ROOM_MEMBER
} != null
roomSummaryUpdater.update(realm, roomId, Membership.JOIN, roomSync.summary, roomSync.unreadNotifications, updateMembers = hasRoomMember)
roomSummaryUpdater.update(
realm,
roomId,
Membership.JOIN,
roomSync.summary,
roomSync.unreadNotifications,
updateMembers = hasRoomMember,
ephemeralResult = ephemeralResult)
return roomEntity
}
@ -215,16 +224,33 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
return chunkEntity
}
@Suppress("UNCHECKED_CAST")
data class EphemeralResult(
val typingUserIds: List<String> = emptyList()
)
private fun handleEphemeral(realm: Realm,
roomId: String,
ephemeral: RoomSyncEphemeral,
isInitialSync: Boolean) {
isInitialSync: Boolean): EphemeralResult {
var result = EphemeralResult()
for (event in ephemeral.events) {
if (event.type != EventType.RECEIPT) continue
val readReceiptContent = event.content as? ReadReceiptContent ?: continue
readReceiptHandler.handle(realm, roomId, readReceiptContent, isInitialSync)
when (event.type) {
EventType.RECEIPT -> {
@Suppress("UNCHECKED_CAST")
(event.content as? ReadReceiptContent)?.let { readReceiptContent ->
readReceiptHandler.handle(realm, roomId, readReceiptContent, isInitialSync)
}
}
EventType.TYPING -> {
event.content.toModel<TypingEventContent>()?.let { typingEventContent ->
result = result.copy(typingUserIds = typingEventContent.typingUserIds)
}
}
else -> Timber.w("Ephemeral event type '${event.type}' not yet supported")
}
}
return result
}
private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) {

View File

@ -22,9 +22,11 @@ import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.riotx.core.epoxy.zeroItem
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.typing.TypingHelper
import javax.inject.Inject
class BreadcrumbsController @Inject constructor(
private val typingHelper: TypingHelper,
private val avatarRenderer: AvatarRenderer
) : EpoxyController() {
@ -62,6 +64,7 @@ class BreadcrumbsController @Inject constructor(
unreadNotificationCount(it.notificationCount)
showHighlighted(it.highlightCount > 0)
hasUnreadMessage(it.hasUnreadMessages)
hasTypingUsers(typingHelper.excludeCurrentUser(it.typingRoomMemberIds).isNotEmpty())
hasDraft(it.userDrafts.isNotEmpty())
itemClickListener(
DebouncedClickListener(View.OnClickListener { _ ->

View File

@ -37,6 +37,7 @@ abstract class BreadcrumbsItem : VectorEpoxyModel<BreadcrumbsItem.Holder>() {
@EpoxyAttribute var unreadNotificationCount: Int = 0
@EpoxyAttribute var showHighlighted: Boolean = false
@EpoxyAttribute var hasUnreadMessage: Boolean = false
@EpoxyAttribute var hasTypingUsers: Boolean = false
@EpoxyAttribute var hasDraft: Boolean = false
@EpoxyAttribute var itemClickListener: View.OnClickListener? = null
@ -44,6 +45,7 @@ abstract class BreadcrumbsItem : VectorEpoxyModel<BreadcrumbsItem.Holder>() {
super.bind(holder)
holder.rootView.setOnClickListener(itemClickListener)
holder.unreadIndentIndicator.isVisible = hasUnreadMessage
holder.typingIndicator.isVisible = hasTypingUsers
avatarRenderer.render(matrixItem, holder.avatarImageView)
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
holder.draftIndentIndicator.isVisible = hasDraft
@ -53,6 +55,7 @@ abstract class BreadcrumbsItem : VectorEpoxyModel<BreadcrumbsItem.Holder>() {
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.breadcrumbsUnreadCounterBadgeView)
val unreadIndentIndicator by bind<View>(R.id.breadcrumbsUnreadIndicator)
val draftIndentIndicator by bind<View>(R.id.breadcrumbsDraftBadge)
val typingIndicator by bind<View>(R.id.breadcrumbsTypingView)
val avatarImageView by bind<ImageView>(R.id.breadcrumbsImageView)
val rootView by bind<ViewGroup>(R.id.breadcrumbsRoot)
}

View File

@ -24,6 +24,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.core.platform.VectorViewModelAction
sealed class RoomDetailAction : VectorViewModelAction {
data class UserIsTyping(val isTyping: Boolean) : RoomDetailAction()
data class SaveDraft(val draft: String) : RoomDetailAction()
data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : RoomDetailAction()
data class SendMedia(val attachments: List<ContentAttachmentData>) : RoomDetailAction()

View File

@ -20,6 +20,7 @@ import android.annotation.SuppressLint
import android.app.Activity.RESULT_OK
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Bundle
@ -50,6 +51,7 @@ import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.ImageLoader
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
import com.jakewharton.rxbinding3.widget.textChanges
import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData
@ -100,6 +102,7 @@ import im.vector.riotx.features.permalink.PermalinkHandler
import im.vector.riotx.features.reactions.EmojiReactionPickerActivity
import im.vector.riotx.features.settings.VectorPreferences
import im.vector.riotx.features.share.SharedData
import im.vector.riotx.features.themes.ThemeUtils
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import kotlinx.android.parcel.Parcelize
@ -109,6 +112,7 @@ import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*
import org.commonmark.parser.Parser
import timber.log.Timber
import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@Parcelize
@ -246,9 +250,9 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode) { mode ->
when (mode) {
is SendMode.REGULAR -> renderRegularMode(mode.text)
is SendMode.EDIT -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text)
is SendMode.QUOTE -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.quote, mode.text)
is SendMode.REPLY -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text)
is SendMode.EDIT -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text)
is SendMode.QUOTE -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.quote, mode.text)
is SendMode.REPLY -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text)
}
}
@ -275,9 +279,9 @@ class RoomDetailFragment @Inject constructor(
super.onActivityCreated(savedInstanceState)
if (savedInstanceState == null) {
when (val sharedData = roomDetailArgs.sharedData) {
is SharedData.Text -> roomDetailViewModel.handle(RoomDetailAction.SendMessage(sharedData.text, false))
is SharedData.Text -> roomDetailViewModel.handle(RoomDetailAction.SendMessage(sharedData.text, false))
is SharedData.Attachments -> roomDetailViewModel.handle(RoomDetailAction.SendMedia(sharedData.attachmentData))
null -> Timber.v("No share data to process")
null -> Timber.v("No share data to process")
}
}
}
@ -501,7 +505,7 @@ class RoomDetailFragment @Inject constructor(
is MessageTextItem -> {
return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED
}
else -> false
else -> false
}
}
}
@ -516,9 +520,9 @@ class RoomDetailFragment @Inject constructor(
withState(roomDetailViewModel) {
val showJumpToUnreadBanner = when (it.unreadState) {
UnreadState.Unknown,
UnreadState.HasNoUnread -> false
UnreadState.HasNoUnread -> false
is UnreadState.ReadMarkerNotLoaded -> true
is UnreadState.HasUnread -> {
is UnreadState.HasUnread -> {
if (it.canShowJumpToReadMarker) {
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
val positionOfReadMarker = timelineEventController.getPositionOfReadMarker()
@ -539,6 +543,9 @@ class RoomDetailFragment @Inject constructor(
private fun setupComposer() {
autoCompleter.setup(composerLayout.composerEditText)
observerUserTyping()
composerLayout.callback = object : TextComposerView.Callback {
override fun onAddAttachment() {
if (!::attachmentTypeSelector.isInitialized) {
@ -575,6 +582,18 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun observerUserTyping() {
composerLayout.composerEditText.textChanges()
.skipInitialValue()
.debounce(300, TimeUnit.MILLISECONDS)
.map { it.isNotEmpty() }
.subscribe {
Timber.d("Typing: User is typing: $it")
roomDetailViewModel.handle(RoomDetailAction.UserIsTyping(it))
}
.disposeOnDestroyView()
}
private fun sendUri(uri: Uri): Boolean {
val shareIntent = Intent(Intent.ACTION_SEND, uri)
val isHandled = attachmentsHelper.handleShareIntent(shareIntent)
@ -627,13 +646,29 @@ class RoomDetailFragment @Inject constructor(
} else {
roomToolbarTitleView.text = it.displayName
avatarRenderer.render(it.toMatrixItem(), roomToolbarAvatarImageView)
roomToolbarSubtitleView.setTextOrHide(it.topic)
renderSubTitle(state.typingMessage, it.topic)
}
jumpToBottomView.count = it.notificationCount
jumpToBottomView.drawBadge = it.hasUnreadMessages
}
}
private fun renderSubTitle(typingMessage: String?, topic: String) {
// TODO Temporary place to put typing data
roomToolbarSubtitleView.let {
it.setTextOrHide(typingMessage ?: topic)
if (typingMessage == null) {
it.setTextColor(ThemeUtils.getColor(requireContext(), R.attr.vctr_toolbar_secondary_text_color))
it.setTypeface(null, Typeface.NORMAL)
} else {
it.setTextColor(ContextCompat.getColor(requireContext(), R.color.riotx_accent))
it.setTypeface(null, Typeface.BOLD)
}
}
}
private fun renderTombstoneEventHandling(async: Async<String>) {
when (async) {
is Loading -> {
@ -646,7 +681,7 @@ class RoomDetailFragment @Inject constructor(
navigator.openRoom(vectorBaseActivity, async())
vectorBaseActivity.finish()
}
is Fail -> {
is Fail -> {
vectorBaseActivity.hideWaitingView()
vectorBaseActivity.toast(errorFormatter.toHumanReadable(async.error))
}
@ -655,23 +690,23 @@ class RoomDetailFragment @Inject constructor(
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) {
is SendMessageResult.MessageSent -> {
is SendMessageResult.MessageSent -> {
updateComposerText("")
}
is SendMessageResult.SlashCommandHandled -> {
is SendMessageResult.SlashCommandHandled -> {
sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) }
updateComposerText("")
}
is SendMessageResult.SlashCommandError -> {
is SendMessageResult.SlashCommandError -> {
displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
}
is SendMessageResult.SlashCommandUnknown -> {
is SendMessageResult.SlashCommandUnknown -> {
displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command))
}
is SendMessageResult.SlashCommandResultOk -> {
is SendMessageResult.SlashCommandResultOk -> {
updateComposerText("")
}
is SendMessageResult.SlashCommandResultError -> {
is SendMessageResult.SlashCommandResultError -> {
displayCommandError(sendMessageResult.throwable.localizedMessage)
}
is SendMessageResult.SlashCommandNotImplemented -> {
@ -709,7 +744,7 @@ class RoomDetailFragment @Inject constructor(
private fun displayRoomDetailActionResult(result: Async<RoomDetailAction>) {
when (result) {
is Fail -> {
is Fail -> {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.dialog_title_error)
.setMessage(errorFormatter.toHumanReadable(result.error))
@ -720,7 +755,7 @@ class RoomDetailFragment @Inject constructor(
when (val data = result.invoke()) {
is RoomDetailAction.ReportContent -> {
when {
data.spam -> {
data.spam -> {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.content_reported_as_spam_title)
.setMessage(R.string.content_reported_as_spam_content)
@ -742,7 +777,7 @@ class RoomDetailFragment @Inject constructor(
.show()
.withColoredButton(DialogInterface.BUTTON_NEGATIVE)
}
else -> {
else -> {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.content_reported_title)
.setMessage(R.string.content_reported_content)
@ -855,14 +890,14 @@ class RoomDetailFragment @Inject constructor(
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (allGranted(grantResults)) {
when (requestCode) {
PERMISSION_REQUEST_CODE_DOWNLOAD_FILE -> {
PERMISSION_REQUEST_CODE_DOWNLOAD_FILE -> {
val action = roomDetailViewModel.pendingAction
if (action != null) {
roomDetailViewModel.pendingAction = null
roomDetailViewModel.handle(action)
}
}
PERMISSION_REQUEST_CODE_INCOMING_URI -> {
PERMISSION_REQUEST_CODE_INCOMING_URI -> {
val pendingUri = roomDetailViewModel.pendingUri
if (pendingUri != null) {
roomDetailViewModel.pendingUri = null
@ -960,23 +995,23 @@ class RoomDetailFragment @Inject constructor(
private fun handleActions(action: EventSharedAction) {
when (action) {
is EventSharedAction.AddReaction -> {
is EventSharedAction.AddReaction -> {
startActivityForResult(EmojiReactionPickerActivity.intent(requireContext(), action.eventId), REACTION_SELECT_REQUEST_CODE)
}
is EventSharedAction.ViewReactions -> {
is EventSharedAction.ViewReactions -> {
ViewReactionsBottomSheet.newInstance(roomDetailArgs.roomId, action.messageInformationData)
.show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS")
}
is EventSharedAction.Copy -> {
is EventSharedAction.Copy -> {
// I need info about the current selected message :/
copyToClipboard(requireContext(), action.content, false)
val msg = requireContext().getString(R.string.copied_to_clipboard)
showSnackWithMessage(msg, Snackbar.LENGTH_SHORT)
}
is EventSharedAction.Delete -> {
is EventSharedAction.Delete -> {
roomDetailViewModel.handle(RoomDetailAction.RedactAction(action.eventId, context?.getString(R.string.event_redacted_by_user_reason)))
}
is EventSharedAction.Share -> {
is EventSharedAction.Share -> {
// TODO current data communication is too limited
// Need to now the media type
// TODO bad, just POC
@ -1004,10 +1039,10 @@ class RoomDetailFragment @Inject constructor(
}
)
}
is EventSharedAction.ViewEditHistory -> {
is EventSharedAction.ViewEditHistory -> {
onEditedDecorationClicked(action.messageInformationData)
}
is EventSharedAction.ViewSource -> {
is EventSharedAction.ViewSource -> {
val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null)
view.findViewById<TextView>(R.id.event_content_text_view)?.let {
it.text = action.content
@ -1018,7 +1053,7 @@ class RoomDetailFragment @Inject constructor(
.setPositiveButton(R.string.ok, null)
.show()
}
is EventSharedAction.ViewDecryptedSource -> {
is EventSharedAction.ViewDecryptedSource -> {
val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null)
view.findViewById<TextView>(R.id.event_content_text_view)?.let {
it.text = action.content
@ -1029,31 +1064,31 @@ class RoomDetailFragment @Inject constructor(
.setPositiveButton(R.string.ok, null)
.show()
}
is EventSharedAction.QuickReact -> {
is EventSharedAction.QuickReact -> {
// eventId,ClickedOn,Add
roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
}
is EventSharedAction.Edit -> {
is EventSharedAction.Edit -> {
roomDetailViewModel.handle(RoomDetailAction.EnterEditMode(action.eventId, composerLayout.text.toString()))
}
is EventSharedAction.Quote -> {
is EventSharedAction.Quote -> {
roomDetailViewModel.handle(RoomDetailAction.EnterQuoteMode(action.eventId, composerLayout.text.toString()))
}
is EventSharedAction.Reply -> {
is EventSharedAction.Reply -> {
roomDetailViewModel.handle(RoomDetailAction.EnterReplyMode(action.eventId, composerLayout.text.toString()))
}
is EventSharedAction.CopyPermalink -> {
is EventSharedAction.CopyPermalink -> {
val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, action.eventId)
copyToClipboard(requireContext(), permalink, false)
showSnackWithMessage(requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT)
}
is EventSharedAction.Resend -> {
is EventSharedAction.Resend -> {
roomDetailViewModel.handle(RoomDetailAction.ResendMessage(action.eventId))
}
is EventSharedAction.Remove -> {
is EventSharedAction.Remove -> {
roomDetailViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId))
}
is EventSharedAction.ReportContentSpam -> {
is EventSharedAction.ReportContentSpam -> {
roomDetailViewModel.handle(RoomDetailAction.ReportContent(
action.eventId, action.senderId, "This message is spam", spam = true))
}
@ -1061,19 +1096,19 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.ReportContent(
action.eventId, action.senderId, "This message is inappropriate", inappropriate = true))
}
is EventSharedAction.ReportContentCustom -> {
is EventSharedAction.ReportContentCustom -> {
promptReasonToReportContent(action)
}
is EventSharedAction.IgnoreUser -> {
is EventSharedAction.IgnoreUser -> {
roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(action.senderId))
}
is EventSharedAction.OnUrlClicked -> {
is EventSharedAction.OnUrlClicked -> {
onUrlClicked(action.url)
}
is EventSharedAction.OnUrlLongClicked -> {
is EventSharedAction.OnUrlLongClicked -> {
onUrlLongClicked(action.url)
}
else -> {
else -> {
Toast.makeText(context, "Action $action is not implemented yet", Toast.LENGTH_LONG).show()
}
}
@ -1181,10 +1216,10 @@ class RoomDetailFragment @Inject constructor(
private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) {
when (type) {
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera()
AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile()
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera()
AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile()
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery()
AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio()
AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio()
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact()
AttachmentTypeSelectorView.Type.STICKER -> vectorBaseActivity.notImplemented("Adding stickers")
}

View File

@ -20,12 +20,7 @@ import android.net.Uri
import androidx.annotation.IdRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.ViewModelContext
import com.airbnb.mvrx.*
import com.jakewharton.rxrelay2.BehaviorRelay
import com.jakewharton.rxrelay2.PublishRelay
import com.squareup.inject.assisted.Assisted
@ -67,6 +62,7 @@ import im.vector.riotx.core.utils.subscribeLogError
import im.vector.riotx.features.command.CommandParser
import im.vector.riotx.features.command.ParsedCommand
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import im.vector.riotx.features.home.room.typing.TypingHelper
import im.vector.riotx.features.settings.VectorPreferences
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
@ -83,6 +79,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
userPreferencesProvider: UserPreferencesProvider,
private val vectorPreferences: VectorPreferences,
private val stringProvider: StringProvider,
private val typingHelper: TypingHelper,
private val session: Session
) : VectorViewModel<RoomDetailViewState, RoomDetailAction>(initialState), Timeline.Listener {
@ -92,16 +89,16 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private val visibleEventsObservable = BehaviorRelay.create<RoomDetailAction.TimelineEventTurnsVisible>()
private val timelineSettings = if (userPreferencesProvider.shouldShowHiddenEvents()) {
TimelineSettings(30,
filterEdits = false,
filterTypes = true,
allowedTypes = TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES,
buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts())
filterEdits = false,
filterTypes = true,
allowedTypes = TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES,
buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts())
} else {
TimelineSettings(30,
filterEdits = true,
filterTypes = true,
allowedTypes = TimelineDisplayableEvents.DISPLAYABLE_TYPES,
buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts())
filterEdits = true,
filterTypes = true,
allowedTypes = TimelineDisplayableEvents.DISPLAYABLE_TYPES,
buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts())
}
private var timelineEvents = PublishRelay.create<List<TimelineEvent>>()
@ -159,6 +156,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
override fun handle(action: RoomDetailAction) {
when (action) {
is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action)
is RoomDetailAction.SaveDraft -> handleSaveDraft(action)
is RoomDetailAction.SendMessage -> handleSendMessage(action)
is RoomDetailAction.SendMedia -> handleSendMedia(action)
@ -236,32 +234,41 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
copy(
// Create a sendMode from a draft and retrieve the TimelineEvent
sendMode = when (draft) {
is UserDraft.REGULAR -> SendMode.REGULAR(draft.text)
is UserDraft.QUOTE -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.QUOTE(timelineEvent, draft.text)
}
}
is UserDraft.REPLY -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.REPLY(timelineEvent, draft.text)
}
}
is UserDraft.EDIT -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.EDIT(timelineEvent, draft.text)
}
}
} ?: SendMode.REGULAR("")
is UserDraft.REGULAR -> SendMode.REGULAR(draft.text)
is UserDraft.QUOTE -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.QUOTE(timelineEvent, draft.text)
}
}
is UserDraft.REPLY -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.REPLY(timelineEvent, draft.text)
}
}
is UserDraft.EDIT -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.EDIT(timelineEvent, draft.text)
}
}
} ?: SendMode.REGULAR("")
)
}
}
.disposeOnClear()
}
private fun handleUserIsTyping(action: RoomDetailAction.UserIsTyping) {
if (vectorPreferences.sendTypingNotifs()) {
if (action.isTyping) {
room.userIsTyping()
} else {
room.userStopsTyping()
}
}
}
private fun handleTombstoneEvent(action: RoomDetailAction.HandleTombstoneEvent) {
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>()
?: return
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>() ?: return
val roomId = tombstoneContent.replacementRoom ?: ""
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
@ -399,7 +406,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is SendMode.EDIT -> {
// is original event a reply?
val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
if (inReplyTo != null) {
// TODO check if same content?
room.getTimeLineEvent(inReplyTo)?.let {
@ -408,13 +415,13 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} else {
val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val existingBody = messageContent?.body ?: ""
if (existingBody != action.text) {
room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "",
messageContent?.type ?: MessageType.MSGTYPE_TEXT,
action.text,
action.autoMarkdown)
messageContent?.type ?: MessageType.MSGTYPE_TEXT,
action.text,
action.autoMarkdown)
} else {
Timber.w("Same message content, do not send edition")
}
@ -425,7 +432,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is SendMode.QUOTE -> {
val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val textMsg = messageContent?.body
val finalText = legacyRiotQuoteText(textMsg, action.text.toString())
@ -541,7 +548,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) {
null -> room.sendMedias(attachments)
else -> _fileTooBigEvent.postValue(LiveEvent(FileTooBigError(tooBigFile.name
?: tooBigFile.path, tooBigFile.size, maxUploadFileSize)))
?: tooBigFile.path, tooBigFile.size, maxUploadFileSize)))
}
}
}
@ -730,8 +737,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
.buffer(1, TimeUnit.SECONDS)
.filter { it.isNotEmpty() }
.subscribeBy(onNext = { actions ->
val bufferedMostRecentDisplayedEvent = actions.maxBy { it.event.displayIndex }?.event
?: return@subscribeBy
val bufferedMostRecentDisplayedEvent = actions.maxBy { it.event.displayIndex }?.event ?: return@subscribeBy
val globalMostRecentDisplayedEvent = mostRecentDisplayedEvent
if (trackUnreadMessages.get()) {
if (globalMostRecentDisplayedEvent == null) {
@ -794,7 +800,14 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
room.rx().liveRoomSummary()
.unwrap()
.execute { async ->
copy(asyncRoomSummary = async)
val typingRoomMembers =
typingHelper.toTypingRoomMembers(async.invoke()?.typingRoomMemberIds.orEmpty(), room)
copy(
asyncRoomSummary = async,
typingRoomMembers = typingRoomMembers,
typingMessage = typingHelper.toTypingMessage(typingRoomMembers)
)
}
}
@ -881,6 +894,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
override fun onCleared() {
timeline.dispose()
timeline.removeAllListeners()
if (vectorPreferences.sendTypingNotifs()) {
room.userStopsTyping()
}
super.onCleared()
}
}

View File

@ -24,6 +24,7 @@ import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.sync.SyncState
import im.vector.matrix.android.api.session.user.model.User
import im.vector.matrix.android.api.util.MatrixItem
/**
* Describes the current send mode:
@ -43,7 +44,7 @@ sealed class SendMode(open val text: String) {
sealed class UnreadState {
object Unknown : UnreadState()
object HasNoUnread : UnreadState()
data class ReadMarkerNotLoaded(val readMarkerId: String): UnreadState()
data class ReadMarkerNotLoaded(val readMarkerId: String) : UnreadState()
data class HasUnread(val firstUnreadEventId: String) : UnreadState()
}
@ -52,6 +53,8 @@ data class RoomDetailViewState(
val eventId: String?,
val asyncInviter: Async<User> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val typingRoomMembers: List<MatrixItem.UserItem>? = null,
val typingMessage: String? = null,
val sendMode: SendMode = SendMode.REGULAR(""),
val tombstoneEvent: Event? = null,
val tombstoneEventHandling: Async<String> = Uninitialized,

View File

@ -20,6 +20,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
@ -27,6 +28,7 @@ import im.vector.matrix.android.api.util.MatrixItem
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.features.home.AvatarRenderer
@EpoxyModelClass(layout = R.layout.item_room)
@ -36,6 +38,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
@EpoxyAttribute lateinit var matrixItem: MatrixItem
@EpoxyAttribute lateinit var lastFormattedEvent: CharSequence
@EpoxyAttribute lateinit var lastEventTime: CharSequence
@EpoxyAttribute var typingString: CharSequence? = null
@EpoxyAttribute var unreadNotificationCount: Int = 0
@EpoxyAttribute var hasUnreadMessage: Boolean = false
@EpoxyAttribute var hasDraft: Boolean = false
@ -50,6 +53,8 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
holder.titleView.text = matrixItem.getBestName()
holder.lastEventTimeView.text = lastEventTime
holder.lastEventView.text = lastFormattedEvent
holder.typingView.setTextOrHide(typingString)
holder.lastEventView.isInvisible = holder.typingView.isVisible
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
holder.unreadIndentIndicator.isVisible = hasUnreadMessage
holder.draftView.isVisible = hasDraft
@ -61,6 +66,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.roomUnreadCounterBadgeView)
val unreadIndentIndicator by bind<View>(R.id.roomUnreadIndicator)
val lastEventView by bind<TextView>(R.id.roomLastEventView)
val typingView by bind<TextView>(R.id.roomTypingView)
val draftView by bind<ImageView>(R.id.roomDraftBadge)
val lastEventTimeView by bind<TextView>(R.id.roomLastEventTimeView)
val avatarImageView by bind<ImageView>(R.id.roomAvatarImageView)

View File

@ -17,6 +17,7 @@
package im.vector.riotx.features.home.room.list
import android.view.View
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomSummary
@ -32,6 +33,7 @@ import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.format.NoticeEventFormatter
import im.vector.riotx.features.home.room.typing.TypingHelper
import me.gujun.android.span.span
import javax.inject.Inject
@ -39,6 +41,8 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
private val dateFormatter: VectorDateFormatter,
private val colorProvider: ColorProvider,
private val stringProvider: StringProvider,
private val typingHelper: TypingHelper,
private val session: Session,
private val avatarRenderer: AvatarRenderer) {
fun create(roomSummary: RoomSummary,
@ -121,11 +125,22 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
dateFormatter.formatMessageDay(date)
}
}
val typingString = typingHelper.excludeCurrentUser(roomSummary.typingRoomMemberIds)
.takeIf { it.isNotEmpty() }
?.let { typingMembers ->
// It's not ideal to get a Room and to fetch data from DB here, but let's keep it like this for the moment
val room = session.getRoom(roomSummary.roomId)
val typingRoomMembers = typingHelper.toTypingRoomMembers(typingMembers, room)
typingHelper.toTypingMessage(typingRoomMembers)
}
return RoomSummaryItem_()
.id(roomSummary.roomId)
.avatarRenderer(avatarRenderer)
.matrixItem(roomSummary.toMatrixItem())
.lastEventTime(latestEventTime)
.typingString(typingString)
.lastFormattedEvent(latestFormattedEvent)
.showHighlighted(showHighlighted)
.unreadNotificationCount(unreadCount)

View File

@ -0,0 +1,69 @@
/*
* Copyright 2020 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.typing
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.members.MembershipService
import im.vector.matrix.android.api.util.MatrixItem
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.riotx.R
import im.vector.riotx.core.resources.StringProvider
import javax.inject.Inject
class TypingHelper @Inject constructor(
private val session: Session,
private val stringProvider: StringProvider
) {
/**
* Exclude current user from the list of typing users
*/
fun excludeCurrentUser(
typingUserIds: List<String>
): List<String> {
return typingUserIds
.filter { it != session.myUserId }
}
/**
* Convert a list of userId to a list of maximum 3 UserItems
*/
fun toTypingRoomMembers(
typingUserIds: List<String>,
membershipService: MembershipService?
): List<MatrixItem.UserItem> {
return excludeCurrentUser(typingUserIds)
.take(3)
.mapNotNull { membershipService?.getRoomMember(it) }
.map { it.toMatrixItem() }
}
/**
* Convert a list of typing UserItems to a human readable String
*/
fun toTypingMessage(typingUserItems: List<MatrixItem.UserItem>): String? {
return when {
typingUserItems.isEmpty() ->
null
typingUserItems.size == 1 ->
stringProvider.getString(R.string.room_one_user_is_typing, typingUserItems[0].getBestName())
typingUserItems.size == 2 ->
stringProvider.getString(R.string.room_two_users_are_typing, typingUserItems[0].getBestName(), typingUserItems[1].getBestName())
else ->
stringProvider.getString(R.string.room_many_users_are_typing, typingUserItems[0].getBestName(), typingUserItems[1].getBestName())
}
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="40dp" />
<solid android:color="@color/riotx_accent" />
</shape>

View File

@ -53,6 +53,23 @@
tools:text="24"
tools:visibility="visible" />
<TextView
android:id="@+id/breadcrumbsTypingView"
android:layout_width="20dp"
android:layout_height="20dp"
android:background="@drawable/bg_breadcrumbs_typing"
android:gravity="center"
android:text="@string/ellipsis"
android:textColor="@android:color/white"
android:textSize="11sp"
android:textStyle="bold"
android:visibility="gone"
app:layout_constraintCircle="@+id/breadcrumbsImageView"
app:layout_constraintCircleAngle="135"
app:layout_constraintCircleRadius="28dp"
tools:ignore="MissingConstraints"
tools:visibility="visible" />
<ImageView
android:id="@+id/breadcrumbsDraftBadge"
android:layout_width="20dp"
@ -62,7 +79,7 @@
android:src="@drawable/ic_edit"
android:visibility="gone"
app:layout_constraintCircle="@+id/breadcrumbsImageView"
app:layout_constraintCircleAngle="135"
app:layout_constraintCircleAngle="225"
app:layout_constraintCircleRadius="28dp"
tools:ignore="MissingConstraints"
tools:visibility="visible" />

View File

@ -15,11 +15,11 @@
android:layout_width="4dp"
android:layout_height="0dp"
android:background="?attr/colorAccent"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:visibility="gone"
tools:visibility="visible"/>
tools:visibility="visible" />
<ImageView
android:id="@+id/roomAvatarImageView"
@ -128,6 +128,23 @@
app:layout_constraintTop_toBottomOf="@+id/roomNameView"
tools:text="@sample/matrix.json/data/message" />
<TextView
android:id="@+id/roomTypingView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/riotx_accent"
android:textSize="15sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/roomNameView"
app:layout_constraintTop_toBottomOf="@+id/roomNameView"
tools:text="Alice is typing…" />
<!-- Margin bottom does not work, so I use space -->
<Space
android:id="@+id/roomLastEventBottomSpace"

View File

@ -3,6 +3,7 @@
<string name="debug_screen" translatable="false">Debug screen</string>
<string name="ellipsis" translatable="false"></string>
<string name="plus_sign" translatable="false">+</string>
<string name="semicolon_sign" translatable="false">:</string>

View File

@ -37,8 +37,7 @@
android:defaultValue="true"
android:key="SETTINGS_SEND_TYPING_NOTIF_KEY"
android:summary="@string/settings_send_typing_notifs_summary"
android:title="@string/settings_send_typing_notifs"
app:isPreferenceVisible="@bool/false_not_implemented" />
android:title="@string/settings_send_typing_notifs" />
<im.vector.riotx.core.preference.VectorSwitchPreference
android:defaultValue="false"