diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 0cb5196fb..314e0e8a5 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -13,6 +13,8 @@ "room_timeline_permalink_creation_failure" = "Failed creating the permalink"; +"room_timeline_replying_to" = "Replying to %@"; + // MARK: - Authentication "authentication_login_title" = "Welcome back!"; diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 4f0c44813..fd75f2e02 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -24,6 +24,10 @@ extension ElementL10n { public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title") /// Failed creating the permalink public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure") + /// Replying to %@ + public static func roomTimelineReplyingTo(_ p1: Any) -> String { + return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1)) + } /// Bubbled Timeline public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description") /// Plain Timeline diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 7ef42e023..e8e32922d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -24,6 +24,12 @@ enum TimelineItemContextMenuAction: Hashable { case quote case copyPermalink case redact + case reply +} + +enum RoomScreenComposerMode: Equatable { + case `default` + case reply(id: String, displayName: String) } enum RoomScreenViewAction { @@ -33,6 +39,7 @@ enum RoomScreenViewAction { case linkClicked(url: URL) case sendMessage case sendReaction(key: String, eventID: String) + case cancelReply } struct RoomScreenViewState: BindableState { @@ -45,6 +52,9 @@ struct RoomScreenViewState: BindableState { var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)? + var composerMode: RoomScreenComposerMode = .default + + var messageComposerDisabled = false // Remove this when we have local echoes var sendButtonDisabled: Bool { bindings.composerText.count == 0 } @@ -52,6 +62,7 @@ struct RoomScreenViewState: BindableState { struct RoomScreenViewStateBindings { var composerText: String + var composerFocused: Bool /// Information describing the currently displayed alert. var alertInfo: AlertInfo? diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index e8139cfa0..466d22878 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -39,7 +39,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥", roomAvatar: roomAvatar, roomEncryptionBadge: roomEncryptionBadge, - bindings: .init(composerText: ""))) + bindings: .init(composerText: "", composerFocused: false))) timelineController.callbacks .receive(on: DispatchQueue.main) @@ -75,7 +75,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol default: state.isBackPaginating = false } - case .itemAppeared(let id): await timelineController.processItemAppearance(id) case .itemDisappeared(let id): @@ -83,15 +82,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol case .linkClicked(let url): MXLog.warning("Link clicked: \(url)") case .sendMessage: - guard state.bindings.composerText.count > 0 else { - return - } - - await timelineController.sendMessage(state.bindings.composerText) - state.bindings.composerText = "" + await sendCurrentMessage() case .sendReaction(let key, _): #warning("Reaction implementation awaiting SDK support.") MXLog.warning("React with \(key) failed. Not implemented.") + case .cancelReply: + state.composerMode = .default } } @@ -105,6 +101,35 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol state.items = stateItems } + private func sendCurrentMessage() async { + guard !state.bindings.composerText.isEmpty else { + fatalError("This message should never be empty") + } + + state.messageComposerDisabled = true + + switch state.composerMode { + case .reply(let itemId, _): + await timelineController.sendReply(state.bindings.composerText, to: itemId) + default: + await timelineController.sendMessage(state.bindings.composerText) + } + + state.bindings.composerText = "" + state.composerMode = .default + + state.messageComposerDisabled = false + } + + private func displayError(_ type: RoomScreenErrorType) { + switch type { + case .alert(let message): + state.bindings.alertInfo = AlertInfo(id: type, + title: ElementL10n.dialogTitleError, + message: message) + } + } + // MARK: ContextMenus private func buildContexMenuForItemId(_ itemId: String) -> TimelineItemContextMenu { @@ -120,7 +145,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } let actions: [TimelineItemContextMenuAction] = [ - .copy, .quote, .copyPermalink + .copy, .quote, .copyPermalink, .reply ] #warning("Outgoing actions to be handled with the new Timeline API.") @@ -141,6 +166,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol case .copy: UIPasteboard.general.string = item.text case .quote: + state.bindings.composerFocused = true state.bindings.composerText = "> \(item.text)" case .copyPermalink: do { @@ -151,15 +177,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } case .redact: redact(itemId) + case .reply: + state.bindings.composerFocused = true + state.composerMode = .reply(id: item.id, displayName: item.senderDisplayName ?? item.senderId) } - } - - private func displayError(_ type: RoomScreenErrorType) { - switch type { - case .alert(let message): - state.bindings.alertInfo = AlertInfo(id: type, - title: ElementL10n.dialogTitleError, - message: message) + + switch action { + case .reply: + break + default: + state.composerMode = .default } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift index bb6d6d71a..cf764fce5 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposer.swift @@ -18,28 +18,77 @@ import SwiftUI struct MessageComposer: View { @Binding var text: String - var disabled: Bool - let action: () -> Void + @Binding var focused: Bool + let sendingDisabled: Bool + let type: RoomScreenComposerMode + + let sendAction: () -> Void + let replyCancellationAction: () -> Void var body: some View { HStack(alignment: .bottom) { - MessageComposerTextField(placeholder: "Send a message", text: $text, maxHeight: 300) + let rect = RoundedRectangle(cornerRadius: 8.0) + VStack(alignment: .leading, spacing: 2.0) { + if case let .reply(_, displayName) = type { + MessageComposerReplyHeader(displayName: displayName, action: replyCancellationAction) + } + MessageComposerTextField(placeholder: "Send a message", + text: $text, + focused: $focused, + maxHeight: 300) + } + .padding(4.0) + .frame(minHeight: 44.0) + .clipShape(rect) + .overlay(rect.stroke(borderColor, lineWidth: borderWidth)) + .animation(.elementDefault, value: type) + .animation(.elementDefault, value: borderWidth) + Button { - action() + sendAction() } label: { Image(uiImage: Asset.Images.timelineComposerSendMessage.image) .background(Circle() .foregroundColor(.global.white) - .padding(2) ) } .padding(.bottom, 6.0) - .disabled(disabled) - .opacity(disabled ? 0.5 : 1.0) - .animation(.elementDefault, value: disabled) + .disabled(sendingDisabled) + .opacity(sendingDisabled ? 0.5 : 1.0) + .animation(.elementDefault, value: sendingDisabled) .keyboardShortcut(.return, modifiers: [.command]) } } + + private var borderColor: Color { + .element.accent + } + + private var borderWidth: CGFloat { + focused ? 2.0 : 1.0 + } +} + +private struct MessageComposerReplyHeader: View { + let displayName: String + let action: () -> Void + + var body: some View { + HStack(alignment: .center) { + Label(ElementL10n.roomTimelineReplyingTo(displayName), systemImage: "arrow.uturn.left") + .font(.element.caption2) + .foregroundColor(.element.secondaryContent) + .lineLimit(1) + Spacer() + Button { + action() + } label: { + Image(systemName: "xmark") + .font(.element.caption2) + .padding(4.0) + } + } + } } struct MessageComposer_Previews: PreviewProvider { @@ -51,8 +100,20 @@ struct MessageComposer_Previews: PreviewProvider { @ViewBuilder static var body: some View { VStack { - MessageComposer(text: .constant(""), disabled: true) { } - MessageComposer(text: .constant("Some message"), disabled: false) { } + MessageComposer(text: .constant(""), + focused: .constant(false), + sendingDisabled: true, + type: .default, + sendAction: { }, + replyCancellationAction: { }) + + MessageComposer(text: .constant("Some message"), + focused: .constant(false), + sendingDisabled: false, + type: .reply(id: UUID().uuidString, + displayName: "John Doe"), + sendAction: { }, + replyCancellationAction: { }) } .tint(.element.accent) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift index dbcfda19d..a4bb123e9 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/MessageComposerTextField.swift @@ -18,8 +18,9 @@ import SwiftUI struct MessageComposerTextField: View { @Binding private var text: String + @Binding private var focused: Bool + @State private var dynamicHeight: CGFloat = 100 - @State private var isEditing = false private let placeholder: String private let maxHeight: CGFloat @@ -28,36 +29,24 @@ struct MessageComposerTextField: View { text.isEmpty } - init(placeholder: String, text: Binding, maxHeight: CGFloat) { + init(placeholder: String, text: Binding, focused: Binding, maxHeight: CGFloat) { self.placeholder = placeholder _text = text + _focused = focused self.maxHeight = maxHeight } private var placeholderColor: Color { - .gray - } - - private var borderColor: Color { - .element.accent - } - - private var borderWidth: CGFloat { - isEditing ? 2.0 : 1.0 + .element.secondaryContent } var body: some View { - let rect = RoundedRectangle(cornerRadius: 8.0) - return UITextViewWrapper(text: $text, - calculatedHeight: $dynamicHeight, - isEditing: $isEditing, - maxHeight: maxHeight) + UITextViewWrapper(text: $text, + calculatedHeight: $dynamicHeight, + focused: $focused, + maxHeight: maxHeight) .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight) - .padding(4.0) .background(placeholderView, alignment: .topLeading) - .clipShape(rect) - .overlay(rect.stroke(borderColor, lineWidth: borderWidth)) - .animation(.elementDefault, value: isEditing) } @ViewBuilder @@ -65,8 +54,6 @@ struct MessageComposerTextField: View { if showingPlaceholder { Text(placeholder) .foregroundColor(placeholderColor) - .padding(.leading, 8.0) - .padding(.top, 12.0) } } } @@ -76,7 +63,7 @@ private struct UITextViewWrapper: UIViewRepresentable { @Binding var text: String @Binding var calculatedHeight: CGFloat - @Binding var isEditing: Bool + @Binding var focused: Bool let maxHeight: CGFloat @@ -90,23 +77,33 @@ private struct UITextViewWrapper: UIViewRepresentable { textView.isUserInteractionEnabled = true textView.backgroundColor = UIColor.clear textView.returnKeyType = .default + textView.textContainer.lineFragmentPadding = 0.0 + textView.textContainerInset = .zero textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return textView } - func updateUIView(_ view: UITextView, context: UIViewRepresentableContext) { - if view.text != text { - view.text = text + func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext) { + if textView.text != text { + textView.text = text + } + + UITextViewWrapper.recalculateHeight(view: textView, result: $calculatedHeight, maxHeight: maxHeight) + + if focused, textView.window != nil, !textView.isFirstResponder { + // Avoid cycle detected through attribute warnings + DispatchQueue.main.async { + textView.becomeFirstResponder() + } } - - UITextViewWrapper.recalculateHeight(view: view, result: $calculatedHeight, maxHeight: maxHeight) } func makeCoordinator() -> Coordinator { Coordinator(text: $text, height: $calculatedHeight, - isEditing: $isEditing, + focused: $focused, maxHeight: maxHeight) } @@ -117,22 +114,22 @@ private struct UITextViewWrapper: UIViewRepresentable { if result.wrappedValue != height { DispatchQueue.main.async { - result.wrappedValue = height // !! must be called asynchronously + result.wrappedValue = height // Must be called asynchronously } } } final class Coordinator: NSObject, UITextViewDelegate { - var text: Binding - var calculatedHeight: Binding - var isEditing: Binding + private var text: Binding + private var calculatedHeight: Binding + private var focused: Binding - let maxHeight: CGFloat + private let maxHeight: CGFloat - init(text: Binding, height: Binding, isEditing: Binding, maxHeight: CGFloat) { + init(text: Binding, height: Binding, focused: Binding, maxHeight: CGFloat) { self.text = text calculatedHeight = height - self.isEditing = isEditing + self.focused = focused self.maxHeight = maxHeight } @@ -143,16 +140,12 @@ private struct UITextViewWrapper: UIViewRepresentable { maxHeight: maxHeight) } - func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - true - } - func textViewDidBeginEditing(_ textView: UITextView) { - isEditing.wrappedValue = true + focused.wrappedValue = true } func textViewDidEndEditing(_ textView: UITextView) { - isEditing.wrappedValue = false + focused.wrappedValue = false } } } @@ -173,13 +166,15 @@ struct MessageComposerTextField_Previews: PreviewProvider { struct PreviewWrapper: View { @State var text: String + @State var focused: Bool init(text: String) { _text = .init(initialValue: text) + _focused = .init(initialValue: false) } var body: some View { - MessageComposerTextField(placeholder: "Placeholder", text: $text, maxHeight: 300) + MessageComposerTextField(placeholder: "Placeholder", text: $text, focused: $focused, maxHeight: 300) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index eec91f0d5..373695651 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -24,10 +24,16 @@ struct RoomScreen: View { TimelineView() .environmentObject(context) - MessageComposer(text: $context.composerText, disabled: context.viewState.sendButtonDisabled) { + MessageComposer(text: $context.composerText, + focused: $context.composerFocused, + sendingDisabled: context.viewState.sendButtonDisabled, + type: context.viewState.composerMode) { sendMessage() + } replyCancellationAction: { + context.send(viewAction: .cancelReply) } .padding() + .opacity(context.viewState.messageComposerDisabled ? 0.5 : 1.0) } .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift index b7a32f75c..3923c64bc 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemContextMenu.swift @@ -36,6 +36,10 @@ public struct TimelineItemContextMenu: View { Button { callback(item) } label: { Label(ElementL10n.permalink, systemImage: "link") } + case .reply: + Button { callback(item) } label: { + Label(ElementL10n.reply, systemImage: "arrow.uturn.left") + } case .redact: Button(role: .destructive) { callback(item) } label: { Label(ElementL10n.messageActionItemRedact, systemImage: "trash") diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift index d776fbd31..acb5cf640 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineItemList.swift @@ -48,6 +48,7 @@ struct TimelineItemList: View { .contextMenu(menuItems: { context.viewState.contextMenuBuilder?(timelineItem.id) }) + .opacity(opacityForItem(timelineItem)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(settings.timelineStyle.listRowInsets) @@ -142,6 +143,14 @@ struct TimelineItemList: View { context.send(viewAction: .loadPreviousPage) } + private func opacityForItem(_ item: RoomTimelineViewProvider) -> Double { + guard case let .reply(selectedItemId, _) = context.viewState.composerMode else { + return 1.0 + } + + return selectedItemId == item.id ? 1.0 : 0.5 + } + private var isPreview: Bool { #if DEBUG return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" diff --git a/ElementX/Sources/Services/Room/MockRoomProxy.swift b/ElementX/Sources/Services/Room/MockRoomProxy.swift index 83e5d0c4e..0cba93153 100644 --- a/ElementX/Sources/Services/Room/MockRoomProxy.swift +++ b/ElementX/Sources/Services/Room/MockRoomProxy.swift @@ -54,7 +54,7 @@ struct MockRoomProxy: RoomProxyProtocol { .failure(.backwardStreamNotAvailable) } - func sendMessage(_ message: String) async -> Result { + func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result { .failure(.failedSendingMessage) } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 415064d52..8fc883909 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -175,17 +175,24 @@ class RoomProxy: RoomProxyProtocol { .value } - func sendMessage(_ message: String) async -> Result { + func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result { sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true) defer { sendMessageBgTask?.stop() } - let messageContent = messageEventContentFromMarkdown(md: message) + let transactionId = genTransactionId() return await Task(priority: .high) { () -> Result in do { + // Disabled until available in Rust + // if let inReplyToEventId = inReplyToEventId { + // #warning("Markdown support when available in Ruma") + // try self.room.sendReply(msg: message, inReplyToEventId: inReplyToEventId, txnId: transactionId) + // } else { + let messageContent = messageEventContentFromMarkdown(md: message) try self.room.send(msg: messageContent, txnId: transactionId) + // } return .success(()) } catch { return .failure(.failedSendingMessage) diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index d5166baf2..28bf207e5 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -55,9 +55,15 @@ protocol RoomProxyProtocol { func paginateBackwards(count: UInt) async -> Result - func sendMessage(_ message: String) async -> Result + func sendMessage(_ message: String, inReplyToEventId: String?) async -> Result func redact(_ eventID: String) async -> Result var callbacks: PassthroughSubject { get } } + +extension RoomProxyProtocol { + func sendMessage(_ message: String) async -> Result { + await sendMessage(message, inReplyToEventId: nil) + } +} diff --git a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift index 83d07fffe..cfac29089 100644 --- a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift @@ -66,5 +66,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { func sendMessage(_ message: String) async { } + func sendReply(_ message: String, to itemId: String) async { } + func redact(_ eventID: String) async { } } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift index 70bdddad8..9ebafb9a0 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift @@ -104,13 +104,20 @@ class RoomTimelineController: RoomTimelineControllerProtocol { } } + func sendReply(_ message: String, to itemId: String) async { + switch await timelineProvider.sendMessage(message, inReplyToItemId: itemId) { + default: + break + } + } + func redact(_ eventID: String) async { switch await timelineProvider.redact(eventID) { default: break } } - + // MARK: - Private @objc private func contentSizeCategoryDidChange() { diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift index 3416bdbc3..75b138abf 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift @@ -41,5 +41,7 @@ protocol RoomTimelineControllerProtocol { func sendMessage(_ message: String) async + func sendReply(_ message: String, to itemId: String) async + func redact(_ eventID: String) async } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift index b0054cbbb..7105cb601 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift @@ -51,8 +51,8 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol { } } - func sendMessage(_ message: String) async -> Result { - switch await roomProxy.sendMessage(message) { + func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result { + switch await roomProxy.sendMessage(message, inReplyToEventId: inReplyToItemId) { case .success: return .success(()) case .failure: diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift index a84a9e08e..38fa38277 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProviderProtocol.swift @@ -35,7 +35,13 @@ protocol RoomTimelineProviderProtocol { func paginateBackwards(_ count: UInt) async -> Result - func sendMessage(_ message: String) async -> Result + func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result func redact(_ eventID: String) async -> Result } + +extension RoomTimelineProviderProtocol { + func sendMessage(_ message: String) async -> Result { + await sendMessage(message, inReplyToItemId: nil) + } +} diff --git a/changelog.d/114.feature b/changelog.d/114.feature new file mode 100644 index 000000000..969db49ca --- /dev/null +++ b/changelog.d/114.feature @@ -0,0 +1 @@ +Implemented timeline item repyling \ No newline at end of file