mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-07 04:48:34 +00:00
Send read receipts as messages are displayed. (#1426)
This commit is contained in:
@@ -91,10 +91,6 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
|
||||||
viewModel.context.send(viewAction: .markRoomAsRead)
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPresentable() -> AnyView {
|
func toPresentable() -> AnyView {
|
||||||
AnyView(RoomScreen(context: viewModel.context))
|
AnyView(RoomScreen(context: viewModel.context))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ enum RoomScreenViewAction {
|
|||||||
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
|
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
|
||||||
case cancelReply
|
case cancelReply
|
||||||
case cancelEdit
|
case cancelEdit
|
||||||
/// Mark the entire room as read - this is heavy handed as a starting point for now.
|
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
|
||||||
case markRoomAsRead
|
|
||||||
case paginateBackwards
|
case paginateBackwards
|
||||||
|
|
||||||
case timelineItemMenu(itemID: TimelineItemIdentifier)
|
case timelineItemMenu(itemID: TimelineItemIdentifier)
|
||||||
@@ -85,7 +84,7 @@ enum RoomScreenViewAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct RoomScreenViewState: BindableState {
|
struct RoomScreenViewState: BindableState {
|
||||||
var roomId: String
|
var roomID: String
|
||||||
var roomTitle = ""
|
var roomTitle = ""
|
||||||
var roomAvatarURL: URL?
|
var roomAvatarURL: URL?
|
||||||
var members: [String: RoomMemberState] = [:]
|
var members: [String: RoomMemberState] = [:]
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
self.userIndicatorController = userIndicatorController
|
self.userIndicatorController = userIndicatorController
|
||||||
self.notificationCenterProtocol = notificationCenterProtocol
|
self.notificationCenterProtocol = notificationCenterProtocol
|
||||||
|
|
||||||
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID,
|
super.init(initialViewState: RoomScreenViewState(roomID: timelineController.roomID,
|
||||||
roomTitle: roomProxy.roomTitle,
|
roomTitle: roomProxy.roomTitle,
|
||||||
roomAvatarURL: roomProxy.avatarURL,
|
roomAvatarURL: roomProxy.avatarURL,
|
||||||
timelineStyle: appSettings.timelineStyle,
|
timelineStyle: appSettings.timelineStyle,
|
||||||
@@ -103,8 +103,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
case .cancelEdit:
|
case .cancelEdit:
|
||||||
setComposerMode(.default)
|
setComposerMode(.default)
|
||||||
state.bindings.composerText = ""
|
state.bindings.composerText = ""
|
||||||
case .markRoomAsRead:
|
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
|
||||||
Task { await markRoomAsRead() }
|
Task { await sendReadReceiptIfNeeded(for: lastVisibleItemID) }
|
||||||
case .timelineItemMenu(let itemID):
|
case .timelineItemMenu(let itemID):
|
||||||
Task {
|
Task {
|
||||||
if case let .success(value) = await roomProxy.canUserRedact(userID: roomProxy.ownUserID) {
|
if case let .success(value) = await roomProxy.canUserRedact(userID: roomProxy.ownUserID) {
|
||||||
@@ -245,9 +245,46 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func markRoomAsRead() async {
|
/// The ID of the newest item in the room that the user has seen.
|
||||||
notificationCenterProtocol.post(name: .roomMarkedAsRead, object: roomProxy.id)
|
/// This includes both event based items and virtual items.
|
||||||
_ = await timelineController.markRoomAsRead()
|
private var lastReadItemID: TimelineItemIdentifier?
|
||||||
|
private func sendReadReceiptIfNeeded(for lastVisibleItemID: TimelineItemIdentifier) async -> Result<Void, RoomTimelineControllerError> {
|
||||||
|
guard lastReadItemID != lastVisibleItemID,
|
||||||
|
let eventItemID = eventBasedItem(nearest: lastVisibleItemID)
|
||||||
|
else { return .success(()) }
|
||||||
|
|
||||||
|
// Make sure the item is newer than the item that was last marked as read.
|
||||||
|
if let lastReadItemIndex = state.timelineViewState.timelineIDs.firstIndex(of: lastReadItemID?.timelineID ?? ""),
|
||||||
|
let lastVisibleItemIndex = state.timelineViewState.timelineIDs.firstIndex(of: eventItemID.timelineID),
|
||||||
|
lastReadItemIndex > lastVisibleItemIndex {
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the last read item ID to avoid attempting duplicate requests.
|
||||||
|
lastReadItemID = lastVisibleItemID
|
||||||
|
|
||||||
|
// Clear any notifications from notification center.
|
||||||
|
if lastVisibleItemID.timelineID == state.timelineViewState.timelineIDs.last {
|
||||||
|
notificationCenterProtocol.post(name: .roomMarkedAsRead, object: roomProxy.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch await timelineController.sendReadReceipt(for: eventItemID) {
|
||||||
|
case .success:
|
||||||
|
return .success(())
|
||||||
|
case .failure:
|
||||||
|
return .failure(.generic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first item ID that contains an `eventID` starting from the supplied ID, working backwards through the timeline.
|
||||||
|
private func eventBasedItem(nearest itemID: TimelineItemIdentifier) -> TimelineItemIdentifier? {
|
||||||
|
guard itemID.eventID == nil else { return itemID }
|
||||||
|
|
||||||
|
let timelineIDs = state.timelineViewState.itemViewStates.map(\.identifier)
|
||||||
|
guard let index = timelineIDs.firstIndex(of: itemID) else { return nil }
|
||||||
|
|
||||||
|
let nearestItemID = timelineIDs[..<index].last(where: { $0.eventID != nil })
|
||||||
|
return nearestItemID
|
||||||
}
|
}
|
||||||
|
|
||||||
private func itemTapped(with itemID: TimelineItemIdentifier) async {
|
private func itemTapped(with itemID: TimelineItemIdentifier) async {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ struct RoomHeaderView: View {
|
|||||||
@ViewBuilder private var roomAvatar: some View {
|
@ViewBuilder private var roomAvatar: some View {
|
||||||
LoadableAvatarImage(url: context.viewState.roomAvatarURL,
|
LoadableAvatarImage(url: context.viewState.roomAvatarURL,
|
||||||
name: context.viewState.roomTitle,
|
name: context.viewState.roomTitle,
|
||||||
contentID: context.viewState.roomId,
|
contentID: context.viewState.roomID,
|
||||||
avatarSize: .room(on: .timeline),
|
avatarSize: .room(on: .timeline),
|
||||||
imageProvider: context.imageProvider)
|
imageProvider: context.imageProvider)
|
||||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.avatar)
|
.accessibilityIdentifier(A11yIdentifiers.roomScreen.avatar)
|
||||||
|
|||||||
@@ -56,13 +56,6 @@ struct RoomScreen: View {
|
|||||||
}
|
}
|
||||||
.interactiveQuickLook(item: $context.mediaPreviewItem)
|
.interactiveQuickLook(item: $context.mediaPreviewItem)
|
||||||
.track(screen: .room)
|
.track(screen: .room)
|
||||||
.task(id: context.viewState.roomId) {
|
|
||||||
// Give a couple of seconds for items to load and to see them.
|
|
||||||
try? await Task.sleep(for: .seconds(2))
|
|
||||||
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
context.send(viewAction: .markRoomAsRead)
|
|
||||||
}
|
|
||||||
.onDrop(of: ["public.item"], isTargeted: $dragOver) { providers -> Bool in
|
.onDrop(of: ["public.item"], isTargeted: $dragOver) { providers -> Bool in
|
||||||
guard let provider = providers.first,
|
guard let provider = providers.first,
|
||||||
provider.isSupportedForPasteOrDrop else {
|
provider.isSupportedForPasteOrDrop else {
|
||||||
@@ -89,7 +82,7 @@ struct RoomScreen: View {
|
|||||||
|
|
||||||
private var timeline: some View {
|
private var timeline: some View {
|
||||||
timelineSwitch
|
timelineSwitch
|
||||||
.id(context.viewState.roomId)
|
.id(context.viewState.roomID)
|
||||||
.environmentObject(context)
|
.environmentObject(context)
|
||||||
.environment(\.timelineStyle, context.viewState.timelineStyle)
|
.environment(\.timelineStyle, context.viewState.timelineStyle)
|
||||||
.environment(\.readReceiptsEnabled, context.viewState.readReceiptsEnabled)
|
.environment(\.readReceiptsEnabled, context.viewState.readReceiptsEnabled)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class TimelineTableViewController: UIViewController {
|
|||||||
if timelineItemsDictionary.isEmpty {
|
if timelineItemsDictionary.isEmpty {
|
||||||
paginateBackwardsPublisher.send()
|
paginateBackwardsPublisher.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendReadReceiptIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +243,15 @@ class TimelineTableViewController: UIViewController {
|
|||||||
|
|
||||||
coordinator.send(viewAction: .paginateBackwards)
|
coordinator.send(viewAction: .paginateBackwards)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func sendReadReceiptIfNeeded() {
|
||||||
|
guard let lastVisibleItemIndexPath = tableView.indexPathsForVisibleRows?.first,
|
||||||
|
let lastVisibleItemTimelineID = dataSource?.itemIdentifier(for: lastVisibleItemIndexPath),
|
||||||
|
let lastVisibleItemID = timelineItemsDictionary[lastVisibleItemTimelineID]?.identifier
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
coordinator.send(viewAction: .sendReadReceiptIfNeeded(lastVisibleItemID))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UITableViewDelegate
|
// MARK: - UITableViewDelegate
|
||||||
@@ -271,6 +282,18 @@ extension TimelineTableViewController: UITableViewDelegate {
|
|||||||
scrollToTop(animated: true)
|
scrollToTop(animated: true)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||||
|
sendReadReceiptIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
sendReadReceiptIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||||
|
sendReadReceiptIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layout Types
|
// MARK: - Layout Types
|
||||||
|
|||||||
+11
-2
@@ -26,7 +26,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
/// An array of timeline items that will be appended in order when ``simulateIncomingItems()`` is called.
|
/// An array of timeline items that will be appended in order when ``simulateIncomingItems()`` is called.
|
||||||
var incomingItems: [RoomTimelineItemProtocol] = []
|
var incomingItems: [RoomTimelineItemProtocol] = []
|
||||||
|
|
||||||
let roomID = "MockRoomIdentifier"
|
var roomProxy: RoomProxyProtocol?
|
||||||
|
var roomID: String { roomProxy?.id ?? "MockRoomIdentifier" }
|
||||||
|
|
||||||
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
||||||
|
|
||||||
@@ -49,7 +50,15 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
return .success(())
|
return .success(())
|
||||||
}
|
}
|
||||||
|
|
||||||
func markRoomAsRead() async -> Result<Void, RoomTimelineControllerError> { .success(()) }
|
func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result<Void, RoomTimelineControllerError> {
|
||||||
|
guard let roomProxy, let eventID = itemID.eventID else { return .failure(.generic) }
|
||||||
|
switch await roomProxy.sendReadReceipt(for: eventID) {
|
||||||
|
case .success:
|
||||||
|
return .success(())
|
||||||
|
case .failure:
|
||||||
|
return .failure(.generic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func processItemAppearance(_ itemID: TimelineItemIdentifier) async { }
|
func processItemAppearance(_ itemID: TimelineItemIdentifier) async { }
|
||||||
|
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func markRoomAsRead() async -> Result<Void, RoomTimelineControllerError> {
|
func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result<Void, RoomTimelineControllerError> {
|
||||||
guard roomProxy.hasUnreadNotifications,
|
guard roomProxy.hasUnreadNotifications,
|
||||||
let eventID = timelineItems.last?.id.eventID
|
let eventID = itemID.eventID
|
||||||
else { return .success(()) }
|
else { return .success(()) }
|
||||||
|
|
||||||
switch await roomProxy.sendReadReceipt(for: eventID) {
|
switch await roomProxy.sendReadReceipt(for: eventID) {
|
||||||
|
|||||||
+1
-1
@@ -49,7 +49,7 @@ protocol RoomTimelineControllerProtocol {
|
|||||||
|
|
||||||
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomTimelineControllerError>
|
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomTimelineControllerError>
|
||||||
|
|
||||||
func markRoomAsRead() async -> Result<Void, RoomTimelineControllerError>
|
func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result<Void, RoomTimelineControllerError>
|
||||||
|
|
||||||
func sendMessage(_ message: String, inReplyTo itemID: TimelineItemIdentifier?) async
|
func sendMessage(_ message: String, inReplyTo itemID: TimelineItemIdentifier?) async
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
override func tearDown() async throws {
|
override func tearDown() async throws {
|
||||||
userIndicatorControllerMock = nil
|
userIndicatorControllerMock = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Grouping
|
||||||
|
|
||||||
func testMessageGrouping() {
|
func testMessageGrouping() {
|
||||||
// Given 3 messages from Bob.
|
// Given 3 messages from Bob.
|
||||||
@@ -175,6 +177,8 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[1].groupStyle, .middle, "Nothing should prevent the second message from being grouped.")
|
||||||
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.")
|
XCTAssertEqual(viewModel.state.timelineViewState.itemViewStates[2].groupStyle, .last, "Reactions on the last message should not prevent it from being grouped.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - User Details
|
||||||
|
|
||||||
func testGoToUserDetailsSuccessNoDelay() async {
|
func testGoToUserDetailsSuccessNoDelay() async {
|
||||||
// Setup
|
// Setup
|
||||||
@@ -277,6 +281,8 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1)
|
XCTAssert(roomProxyMock.getMemberUserIDCallsCount == 1)
|
||||||
XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob")
|
XCTAssertEqual(roomProxyMock.getMemberUserIDReceivedUserID, "bob")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sending
|
||||||
|
|
||||||
func testRetrySend() async throws {
|
func testRetrySend() async throws {
|
||||||
// Setup
|
// Setup
|
||||||
@@ -352,26 +358,139 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
await Task.yield()
|
await Task.yield()
|
||||||
XCTAssert(roomProxyMock.cancelSendTransactionIDCallsCount == 0)
|
XCTAssert(roomProxyMock.cancelSendTransactionIDCallsCount == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMarkAsRead() async throws {
|
// MARK: - Read Receipts
|
||||||
// Setup
|
|
||||||
let notificationCenterMock = NotificationCenterMock()
|
// swiftlint:disable force_unwrapping
|
||||||
|
|
||||||
|
func testSendReadReceipt() async throws {
|
||||||
|
// Given a room with only text items in the timeline
|
||||||
|
let items = [TextRoomTimelineItem(eventID: "t1"),
|
||||||
|
TextRoomTimelineItem(eventID: "t2"),
|
||||||
|
TextRoomTimelineItem(eventID: "t3")]
|
||||||
|
let (viewModel, roomProxy, _, notificationCenter) = readReceiptsConfiguration(with: items)
|
||||||
|
|
||||||
|
// When sending a read receipt for the last item.
|
||||||
|
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id))
|
||||||
|
try await Task.sleep(for: .microseconds(100))
|
||||||
|
|
||||||
|
// Then the receipt should be sent.
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForCalled, true)
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t3")
|
||||||
|
|
||||||
|
// And the notifications should be cleared.
|
||||||
|
XCTAssertEqual(notificationCenter.postNameObjectReceivedArguments?.aName, .roomMarkedAsRead)
|
||||||
|
let roomID = notificationCenter.postNameObjectReceivedArguments?.anObject as? String
|
||||||
|
XCTAssertEqual(roomID, roomProxy.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSendMoreReadReceipts() async throws {
|
||||||
|
// Given a room with only text items in the timeline that are all read.
|
||||||
|
let items = [TextRoomTimelineItem(eventID: "t1"),
|
||||||
|
TextRoomTimelineItem(eventID: "t2"),
|
||||||
|
TextRoomTimelineItem(eventID: "t3")]
|
||||||
|
let (viewModel, roomProxy, timelineController, _) = readReceiptsConfiguration(with: items)
|
||||||
|
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id))
|
||||||
|
try await Task.sleep(for: .microseconds(100))
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 1)
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t3")
|
||||||
|
|
||||||
|
// When sending a receipt for the first item in the timeline.
|
||||||
|
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.first!.id))
|
||||||
|
try await Task.sleep(for: .microseconds(100))
|
||||||
|
|
||||||
|
// Then the request should be ignored.
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 1)
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t3")
|
||||||
|
|
||||||
|
// When a new message is received and marked as read.
|
||||||
|
let newMessage = TextRoomTimelineItem(eventID: "t4")
|
||||||
|
timelineController.timelineItems.append(newMessage)
|
||||||
|
timelineController.callbacks.send(.updatedTimelineItems)
|
||||||
|
try await Task.sleep(for: .microseconds(500))
|
||||||
|
|
||||||
|
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(newMessage.id))
|
||||||
|
try await Task.sleep(for: .microseconds(100))
|
||||||
|
|
||||||
|
// Then the request should be made.
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 2)
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t4")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSendReadReceiptWithoutEvents() async throws {
|
||||||
|
// Given a room with only virtual items.
|
||||||
|
let items = [SeparatorRoomTimelineItem(timelineID: "v1"),
|
||||||
|
SeparatorRoomTimelineItem(timelineID: "v2"),
|
||||||
|
SeparatorRoomTimelineItem(timelineID: "v3")]
|
||||||
|
let (viewModel, roomProxy, _, _) = readReceiptsConfiguration(with: items)
|
||||||
|
|
||||||
|
// When sending a read receipt for the last item.
|
||||||
|
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id))
|
||||||
|
try await Task.sleep(for: .microseconds(100))
|
||||||
|
|
||||||
|
// Then nothing should be sent.
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForCalled, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSendReadReceiptVirtualLast() async throws {
|
||||||
|
// Given a room where the last event is a virtual item.
|
||||||
|
let items: [RoomTimelineItemProtocol] = [TextRoomTimelineItem(eventID: "t1"),
|
||||||
|
TextRoomTimelineItem(eventID: "t2"),
|
||||||
|
SeparatorRoomTimelineItem(timelineID: "v3")]
|
||||||
|
let (viewModel, roomProxy, _, _) = readReceiptsConfiguration(with: items)
|
||||||
|
|
||||||
|
// When sending a read receipt for the last item.
|
||||||
|
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id))
|
||||||
|
try await Task.sleep(for: .microseconds(100))
|
||||||
|
|
||||||
|
// Then a read receipt should be sent for the item before it.
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForCalled, true)
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSendReadReceiptMultipleRequests() async throws {
|
||||||
|
// Given a room where the last event is a virtual item which was already read.
|
||||||
|
let items: [RoomTimelineItemProtocol] = [TextRoomTimelineItem(eventID: "t1"),
|
||||||
|
TextRoomTimelineItem(eventID: "t2"),
|
||||||
|
SeparatorRoomTimelineItem(timelineID: "v3")]
|
||||||
|
let (viewModel, roomProxy, _, _) = readReceiptsConfiguration(with: items)
|
||||||
|
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id))
|
||||||
|
try await Task.sleep(for: .microseconds(100))
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 1)
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForReceivedEventID, "t2")
|
||||||
|
|
||||||
|
// When sending the same receipt again
|
||||||
|
viewModel.context.send(viewAction: .sendReadReceiptIfNeeded(items.last!.id))
|
||||||
|
try await Task.sleep(for: .microseconds(100))
|
||||||
|
|
||||||
|
// Then the second call should be ignored.
|
||||||
|
XCTAssertEqual(roomProxy.sendReadReceiptForCallsCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// swiftlint:enable force_unwrapping
|
||||||
|
// swiftlint:disable:next large_tuple
|
||||||
|
private func readReceiptsConfiguration(with items: [RoomTimelineItemProtocol]) -> (RoomScreenViewModel,
|
||||||
|
RoomProxyMock,
|
||||||
|
MockRoomTimelineController,
|
||||||
|
NotificationCenterMock) {
|
||||||
|
let notificationCenter = NotificationCenterMock()
|
||||||
|
let roomProxy = RoomProxyMock(with: .init(displayName: ""))
|
||||||
let timelineController = MockRoomTimelineController()
|
let timelineController = MockRoomTimelineController()
|
||||||
let roomProxyMock = RoomProxyMock(with: .init(displayName: ""))
|
|
||||||
|
roomProxy.sendReadReceiptForReturnValue = .success(())
|
||||||
|
roomProxy.underlyingHasUnreadNotifications = true
|
||||||
|
timelineController.timelineItems = items
|
||||||
|
timelineController.roomProxy = roomProxy
|
||||||
|
|
||||||
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
let viewModel = RoomScreenViewModel(timelineController: timelineController,
|
||||||
mediaProvider: MockMediaProvider(),
|
mediaProvider: MockMediaProvider(),
|
||||||
roomProxy: roomProxyMock,
|
roomProxy: roomProxy,
|
||||||
appSettings: ServiceLocator.shared.settings,
|
appSettings: ServiceLocator.shared.settings,
|
||||||
analytics: ServiceLocator.shared.analytics,
|
analytics: ServiceLocator.shared.analytics,
|
||||||
userIndicatorController: userIndicatorControllerMock,
|
userIndicatorController: userIndicatorControllerMock,
|
||||||
notificationCenterProtocol: notificationCenterMock)
|
notificationCenterProtocol: notificationCenter)
|
||||||
|
|
||||||
viewModel.context.send(viewAction: .markRoomAsRead)
|
return (viewModel, roomProxy, timelineController, notificationCenter)
|
||||||
try await Task.sleep(for: .microseconds(100))
|
|
||||||
XCTAssertEqual(notificationCenterMock.postNameObjectReceivedArguments?.aName, .roomMarkedAsRead)
|
|
||||||
let roomID = notificationCenterMock.postNameObjectReceivedArguments?.anObject as? String
|
|
||||||
XCTAssertEqual(roomID, roomProxyMock.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,3 +506,20 @@ private extension TextRoomTimelineItem {
|
|||||||
properties: RoomTimelineItemProperties(reactions: reactions))
|
properties: RoomTimelineItemProperties(reactions: reactions))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension SeparatorRoomTimelineItem {
|
||||||
|
init(timelineID: String) {
|
||||||
|
self.init(id: .init(timelineID: timelineID), text: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension TextRoomTimelineItem {
|
||||||
|
init(eventID: String) {
|
||||||
|
self.init(id: .init(timelineID: UUID().uuidString, eventID: eventID),
|
||||||
|
timestamp: "",
|
||||||
|
isOutgoing: false,
|
||||||
|
isEditable: false,
|
||||||
|
sender: .init(id: ""),
|
||||||
|
content: .init(body: "Hello, World!"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Send read receipts as messages are displayed instead of on opening/closing rooms.
|
||||||
Reference in New Issue
Block a user