mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +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 {
|
||||
AnyView(RoomScreen(context: viewModel.context))
|
||||
}
|
||||
|
||||
@@ -59,8 +59,7 @@ enum RoomScreenViewAction {
|
||||
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
|
||||
case cancelReply
|
||||
case cancelEdit
|
||||
/// Mark the entire room as read - this is heavy handed as a starting point for now.
|
||||
case markRoomAsRead
|
||||
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
|
||||
case paginateBackwards
|
||||
|
||||
case timelineItemMenu(itemID: TimelineItemIdentifier)
|
||||
@@ -85,7 +84,7 @@ enum RoomScreenViewAction {
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
var roomId: String
|
||||
var roomID: String
|
||||
var roomTitle = ""
|
||||
var roomAvatarURL: URL?
|
||||
var members: [String: RoomMemberState] = [:]
|
||||
|
||||
@@ -53,7 +53,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
self.userIndicatorController = userIndicatorController
|
||||
self.notificationCenterProtocol = notificationCenterProtocol
|
||||
|
||||
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID,
|
||||
super.init(initialViewState: RoomScreenViewState(roomID: timelineController.roomID,
|
||||
roomTitle: roomProxy.roomTitle,
|
||||
roomAvatarURL: roomProxy.avatarURL,
|
||||
timelineStyle: appSettings.timelineStyle,
|
||||
@@ -103,8 +103,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
case .cancelEdit:
|
||||
setComposerMode(.default)
|
||||
state.bindings.composerText = ""
|
||||
case .markRoomAsRead:
|
||||
Task { await markRoomAsRead() }
|
||||
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
|
||||
Task { await sendReadReceiptIfNeeded(for: lastVisibleItemID) }
|
||||
case .timelineItemMenu(let itemID):
|
||||
Task {
|
||||
if case let .success(value) = await roomProxy.canUserRedact(userID: roomProxy.ownUserID) {
|
||||
@@ -245,9 +245,46 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
}
|
||||
|
||||
private func markRoomAsRead() async {
|
||||
notificationCenterProtocol.post(name: .roomMarkedAsRead, object: roomProxy.id)
|
||||
_ = await timelineController.markRoomAsRead()
|
||||
/// The ID of the newest item in the room that the user has seen.
|
||||
/// This includes both event based items and virtual items.
|
||||
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 {
|
||||
|
||||
@@ -40,7 +40,7 @@ struct RoomHeaderView: View {
|
||||
@ViewBuilder private var roomAvatar: some View {
|
||||
LoadableAvatarImage(url: context.viewState.roomAvatarURL,
|
||||
name: context.viewState.roomTitle,
|
||||
contentID: context.viewState.roomId,
|
||||
contentID: context.viewState.roomID,
|
||||
avatarSize: .room(on: .timeline),
|
||||
imageProvider: context.imageProvider)
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.avatar)
|
||||
|
||||
@@ -56,13 +56,6 @@ struct RoomScreen: View {
|
||||
}
|
||||
.interactiveQuickLook(item: $context.mediaPreviewItem)
|
||||
.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
|
||||
guard let provider = providers.first,
|
||||
provider.isSupportedForPasteOrDrop else {
|
||||
@@ -89,7 +82,7 @@ struct RoomScreen: View {
|
||||
|
||||
private var timeline: some View {
|
||||
timelineSwitch
|
||||
.id(context.viewState.roomId)
|
||||
.id(context.viewState.roomID)
|
||||
.environmentObject(context)
|
||||
.environment(\.timelineStyle, context.viewState.timelineStyle)
|
||||
.environment(\.readReceiptsEnabled, context.viewState.readReceiptsEnabled)
|
||||
|
||||
@@ -48,6 +48,8 @@ class TimelineTableViewController: UIViewController {
|
||||
if timelineItemsDictionary.isEmpty {
|
||||
paginateBackwardsPublisher.send()
|
||||
}
|
||||
|
||||
sendReadReceiptIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +243,15 @@ class TimelineTableViewController: UIViewController {
|
||||
|
||||
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
|
||||
@@ -271,6 +282,18 @@ extension TimelineTableViewController: UITableViewDelegate {
|
||||
scrollToTop(animated: true)
|
||||
return false
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
sendReadReceiptIfNeeded()
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
sendReadReceiptIfNeeded()
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
sendReadReceiptIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
var incomingItems: [RoomTimelineItemProtocol] = []
|
||||
|
||||
let roomID = "MockRoomIdentifier"
|
||||
var roomProxy: RoomProxyProtocol?
|
||||
var roomID: String { roomProxy?.id ?? "MockRoomIdentifier" }
|
||||
|
||||
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
|
||||
|
||||
@@ -49,7 +50,15 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
|
||||
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 { }
|
||||
|
||||
|
||||
@@ -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,
|
||||
let eventID = timelineItems.last?.id.eventID
|
||||
let eventID = itemID.eventID
|
||||
else { return .success(()) }
|
||||
|
||||
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 markRoomAsRead() async -> Result<Void, RoomTimelineControllerError>
|
||||
func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result<Void, RoomTimelineControllerError>
|
||||
|
||||
func sendMessage(_ message: String, inReplyTo itemID: TimelineItemIdentifier?) async
|
||||
|
||||
|
||||
Reference in New Issue
Block a user