Add support for adding/editing/removing media captions. (#3547)

* Add support for editing media captions.

* Add composer snapshots.

* PR comment.
This commit is contained in:
Doug 2024-11-21 18:18:27 +00:00 committed by GitHub
parent c081e538b4
commit 7e1476d973
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 178 additions and 61 deletions

View File

@ -22,6 +22,7 @@
"a11y_voice_message_record" = "Record voice message."; "a11y_voice_message_record" = "Record voice message.";
"a11y_voice_message_stop_recording" = "Stop recording"; "a11y_voice_message_stop_recording" = "Stop recording";
"action_accept" = "Accept"; "action_accept" = "Accept";
"action_add_caption" = "Add caption";
"action_add_to_timeline" = "Add to timeline"; "action_add_to_timeline" = "Add to timeline";
"action_back" = "Back"; "action_back" = "Back";
"action_call" = "Call"; "action_call" = "Call";
@ -47,6 +48,7 @@
"action_discard" = "Discard"; "action_discard" = "Discard";
"action_done" = "Done"; "action_done" = "Done";
"action_edit" = "Edit"; "action_edit" = "Edit";
"action_edit_caption" = "Edit caption";
"action_edit_poll" = "Edit poll"; "action_edit_poll" = "Edit poll";
"action_enable" = "Enable"; "action_enable" = "Enable";
"action_end_poll" = "End poll"; "action_end_poll" = "End poll";
@ -81,6 +83,7 @@
"action_react" = "React"; "action_react" = "React";
"action_reject" = "Reject"; "action_reject" = "Reject";
"action_remove" = "Remove"; "action_remove" = "Remove";
"action_remove_caption" = "Remove caption";
"action_reply" = "Reply"; "action_reply" = "Reply";
"action_reply_in_thread" = "Reply in thread"; "action_reply_in_thread" = "Reply in thread";
"action_report_bug" = "Report bug"; "action_report_bug" = "Report bug";
@ -119,6 +122,7 @@
"banner_set_up_recovery_title" = "Set up recovery to protect your account"; "banner_set_up_recovery_title" = "Set up recovery to protect your account";
"common_about" = "About"; "common_about" = "About";
"common_acceptable_use_policy" = "Acceptable use policy"; "common_acceptable_use_policy" = "Acceptable use policy";
"common_adding_caption" = "Adding caption";
"common_advanced_settings" = "Advanced settings"; "common_advanced_settings" = "Advanced settings";
"common_analytics" = "Analytics"; "common_analytics" = "Analytics";
"common_appearance" = "Appearance"; "common_appearance" = "Appearance";
@ -137,6 +141,7 @@
"common_direct_chat" = "Direct chat"; "common_direct_chat" = "Direct chat";
"common_edited_suffix" = "(edited)"; "common_edited_suffix" = "(edited)";
"common_editing" = "Editing"; "common_editing" = "Editing";
"common_editing_caption" = "Editing caption";
"common_emote" = "* %1$@ %2$@"; "common_emote" = "* %1$@ %2$@";
"common_encryption" = "Encryption"; "common_encryption" = "Encryption";
"common_encryption_enabled" = "Encryption enabled"; "common_encryption_enabled" = "Encryption enabled";

View File

@ -76,6 +76,8 @@ internal enum L10n {
internal static var a11yVoiceMessageStopRecording: String { return L10n.tr("Localizable", "a11y_voice_message_stop_recording") } internal static var a11yVoiceMessageStopRecording: String { return L10n.tr("Localizable", "a11y_voice_message_stop_recording") }
/// Accept /// Accept
internal static var actionAccept: String { return L10n.tr("Localizable", "action_accept") } internal static var actionAccept: String { return L10n.tr("Localizable", "action_accept") }
/// Add caption
internal static var actionAddCaption: String { return L10n.tr("Localizable", "action_add_caption") }
/// Add to timeline /// Add to timeline
internal static var actionAddToTimeline: String { return L10n.tr("Localizable", "action_add_to_timeline") } internal static var actionAddToTimeline: String { return L10n.tr("Localizable", "action_add_to_timeline") }
/// Back /// Back
@ -126,6 +128,8 @@ internal enum L10n {
internal static var actionDone: String { return L10n.tr("Localizable", "action_done") } internal static var actionDone: String { return L10n.tr("Localizable", "action_done") }
/// Edit /// Edit
internal static var actionEdit: String { return L10n.tr("Localizable", "action_edit") } internal static var actionEdit: String { return L10n.tr("Localizable", "action_edit") }
/// Edit caption
internal static var actionEditCaption: String { return L10n.tr("Localizable", "action_edit_caption") }
/// Edit poll /// Edit poll
internal static var actionEditPoll: String { return L10n.tr("Localizable", "action_edit_poll") } internal static var actionEditPoll: String { return L10n.tr("Localizable", "action_edit_poll") }
/// Enable /// Enable
@ -198,6 +202,8 @@ internal enum L10n {
internal static var actionReject: String { return L10n.tr("Localizable", "action_reject") } internal static var actionReject: String { return L10n.tr("Localizable", "action_reject") }
/// Remove /// Remove
internal static var actionRemove: String { return L10n.tr("Localizable", "action_remove") } internal static var actionRemove: String { return L10n.tr("Localizable", "action_remove") }
/// Remove caption
internal static var actionRemoveCaption: String { return L10n.tr("Localizable", "action_remove_caption") }
/// Reply /// Reply
internal static var actionReply: String { return L10n.tr("Localizable", "action_reply") } internal static var actionReply: String { return L10n.tr("Localizable", "action_reply") }
/// Reply in thread /// Reply in thread
@ -276,6 +282,8 @@ internal enum L10n {
internal static var commonAbout: String { return L10n.tr("Localizable", "common_about") } internal static var commonAbout: String { return L10n.tr("Localizable", "common_about") }
/// Acceptable use policy /// Acceptable use policy
internal static var commonAcceptableUsePolicy: String { return L10n.tr("Localizable", "common_acceptable_use_policy") } internal static var commonAcceptableUsePolicy: String { return L10n.tr("Localizable", "common_acceptable_use_policy") }
/// Adding caption
internal static var commonAddingCaption: String { return L10n.tr("Localizable", "common_adding_caption") }
/// Advanced settings /// Advanced settings
internal static var commonAdvancedSettings: String { return L10n.tr("Localizable", "common_advanced_settings") } internal static var commonAdvancedSettings: String { return L10n.tr("Localizable", "common_advanced_settings") }
/// Analytics /// Analytics
@ -312,6 +320,8 @@ internal enum L10n {
internal static var commonEditedSuffix: String { return L10n.tr("Localizable", "common_edited_suffix") } internal static var commonEditedSuffix: String { return L10n.tr("Localizable", "common_edited_suffix") }
/// Editing /// Editing
internal static var commonEditing: String { return L10n.tr("Localizable", "common_editing") } internal static var commonEditing: String { return L10n.tr("Localizable", "common_editing") }
/// Editing caption
internal static var commonEditingCaption: String { return L10n.tr("Localizable", "common_editing_caption") }
/// * %1$@ %2$@ /// * %1$@ %2$@
internal static func commonEmote(_ p1: Any, _ p2: Any) -> String { internal static func commonEmote(_ p1: Any, _ p2: Any) -> String {
return L10n.tr("Localizable", "common_emote", String(describing: p1), String(describing: p2)) return L10n.tr("Localizable", "common_emote", String(describing: p1), String(describing: p2))

View File

@ -14133,8 +14133,8 @@ class TimelineProxyMock: TimelineProxyProtocol {
var editNewContentCalled: Bool { var editNewContentCalled: Bool {
return editNewContentCallsCount > 0 return editNewContentCallsCount > 0
} }
var editNewContentReceivedArguments: (eventOrTransactionID: EventOrTransactionId, newContent: RoomMessageEventContentWithoutRelation)? var editNewContentReceivedArguments: (eventOrTransactionID: EventOrTransactionId, newContent: EditedContent)?
var editNewContentReceivedInvocations: [(eventOrTransactionID: EventOrTransactionId, newContent: RoomMessageEventContentWithoutRelation)] = [] var editNewContentReceivedInvocations: [(eventOrTransactionID: EventOrTransactionId, newContent: EditedContent)] = []
var editNewContentUnderlyingReturnValue: Result<Void, TimelineProxyError>! var editNewContentUnderlyingReturnValue: Result<Void, TimelineProxyError>!
var editNewContentReturnValue: Result<Void, TimelineProxyError>! { var editNewContentReturnValue: Result<Void, TimelineProxyError>! {
@ -14160,9 +14160,9 @@ class TimelineProxyMock: TimelineProxyProtocol {
} }
} }
} }
var editNewContentClosure: ((EventOrTransactionId, RoomMessageEventContentWithoutRelation) async -> Result<Void, TimelineProxyError>)? var editNewContentClosure: ((EventOrTransactionId, EditedContent) async -> Result<Void, TimelineProxyError>)?
func edit(_ eventOrTransactionID: EventOrTransactionId, newContent: RoomMessageEventContentWithoutRelation) async -> Result<Void, TimelineProxyError> { func edit(_ eventOrTransactionID: EventOrTransactionId, newContent: EditedContent) async -> Result<Void, TimelineProxyError> {
editNewContentCallsCount += 1 editNewContentCallsCount += 1
editNewContentReceivedArguments = (eventOrTransactionID: eventOrTransactionID, newContent: newContent) editNewContentReceivedArguments = (eventOrTransactionID: eventOrTransactionID, newContent: newContent)
DispatchQueue.main.async { DispatchQueue.main.async {

View File

@ -284,9 +284,11 @@ extension FormatType {
} }
enum ComposerMode: Equatable { enum ComposerMode: Equatable {
enum EditType { case `default`, addCaption, editCaption }
case `default` case `default`
case reply(eventID: String, replyDetails: TimelineItemReplyDetails, isThread: Bool) case reply(eventID: String, replyDetails: TimelineItemReplyDetails, isThread: Bool)
case edit(originalEventOrTransactionID: EventOrTransactionId) case edit(originalEventOrTransactionID: EventOrTransactionId, type: EditType)
case recordVoiceMessage(state: AudioRecorderState) case recordVoiceMessage(state: AudioRecorderState)
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource, isUploading: Bool) case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource, isUploading: Bool)

View File

@ -267,7 +267,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
case .newMessage: case .newMessage:
set(mode: .default) set(mode: .default)
case .edit(let eventID): case .edit(let eventID):
set(mode: .edit(originalEventOrTransactionID: .eventId(eventId: eventID))) set(mode: .edit(originalEventOrTransactionID: .eventId(eventId: eventID), type: .default))
case .reply(let eventID): case .reply(let eventID):
set(mode: .reply(eventID: eventID, replyDetails: .loading(eventID: eventID), isThread: false)) set(mode: .reply(eventID: eventID, replyDetails: .loading(eventID: eventID), isThread: false))
replyLoadingTask = Task { replyLoadingTask = Task {
@ -323,7 +323,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
switch state.composerMode { switch state.composerMode {
case .default: case .default:
type = .newMessage type = .newMessage
case .edit(.eventId(let originalEventID)): case .edit(.eventId(let originalEventID), .default):
type = .edit(eventID: originalEventID) type = .edit(eventID: originalEventID)
case .reply(let eventID, _, _): case .reply(let eventID, _, _):
type = .reply(eventID: eventID) type = .reply(eventID: eventID)

View File

@ -82,8 +82,8 @@ struct MessageComposer: View {
switch mode { switch mode {
case .reply(_, let replyDetails, _): case .reply(_, let replyDetails, _):
MessageComposerReplyHeader(replyDetails: replyDetails, action: cancellationAction) MessageComposerReplyHeader(replyDetails: replyDetails, action: cancellationAction)
case .edit: case .edit(_, let editType):
MessageComposerEditHeader(action: cancellationAction) MessageComposerEditHeader(editType: editType, action: cancellationAction)
case .recordVoiceMessage, .previewVoiceMessage, .default: case .recordVoiceMessage, .previewVoiceMessage, .default:
EmptyView() EmptyView()
} }
@ -152,14 +152,20 @@ private struct MessageComposerReplyHeader: View {
} }
private struct MessageComposerEditHeader: View { private struct MessageComposerEditHeader: View {
let editType: ComposerMode.EditType
let action: () -> Void let action: () -> Void
private var title: String {
switch editType {
case .default: L10n.commonEditing
case .addCaption: L10n.commonAddingCaption
case .editCaption: L10n.commonEditingCaption
}
}
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
Label(L10n.commonEditing, Label(title, icon: \.editSolid, iconSize: .xSmall, relativeTo: .compound.bodySMSemibold)
icon: \.editSolid,
iconSize: .xSmall,
relativeTo: .compound.bodySMSemibold)
.labelStyle(MessageComposerHeaderLabelStyle()) .labelStyle(MessageComposerHeaderLabelStyle())
Spacer() Spacer()
Button(action: action) { Button(action: action) {
@ -294,13 +300,20 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
messageComposer() messageComposer()
messageComposer(.init(string: "Some message"), messageComposer(.init(string: "Some message"),
mode: .edit(originalEventOrTransactionID: .eventId(eventId: UUID().uuidString))) mode: .edit(originalEventOrTransactionID: .eventId(eventId: UUID().uuidString), type: .default))
messageComposer(mode: .reply(eventID: UUID().uuidString, messageComposer(mode: .reply(eventID: UUID().uuidString,
replyDetails: .loaded(sender: .init(id: "Kirk"), replyDetails: .loaded(sender: .init(id: "Kirk"),
eventID: "123", eventID: "123",
eventContent: .message(.text(.init(body: "Text: Where the wild things are")))), eventContent: .message(.text(.init(body: "Text: Where the wild things are")))),
isThread: false)) isThread: false))
Color.clear.frame(height: 20)
messageComposer(.init(string: "Some new caption"),
mode: .edit(originalEventOrTransactionID: .eventId(eventId: UUID().uuidString), type: .addCaption))
messageComposer(.init(string: "Some updated caption"),
mode: .edit(originalEventOrTransactionID: .eventId(eventId: UUID().uuidString), type: .editCaption))
} }
.padding(.horizontal) .padding(.horizontal)

View File

@ -100,10 +100,7 @@ class TimelineInteractionHandler {
switch action { switch action {
case .copy: case .copy:
guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { return }
return
}
UIPasteboard.general.string = messageTimelineItem.body UIPasteboard.general.string = messageTimelineItem.body
case .edit: case .edit:
switch timelineItem { switch timelineItem {
@ -118,6 +115,19 @@ class TimelineInteractionHandler {
default: default:
MXLog.error("Cannot edit item with id: \(timelineItem.id)") MXLog.error("Cannot edit item with id: \(timelineItem.id)")
} }
case .addCaption, .editCaption:
switch timelineItem {
case let messageTimelineItem as EventBasedMessageTimelineItemProtocol:
processEditMessageEvent(messageTimelineItem)
default:
MXLog.error("Cannot add/edit caption on item with id: \(timelineItem.id)")
}
case .removeCaption:
guard case let .event(_, eventOrTransactionID) = timelineItem.id else {
MXLog.error("Failed removing caption, missing event ID")
return
}
Task { await timelineController.removeCaption(eventOrTransactionID) }
case .copyPermalink: case .copyPermalink:
guard let eventID = eventTimelineItem.id.eventID else { guard let eventID = eventTimelineItem.id.eventID else {
actionsSubject.send(.displayErrorToast(L10n.errorFailedCreatingThePermalink)) actionsSubject.send(.displayErrorToast(L10n.errorFailedCreatingThePermalink))
@ -133,17 +143,10 @@ class TimelineInteractionHandler {
UIPasteboard.general.url = permalinkURL UIPasteboard.general.url = permalinkURL
} }
case .redact: case .redact:
guard case let .event(_, eventOrTransactionID) = itemID else { guard case let .event(_, eventOrTransactionID) = itemID else { fatalError() }
fatalError() Task { await timelineController.redact(eventOrTransactionID) }
}
Task {
await timelineController.redact(eventOrTransactionID)
}
case .reply: case .reply:
guard let eventID = eventTimelineItem.id.eventID else { guard let eventID = eventTimelineItem.id.eventID else { return }
return
}
let replyInfo = buildReplyInfo(for: eventTimelineItem) let replyInfo = buildReplyInfo(for: eventTimelineItem)
let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, eventID: eventID, eventContent: replyInfo.type) let replyDetails = TimelineItemReplyDetails.loaded(sender: eventTimelineItem.sender, eventID: eventID, eventContent: replyInfo.type)
@ -156,21 +159,14 @@ class TimelineInteractionHandler {
MXLog.info("Showing debug info for \(eventTimelineItem.id)") MXLog.info("Showing debug info for \(eventTimelineItem.id)")
actionsSubject.send(.showDebugInfo(debugInfo)) actionsSubject.send(.showDebugInfo(debugInfo))
case .retryDecryption(let sessionID): case .retryDecryption(let sessionID):
Task { Task { await timelineController.retryDecryption(for: sessionID) }
await timelineController.retryDecryption(for: sessionID)
}
case .report: case .report:
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: eventTimelineItem.sender.id)) actionsSubject.send(.displayReportContent(itemID: itemID, senderID: eventTimelineItem.sender.id))
case .react: case .react:
displayEmojiPicker(for: itemID) displayEmojiPicker(for: itemID)
case .toggleReaction(let key): case .toggleReaction(let key):
Task { guard case let .event(_, eventOrTransactionID) = itemID else { fatalError() }
guard case let .event(_, eventOrTransactionID) = itemID else { Task { await timelineController.toggleReaction(key, to: eventOrTransactionID) }
fatalError()
}
await timelineController.toggleReaction(key, to: eventOrTransactionID)
}
case .endPoll(let pollStartID): case .endPoll(let pollStartID):
endPoll(pollStartID: pollStartID) endPoll(pollStartID: pollStartID)
case .pin: case .pin:
@ -202,18 +198,35 @@ class TimelineInteractionHandler {
let text: String let text: String
var htmlText: String? var htmlText: String?
var editType = ComposerMode.EditType.default
switch messageTimelineItem.contentType { switch messageTimelineItem.contentType {
case .text(let content): case .text(let content):
text = content.body text = content.body
htmlText = content.formattedBodyHTMLString htmlText = content.formattedBodyHTMLString
case .emote(let content): case .emote(let content):
text = "/me " + content.body text = "/me " + content.body
case .audio(let content):
text = content.caption ?? ""
htmlText = content.formattedCaptionHTMLString
editType = text.isEmpty ? .addCaption : .editCaption
case .file(let content):
text = content.caption ?? ""
htmlText = content.formattedCaptionHTMLString
editType = text.isEmpty ? .addCaption : .editCaption
case .image(let content):
text = content.caption ?? ""
htmlText = content.formattedCaptionHTMLString
editType = text.isEmpty ? .addCaption : .editCaption
case .video(let content):
text = content.caption ?? ""
htmlText = content.formattedCaptionHTMLString
editType = text.isEmpty ? .addCaption : .editCaption
default: default:
text = messageTimelineItem.body text = messageTimelineItem.body
} }
// Always update the mode first and then the text so that the composer has time to save the text draft // Always update the mode first and then the text so that the composer has time to save the text draft
actionsSubject.send(.composer(action: .setMode(mode: .edit(originalEventOrTransactionID: eventOrTransactionID)))) actionsSubject.send(.composer(action: .setMode(mode: .edit(originalEventOrTransactionID: eventOrTransactionID, type: editType))))
actionsSubject.send(.composer(action: .setText(plainText: text, htmlText: htmlText))) actionsSubject.send(.composer(action: .setText(plainText: text, htmlText: htmlText)))
} }

View File

@ -606,11 +606,17 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
html: html, html: html,
inReplyToEventID: eventID, inReplyToEventID: eventID,
intentionalMentions: intentionalMentions) intentionalMentions: intentionalMentions)
case .edit(let originalEventOrTransactionID): case .edit(let originalEventOrTransactionID, .default):
await timelineController.edit(originalEventOrTransactionID, await timelineController.edit(originalEventOrTransactionID,
message: message, message: message,
html: html, html: html,
intentionalMentions: intentionalMentions) intentionalMentions: intentionalMentions)
case .edit(let originalEventOrTransactionID, .addCaption),
.edit(let originalEventOrTransactionID, .editCaption):
await timelineController.editCaption(originalEventOrTransactionID,
message: message,
html: html,
intentionalMentions: intentionalMentions)
case .default: case .default:
switch slashCommand(message: message) { switch slashCommand(message: message) {
case .join: case .join:

View File

@ -57,6 +57,9 @@ struct TimelineItemMenuReaction: Hashable {
enum TimelineItemMenuAction: Identifiable, Hashable { enum TimelineItemMenuAction: Identifiable, Hashable {
case copy case copy
case edit case edit
case addCaption
case editCaption
case removeCaption
case copyPermalink case copyPermalink
case redact case redact
case reply(isThread: Bool) case reply(isThread: Bool)
@ -76,7 +79,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
/// Whether the item should cancel a reply/edit occurring in the composer. /// Whether the item should cancel a reply/edit occurring in the composer.
var switchToDefaultComposer: Bool { var switchToDefaultComposer: Bool {
switch self { switch self {
case .reply, .edit: case .reply, .edit, .addCaption, .editCaption:
return false return false
default: default:
return true return true
@ -106,7 +109,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
/// Whether or not the action is destructive. /// Whether or not the action is destructive.
var isDestructive: Bool { var isDestructive: Bool {
switch self { switch self {
case .redact, .report: case .redact, .report, .removeCaption:
return true return true
default: default:
return false return false
@ -130,6 +133,12 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
Label(L10n.actionCopy, icon: \.copy) Label(L10n.actionCopy, icon: \.copy)
case .edit: case .edit:
Label(L10n.actionEdit, icon: \.edit) Label(L10n.actionEdit, icon: \.edit)
case .addCaption:
Label(L10n.actionAddCaption, icon: \.edit)
case .editCaption:
Label(L10n.actionEditCaption, icon: \.edit)
case .removeCaption:
Label(L10n.actionRemoveCaption, icon: \.delete)
case .copyPermalink: case .copyPermalink:
Label(L10n.actionCopyLinkToMessage, icon: \.link) Label(L10n.actionCopyLinkToMessage, icon: \.link)
case .reply(let isThread): case .reply(let isThread):

View File

@ -65,7 +65,15 @@ struct TimelineItemMenuActionProvider {
} }
if item.isEditable { if item.isEditable {
actions.append(.edit) if let messageItem = item as? EventBasedMessageTimelineItemProtocol, messageItem.supportsMediaCaption {
if messageItem.hasMediaCaption {
actions.append(contentsOf: [.editCaption, .removeCaption])
} else {
actions.append(.addCaption)
}
} else if !(item is VoiceMessageRoomTimelineItem) {
actions.append(.edit)
}
} }
if canCurrentUserPin, let eventID = item.id.eventID { if canCurrentUserPin, let eventID = item.id.eventID {

View File

@ -91,6 +91,13 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
html: String?, html: String?,
intentionalMentions: IntentionalMentions) async { } intentionalMentions: IntentionalMentions) async { }
func editCaption(_ eventOrTransactionID: EventOrTransactionId,
message: String,
html: String?,
intentionalMentions: IntentionalMentions) async { }
func removeCaption(_ eventOrTransactionID: EventOrTransactionId) async { }
func redact(_ eventOrTransactionID: EventOrTransactionId) async { } func redact(_ eventOrTransactionID: EventOrTransactionId) async { }
func pin(eventID: String) async { } func pin(eventID: String) async { }

View File

@ -238,7 +238,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
html: html, html: html,
intentionalMentions: intentionalMentions.toRustMentions()) intentionalMentions: intentionalMentions.toRustMentions())
switch await activeTimeline.edit(eventOrTransactionID, newContent: messageContent) { switch await activeTimeline.edit(eventOrTransactionID, newContent: .roomMessage(content: messageContent)) {
case .success: case .success:
MXLog.info("Finished editing message by event") MXLog.info("Finished editing message by event")
case let .failure(error): case let .failure(error):
@ -246,6 +246,34 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
} }
} }
func editCaption(_ eventOrTransactionID: EventOrTransactionId,
message: String,
html: String?,
intentionalMentions: IntentionalMentions) async {
// We're waiting on an API for including mentions: https://github.com/matrix-org/matrix-rust-sdk/issues/4302
MXLog.info("Editing timeline item caption: \(eventOrTransactionID) in \(roomID)")
// When formattedCaption is nil, caption will be parsed as markdown and generate the HTML for us.
let newContent = createCaptionEdit(caption: message, formattedCaption: html.map { .init(format: .html, body: $0) })
switch await activeTimeline.edit(eventOrTransactionID, newContent: newContent) {
case .success:
MXLog.info("Finished editing caption")
case let .failure(error):
MXLog.error("Failed editing caption with error: \(error)")
}
}
func removeCaption(_ eventOrTransactionID: EventOrTransactionId) async {
// Set a `nil` caption to remove it from the event.
let newContent = createCaptionEdit(caption: nil, formattedCaption: nil)
switch await activeTimeline.edit(eventOrTransactionID, newContent: newContent) {
case .success:
MXLog.info("Finished removing caption.")
case let .failure(error):
MXLog.error("Failed removing caption with error: \(error)")
}
}
func redact(_ eventOrTransactionID: EventOrTransactionId) async { func redact(_ eventOrTransactionID: EventOrTransactionId) async {
MXLog.info("Send redaction in \(roomID)") MXLog.info("Send redaction in \(roomID)")

View File

@ -56,6 +56,13 @@ protocol RoomTimelineControllerProtocol {
html: String?, html: String?,
intentionalMentions: IntentionalMentions) async intentionalMentions: IntentionalMentions) async
func editCaption(_ eventOrTransactionID: EventOrTransactionId,
message: String,
html: String?,
intentionalMentions: IntentionalMentions) async
func removeCaption(_ eventOrTransactionID: EventOrTransactionId) async
func toggleReaction(_ reaction: String, to eventOrTransactionID: EventOrTransactionId) async func toggleReaction(_ reaction: String, to eventOrTransactionID: EventOrTransactionId) async
func redact(_ eventOrTransactionID: EventOrTransactionId) async func redact(_ eventOrTransactionID: EventOrTransactionId) async

View File

@ -26,6 +26,15 @@ protocol EventBasedMessageTimelineItemProtocol: EventBasedTimelineItemProtocol {
} }
extension EventBasedMessageTimelineItemProtocol { extension EventBasedMessageTimelineItemProtocol {
var supportsMediaCaption: Bool {
switch contentType {
case .audio, .file, .image, .video:
true
case .emote, .notice, .text, .location, .voice:
false
}
}
var hasMediaCaption: Bool { var hasMediaCaption: Bool {
switch contentType { switch contentType {
case .audio(let content): case .audio(let content):

View File

@ -164,9 +164,9 @@ final class TimelineProxy: TimelineProxyProtocol {
} }
} }
func edit(_ eventOrTransactionID: EventOrTransactionId, newContent: RoomMessageEventContentWithoutRelation) async -> Result<Void, TimelineProxyError> { func edit(_ eventOrTransactionID: EventOrTransactionId, newContent: EditedContent) async -> Result<Void, TimelineProxyError> {
do { do {
try await timeline.edit(eventOrTransactionId: eventOrTransactionID, newContent: .roomMessage(content: newContent)) try await timeline.edit(eventOrTransactionId: eventOrTransactionID, newContent: newContent)
MXLog.info("Finished editing timeline item: \(eventOrTransactionID)") MXLog.info("Finished editing timeline item: \(eventOrTransactionID)")

View File

@ -38,7 +38,7 @@ protocol TimelineProxyProtocol {
func paginateForwards(requestSize: UInt16) async -> Result<Void, TimelineProxyError> func paginateForwards(requestSize: UInt16) async -> Result<Void, TimelineProxyError>
func edit(_ eventOrTransactionID: EventOrTransactionId, func edit(_ eventOrTransactionID: EventOrTransactionId,
newContent: RoomMessageEventContentWithoutRelation) async -> Result<Void, TimelineProxyError> newContent: EditedContent) async -> Result<Void, TimelineProxyError>
func redact(_ eventOrTransactionID: EventOrTransactionId, func redact(_ eventOrTransactionID: EventOrTransactionId,
reason: String?) async -> Result<Void, TimelineProxyError> reason: String?) async -> Result<Void, TimelineProxyError>

View File

@ -31,14 +31,14 @@ class ComposerToolbarViewModelTests: XCTestCase {
} }
func testComposerFocus() { func testComposerFocus() {
viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventId(eventId: "mock")))) viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventId(eventId: "mock"), type: .default)))
XCTAssertTrue(viewModel.state.bindings.composerFocused) XCTAssertTrue(viewModel.state.bindings.composerFocused)
viewModel.process(timelineAction: .removeFocus) viewModel.process(timelineAction: .removeFocus)
XCTAssertFalse(viewModel.state.bindings.composerFocused) XCTAssertFalse(viewModel.state.bindings.composerFocused)
} }
func testComposerMode() { func testComposerMode() {
let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventId(eventId: "mock")) let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventId(eventId: "mock"), type: .default)
viewModel.process(timelineAction: .setMode(mode: mode)) viewModel.process(timelineAction: .setMode(mode: mode))
XCTAssertEqual(viewModel.state.composerMode, mode) XCTAssertEqual(viewModel.state.composerMode, mode)
viewModel.process(timelineAction: .clear) viewModel.process(timelineAction: .clear)
@ -46,7 +46,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
} }
func testComposerModeIsPublished() { func testComposerModeIsPublished() {
let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventId(eventId: "mock")) let mode: ComposerMode = .edit(originalEventOrTransactionID: .eventId(eventId: "mock"), type: .default)
let expectation = expectation(description: "Composer mode is published") let expectation = expectation(description: "Composer mode is published")
let cancellable = viewModel let cancellable = viewModel
.context .context
@ -226,7 +226,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
} }
viewModel.context.composerFormattingEnabled = false viewModel.context.composerFormattingEnabled = false
viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventId(eventId: "testID")))) viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventId(eventId: "testID"), type: .default)))
viewModel.context.plainComposerText = .init(string: "Hello world!") viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.saveDraft() viewModel.saveDraft()
@ -385,7 +385,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
await fulfillment(of: [expectation], timeout: 10) await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled) XCTAssertFalse(viewModel.context.composerFormattingEnabled)
XCTAssertEqual(viewModel.state.composerMode, .edit(originalEventOrTransactionID: .eventId(eventId: "testID"))) XCTAssertEqual(viewModel.state.composerMode, .edit(originalEventOrTransactionID: .eventId(eventId: "testID"), type: .default))
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world!")) XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world!"))
} }
@ -473,7 +473,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
func testSaveVolatileDraftWhenEditing() { func testSaveVolatileDraftWhenEditing() {
viewModel.context.composerFormattingEnabled = false viewModel.context.composerFormattingEnabled = false
viewModel.context.plainComposerText = .init(string: "Hello world!") viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventId(eventId: UUID().uuidString)))) viewModel.process(timelineAction: .setMode(mode: .edit(originalEventOrTransactionID: .eventId(eventId: UUID().uuidString), type: .default)))
let draft = draftServiceMock.saveVolatileDraftReceivedDraft let draft = draftServiceMock.saveVolatileDraftReceivedDraft
XCTAssertNotNil(draft) XCTAssertNotNil(draft)