Fixes vector-im/element-x-ios/issues/114 - Replying to timeline items

This commit is contained in:
Stefan Ceriu 2022-09-14 16:21:23 +03:00 committed by GitHub
parent dd2385711a
commit ffee192e89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 229 additions and 79 deletions

View File

@ -13,6 +13,8 @@
"room_timeline_permalink_creation_failure" = "Failed creating the permalink"; "room_timeline_permalink_creation_failure" = "Failed creating the permalink";
"room_timeline_replying_to" = "Replying to %@";
// MARK: - Authentication // MARK: - Authentication
"authentication_login_title" = "Welcome back!"; "authentication_login_title" = "Welcome back!";

View File

@ -24,6 +24,10 @@ extension ElementL10n {
public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title") public static let authenticationServerInfoTitle = ElementL10n.tr("Untranslated", "authentication_server_info_title")
/// Failed creating the permalink /// Failed creating the permalink
public static let roomTimelinePermalinkCreationFailure = ElementL10n.tr("Untranslated", "room_timeline_permalink_creation_failure") 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 /// Bubbled Timeline
public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description") public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description")
/// Plain Timeline /// Plain Timeline

View File

@ -24,6 +24,12 @@ enum TimelineItemContextMenuAction: Hashable {
case quote case quote
case copyPermalink case copyPermalink
case redact case redact
case reply
}
enum RoomScreenComposerMode: Equatable {
case `default`
case reply(id: String, displayName: String)
} }
enum RoomScreenViewAction { enum RoomScreenViewAction {
@ -33,6 +39,7 @@ enum RoomScreenViewAction {
case linkClicked(url: URL) case linkClicked(url: URL)
case sendMessage case sendMessage
case sendReaction(key: String, eventID: String) case sendReaction(key: String, eventID: String)
case cancelReply
} }
struct RoomScreenViewState: BindableState { struct RoomScreenViewState: BindableState {
@ -45,6 +52,9 @@ struct RoomScreenViewState: BindableState {
var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)? var contextMenuBuilder: (@MainActor (_ itemId: String) -> TimelineItemContextMenu)?
var composerMode: RoomScreenComposerMode = .default
var messageComposerDisabled = false // Remove this when we have local echoes
var sendButtonDisabled: Bool { var sendButtonDisabled: Bool {
bindings.composerText.count == 0 bindings.composerText.count == 0
} }
@ -52,6 +62,7 @@ struct RoomScreenViewState: BindableState {
struct RoomScreenViewStateBindings { struct RoomScreenViewStateBindings {
var composerText: String var composerText: String
var composerFocused: Bool
/// Information describing the currently displayed alert. /// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomScreenErrorType>? var alertInfo: AlertInfo<RoomScreenErrorType>?

View File

@ -39,7 +39,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥", super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "Unknown room 💥",
roomAvatar: roomAvatar, roomAvatar: roomAvatar,
roomEncryptionBadge: roomEncryptionBadge, roomEncryptionBadge: roomEncryptionBadge,
bindings: .init(composerText: ""))) bindings: .init(composerText: "", composerFocused: false)))
timelineController.callbacks timelineController.callbacks
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
@ -75,7 +75,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
default: default:
state.isBackPaginating = false state.isBackPaginating = false
} }
case .itemAppeared(let id): case .itemAppeared(let id):
await timelineController.processItemAppearance(id) await timelineController.processItemAppearance(id)
case .itemDisappeared(let id): case .itemDisappeared(let id):
@ -83,15 +82,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .linkClicked(let url): case .linkClicked(let url):
MXLog.warning("Link clicked: \(url)") MXLog.warning("Link clicked: \(url)")
case .sendMessage: case .sendMessage:
guard state.bindings.composerText.count > 0 else { await sendCurrentMessage()
return
}
await timelineController.sendMessage(state.bindings.composerText)
state.bindings.composerText = ""
case .sendReaction(let key, _): case .sendReaction(let key, _):
#warning("Reaction implementation awaiting SDK support.") #warning("Reaction implementation awaiting SDK support.")
MXLog.warning("React with \(key) failed. Not implemented.") MXLog.warning("React with \(key) failed. Not implemented.")
case .cancelReply:
state.composerMode = .default
} }
} }
@ -105,6 +101,35 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.items = stateItems 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 // MARK: ContextMenus
private func buildContexMenuForItemId(_ itemId: String) -> TimelineItemContextMenu { private func buildContexMenuForItemId(_ itemId: String) -> TimelineItemContextMenu {
@ -120,7 +145,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
let actions: [TimelineItemContextMenuAction] = [ let actions: [TimelineItemContextMenuAction] = [
.copy, .quote, .copyPermalink .copy, .quote, .copyPermalink, .reply
] ]
#warning("Outgoing actions to be handled with the new Timeline API.") #warning("Outgoing actions to be handled with the new Timeline API.")
@ -141,6 +166,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
case .copy: case .copy:
UIPasteboard.general.string = item.text UIPasteboard.general.string = item.text
case .quote: case .quote:
state.bindings.composerFocused = true
state.bindings.composerText = "> \(item.text)" state.bindings.composerText = "> \(item.text)"
case .copyPermalink: case .copyPermalink:
do { do {
@ -151,15 +177,16 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
case .redact: case .redact:
redact(itemId) redact(itemId)
case .reply:
state.bindings.composerFocused = true
state.composerMode = .reply(id: item.id, displayName: item.senderDisplayName ?? item.senderId)
} }
}
switch action {
private func displayError(_ type: RoomScreenErrorType) { case .reply:
switch type { break
case .alert(let message): default:
state.bindings.alertInfo = AlertInfo(id: type, state.composerMode = .default
title: ElementL10n.dialogTitleError,
message: message)
} }
} }

View File

@ -18,28 +18,77 @@ import SwiftUI
struct MessageComposer: View { struct MessageComposer: View {
@Binding var text: String @Binding var text: String
var disabled: Bool @Binding var focused: Bool
let action: () -> Void let sendingDisabled: Bool
let type: RoomScreenComposerMode
let sendAction: () -> Void
let replyCancellationAction: () -> Void
var body: some View { var body: some View {
HStack(alignment: .bottom) { 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 { Button {
action() sendAction()
} label: { } label: {
Image(uiImage: Asset.Images.timelineComposerSendMessage.image) Image(uiImage: Asset.Images.timelineComposerSendMessage.image)
.background(Circle() .background(Circle()
.foregroundColor(.global.white) .foregroundColor(.global.white)
.padding(2)
) )
} }
.padding(.bottom, 6.0) .padding(.bottom, 6.0)
.disabled(disabled) .disabled(sendingDisabled)
.opacity(disabled ? 0.5 : 1.0) .opacity(sendingDisabled ? 0.5 : 1.0)
.animation(.elementDefault, value: disabled) .animation(.elementDefault, value: sendingDisabled)
.keyboardShortcut(.return, modifiers: [.command]) .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 { struct MessageComposer_Previews: PreviewProvider {
@ -51,8 +100,20 @@ struct MessageComposer_Previews: PreviewProvider {
@ViewBuilder @ViewBuilder
static var body: some View { static var body: some View {
VStack { VStack {
MessageComposer(text: .constant(""), disabled: true) { } MessageComposer(text: .constant(""),
MessageComposer(text: .constant("Some message"), disabled: false) { } 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) .tint(.element.accent)
} }

View File

@ -18,8 +18,9 @@ import SwiftUI
struct MessageComposerTextField: View { struct MessageComposerTextField: View {
@Binding private var text: String @Binding private var text: String
@Binding private var focused: Bool
@State private var dynamicHeight: CGFloat = 100 @State private var dynamicHeight: CGFloat = 100
@State private var isEditing = false
private let placeholder: String private let placeholder: String
private let maxHeight: CGFloat private let maxHeight: CGFloat
@ -28,36 +29,24 @@ struct MessageComposerTextField: View {
text.isEmpty text.isEmpty
} }
init(placeholder: String, text: Binding<String>, maxHeight: CGFloat) { init(placeholder: String, text: Binding<String>, focused: Binding<Bool>, maxHeight: CGFloat) {
self.placeholder = placeholder self.placeholder = placeholder
_text = text _text = text
_focused = focused
self.maxHeight = maxHeight self.maxHeight = maxHeight
} }
private var placeholderColor: Color { private var placeholderColor: Color {
.gray .element.secondaryContent
}
private var borderColor: Color {
.element.accent
}
private var borderWidth: CGFloat {
isEditing ? 2.0 : 1.0
} }
var body: some View { var body: some View {
let rect = RoundedRectangle(cornerRadius: 8.0) UITextViewWrapper(text: $text,
return UITextViewWrapper(text: $text, calculatedHeight: $dynamicHeight,
calculatedHeight: $dynamicHeight, focused: $focused,
isEditing: $isEditing, maxHeight: maxHeight)
maxHeight: maxHeight)
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight) .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
.padding(4.0)
.background(placeholderView, alignment: .topLeading) .background(placeholderView, alignment: .topLeading)
.clipShape(rect)
.overlay(rect.stroke(borderColor, lineWidth: borderWidth))
.animation(.elementDefault, value: isEditing)
} }
@ViewBuilder @ViewBuilder
@ -65,8 +54,6 @@ struct MessageComposerTextField: View {
if showingPlaceholder { if showingPlaceholder {
Text(placeholder) Text(placeholder)
.foregroundColor(placeholderColor) .foregroundColor(placeholderColor)
.padding(.leading, 8.0)
.padding(.top, 12.0)
} }
} }
} }
@ -76,7 +63,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
@Binding var text: String @Binding var text: String
@Binding var calculatedHeight: CGFloat @Binding var calculatedHeight: CGFloat
@Binding var isEditing: Bool @Binding var focused: Bool
let maxHeight: CGFloat let maxHeight: CGFloat
@ -90,23 +77,33 @@ private struct UITextViewWrapper: UIViewRepresentable {
textView.isUserInteractionEnabled = true textView.isUserInteractionEnabled = true
textView.backgroundColor = UIColor.clear textView.backgroundColor = UIColor.clear
textView.returnKeyType = .default textView.returnKeyType = .default
textView.textContainer.lineFragmentPadding = 0.0
textView.textContainerInset = .zero
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView return textView
} }
func updateUIView(_ view: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) { func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if view.text != text { if textView.text != text {
view.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 { func makeCoordinator() -> Coordinator {
Coordinator(text: $text, Coordinator(text: $text,
height: $calculatedHeight, height: $calculatedHeight,
isEditing: $isEditing, focused: $focused,
maxHeight: maxHeight) maxHeight: maxHeight)
} }
@ -117,22 +114,22 @@ private struct UITextViewWrapper: UIViewRepresentable {
if result.wrappedValue != height { if result.wrappedValue != height {
DispatchQueue.main.async { DispatchQueue.main.async {
result.wrappedValue = height // !! must be called asynchronously result.wrappedValue = height // Must be called asynchronously
} }
} }
} }
final class Coordinator: NSObject, UITextViewDelegate { final class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String> private var text: Binding<String>
var calculatedHeight: Binding<CGFloat> private var calculatedHeight: Binding<CGFloat>
var isEditing: Binding<Bool> private var focused: Binding<Bool>
let maxHeight: CGFloat private let maxHeight: CGFloat
init(text: Binding<String>, height: Binding<CGFloat>, isEditing: Binding<Bool>, maxHeight: CGFloat) { init(text: Binding<String>, height: Binding<CGFloat>, focused: Binding<Bool>, maxHeight: CGFloat) {
self.text = text self.text = text
calculatedHeight = height calculatedHeight = height
self.isEditing = isEditing self.focused = focused
self.maxHeight = maxHeight self.maxHeight = maxHeight
} }
@ -143,16 +140,12 @@ private struct UITextViewWrapper: UIViewRepresentable {
maxHeight: maxHeight) maxHeight: maxHeight)
} }
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
true
}
func textViewDidBeginEditing(_ textView: UITextView) { func textViewDidBeginEditing(_ textView: UITextView) {
isEditing.wrappedValue = true focused.wrappedValue = true
} }
func textViewDidEndEditing(_ textView: UITextView) { func textViewDidEndEditing(_ textView: UITextView) {
isEditing.wrappedValue = false focused.wrappedValue = false
} }
} }
} }
@ -173,13 +166,15 @@ struct MessageComposerTextField_Previews: PreviewProvider {
struct PreviewWrapper: View { struct PreviewWrapper: View {
@State var text: String @State var text: String
@State var focused: Bool
init(text: String) { init(text: String) {
_text = .init(initialValue: text) _text = .init(initialValue: text)
_focused = .init(initialValue: false)
} }
var body: some View { var body: some View {
MessageComposerTextField(placeholder: "Placeholder", text: $text, maxHeight: 300) MessageComposerTextField(placeholder: "Placeholder", text: $text, focused: $focused, maxHeight: 300)
} }
} }
} }

View File

@ -24,10 +24,16 @@ struct RoomScreen: View {
TimelineView() TimelineView()
.environmentObject(context) .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() sendMessage()
} replyCancellationAction: {
context.send(viewAction: .cancelReply)
} }
.padding() .padding()
.opacity(context.viewState.messageComposerDisabled ? 0.5 : 1.0)
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {

View File

@ -36,6 +36,10 @@ public struct TimelineItemContextMenu: View {
Button { callback(item) } label: { Button { callback(item) } label: {
Label(ElementL10n.permalink, systemImage: "link") Label(ElementL10n.permalink, systemImage: "link")
} }
case .reply:
Button { callback(item) } label: {
Label(ElementL10n.reply, systemImage: "arrow.uturn.left")
}
case .redact: case .redact:
Button(role: .destructive) { callback(item) } label: { Button(role: .destructive) { callback(item) } label: {
Label(ElementL10n.messageActionItemRedact, systemImage: "trash") Label(ElementL10n.messageActionItemRedact, systemImage: "trash")

View File

@ -48,6 +48,7 @@ struct TimelineItemList: View {
.contextMenu(menuItems: { .contextMenu(menuItems: {
context.viewState.contextMenuBuilder?(timelineItem.id) context.viewState.contextMenuBuilder?(timelineItem.id)
}) })
.opacity(opacityForItem(timelineItem))
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(settings.timelineStyle.listRowInsets) .listRowInsets(settings.timelineStyle.listRowInsets)
@ -142,6 +143,14 @@ struct TimelineItemList: View {
context.send(viewAction: .loadPreviousPage) 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 { private var isPreview: Bool {
#if DEBUG #if DEBUG
return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" return ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"

View File

@ -54,7 +54,7 @@ struct MockRoomProxy: RoomProxyProtocol {
.failure(.backwardStreamNotAvailable) .failure(.backwardStreamNotAvailable)
} }
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> { func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result<Void, RoomProxyError> {
.failure(.failedSendingMessage) .failure(.failedSendingMessage)
} }

View File

@ -175,17 +175,24 @@ class RoomProxy: RoomProxyProtocol {
.value .value
} }
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> { func sendMessage(_ message: String, inReplyToEventId: String? = nil) async -> Result<Void, RoomProxyError> {
sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true) sendMessageBgTask = backgroundTaskService.startBackgroundTask(withName: "SendMessage", isReusable: true)
defer { defer {
sendMessageBgTask?.stop() sendMessageBgTask?.stop()
} }
let messageContent = messageEventContentFromMarkdown(md: message)
let transactionId = genTransactionId() let transactionId = genTransactionId()
return await Task(priority: .high) { () -> Result<Void, RoomProxyError> in return await Task(priority: .high) { () -> Result<Void, RoomProxyError> in
do { 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) try self.room.send(msg: messageContent, txnId: transactionId)
// }
return .success(()) return .success(())
} catch { } catch {
return .failure(.failedSendingMessage) return .failure(.failedSendingMessage)

View File

@ -55,9 +55,15 @@ protocol RoomProxyProtocol {
func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError> func paginateBackwards(count: UInt) async -> Result<Void, RoomProxyError>
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> func sendMessage(_ message: String, inReplyToEventId: String?) async -> Result<Void, RoomProxyError>
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get } var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
} }
extension RoomProxyProtocol {
func sendMessage(_ message: String) async -> Result<Void, RoomProxyError> {
await sendMessage(message, inReplyToEventId: nil)
}
}

View File

@ -66,5 +66,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
func sendMessage(_ message: String) async { } func sendMessage(_ message: String) async { }
func sendReply(_ message: String, to itemId: String) async { }
func redact(_ eventID: String) async { } func redact(_ eventID: String) async { }
} }

View File

@ -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 { func redact(_ eventID: String) async {
switch await timelineProvider.redact(eventID) { switch await timelineProvider.redact(eventID) {
default: default:
break break
} }
} }
// MARK: - Private // MARK: - Private
@objc private func contentSizeCategoryDidChange() { @objc private func contentSizeCategoryDidChange() {

View File

@ -41,5 +41,7 @@ protocol RoomTimelineControllerProtocol {
func sendMessage(_ message: String) async func sendMessage(_ message: String) async
func sendReply(_ message: String, to itemId: String) async
func redact(_ eventID: String) async func redact(_ eventID: String) async
} }

View File

@ -51,8 +51,8 @@ class RoomTimelineProvider: RoomTimelineProviderProtocol {
} }
} }
func sendMessage(_ message: String) async -> Result<Void, RoomTimelineProviderError> { func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result<Void, RoomTimelineProviderError> {
switch await roomProxy.sendMessage(message) { switch await roomProxy.sendMessage(message, inReplyToEventId: inReplyToItemId) {
case .success: case .success:
return .success(()) return .success(())
case .failure: case .failure:

View File

@ -35,7 +35,13 @@ protocol RoomTimelineProviderProtocol {
func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError> func paginateBackwards(_ count: UInt) async -> Result<Void, RoomTimelineProviderError>
func sendMessage(_ message: String) async -> Result<Void, RoomTimelineProviderError> func sendMessage(_ message: String, inReplyToItemId: String?) async -> Result<Void, RoomTimelineProviderError>
func redact(_ eventID: String) async -> Result<Void, RoomTimelineProviderError> func redact(_ eventID: String) async -> Result<Void, RoomTimelineProviderError>
} }
extension RoomTimelineProviderProtocol {
func sendMessage(_ message: String) async -> Result<Void, RoomTimelineProviderError> {
await sendMessage(message, inReplyToItemId: nil)
}
}

1
changelog.d/114.feature Normal file
View File

@ -0,0 +1 @@
Implemented timeline item repyling