Fixes #2147 - Scroll the timeline to the bottom when sending message as well as media, polls and locations.

This commit is contained in:
Stefan Ceriu
2023-12-04 15:55:28 +02:00
committed by Stefan Ceriu
parent 6905ac5f70
commit 8189b0a471
9 changed files with 92 additions and 31 deletions
@@ -1939,11 +1939,11 @@ class RoomProxyMock: RoomProxyProtocol {
set(value) { underlyingActiveMembersCount = value } set(value) { underlyingActiveMembersCount = value }
} }
var underlyingActiveMembersCount: Int! var underlyingActiveMembersCount: Int!
var stateUpdatesPublisher: AnyPublisher<Void, Never> { var actions: AnyPublisher<RoomProxyAction, Never> {
get { return underlyingStateUpdatesPublisher } get { return underlyingActions }
set(value) { underlyingStateUpdatesPublisher = value } set(value) { underlyingActions = value }
} }
var underlyingStateUpdatesPublisher: AnyPublisher<Void, Never>! var underlyingActions: AnyPublisher<RoomProxyAction, Never>!
var timeline: TimelineProxyProtocol { var timeline: TimelineProxyProtocol {
get { return underlyingTimeline } get { return underlyingTimeline }
set(value) { underlyingTimeline = value } set(value) { underlyingTimeline = value }
@@ -2578,6 +2578,11 @@ class SessionVerificationControllerProxyMock: SessionVerificationControllerProxy
} }
} }
class TimelineProxyMock: TimelineProxyProtocol { class TimelineProxyMock: TimelineProxyProtocol {
var actions: AnyPublisher<TimelineProxyAction, Never> {
get { return underlyingActions }
set(value) { underlyingActions = value }
}
var underlyingActions: AnyPublisher<TimelineProxyAction, Never>!
var timelineProvider: RoomTimelineProviderProtocol { var timelineProvider: RoomTimelineProviderProtocol {
get { return underlyingTimelineProvider } get { return underlyingTimelineProvider }
set(value) { underlyingTimelineProvider = value } set(value) { underlyingTimelineProvider = value }
+11 -2
View File
@@ -32,11 +32,18 @@ struct RoomProxyMockConfiguration {
var canonicalAlias: String? var canonicalAlias: String?
var alternativeAliases: [String] = [] var alternativeAliases: [String] = []
var hasUnreadNotifications = Bool.random() var hasUnreadNotifications = Bool.random()
var canUserTriggerRoomNotification = false
var timeline = {
let mock = TimelineProxyMock()
mock.underlyingActions = Empty(completeImmediately: false).eraseToAnyPublisher()
return mock
}()
var members: [RoomMemberProxyProtocol]? var members: [RoomMemberProxyProtocol]?
var inviter: RoomMemberProxyMock? var inviter: RoomMemberProxyMock?
var memberForID: RoomMemberProxyMock = .mockMe var memberForID: RoomMemberProxyMock = .mockMe
var ownUserID = "@alice:somewhere.org" var ownUserID = "@alice:somewhere.org"
var canUserTriggerRoomNotification = false
var invitedMembersCount = 100 var invitedMembersCount = 100
var joinedMembersCount = 50 var joinedMembersCount = 50
@@ -62,6 +69,8 @@ extension RoomProxyMock {
alternativeAliases = configuration.alternativeAliases alternativeAliases = configuration.alternativeAliases
hasUnreadNotifications = configuration.hasUnreadNotifications hasUnreadNotifications = configuration.hasUnreadNotifications
timeline = configuration.timeline
invitedMembersCount = configuration.invitedMembersCount invitedMembersCount = configuration.invitedMembersCount
joinedMembersCount = configuration.joinedMembersCount joinedMembersCount = configuration.joinedMembersCount
activeMembersCount = configuration.activeMembersCount activeMembersCount = configuration.activeMembersCount
@@ -79,7 +88,7 @@ extension RoomProxyMock {
updateMembersClosure = { } updateMembersClosure = { }
acceptInvitationClosure = { .success(()) } acceptInvitationClosure = { .success(()) }
underlyingStateUpdatesPublisher = Empty(completeImmediately: false).eraseToAnyPublisher() underlyingActions = Empty(completeImmediately: false).eraseToAnyPublisher()
setNameClosure = { _ in .success(()) } setNameClosure = { _ in .success(()) }
setTopicClosure = { _ in .success(()) } setTopicClosure = { _ in .success(()) }
getMemberUserIDReturnValue = .success(configuration.memberForID) getMemberUserIDReturnValue = .success(configuration.memberForID)
@@ -120,7 +120,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
// MARK: - Private // MARK: - Private
private func setupRoomSubscription() { private func setupRoomSubscription() {
roomProxy.stateUpdatesPublisher roomProxy.actions
.filter { $0 == .stateUpdate }
.throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true) .throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in .sink { [weak self] _ in
guard let self else { return } guard let self else { return }
@@ -279,7 +279,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
.store(in: &cancellables) .store(in: &cancellables)
roomProxy roomProxy
.stateUpdatesPublisher .actions
.filter { $0 == .stateUpdate }
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in .sink { [weak self] _ in
guard let self else { return } guard let self else { return }
@@ -289,6 +290,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
.store(in: &cancellables) .store(in: &cancellables)
roomProxy.timeline.actions
.filter { $0 == .sentMessage }
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.state.timelineViewState.scrollToBottomPublisher.send(())
}
.store(in: &cancellables)
appSettings.$timelineStyle appSettings.$timelineStyle
.weakAssign(to: \.state.timelineStyle, on: self) .weakAssign(to: \.state.timelineStyle, on: self)
.store(in: &cancellables) .store(in: &cancellables)
@@ -468,8 +477,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .recordVoiceMessage, .previewVoiceMessage: case .recordVoiceMessage, .previewVoiceMessage:
fatalError("invalid composer mode.") fatalError("invalid composer mode.")
} }
state.timelineViewState.scrollToBottomPublisher.send(())
} }
private func trackComposerMode(_ mode: RoomScreenComposerMode) { private func trackComposerMode(_ mode: RoomScreenComposerMode) {
@@ -42,9 +42,9 @@ class RoomProxy: RoomProxyProtocol {
membersSubject.asCurrentValuePublisher() membersSubject.asCurrentValuePublisher()
} }
private let stateUpdatesSubject = PassthroughSubject<Void, Never>() private let actionsSubject = PassthroughSubject<RoomProxyAction, Never>()
var stateUpdatesPublisher: AnyPublisher<Void, Never> { var actions: AnyPublisher<RoomProxyAction, Never> {
stateUpdatesSubject.eraseToAnyPublisher() actionsSubject.eraseToAnyPublisher()
} }
var ownUserID: String { var ownUserID: String {
@@ -407,7 +407,7 @@ class RoomProxy: RoomProxyProtocol {
private func subscribeToRoomStateUpdates() { private func subscribeToRoomStateUpdates() {
roomInfoObservationToken = room.subscribeToRoomInfoUpdates(listener: RoomInfoUpdateListener { [weak self] in roomInfoObservationToken = room.subscribeToRoomInfoUpdates(listener: RoomInfoUpdateListener { [weak self] in
MXLog.info("Received room info update") MXLog.info("Received room info update")
self?.stateUpdatesSubject.send(()) self?.actionsSubject.send(.stateUpdate)
}) })
} }
} }
@@ -35,6 +35,10 @@ enum RoomProxyError: Error, Equatable {
case failedCheckingPermission case failedCheckingPermission
} }
enum RoomProxyAction {
case stateUpdate
}
// sourcery: AutoMockable // sourcery: AutoMockable
protocol RoomProxyProtocol { protocol RoomProxyProtocol {
var id: String { get } var id: String { get }
@@ -65,9 +69,7 @@ protocol RoomProxyProtocol {
var activeMembersCount: Int { get } var activeMembersCount: Int { get }
/// Publishes room state updates var actions: AnyPublisher<RoomProxyAction, Never> { get }
/// The thread on which this publisher sends the output isn't defined.
var stateUpdatesPublisher: AnyPublisher<Void, Never> { get }
var timeline: TimelineProxyProtocol { get } var timeline: TimelineProxyProtocol { get }
@@ -28,11 +28,6 @@ final class TimelineProxy: TimelineProxyProtocol {
private let messageSendingDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy.message_sending", qos: .userInitiated) private let messageSendingDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy.message_sending", qos: .userInitiated)
private let userInitiatedDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy.user_initiated", qos: .userInitiated) private let userInitiatedDispatchQueue = DispatchQueue(label: "io.element.elementx.roomproxy.user_initiated", qos: .userInitiated)
init(timeline: Timeline, backgroundTaskService: BackgroundTaskServiceProtocol) {
self.timeline = timeline
self.backgroundTaskService = backgroundTaskService
}
private var backPaginationStateObservationToken: TaskHandle? private var backPaginationStateObservationToken: TaskHandle?
private var roomTimelineObservationToken: TaskHandle? private var roomTimelineObservationToken: TaskHandle?
private var timelineListener: RoomTimelineListener? private var timelineListener: RoomTimelineListener?
@@ -40,6 +35,11 @@ final class TimelineProxy: TimelineProxyProtocol {
private let backPaginationStateSubject = PassthroughSubject<BackPaginationStatus, Never>() private let backPaginationStateSubject = PassthroughSubject<BackPaginationStatus, Never>()
private let timelineUpdatesSubject = PassthroughSubject<[TimelineDiff], Never>() private let timelineUpdatesSubject = PassthroughSubject<[TimelineDiff], Never>()
private let actionsSubject = PassthroughSubject<TimelineProxyAction, Never>()
var actions: AnyPublisher<TimelineProxyAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var innerTimelineProvider: RoomTimelineProviderProtocol! private var innerTimelineProvider: RoomTimelineProviderProtocol!
var timelineProvider: RoomTimelineProviderProtocol { var timelineProvider: RoomTimelineProviderProtocol {
innerTimelineProvider innerTimelineProvider
@@ -54,6 +54,11 @@ final class TimelineProxy: TimelineProxyProtocol {
roomTimelineObservationToken?.cancel() roomTimelineObservationToken?.cancel()
} }
init(timeline: Timeline, backgroundTaskService: BackgroundTaskServiceProtocol) {
self.timeline = timeline
self.backgroundTaskService = backgroundTaskService
}
func subscribeForUpdates() async { func subscribeForUpdates() async {
guard innerTimelineProvider == nil else { guard innerTimelineProvider == nil else {
MXLog.warning("Timeline already subscribed for updates") MXLog.warning("Timeline already subscribed for updates")
@@ -104,6 +109,7 @@ final class TimelineProxy: TimelineProxyProtocol {
do { do {
let originalEvent = try self.timeline.getEventTimelineItemByEventId(eventId: eventID) let originalEvent = try self.timeline.getEventTimelineItemByEventId(eventId: eventID)
try self.timeline.edit(newContent: messageContent, editItem: originalEvent) try self.timeline.edit(newContent: messageContent, editItem: originalEvent)
return .success(()) return .success(())
} catch { } catch {
return .failure(.failedEditingMessage) return .failure(.failedEditingMessage)
@@ -178,6 +184,8 @@ final class TimelineProxy: TimelineProxyProtocol {
return .failure(.failedSendingMedia) return .failure(.failedSendingMedia)
} }
actionsSubject.send(.sentMessage)
return .success(()) return .success(())
} }
@@ -202,6 +210,8 @@ final class TimelineProxy: TimelineProxyProtocol {
return .failure(.failedSendingMedia) return .failure(.failedSendingMedia)
} }
actionsSubject.send(.sentMessage)
return .success(()) return .success(())
} }
@@ -227,6 +237,8 @@ final class TimelineProxy: TimelineProxyProtocol {
return .failure(.failedSendingMedia) return .failure(.failedSendingMedia)
} }
actionsSubject.send(.sentMessage)
return .success(()) return .success(())
} }
@@ -241,11 +253,15 @@ final class TimelineProxy: TimelineProxyProtocol {
} }
return await Task.dispatch(on: messageSendingDispatchQueue) { return await Task.dispatch(on: messageSendingDispatchQueue) {
.success(self.timeline.sendLocation(body: body, self.timeline.sendLocation(body: body,
geoUri: geoURI.string, geoUri: geoURI.string,
description: description, description: description,
zoomLevel: zoomLevel, zoomLevel: zoomLevel,
assetType: assetType)) assetType: assetType)
self.actionsSubject.send(.sentMessage)
return .success(())
} }
} }
@@ -271,6 +287,8 @@ final class TimelineProxy: TimelineProxyProtocol {
return .failure(.failedSendingMedia) return .failure(.failedSendingMedia)
} }
actionsSubject.send(.sentMessage)
return .success(()) return .success(())
} }
@@ -296,6 +314,8 @@ final class TimelineProxy: TimelineProxyProtocol {
return .failure(.failedSendingMedia) return .failure(.failedSendingMedia)
} }
actionsSubject.send(.sentMessage)
return .success(()) return .success(())
} }
@@ -323,6 +343,9 @@ final class TimelineProxy: TimelineProxyProtocol {
} catch { } catch {
return .failure(.failedSendingMessage) return .failure(.failedSendingMessage)
} }
self.actionsSubject.send(.sentMessage)
return .success(()) return .success(())
} }
} }
@@ -335,6 +358,9 @@ final class TimelineProxy: TimelineProxyProtocol {
return await Task.dispatch(on: messageSendingDispatchQueue) { return await Task.dispatch(on: messageSendingDispatchQueue) {
self.timeline.send(msg: messageContent) self.timeline.send(msg: messageContent)
self.actionsSubject.send(.sentMessage)
return .success(()) return .success(())
} }
} }
@@ -376,7 +402,11 @@ final class TimelineProxy: TimelineProxyProtocol {
func createPoll(question: String, answers: [String], pollKind: Poll.Kind) async -> Result<Void, TimelineProxyError> { func createPoll(question: String, answers: [String], pollKind: Poll.Kind) async -> Result<Void, TimelineProxyError> {
await Task.dispatch(on: .global()) { await Task.dispatch(on: .global()) {
do { do {
return try .success(self.timeline.createPoll(question: question, answers: answers, maxSelections: 1, pollKind: .init(pollKind: pollKind))) try self.timeline.createPoll(question: question, answers: answers, maxSelections: 1, pollKind: .init(pollKind: pollKind))
self.actionsSubject.send(.sentMessage)
return .success(())
} catch { } catch {
MXLog.error("Failed creating a poll: \(error)") MXLog.error("Failed creating a poll: \(error)")
return .failure(.failedCreatingPoll) return .failure(.failedCreatingPoll)
@@ -33,9 +33,16 @@ enum TimelineProxyError: Error, Equatable {
case failedSendingPollResponse case failedSendingPollResponse
} }
enum TimelineProxyAction {
case sentMessage
}
// sourcery: AutoMockable // sourcery: AutoMockable
protocol TimelineProxyProtocol { protocol TimelineProxyProtocol {
var actions: AnyPublisher<TimelineProxyAction, Never> { get }
var timelineProvider: RoomTimelineProviderProtocol { get } var timelineProvider: RoomTimelineProviderProtocol { get }
func subscribeForUpdates() async func subscribeForUpdates() async
/// Cancels a failed message given its transaction ID from the timeline /// Cancels a failed message given its transaction ID from the timeline
@@ -66,7 +66,7 @@ final class CompletionSuggestionServiceTests: XCTestCase {
func testUserSuggestonsIncludingAllUsers() async throws { func testUserSuggestonsIncludingAllUsers() async throws {
let alice: RoomMemberProxyMock = .mockAlice let alice: RoomMemberProxyMock = .mockAlice
let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe] let members: [RoomMemberProxyMock] = [alice, .mockBob, .mockCharlie, .mockMe]
let roomProxyMock = RoomProxyMock(with: .init(displayName: "test", members: members, canUserTriggerRoomNotification: true)) let roomProxyMock = RoomProxyMock(with: .init(displayName: "test", canUserTriggerRoomNotification: true, members: members))
let service = CompletionSuggestionService(roomProxy: roomProxyMock) let service = CompletionSuggestionService(roomProxy: roomProxyMock)
var deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in var deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in
@@ -92,7 +92,7 @@ final class CompletionSuggestionServiceTests: XCTestCase {
let alice: RoomMemberProxyMock = .mockAlice let alice: RoomMemberProxyMock = .mockAlice
let bob: RoomMemberProxyMock = .mockBob let bob: RoomMemberProxyMock = .mockBob
let members: [RoomMemberProxyMock] = [alice, bob, .mockMe] let members: [RoomMemberProxyMock] = [alice, bob, .mockMe]
let roomProxyMock = RoomProxyMock(with: .init(displayName: "test", members: members, canUserTriggerRoomNotification: true)) let roomProxyMock = RoomProxyMock(with: .init(displayName: "test", canUserTriggerRoomNotification: true, members: members))
let service = CompletionSuggestionService(roomProxy: roomProxyMock) let service = CompletionSuggestionService(roomProxy: roomProxyMock)
var deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in var deferred = deferFulfillment(service.suggestionsPublisher) { suggestions in