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 ✨: Features ✨:
- - Send and render typing events (#564)
Improvements 🙌: Improvements 🙌:
- Render events m.room.encryption and m.room.guest_access in the timeline - 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.RoomSummary
import im.vector.matrix.android.api.session.room.model.relation.RelationService 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.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.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.DraftService
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
import im.vector.matrix.android.api.session.room.timeline.TimelineService 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.Optional
/** /**
@ -38,6 +39,7 @@ interface Room :
SendService, SendService,
DraftService, DraftService,
ReadService, ReadService,
TypingService,
MembershipService, MembershipService,
StateService, StateService,
ReportingService, ReportingService,

View File

@ -42,7 +42,8 @@ data class RoomSummary(
val versioningState: VersioningState = VersioningState.NONE, val versioningState: VersioningState = VersioningState.NONE,
val readMarkerId: String? = null, val readMarkerId: String? = null,
val userDrafts: List<UserDraft> = emptyList(), val userDrafts: List<UserDraft> = emptyList(),
var isEncrypted: Boolean var isEncrypted: Boolean,
val typingRoomMemberIds: List<String> = emptyList()
) { ) {
val isVersioned: Boolean 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.api.session.room.model.tag.RoomTag
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import java.util.UUID import java.util.*
import javax.inject.Inject import javax.inject.Inject
internal class RoomSummaryMapper @Inject constructor( internal class RoomSummaryMapper @Inject constructor(
@ -71,7 +71,8 @@ internal class RoomSummaryMapper @Inject constructor(
userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) } ?: emptyList(), userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) } ?: emptyList(),
canonicalAlias = roomSummaryEntity.canonicalAlias, canonicalAlias = roomSummaryEntity.canonicalAlias,
aliases = roomSummaryEntity.aliases.toList(), 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(), var aliases: RealmList<String> = RealmList(),
// this is required for querying // this is required for querying
var flatAliases: String = "", var flatAliases: String = "",
var isEncrypted: Boolean = false var isEncrypted: Boolean = false,
var typingUserIds: RealmList<String> = RealmList()
) : RealmObject() { ) : RealmObject() {
private var membershipStr: String = Membership.NONE.name 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.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService 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.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.Optional
import im.vector.matrix.android.api.util.toOptional import im.vector.matrix.android.api.util.toOptional
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper 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 stateService: StateService,
private val reportingService: ReportingService, private val reportingService: ReportingService,
private val readService: ReadService, private val readService: ReadService,
private val typingService: TypingService,
private val cryptoService: CryptoService, private val cryptoService: CryptoService,
private val relationService: RelationService, private val relationService: RelationService,
private val roomMembersService: MembershipService, private val roomMembersService: MembershipService,
@ -60,6 +62,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
StateService by stateService, StateService by stateService,
ReportingService by reportingService, ReportingService by reportingService,
ReadService by readService, ReadService by readService,
TypingService by typingService,
RelationService by relationService, RelationService by relationService,
MembershipService by roomMembersService, MembershipService by roomMembersService,
RoomPushRuleService by roomPushRuleService { 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.Content
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.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.CreateRoomParams
import im.vector.matrix.android.api.session.room.model.create.CreateRoomResponse 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.PublicRoomsParams
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoomsResponse 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.api.session.room.model.thirdparty.ThirdPartyProtocol
import im.vector.matrix.android.internal.network.NetworkConstants 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.RoomMembersResponse
import im.vector.matrix.android.internal.session.room.membership.joining.InviteBody import im.vector.matrix.android.internal.session.room.membership.joining.InviteBody
import im.vector.matrix.android.internal.session.room.relation.RelationsResponse 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.send.SendResponse
import im.vector.matrix.android.internal.session.room.timeline.EventContextResponse 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.timeline.PaginationResponse
import im.vector.matrix.android.internal.session.room.typing.TypingBody
import retrofit2.Call import retrofit2.Call
import retrofit2.http.* import retrofit2.http.*
@ -268,4 +269,12 @@ internal interface RoomAPI {
*/ */
@GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "directory/room/{roomAlias}") @GET(NetworkConstants.URI_API_PREFIX_PATH_R0 + "directory/room/{roomAlias}")
fun getRoomIdByAlias(@Path("roomAlias") roomAlias: String): Call<RoomAliasDescription> 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.send.DefaultSendService
import im.vector.matrix.android.internal.session.room.state.DefaultStateService 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.timeline.DefaultTimelineService
import im.vector.matrix.android.internal.session.room.typing.DefaultTypingService
import javax.inject.Inject import javax.inject.Inject
internal interface RoomFactory { internal interface RoomFactory {
@ -46,6 +47,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
private val stateServiceFactory: DefaultStateService.Factory, private val stateServiceFactory: DefaultStateService.Factory,
private val reportingServiceFactory: DefaultReportingService.Factory, private val reportingServiceFactory: DefaultReportingService.Factory,
private val readServiceFactory: DefaultReadService.Factory, private val readServiceFactory: DefaultReadService.Factory,
private val typingServiceFactory: DefaultTypingService.Factory,
private val relationServiceFactory: DefaultRelationService.Factory, private val relationServiceFactory: DefaultRelationService.Factory,
private val membershipServiceFactory: DefaultMembershipService.Factory, private val membershipServiceFactory: DefaultMembershipService.Factory,
private val roomPushRuleServiceFactory: DefaultRoomPushRuleService.Factory) : private val roomPushRuleServiceFactory: DefaultRoomPushRuleService.Factory) :
@ -62,6 +64,7 @@ internal class DefaultRoomFactory @Inject constructor(private val monarchy: Mona
stateServiceFactory.create(roomId), stateServiceFactory.create(roomId),
reportingServiceFactory.create(roomId), reportingServiceFactory.create(roomId),
readServiceFactory.create(roomId), readServiceFactory.create(roomId),
typingServiceFactory.create(roomId),
cryptoService, cryptoService,
relationServiceFactory.create(roomId), relationServiceFactory.create(roomId),
membershipServiceFactory.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.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.session.room.typing.DefaultSendTypingTask
import im.vector.matrix.android.internal.session.room.typing.SendTypingTask
import retrofit2.Retrofit import retrofit2.Retrofit
@Module @Module
@ -68,74 +70,77 @@ internal abstract class RoomModule {
} }
@Binds @Binds
abstract fun bindRoomFactory(roomFactory: DefaultRoomFactory): RoomFactory abstract fun bindRoomFactory(factory: DefaultRoomFactory): RoomFactory
@Binds @Binds
abstract fun bindRoomService(roomService: DefaultRoomService): RoomService abstract fun bindRoomService(service: DefaultRoomService): RoomService
@Binds @Binds
abstract fun bindRoomDirectoryService(roomDirectoryService: DefaultRoomDirectoryService): RoomDirectoryService abstract fun bindRoomDirectoryService(service: DefaultRoomDirectoryService): RoomDirectoryService
@Binds @Binds
abstract fun bindEventRelationsAggregationTask(eventRelationsAggregationTask: DefaultEventRelationsAggregationTask): EventRelationsAggregationTask abstract fun bindFileService(service: DefaultFileService): FileService
@Binds @Binds
abstract fun bindCreateRoomTask(createRoomTask: DefaultCreateRoomTask): CreateRoomTask abstract fun bindEventRelationsAggregationTask(task: DefaultEventRelationsAggregationTask): EventRelationsAggregationTask
@Binds @Binds
abstract fun bindGetPublicRoomTask(getPublicRoomTask: DefaultGetPublicRoomTask): GetPublicRoomTask abstract fun bindCreateRoomTask(task: DefaultCreateRoomTask): CreateRoomTask
@Binds @Binds
abstract fun bindGetThirdPartyProtocolsTask(getThirdPartyProtocolsTask: DefaultGetThirdPartyProtocolsTask): GetThirdPartyProtocolsTask abstract fun bindGetPublicRoomTask(task: DefaultGetPublicRoomTask): GetPublicRoomTask
@Binds @Binds
abstract fun bindInviteTask(inviteTask: DefaultInviteTask): InviteTask abstract fun bindGetThirdPartyProtocolsTask(task: DefaultGetThirdPartyProtocolsTask): GetThirdPartyProtocolsTask
@Binds @Binds
abstract fun bindJoinRoomTask(joinRoomTask: DefaultJoinRoomTask): JoinRoomTask abstract fun bindInviteTask(task: DefaultInviteTask): InviteTask
@Binds @Binds
abstract fun bindLeaveRoomTask(leaveRoomTask: DefaultLeaveRoomTask): LeaveRoomTask abstract fun bindJoinRoomTask(task: DefaultJoinRoomTask): JoinRoomTask
@Binds @Binds
abstract fun bindLoadRoomMembersTask(loadRoomMembersTask: DefaultLoadRoomMembersTask): LoadRoomMembersTask abstract fun bindLeaveRoomTask(task: DefaultLeaveRoomTask): LeaveRoomTask
@Binds @Binds
abstract fun bindPruneEventTask(pruneEventTask: DefaultPruneEventTask): PruneEventTask abstract fun bindLoadRoomMembersTask(task: DefaultLoadRoomMembersTask): LoadRoomMembersTask
@Binds @Binds
abstract fun bindSetReadMarkersTask(setReadMarkersTask: DefaultSetReadMarkersTask): SetReadMarkersTask abstract fun bindPruneEventTask(task: DefaultPruneEventTask): PruneEventTask
@Binds @Binds
abstract fun bindMarkAllRoomsReadTask(markAllRoomsReadTask: DefaultMarkAllRoomsReadTask): MarkAllRoomsReadTask abstract fun bindSetReadMarkersTask(task: DefaultSetReadMarkersTask): SetReadMarkersTask
@Binds @Binds
abstract fun bindFindReactionEventForUndoTask(findReactionEventForUndoTask: DefaultFindReactionEventForUndoTask): FindReactionEventForUndoTask abstract fun bindMarkAllRoomsReadTask(task: DefaultMarkAllRoomsReadTask): MarkAllRoomsReadTask
@Binds @Binds
abstract fun bindUpdateQuickReactionTask(updateQuickReactionTask: DefaultUpdateQuickReactionTask): UpdateQuickReactionTask abstract fun bindFindReactionEventForUndoTask(task: DefaultFindReactionEventForUndoTask): FindReactionEventForUndoTask
@Binds @Binds
abstract fun bindSendStateTask(sendStateTask: DefaultSendStateTask): SendStateTask abstract fun bindUpdateQuickReactionTask(task: DefaultUpdateQuickReactionTask): UpdateQuickReactionTask
@Binds @Binds
abstract fun bindReportContentTask(reportContentTask: DefaultReportContentTask): ReportContentTask abstract fun bindSendStateTask(task: DefaultSendStateTask): SendStateTask
@Binds @Binds
abstract fun bindGetContextOfEventTask(getContextOfEventTask: DefaultGetContextOfEventTask): GetContextOfEventTask abstract fun bindReportContentTask(task: DefaultReportContentTask): ReportContentTask
@Binds @Binds
abstract fun bindClearUnlinkedEventsTask(clearUnlinkedEventsTask: DefaultClearUnlinkedEventsTask): ClearUnlinkedEventsTask abstract fun bindGetContextOfEventTask(task: DefaultGetContextOfEventTask): GetContextOfEventTask
@Binds @Binds
abstract fun bindPaginationTask(paginationTask: DefaultPaginationTask): PaginationTask abstract fun bindClearUnlinkedEventsTask(task: DefaultClearUnlinkedEventsTask): ClearUnlinkedEventsTask
@Binds @Binds
abstract fun bindFileService(fileService: DefaultFileService): FileService abstract fun bindPaginationTask(task: DefaultPaginationTask): PaginationTask
@Binds @Binds
abstract fun bindFetchEditHistoryTask(fetchEditHistoryTask: DefaultFetchEditHistoryTask): FetchEditHistoryTask abstract fun bindFetchEditHistoryTask(task: DefaultFetchEditHistoryTask): FetchEditHistoryTask
@Binds @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.RoomCanonicalAliasContent
import im.vector.matrix.android.api.session.room.model.RoomTopicContent 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.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.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.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.query.* import im.vector.matrix.android.internal.database.query.*
import im.vector.matrix.android.internal.di.UserId 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.RoomDisplayNameResolver
import im.vector.matrix.android.internal.session.room.membership.RoomMembers 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.RoomSyncSummary
import im.vector.matrix.android.internal.session.sync.model.RoomSyncUnreadNotifications import im.vector.matrix.android.internal.session.sync.model.RoomSyncUnreadNotifications
import io.realm.Realm import io.realm.Realm
@ -65,7 +66,8 @@ internal class RoomSummaryUpdater @Inject constructor(
membership: Membership? = null, membership: Membership? = null,
roomSummary: RoomSyncSummary? = null, roomSummary: RoomSyncSummary? = null,
unreadNotifications: RoomSyncUnreadNotifications? = null, unreadNotifications: RoomSyncUnreadNotifications? = null,
updateMembers: Boolean = false) { updateMembers: Boolean = false,
ephemeralResult: RoomSyncHandler.EphemeralResult? = null) {
val roomSummaryEntity = RoomSummaryEntity.getOrCreate(realm, roomId) val roomSummaryEntity = RoomSummaryEntity.getOrCreate(realm, roomId)
if (roomSummary != null) { if (roomSummary != null) {
if (roomSummary.heroes.isNotEmpty()) { if (roomSummary.heroes.isNotEmpty()) {
@ -109,6 +111,8 @@ internal class RoomSummaryUpdater @Inject constructor(
roomSummaryEntity.aliases.addAll(roomAliases) roomSummaryEntity.aliases.addAll(roomAliases)
roomSummaryEntity.flatAliases = roomAliases.joinToString(separator = "|", prefix = "|") roomSummaryEntity.flatAliases = roomAliases.joinToString(separator = "|", prefix = "|")
roomSummaryEntity.isEncrypted = encryptionEvent != null roomSummaryEntity.isEncrypted = encryptionEvent != null
roomSummaryEntity.typingUserIds.clear()
roomSummaryEntity.typingUserIds.addAll(ephemeralResult?.typingUserIds.orEmpty())
if (updateMembers) { if (updateMembers) {
val otherRoomMembers = RoomMembers(realm, roomId) 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.membership.RoomMemberEventHandler
import im.vector.matrix.android.internal.session.room.read.FullyReadContent 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.timeline.PaginationDirection
import im.vector.matrix.android.internal.session.room.typing.TypingEventContent
import im.vector.matrix.android.internal.session.sync.model.* import im.vector.matrix.android.internal.session.sync.model.*
import io.realm.Realm import io.realm.Realm
import io.realm.kotlin.createObject import io.realm.kotlin.createObject
@ -97,11 +98,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
isInitialSync: Boolean): RoomEntity { isInitialSync: Boolean): RoomEntity {
Timber.v("Handle join sync for room $roomId") Timber.v("Handle join sync for room $roomId")
if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) { var ephemeralResult: EphemeralResult? = null
handleEphemeral(realm, roomId, roomSync.ephemeral, isInitialSync) 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) handleRoomAccountDataEvents(realm, roomId, roomSync.accountData)
} }
@ -114,7 +116,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
// State event // 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() val minStateIndex = roomEntity.untimelinedStateEvents.where().min(EventEntityFields.STATE_INDEX)?.toInt()
?: Int.MIN_VALUE ?: Int.MIN_VALUE
val untimelinedStateIndex = minStateIndex + 1 val untimelinedStateIndex = minStateIndex + 1
@ -125,7 +127,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
roomMemberEventHandler.handle(realm, roomId, event) roomMemberEventHandler.handle(realm, roomId, event)
} }
} }
if (roomSync.timeline != null && roomSync.timeline.events.isNotEmpty()) { if (roomSync.timeline?.events?.isNotEmpty() == true) {
val chunkEntity = handleTimelineEvents( val chunkEntity = handleTimelineEvents(
realm, realm,
roomEntity, roomEntity,
@ -141,7 +143,14 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
it.type == EventType.STATE_ROOM_MEMBER it.type == EventType.STATE_ROOM_MEMBER
} != null } != 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 return roomEntity
} }
@ -215,17 +224,34 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
return chunkEntity return chunkEntity
} }
@Suppress("UNCHECKED_CAST") data class EphemeralResult(
val typingUserIds: List<String> = emptyList()
)
private fun handleEphemeral(realm: Realm, private fun handleEphemeral(realm: Realm,
roomId: String, roomId: String,
ephemeral: RoomSyncEphemeral, ephemeral: RoomSyncEphemeral,
isInitialSync: Boolean) { isInitialSync: Boolean): EphemeralResult {
var result = EphemeralResult()
for (event in ephemeral.events) { for (event in ephemeral.events) {
if (event.type != EventType.RECEIPT) continue when (event.type) {
val readReceiptContent = event.content as? ReadReceiptContent ?: continue EventType.RECEIPT -> {
@Suppress("UNCHECKED_CAST")
(event.content as? ReadReceiptContent)?.let { readReceiptContent ->
readReceiptHandler.handle(realm, roomId, readReceiptContent, isInitialSync) 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) { private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) {
for (event in accountData.events) { for (event in accountData.events) {

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

View File

@ -37,6 +37,7 @@ abstract class BreadcrumbsItem : VectorEpoxyModel<BreadcrumbsItem.Holder>() {
@EpoxyAttribute var unreadNotificationCount: Int = 0 @EpoxyAttribute var unreadNotificationCount: Int = 0
@EpoxyAttribute var showHighlighted: Boolean = false @EpoxyAttribute var showHighlighted: Boolean = false
@EpoxyAttribute var hasUnreadMessage: Boolean = false @EpoxyAttribute var hasUnreadMessage: Boolean = false
@EpoxyAttribute var hasTypingUsers: Boolean = false
@EpoxyAttribute var hasDraft: Boolean = false @EpoxyAttribute var hasDraft: Boolean = false
@EpoxyAttribute var itemClickListener: View.OnClickListener? = null @EpoxyAttribute var itemClickListener: View.OnClickListener? = null
@ -44,6 +45,7 @@ abstract class BreadcrumbsItem : VectorEpoxyModel<BreadcrumbsItem.Holder>() {
super.bind(holder) super.bind(holder)
holder.rootView.setOnClickListener(itemClickListener) holder.rootView.setOnClickListener(itemClickListener)
holder.unreadIndentIndicator.isVisible = hasUnreadMessage holder.unreadIndentIndicator.isVisible = hasUnreadMessage
holder.typingIndicator.isVisible = hasTypingUsers
avatarRenderer.render(matrixItem, holder.avatarImageView) avatarRenderer.render(matrixItem, holder.avatarImageView)
holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted)) holder.unreadCounterBadgeView.render(UnreadCounterBadgeView.State(unreadNotificationCount, showHighlighted))
holder.draftIndentIndicator.isVisible = hasDraft holder.draftIndentIndicator.isVisible = hasDraft
@ -53,6 +55,7 @@ abstract class BreadcrumbsItem : VectorEpoxyModel<BreadcrumbsItem.Holder>() {
val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.breadcrumbsUnreadCounterBadgeView) val unreadCounterBadgeView by bind<UnreadCounterBadgeView>(R.id.breadcrumbsUnreadCounterBadgeView)
val unreadIndentIndicator by bind<View>(R.id.breadcrumbsUnreadIndicator) val unreadIndentIndicator by bind<View>(R.id.breadcrumbsUnreadIndicator)
val draftIndentIndicator by bind<View>(R.id.breadcrumbsDraftBadge) 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 avatarImageView by bind<ImageView>(R.id.breadcrumbsImageView)
val rootView by bind<ViewGroup>(R.id.breadcrumbsRoot) 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 import im.vector.riotx.core.platform.VectorViewModelAction
sealed class RoomDetailAction : VectorViewModelAction { sealed class RoomDetailAction : VectorViewModelAction {
data class UserIsTyping(val isTyping: Boolean) : RoomDetailAction()
data class SaveDraft(val draft: String) : RoomDetailAction() data class SaveDraft(val draft: String) : RoomDetailAction()
data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : RoomDetailAction() data class SendMessage(val text: CharSequence, val autoMarkdown: Boolean) : RoomDetailAction()
data class SendMedia(val attachments: List<ContentAttachmentData>) : 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.app.Activity.RESULT_OK
import android.content.DialogInterface import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.graphics.Typeface
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@ -50,6 +51,7 @@ import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.ImageLoader import com.github.piasy.biv.loader.ImageLoader
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText 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.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData 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.reactions.EmojiReactionPickerActivity
import im.vector.riotx.features.settings.VectorPreferences import im.vector.riotx.features.settings.VectorPreferences
import im.vector.riotx.features.share.SharedData import im.vector.riotx.features.share.SharedData
import im.vector.riotx.features.themes.ThemeUtils
import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers import io.reactivex.schedulers.Schedulers
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
@ -109,6 +112,7 @@ import kotlinx.android.synthetic.main.merge_overlay_waiting_view.*
import org.commonmark.parser.Parser import org.commonmark.parser.Parser
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@Parcelize @Parcelize
@ -539,6 +543,9 @@ class RoomDetailFragment @Inject constructor(
private fun setupComposer() { private fun setupComposer() {
autoCompleter.setup(composerLayout.composerEditText) autoCompleter.setup(composerLayout.composerEditText)
observerUserTyping()
composerLayout.callback = object : TextComposerView.Callback { composerLayout.callback = object : TextComposerView.Callback {
override fun onAddAttachment() { override fun onAddAttachment() {
if (!::attachmentTypeSelector.isInitialized) { 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 { private fun sendUri(uri: Uri): Boolean {
val shareIntent = Intent(Intent.ACTION_SEND, uri) val shareIntent = Intent(Intent.ACTION_SEND, uri)
val isHandled = attachmentsHelper.handleShareIntent(shareIntent) val isHandled = attachmentsHelper.handleShareIntent(shareIntent)
@ -627,13 +646,29 @@ class RoomDetailFragment @Inject constructor(
} else { } else {
roomToolbarTitleView.text = it.displayName roomToolbarTitleView.text = it.displayName
avatarRenderer.render(it.toMatrixItem(), roomToolbarAvatarImageView) avatarRenderer.render(it.toMatrixItem(), roomToolbarAvatarImageView)
roomToolbarSubtitleView.setTextOrHide(it.topic)
renderSubTitle(state.typingMessage, it.topic)
} }
jumpToBottomView.count = it.notificationCount jumpToBottomView.count = it.notificationCount
jumpToBottomView.drawBadge = it.hasUnreadMessages 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>) { private fun renderTombstoneEventHandling(async: Async<String>) {
when (async) { when (async) {
is Loading -> { is Loading -> {

View File

@ -20,12 +20,7 @@ import android.net.Uri
import androidx.annotation.IdRes import androidx.annotation.IdRes
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.airbnb.mvrx.Async import com.airbnb.mvrx.*
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.jakewharton.rxrelay2.BehaviorRelay import com.jakewharton.rxrelay2.BehaviorRelay
import com.jakewharton.rxrelay2.PublishRelay import com.jakewharton.rxrelay2.PublishRelay
import com.squareup.inject.assisted.Assisted 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.CommandParser
import im.vector.riotx.features.command.ParsedCommand 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.detail.timeline.helper.TimelineDisplayableEvents
import im.vector.riotx.features.home.room.typing.TypingHelper
import im.vector.riotx.features.settings.VectorPreferences import im.vector.riotx.features.settings.VectorPreferences
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.functions.BiFunction import io.reactivex.functions.BiFunction
@ -83,6 +79,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
userPreferencesProvider: UserPreferencesProvider, userPreferencesProvider: UserPreferencesProvider,
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val typingHelper: TypingHelper,
private val session: Session private val session: Session
) : VectorViewModel<RoomDetailViewState, RoomDetailAction>(initialState), Timeline.Listener { ) : VectorViewModel<RoomDetailViewState, RoomDetailAction>(initialState), Timeline.Listener {
@ -159,6 +156,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
override fun handle(action: RoomDetailAction) { override fun handle(action: RoomDetailAction) {
when (action) { when (action) {
is RoomDetailAction.UserIsTyping -> handleUserIsTyping(action)
is RoomDetailAction.SaveDraft -> handleSaveDraft(action) is RoomDetailAction.SaveDraft -> handleSaveDraft(action)
is RoomDetailAction.SendMessage -> handleSendMessage(action) is RoomDetailAction.SendMessage -> handleSendMessage(action)
is RoomDetailAction.SendMedia -> handleSendMedia(action) is RoomDetailAction.SendMedia -> handleSendMedia(action)
@ -259,9 +257,18 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
.disposeOnClear() .disposeOnClear()
} }
private fun handleUserIsTyping(action: RoomDetailAction.UserIsTyping) {
if (vectorPreferences.sendTypingNotifs()) {
if (action.isTyping) {
room.userIsTyping()
} else {
room.userStopsTyping()
}
}
}
private fun handleTombstoneEvent(action: RoomDetailAction.HandleTombstoneEvent) { private fun handleTombstoneEvent(action: RoomDetailAction.HandleTombstoneEvent) {
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>() val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>() ?: return
?: return
val roomId = tombstoneContent.replacementRoom ?: "" val roomId = tombstoneContent.replacementRoom ?: ""
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
@ -730,8 +737,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
.buffer(1, TimeUnit.SECONDS) .buffer(1, TimeUnit.SECONDS)
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
.subscribeBy(onNext = { actions -> .subscribeBy(onNext = { actions ->
val bufferedMostRecentDisplayedEvent = actions.maxBy { it.event.displayIndex }?.event val bufferedMostRecentDisplayedEvent = actions.maxBy { it.event.displayIndex }?.event ?: return@subscribeBy
?: return@subscribeBy
val globalMostRecentDisplayedEvent = mostRecentDisplayedEvent val globalMostRecentDisplayedEvent = mostRecentDisplayedEvent
if (trackUnreadMessages.get()) { if (trackUnreadMessages.get()) {
if (globalMostRecentDisplayedEvent == null) { if (globalMostRecentDisplayedEvent == null) {
@ -794,7 +800,14 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
room.rx().liveRoomSummary() room.rx().liveRoomSummary()
.unwrap() .unwrap()
.execute { async -> .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() { override fun onCleared() {
timeline.dispose() timeline.dispose()
timeline.removeAllListeners() timeline.removeAllListeners()
if (vectorPreferences.sendTypingNotifs()) {
room.userStopsTyping()
}
super.onCleared() 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.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.sync.SyncState 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.session.user.model.User
import im.vector.matrix.android.api.util.MatrixItem
/** /**
* Describes the current send mode: * Describes the current send mode:
@ -43,7 +44,7 @@ sealed class SendMode(open val text: String) {
sealed class UnreadState { sealed class UnreadState {
object Unknown : UnreadState() object Unknown : UnreadState()
object HasNoUnread : 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() data class HasUnread(val firstUnreadEventId: String) : UnreadState()
} }
@ -52,6 +53,8 @@ data class RoomDetailViewState(
val eventId: String?, val eventId: String?,
val asyncInviter: Async<User> = Uninitialized, val asyncInviter: Async<User> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized, val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val typingRoomMembers: List<MatrixItem.UserItem>? = null,
val typingMessage: String? = null,
val sendMode: SendMode = SendMode.REGULAR(""), val sendMode: SendMode = SendMode.REGULAR(""),
val tombstoneEvent: Event? = null, val tombstoneEvent: Event? = null,
val tombstoneEventHandling: Async<String> = Uninitialized, val tombstoneEventHandling: Async<String> = Uninitialized,

View File

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

View File

@ -17,6 +17,7 @@
package im.vector.riotx.features.home.room.list package im.vector.riotx.features.home.room.list
import android.view.View 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.events.model.EventType
import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomSummary 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.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer 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.detail.timeline.format.NoticeEventFormatter
import im.vector.riotx.features.home.room.typing.TypingHelper
import me.gujun.android.span.span import me.gujun.android.span.span
import javax.inject.Inject import javax.inject.Inject
@ -39,6 +41,8 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
private val dateFormatter: VectorDateFormatter, private val dateFormatter: VectorDateFormatter,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val typingHelper: TypingHelper,
private val session: Session,
private val avatarRenderer: AvatarRenderer) { private val avatarRenderer: AvatarRenderer) {
fun create(roomSummary: RoomSummary, fun create(roomSummary: RoomSummary,
@ -121,11 +125,22 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
dateFormatter.formatMessageDay(date) 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_() return RoomSummaryItem_()
.id(roomSummary.roomId) .id(roomSummary.roomId)
.avatarRenderer(avatarRenderer) .avatarRenderer(avatarRenderer)
.matrixItem(roomSummary.toMatrixItem()) .matrixItem(roomSummary.toMatrixItem())
.lastEventTime(latestEventTime) .lastEventTime(latestEventTime)
.typingString(typingString)
.lastFormattedEvent(latestFormattedEvent) .lastFormattedEvent(latestFormattedEvent)
.showHighlighted(showHighlighted) .showHighlighted(showHighlighted)
.unreadNotificationCount(unreadCount) .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:text="24"
tools:visibility="visible" /> 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 <ImageView
android:id="@+id/breadcrumbsDraftBadge" android:id="@+id/breadcrumbsDraftBadge"
android:layout_width="20dp" android:layout_width="20dp"
@ -62,7 +79,7 @@
android:src="@drawable/ic_edit" android:src="@drawable/ic_edit"
android:visibility="gone" android:visibility="gone"
app:layout_constraintCircle="@+id/breadcrumbsImageView" app:layout_constraintCircle="@+id/breadcrumbsImageView"
app:layout_constraintCircleAngle="135" app:layout_constraintCircleAngle="225"
app:layout_constraintCircleRadius="28dp" app:layout_constraintCircleRadius="28dp"
tools:ignore="MissingConstraints" tools:ignore="MissingConstraints"
tools:visibility="visible" /> tools:visibility="visible" />

View File

@ -15,11 +15,11 @@
android:layout_width="4dp" android:layout_width="4dp"
android:layout_height="0dp" android:layout_height="0dp"
android:background="?attr/colorAccent" android:background="?attr/colorAccent"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
android:visibility="gone" tools:visibility="visible" />
tools:visibility="visible"/>
<ImageView <ImageView
android:id="@+id/roomAvatarImageView" android:id="@+id/roomAvatarImageView"
@ -128,6 +128,23 @@
app:layout_constraintTop_toBottomOf="@+id/roomNameView" app:layout_constraintTop_toBottomOf="@+id/roomNameView"
tools:text="@sample/matrix.json/data/message" /> 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 --> <!-- Margin bottom does not work, so I use space -->
<Space <Space
android:id="@+id/roomLastEventBottomSpace" android:id="@+id/roomLastEventBottomSpace"

View File

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

View File

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