mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 13:37:11 +00:00
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:
parent
c081e538b4
commit
7e1476d973
@ -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";
|
||||||
|
@ -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))
|
||||||
|
@ -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 {
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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):
|
||||||
|
@ -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 {
|
||||||
|
@ -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 { }
|
||||||
|
@ -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)")
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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)")
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_messageComposer-iPad-en-GB.1.png
(Stored with Git LFS)
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_messageComposer-iPad-en-GB.1.png
(Stored with Git LFS)
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_messageComposer-iPad-pseudo.1.png
(Stored with Git LFS)
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_messageComposer-iPad-pseudo.1.png
(Stored with Git LFS)
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_messageComposer-iPhone-16-en-GB.1.png
(Stored with Git LFS)
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_messageComposer-iPhone-16-en-GB.1.png
(Stored with Git LFS)
Binary file not shown.
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_messageComposer-iPhone-16-pseudo.1.png
(Stored with Git LFS)
BIN
PreviewTests/Sources/__Snapshots__/PreviewTests/test_messageComposer-iPhone-16-pseudo.1.png
(Stored with Git LFS)
Binary file not shown.
@ -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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user