From 05233dcf5b00ec13258ba25efe88ec4806268eca Mon Sep 17 00:00:00 2001 From: ismailgulek Date: Thu, 10 Nov 2022 14:41:38 +0300 Subject: [PATCH] Message editing (#298) * Add translation for editing mode * Add `is_editable` flag to timeline item * Add edit api implementation * Add edit context menu action * Add changelog * Update Rust package * Add isEditable into video timeline item * Fix cyclomatic complexity * Fix video item thumbnail loading * Fix not updating timeline layout --- .../en.lproj/Untranslated.strings | 1 + .../Generated/Strings+Untranslated.swift | 2 + .../Screens/RoomScreen/RoomScreenModels.swift | 2 + .../RoomScreen/RoomScreenViewModel.swift | 21 +++++-- .../RoomScreen/View/MessageComposer.swift | 57 ++++++++++++++++--- .../Screens/RoomScreen/View/RoomScreen.swift | 2 + .../View/Timeline/EmoteRoomTimelineView.swift | 1 + .../Timeline/EncryptedRoomTimelineView.swift | 1 + .../View/Timeline/ImageRoomTimelineView.swift | 3 + .../Timeline/NoticeRoomTimelineView.swift | 1 + .../Timeline/RedactedRoomTimelineView.swift | 1 + .../View/Timeline/TextRoomTimelineView.swift | 1 + .../View/Timeline/VideoRoomTimelineView.swift | 3 + .../View/TimelineItemContextMenu.swift | 14 +++++ .../RoomScreen/View/TimelineItemList.swift | 26 +++------ .../Sources/Services/Room/MockRoomProxy.swift | 4 ++ .../Sources/Services/Room/RoomProxy.swift | 18 ++++++ .../Services/Room/RoomProxyProtocol.swift | 2 + .../Timeline/MockRoomTimelineController.swift | 9 +++ .../Timeline/MockRoomTimelineProvider.swift | 4 ++ .../Timeline/RoomTimelineController.swift | 9 ++- .../RoomTimelineControllerProtocol.swift | 2 + .../Timeline/RoomTimelineProvider.swift | 9 +++ .../RoomTimelineProviderProtocol.swift | 2 + .../MessageTimelineItem.swift | 4 ++ .../Services/Timeline/TimelineItemProxy.swift | 4 ++ .../EventBasedTimelineItemProtocol.swift | 1 + .../Items/EmoteRoomTimelineItem.swift | 1 + .../Items/EncryptedRoomTimelineItem.swift | 1 + .../Items/ImageRoomTimelineItem.swift | 1 + .../Items/NoticeRoomTimelineItem.swift | 1 + .../Items/RedactedRoomTimelineItem.swift | 1 + .../Items/TextRoomTimelineItem.swift | 1 + .../Items/VideoRoomTimelineItem.swift | 1 + .../RoomTimelineItemFactory.swift | 8 +++ changelog.d/252.feature | 1 + 36 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 changelog.d/252.feature diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index a421c4abf..b50a1473f 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -15,6 +15,7 @@ "room_timeline_permalink_creation_failure" = "Failed creating the permalink"; "room_timeline_replying_to" = "Replying to %@"; +"room_timeline_editing" = "Editing"; "session_verification_banner_title" = "Help keep your messages secure"; "session_verification_banner_message" = "Looks like you’re using a new device. Verify its you."; diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index c8b0f217b..55fbd90b6 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -24,6 +24,8 @@ extension ElementL10n { public static let loginMobileDevice = ElementL10n.tr("Untranslated", "login_mobile_device") /// Tablet public static let loginTabletDevice = ElementL10n.tr("Untranslated", "login_tablet_device") + /// Editing + public static let roomTimelineEditing = ElementL10n.tr("Untranslated", "room_timeline_editing") /// Failed creating the permalink public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure") /// Replying to %@ diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index e09bb206c..6e4874821 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -22,6 +22,7 @@ enum RoomScreenViewModelAction { } enum RoomScreenComposerMode: Equatable { case `default` case reply(id: String, displayName: String) + case edit(originalItemId: String) } enum RoomScreenViewAction { @@ -32,6 +33,7 @@ enum RoomScreenViewAction { case sendMessage case sendReaction(key: String, eventID: String) case cancelReply + case cancelEdit } struct RoomScreenViewState: BindableState { diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 1907eed42..5ff71ad9d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -106,6 +106,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol MXLog.warning("React with \(key) failed. Not implemented.") case .cancelReply: state.composerMode = .default + case .cancelEdit: + state.composerMode = .default } } @@ -139,6 +141,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch currentComposerState { case .reply(let itemId, _): await timelineController.sendReply(currentMessage, to: itemId) + case .edit(let originalItemId): + await timelineController.editMessage(currentMessage, of: originalItemId) default: await timelineController.sendMessage(currentMessage) } @@ -163,15 +167,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol private func contextMenuActionsForItemId(_ itemId: String) -> TimelineItemContextMenuActions { guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }), - timelineItem is EventBasedTimelineItemProtocol else { + let item = timelineItem as? EventBasedTimelineItemProtocol else { return .init(actions: []) } var actions: [TimelineItemContextMenuAction] = [ .copy, .quote, .copyPermalink, .reply ] + + if item.isEditable { + actions.append(.edit) + } - if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isOutgoing { + if item.isOutgoing { actions.append(.redact) } @@ -187,6 +195,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch action { case .copy: UIPasteboard.general.string = item.text + case .edit: + state.bindings.composerFocused = true + state.bindings.composerText = item.text + state.composerMode = .edit(originalItemId: item.id) case .quote: state.bindings.composerFocused = true state.bindings.composerText = "> \(item.text)" @@ -208,10 +220,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.bindings.debugInfo = .init(title: "Timeline item", content: debugDescription) } - switch action { - case .reply: - break - default: + if action.switchToDefaultComposer { state.composerMode = .default } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift index 54cec1f3f..c308c7976 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift @@ -24,15 +24,14 @@ struct MessageComposer: View { let sendAction: () -> Void let replyCancellationAction: () -> Void + let editCancellationAction: () -> Void var body: some View { let rect = RoundedRectangle(cornerRadius: borderRadius) VStack(alignment: .leading, spacing: 4.0) { - if case let .reply(_, displayName) = type { - MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction) - } + header HStack(alignment: .center) { - MessageComposerTextField(placeholder: "Send a message", + MessageComposerTextField(placeholder: ElementL10n.roomMessagePlaceholder, text: $text, focused: $focused, maxHeight: 300) @@ -60,12 +59,24 @@ struct MessageComposer: View { .clipShape(rect) .animation(.elementDefault, value: type) } + + @ViewBuilder + private var header: some View { + switch type { + case .reply(_, let displayName): + MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction) + case .edit: + MessageComposerEditHeader(action: editCancellationAction) + case .default: + EmptyView() + } + } private var borderRadius: CGFloat { switch type { case .default: return 28.0 - case .reply: + case .reply, .edit: return 12.0 } } @@ -94,6 +105,28 @@ private struct MessageComposerReplyHeader: View { } } +private struct MessageComposerEditHeader: View { + let action: () -> Void + + var body: some View { + HStack(alignment: .center) { + Label(ElementL10n.roomTimelineEditing, systemImage: "pencil") + .font(.element.caption2) + .foregroundColor(.element.secondaryContent) + .lineLimit(1) + Spacer() + Button { + action() + } label: { + Image(systemName: "x.circle") + .font(.element.callout) + .foregroundColor(.element.secondaryContent) + .padding(4.0) + } + } + } +} + struct MessageComposer_Previews: PreviewProvider { static var previews: some View { body.preferredColorScheme(.light) @@ -108,7 +141,8 @@ struct MessageComposer_Previews: PreviewProvider { sendingDisabled: true, type: .default, sendAction: { }, - replyCancellationAction: { }) + replyCancellationAction: { }, + editCancellationAction: { }) MessageComposer(text: .constant("Some message"), focused: .constant(false), @@ -116,7 +150,16 @@ struct MessageComposer_Previews: PreviewProvider { type: .reply(id: UUID().uuidString, displayName: "John Doe"), sendAction: { }, - replyCancellationAction: { }) + replyCancellationAction: { }, + editCancellationAction: { }) + + MessageComposer(text: .constant("Some message"), + focused: .constant(false), + sendingDisabled: false, + type: .edit(originalItemId: UUID().uuidString), + sendAction: { }, + replyCancellationAction: { }, + editCancellationAction: { }) } .tint(.element.accent) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 55b198457..66378f3d8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -31,6 +31,8 @@ struct RoomScreen: View { sendMessage() } replyCancellationAction: { context.send(viewAction: .cancelReply) + } editCancellationAction: { + context.send(viewAction: .cancelEdit) } .padding() } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift index 78fbdf775..3fb67d3f5 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift @@ -64,6 +64,7 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider { timestamp: timestamp, inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: senderId) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift index a18f731d1..78f587edf 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift @@ -86,6 +86,7 @@ struct EncryptedRoomTimelineView_Previews: PreviewProvider { timestamp: timestamp, inGroupState: .single, isOutgoing: isOutgoing, + isEditable: false, senderId: senderId) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index 55fde92b7..649d630d0 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -68,6 +68,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: "Bob", source: nil, image: UIImage(systemName: "photo"))) @@ -77,6 +78,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: "Bob", source: nil, image: nil)) @@ -86,6 +88,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: "Bob", source: nil, image: nil, diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift index b79cd54be..db19979f5 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift @@ -64,6 +64,7 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider { timestamp: timestamp, inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: senderId) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift index 7ef366662..c4b464fa6 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift @@ -52,6 +52,7 @@ struct RedactedRoomTimelineView_Previews: PreviewProvider { timestamp: timestamp, inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: senderId) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift index 6e803a32a..83a8f0e35 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift @@ -73,6 +73,7 @@ struct TextRoomTimelineView_Previews: PreviewProvider { timestamp: timestamp, inGroupState: .single, isOutgoing: isOutgoing, + isEditable: isOutgoing, senderId: senderId) } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift index a6cb50d23..f2745cd29 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift @@ -78,6 +78,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: "Bob", duration: 21, source: nil, @@ -89,6 +90,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: "Bob", duration: 22, source: nil, @@ -100,6 +102,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { timestamp: "Now", inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: "Bob", duration: 23, source: nil, diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift index 5cffe0797..b59b48fe7 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift @@ -23,6 +23,7 @@ struct TimelineItemContextMenuActions { enum TimelineItemContextMenuAction: Identifiable, Hashable { case copy + case edit case quote case copyPermalink case redact @@ -30,6 +31,15 @@ enum TimelineItemContextMenuAction: Identifiable, Hashable { case viewSource var id: Self { self } + + var switchToDefaultComposer: Bool { + switch self { + case .reply, .edit: + return false + default: + return true + } + } } public struct TimelineItemContextMenu: View { @@ -53,6 +63,10 @@ public struct TimelineItemContextMenu: View { Button { callback(item) } label: { Label(ElementL10n.actionCopy, systemImage: "doc.on.doc") } + case .edit: + Button { callback(item) } label: { + Label(ElementL10n.edit, systemImage: "pencil") + } case .quote: Button { callback(item) } label: { Label(ElementL10n.actionQuote, systemImage: "quote.bubble") diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift index c73690320..df21ff065 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift @@ -111,17 +111,17 @@ struct TimelineItemList: View { .onReceive(scrollToBottomPublisher) { scrollToBottom(animated: true) } - .onChange(of: context.viewState.items.count) { _ in - guard !context.viewState.items.isEmpty, - context.viewState.items.count != timelineItems.count else { - return + .onChange(of: context.viewState.items) { items in + defer { + // update the items anyway + timelineItems = items } // Pin to the bottom if empty if timelineItems.isEmpty { - if let lastItem = context.viewState.items.last { + if let lastItem = items.last { let pinnedItem = PinnedItem(id: lastItem.id, anchor: .bottom, animated: false) - timelineItems = context.viewState.items + timelineItems = items self.pinnedItem = pinnedItem } @@ -131,9 +131,9 @@ struct TimelineItemList: View { // Pin to the new bottom if visible if let currentLastItem = timelineItems.last, visibleItemIdentifiers.contains(currentLastItem.id), - let newLastItem = context.viewState.items.last { + let newLastItem = items.last { let pinnedItem = PinnedItem(id: newLastItem.id, anchor: .bottom, animated: false) - timelineItems = context.viewState.items + timelineItems = items self.pinnedItem = pinnedItem return @@ -143,20 +143,12 @@ struct TimelineItemList: View { if let currentFirstItem = timelineItems.first, visibleItemIdentifiers.contains(currentFirstItem.id) { let pinnedItem = PinnedItem(id: currentFirstItem.id, anchor: .top, animated: false) - timelineItems = context.viewState.items + timelineItems = items self.pinnedItem = pinnedItem return } - - // Otherwise just update the items - timelineItems = context.viewState.items } - .onChange(of: context.viewState.items, perform: { items in - if timelineItems != items { - timelineItems = items - } - }) .background(GeometryReader { geo in Color.clear.preference(key: ViewFramePreferenceKey.self, value: [geo.frame(in: .global)]) }) diff --git a/ElementX/Sources/Services/Room/MockRoomProxy.swift b/ElementX/Sources/Services/Room/MockRoomProxy.swift index 44bb3daf7..bc6408974 100644 --- a/ElementX/Sources/Services/Room/MockRoomProxy.swift +++ b/ElementX/Sources/Services/Room/MockRoomProxy.swift @@ -65,6 +65,10 @@ struct MockRoomProxy: RoomProxyProtocol { func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result { .failure(.failedSendingMessage) } + + func editMessage(_ newMessage: String, originalEventId: String) async -> Result { + .failure(.failedSendingMessage) + } func redact(_ eventID: String) async -> Result { .failure(.failedRedactingEvent) diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 679a8764c..60f777c52 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -188,6 +188,24 @@ class RoomProxy: RoomProxyProtocol { } } } + + func editMessage(_ newMessage: String, originalEventId: String) async -> Result { + sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true) + defer { + sendMessageBgTask?.stop() + } + + let transactionId = genTransactionId() + + return await Task.dispatch(on: .global()) { + do { + try self.room.edit(newMsg: newMessage, originalEventId: originalEventId, txnId: transactionId) + return .success(()) + } catch { + return .failure(.failedSendingMessage) + } + } + } func redact(_ eventID: String) async -> Result { let transactionID = genTransactionId() diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 48459f6d9..276355ab7 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -59,6 +59,8 @@ protocol RoomProxyProtocol { func paginateBackwards(count: UInt) async -> Result func sendMessage(_ message: String, inReplyToEventId: String?) async -> Result + + func editMessage(_ newMessage: String, originalEventId: String) async -> Result func redact(_ eventID: String) async -> Result } diff --git a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift index 058c21538..705ec4de4 100644 --- a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift @@ -30,6 +30,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { timestamp: "10:10 AM", inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: "", senderDisplayName: "Jacob", properties: RoomTimelineItemProperties(isEdited: true)), @@ -38,6 +39,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { timestamp: "10:11 AM", inGroupState: .beginning, isOutgoing: false, + isEditable: false, senderId: "", senderDisplayName: "Helena", properties: RoomTimelineItemProperties(reactions: [ @@ -48,6 +50,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { timestamp: "10:11 AM", inGroupState: .end, isOutgoing: false, + isEditable: false, senderId: "", senderDisplayName: "Helena", properties: RoomTimelineItemProperties(reactions: [ @@ -61,6 +64,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { timestamp: "5 PM", inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: "", senderDisplayName: "Helena"), TextRoomTimelineItem(id: UUID().uuidString, @@ -68,6 +72,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { timestamp: "5 PM", inGroupState: .beginning, isOutgoing: true, + isEditable: true, senderId: "", senderDisplayName: "Bob"), TextRoomTimelineItem(id: UUID().uuidString, @@ -75,6 +80,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { timestamp: "5 PM", inGroupState: .end, isOutgoing: true, + isEditable: true, senderId: "", senderDisplayName: "Bob", properties: RoomTimelineItemProperties(reactions: [ @@ -91,6 +97,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { timestamp: "5 PM", inGroupState: .single, isOutgoing: false, + isEditable: false, senderId: "", senderDisplayName: "Helena") ] @@ -106,6 +113,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func sendMessage(_ message: String) async { } func sendReply(_ message: String, to itemId: String) async { } + + func editMessage(_ newMessage: String, of itemId: String) async { } func redact(_ eventID: String) async { } diff --git a/ElementX/Sources/Services/Timeline/MockRoomTimelineProvider.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineProvider.swift index dcbd0ba17..a2f9460aa 100644 --- a/ElementX/Sources/Services/Timeline/MockRoomTimelineProvider.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineProvider.swift @@ -30,6 +30,10 @@ struct MockRoomTimelineProvider: RoomTimelineProviderProtocol { func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result { .failure(.failedSendingMessage) } + + func editMessage(_ newMessage: String, originalItemId: String) async -> Result { + .failure(.failedSendingMessage) + } func redact(_ eventID: String) async -> Result { .failure(.failedRedactingItem) diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift index f3aeb9e17..318b585df 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift @@ -121,6 +121,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol { break } } + + func editMessage(_ newMessage: String, of itemId: String) async { + switch await timelineProvider.editMessage(newMessage, originalItemId: itemId) { + default: + break + } + } func redact(_ eventID: String) async { switch await timelineProvider.redact(eventID) { @@ -273,7 +280,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol { switch await mediaProvider.loadImageFromSource(source) { case .success(let image): guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), - var item = timelineItems[index] as? ImageRoomTimelineItem else { + var item = timelineItems[index] as? VideoRoomTimelineItem else { return } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift index 9fec4caa5..77421addf 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift @@ -45,6 +45,8 @@ protocol RoomTimelineControllerProtocol { func sendReply(_ message: String, to itemId: String) async + func editMessage(_ newMessage: String, of itemId: String) async + func redact(_ eventID: String) async func debugDescriptionFor(_ itemId: String) -> String diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift index 7c6f077da..cedd2902d 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift @@ -84,6 +84,15 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol { return .failure(.failedSendingMessage) } } + + func editMessage(_ newMessage: String, originalItemId: String) async -> Result { + switch await roomProxy.editMessage(newMessage, originalEventId: originalItemId) { + case .success: + return .success(()) + case .failure: + return .failure(.failedSendingMessage) + } + } func redact(_ eventID: String) async -> Result { switch await roomProxy.redact(eventID) { diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift index ecf77724f..a50a12969 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift @@ -33,6 +33,8 @@ protocol RoomTimelineProviderProtocol { func paginateBackwards(_ count: UInt) async -> Result func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result + + func editMessage(_ newMessage: String, originalItemId: String) async -> Result func redact(_ eventID: String) async -> Result } diff --git a/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift index 291210805..3ff225298 100644 --- a/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimeLineItemContent/MessageTimelineItem.swift @@ -45,6 +45,10 @@ struct MessageTimelineItem { var isEdited: Bool { item.content().asMessage()?.isEdited() == true } + + var isEditable: Bool { + item.isEditable() + } var inReplyTo: String? { item.content().asMessage()?.inReplyTo() diff --git a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift index d3d20a400..c9c978ec3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift @@ -78,6 +78,10 @@ struct EventTimelineItemProxy: CustomDebugStringConvertible { var isOwn: Bool { item.isOwn() } + + var isEditable: Bool { + item.isEditable() + } var sender: String { item.sender() diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index c9fb688fc..15a78f424 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -52,6 +52,7 @@ protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol { var shouldShowSenderDetails: Bool { get } var inGroupState: TimelineItemInGroupState { get } var isOutgoing: Bool { get } + var isEditable: Bool { get } var senderId: String { get } var senderDisplayName: String? { get set } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift index 7312d59af..f3aaf56d2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EmoteRoomTimelineItem.swift @@ -24,6 +24,7 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa let timestamp: String let inGroupState: TimelineItemInGroupState let isOutgoing: Bool + let isEditable: Bool let senderId: String var senderDisplayName: String? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift index 381c50415..0853a31e1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/EncryptedRoomTimelineItem.swift @@ -29,6 +29,7 @@ struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, let timestamp: String let inGroupState: TimelineItemInGroupState let isOutgoing: Bool + let isEditable: Bool let senderId: String var senderDisplayName: String? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift index ea8d4f796..3353f220e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/ImageRoomTimelineItem.swift @@ -23,6 +23,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa let timestamp: String let inGroupState: TimelineItemInGroupState let isOutgoing: Bool + let isEditable: Bool let senderId: String var senderDisplayName: String? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift index bbcc48036..560c084e7 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/NoticeRoomTimelineItem.swift @@ -24,6 +24,7 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equ let timestamp: String let inGroupState: TimelineItemInGroupState let isOutgoing: Bool + let isEditable: Bool let senderId: String var senderDisplayName: String? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift index 71eb9749b..93d1ae557 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/RedactedRoomTimelineItem.swift @@ -23,6 +23,7 @@ struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, E let timestamp: String let inGroupState: TimelineItemInGroupState let isOutgoing: Bool + let isEditable: Bool let senderId: String var senderDisplayName: String? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift index e9098f27b..28895958d 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/TextRoomTimelineItem.swift @@ -24,6 +24,7 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equat let timestamp: String let inGroupState: TimelineItemInGroupState let isOutgoing: Bool + let isEditable: Bool let senderId: String var senderDisplayName: String? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift index 455fcf70e..9741ff5ef 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/VideoRoomTimelineItem.swift @@ -23,6 +23,7 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equa let timestamp: String let inGroupState: TimelineItemInGroupState let isOutgoing: Bool + let isEditable: Bool let senderId: String var senderDisplayName: String? diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index e79495a94..ba21e89cb 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -98,6 +98,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { timestamp: eventItemProxy.originServerTs.formatted(date: .omitted, time: .shortened), inGroupState: inGroupState, isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, senderId: eventItemProxy.sender, senderDisplayName: displayName, senderAvatar: avatarImage, @@ -114,6 +115,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { timestamp: eventItemProxy.originServerTs.formatted(date: .omitted, time: .shortened), inGroupState: inGroupState, isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, senderId: eventItemProxy.sender, senderDisplayName: displayName, senderAvatar: avatarImage, @@ -134,6 +136,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { timestamp: eventItemProxy.originServerTs.formatted(date: .omitted, time: .shortened), inGroupState: inGroupState, isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, senderId: eventItemProxy.sender, senderDisplayName: displayName, senderAvatar: avatarImage, @@ -155,6 +158,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened), inGroupState: inGroupState, isOutgoing: isOutgoing, + isEditable: message.isEditable, senderId: message.sender, senderDisplayName: displayName, senderAvatar: avatarImage, @@ -178,6 +182,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened), inGroupState: inGroupState, isOutgoing: isOutgoing, + isEditable: message.isEditable, senderId: message.sender, senderDisplayName: displayName, senderAvatar: avatarImage, @@ -207,6 +212,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened), inGroupState: inGroupState, isOutgoing: isOutgoing, + isEditable: message.isEditable, senderId: message.sender, senderDisplayName: displayName, senderAvatar: avatarImage, @@ -236,6 +242,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened), inGroupState: inGroupState, isOutgoing: isOutgoing, + isEditable: message.isEditable, senderId: message.sender, senderDisplayName: displayName, senderAvatar: avatarImage, @@ -257,6 +264,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened), inGroupState: inGroupState, isOutgoing: isOutgoing, + isEditable: message.isEditable, senderId: message.sender, senderDisplayName: displayName, senderAvatar: avatarImage, diff --git a/changelog.d/252.feature b/changelog.d/252.feature new file mode 100644 index 000000000..91682e908 --- /dev/null +++ b/changelog.d/252.feature @@ -0,0 +1 @@ +Timeline: Implement message editing via context menu.