diff --git a/CHANGES.md b/CHANGES.md index 94eca36ff9..63755c2f0a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,7 @@ Changes in RiotX 0.13.0 (2020-XX-XX) =================================================== Features ✨: - - + - Send and render typing events (#564) Improvements 🙌: - diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt index 3221c355e8..0c3316e802 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt @@ -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, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt index c18645ddbd..2f420de164 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt @@ -42,7 +42,8 @@ data class RoomSummary( val versioningState: VersioningState = VersioningState.NONE, val readMarkerId: String? = null, val userDrafts: List = emptyList(), - var isEncrypted: Boolean + var isEncrypted: Boolean, + val typingRoomMemberIds: List = emptyList() ) { val isVersioned: Boolean diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/typing/TypingService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/typing/TypingService.kt new file mode 100644 index 0000000000..8ef550531e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/typing/TypingService.kt @@ -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() +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt index 7d25a846ff..3d1b5a2d08 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt @@ -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() ) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt index 4c99832b39..d857d8810c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt @@ -44,7 +44,8 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "", var aliases: RealmList = RealmList(), // this is required for querying var flatAliases: String = "", - var isEncrypted: Boolean = false + var isEncrypted: Boolean = false, + var typingUserIds: RealmList = RealmList() ) : RealmObject() { private var membershipStr: String = Membership.NONE.name diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt index 5c87f84d73..fc95dd4bb4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt @@ -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 { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt index 6896788de9..622ffbe2f0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomAPI.kt @@ -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 + + /** + * 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 } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt index a21a3b4a8d..b24bb73d56 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt @@ -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), diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index cc786a7493..5551930bd1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -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 } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt index 9fa922b940..6d4dac64b5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomSummaryUpdater.kt @@ -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()?.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) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/DefaultTypingService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/DefaultTypingService.kt new file mode 100644 index 0000000000..4989d8a235 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/DefaultTypingService.kt @@ -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 { + 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 + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/SendTypingTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/SendTypingTask.kt new file mode 100644 index 0000000000..a668676161 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/SendTypingTask.kt @@ -0,0 +1,58 @@ +/* + * 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 { + + data class Params( + val roomId: String, + val isTyping: Boolean, + val delay: Long? = null, + val typingTimeoutMillis: Int? = 30_000 + ) +} + +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) { + if (params.delay != null) { + delay(params.delay) + } + + val body = if (params.isTyping) { + TypingBody(true, params.typingTimeoutMillis) + } else { + TypingBody(false, null) + } + + executeRequest(eventBus) { + apiCall = roomAPI.sendTypingState(params.roomId, userId, body) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/TypingBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/TypingBody.kt new file mode 100644 index 0000000000..26099599d7 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/TypingBody.kt @@ -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? +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/TypingEventContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/TypingEventContent.kt new file mode 100644 index 0000000000..969db3f235 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/typing/TypingEventContent.kt @@ -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 = emptyList() +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt index 488b9ce83d..a1699d9f55 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt @@ -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 = 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()?.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) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsController.kt index 3b77835917..28caf3a2cd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsController.kt @@ -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() == true) hasDraft(it.userDrafts.isNotEmpty()) itemClickListener( DebouncedClickListener(View.OnClickListener { _ -> diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsItem.kt index 6d18a85b75..8b36b307a9 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/breadcrumbs/BreadcrumbsItem.kt @@ -37,6 +37,7 @@ abstract class BreadcrumbsItem : VectorEpoxyModel() { @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() { 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() { val unreadCounterBadgeView by bind(R.id.breadcrumbsUnreadCounterBadgeView) val unreadIndentIndicator by bind(R.id.breadcrumbsUnreadIndicator) val draftIndentIndicator by bind(R.id.breadcrumbsDraftBadge) + val typingIndicator by bind(R.id.breadcrumbsTypingView) val avatarImageView by bind(R.id.breadcrumbsImageView) val rootView by bind(R.id.breadcrumbsRoot) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt index c1743ae3fc..8024e5b547 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt @@ -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) : RoomDetailAction() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index daf7755fc4..675b2242b8 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -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) { 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) { 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, 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(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(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") } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index c93358a04e..e6870dbb7f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -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 @@ -68,6 +63,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 @@ -84,6 +80,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(initialState), Timeline.Listener { @@ -93,16 +90,16 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro private val visibleEventsObservable = BehaviorRelay.create() 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>() @@ -160,6 +157,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) @@ -237,32 +235,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() - ?: return + val tombstoneContent = action.event.getClearContent().toModel() ?: return val roomId = tombstoneContent.replacementRoom ?: "" val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN @@ -400,7 +407,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro is SendMode.EDIT -> { // is original event a reply? val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel()?.relatesTo?.inReplyTo?.eventId - ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId + ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId if (inReplyTo != null) { // TODO check if same content? room.getTimeLineEvent(inReplyTo)?.let { @@ -409,13 +416,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") } @@ -426,7 +433,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()) @@ -542,7 +549,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))) } } } @@ -731,8 +738,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) { @@ -795,7 +801,14 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro room.rx().liveRoomSummary() .unwrap() .execute { async -> - copy(asyncRoomSummary = async) + val typingRoomMembers = + typingHelper.toTypingRoomMembers(async.invoke()?.typingRoomMemberIds, room) + + copy( + asyncRoomSummary = async, + typingRoomMembers = typingRoomMembers, + typingMessage = typingHelper.toTypingMessage(typingRoomMembers) + ) } } @@ -882,6 +895,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro override fun onCleared() { timeline.dispose() timeline.removeAllListeners() + if (vectorPreferences.sendTypingNotifs()) { + room.userStopsTyping() + } super.onCleared() } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index 165ef7b625..43a454d32e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -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 = Uninitialized, val asyncRoomSummary: Async = Uninitialized, + val typingRoomMembers: List? = null, + val typingMessage: String? = null, val sendMode: SendMode = SendMode.REGULAR(""), val tombstoneEvent: Event? = null, val tombstoneEventHandling: Async = Uninitialized, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt index 23a0fd60a2..15e7a63b7b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItem.kt @@ -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() { @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() { 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() { val unreadCounterBadgeView by bind(R.id.roomUnreadCounterBadgeView) val unreadIndentIndicator by bind(R.id.roomUnreadIndicator) val lastEventView by bind(R.id.roomLastEventView) + val typingView by bind(R.id.roomTypingView) val draftView by bind(R.id.roomDraftBadge) val lastEventTimeView by bind(R.id.roomLastEventTimeView) val avatarImageView by bind(R.id.roomAvatarImageView) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt index 84a5f942e8..dde4623334 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/list/RoomSummaryItemFactory.kt @@ -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,21 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte dateFormatter.formatMessageDay(date) } } + + val typingString = if (roomSummary.typingRoomMemberIds.isEmpty()) { + null + } else { + // TODO Check how costly it is to create a Room here + val typingRoomMembers = typingHelper.toTypingRoomMembers(roomSummary.typingRoomMemberIds, session.getRoom(roomSummary.roomId)) + typingHelper.toTypingMessage(typingRoomMembers) + } + return RoomSummaryItem_() .id(roomSummary.roomId) .avatarRenderer(avatarRenderer) .matrixItem(roomSummary.toMatrixItem()) .lastEventTime(latestEventTime) + .typingString(typingString) .lastFormattedEvent(latestFormattedEvent) .showHighlighted(showHighlighted) .unreadNotificationCount(unreadCount) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/typing/TypingHelper.kt b/vector/src/main/java/im/vector/riotx/features/home/room/typing/TypingHelper.kt new file mode 100644 index 0000000000..b6378657b1 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/typing/TypingHelper.kt @@ -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? + ): List? { + return typingUserIds + ?.filter { it != session.myUserId } + } + + /** + * Convert a list of userId to a list of maximum 3 UserItems + */ + fun toTypingRoomMembers( + typingUserIds: List?, + membershipService: MembershipService? + ): List? { + 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?): String? { + return when { + typingUserItems.isNullOrEmpty() -> + 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()) + } + } +} diff --git a/vector/src/main/res/drawable/bg_breadcrumbs_typing.xml b/vector/src/main/res/drawable/bg_breadcrumbs_typing.xml new file mode 100644 index 0000000000..47a095c8ba --- /dev/null +++ b/vector/src/main/res/drawable/bg_breadcrumbs_typing.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_breadcrumbs.xml b/vector/src/main/res/layout/item_breadcrumbs.xml index a7a312f16b..8e59799843 100644 --- a/vector/src/main/res/layout/item_breadcrumbs.xml +++ b/vector/src/main/res/layout/item_breadcrumbs.xml @@ -53,6 +53,23 @@ tools:text="24" tools:visibility="visible" /> + + diff --git a/vector/src/main/res/layout/item_room.xml b/vector/src/main/res/layout/item_room.xml index 741bd47069..3c9e40fae5 100644 --- a/vector/src/main/res/layout/item_room.xml +++ b/vector/src/main/res/layout/item_room.xml @@ -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" /> + + + android:title="@string/settings_send_typing_notifs" />