Add support for copying a caption. (#3563)

* Add timeline item action for copying a media caption.

* Fix the action colours when long pressing a timeline item.

Nice side effect: Reasonable sized buttons when button shapes are enabled.

* Re-arrange the Timeline Item Menu.

* Add timeline item menu snapshots for media items.
This commit is contained in:
Doug
2024-11-29 12:26:59 +00:00
committed by GitHub
parent fdbacbc22d
commit c8627cfd64
56 changed files with 401 additions and 199 deletions
@@ -36,8 +36,10 @@
"action_confirm_password" = "Confirm password"; "action_confirm_password" = "Confirm password";
"action_continue" = "Continue"; "action_continue" = "Continue";
"action_copy" = "Copy"; "action_copy" = "Copy";
"action_copy_caption" = "Copy caption";
"action_copy_link" = "Copy link"; "action_copy_link" = "Copy link";
"action_copy_link_to_message" = "Copy link to message"; "action_copy_link_to_message" = "Copy link to message";
"action_copy_text" = "Copy text";
"action_create" = "Create"; "action_create" = "Create";
"action_create_a_room" = "Create a room"; "action_create_a_room" = "Create a room";
"action_deactivate" = "Deactivate"; "action_deactivate" = "Deactivate";
@@ -84,6 +86,7 @@
"action_reject" = "Reject"; "action_reject" = "Reject";
"action_remove" = "Remove"; "action_remove" = "Remove";
"action_remove_caption" = "Remove caption"; "action_remove_caption" = "Remove caption";
"action_remove_message" = "Remove message";
"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";
+6
View File
@@ -104,10 +104,14 @@ internal enum L10n {
internal static var actionContinue: String { return L10n.tr("Localizable", "action_continue") } internal static var actionContinue: String { return L10n.tr("Localizable", "action_continue") }
/// Copy /// Copy
internal static var actionCopy: String { return L10n.tr("Localizable", "action_copy") } internal static var actionCopy: String { return L10n.tr("Localizable", "action_copy") }
/// Copy caption
internal static var actionCopyCaption: String { return L10n.tr("Localizable", "action_copy_caption") }
/// Copy link /// Copy link
internal static var actionCopyLink: String { return L10n.tr("Localizable", "action_copy_link") } internal static var actionCopyLink: String { return L10n.tr("Localizable", "action_copy_link") }
/// Copy link to message /// Copy link to message
internal static var actionCopyLinkToMessage: String { return L10n.tr("Localizable", "action_copy_link_to_message") } internal static var actionCopyLinkToMessage: String { return L10n.tr("Localizable", "action_copy_link_to_message") }
/// Copy text
internal static var actionCopyText: String { return L10n.tr("Localizable", "action_copy_text") }
/// Create /// Create
internal static var actionCreate: String { return L10n.tr("Localizable", "action_create") } internal static var actionCreate: String { return L10n.tr("Localizable", "action_create") }
/// Create a room /// Create a room
@@ -204,6 +208,8 @@ internal enum L10n {
internal static var actionRemove: String { return L10n.tr("Localizable", "action_remove") } internal static var actionRemove: String { return L10n.tr("Localizable", "action_remove") }
/// Remove caption /// Remove caption
internal static var actionRemoveCaption: String { return L10n.tr("Localizable", "action_remove_caption") } internal static var actionRemoveCaption: String { return L10n.tr("Localizable", "action_remove_caption") }
/// Remove message
internal static var actionRemoveMessage: String { return L10n.tr("Localizable", "action_remove_message") }
/// 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
@@ -7,14 +7,34 @@
import SwiftUI import SwiftUI
extension LabelStyle where Self == MenuSheetLabelStyle { extension ButtonStyle where Self == MenuSheetButtonStyle {
/// A label style for labels that are within a menu that is being presented as a sheet. /// A button style for buttons that are within a menu that is being presented as a sheet.
static var menuSheet: Self { MenuSheetLabelStyle() } static var menuSheet: Self { MenuSheetButtonStyle() }
} }
/// The style used for labels that are part of a menu that's presented as /// The style used for buttons that are part of a menu that's presented as
/// a sheet as `TimelineItemMenu` and `RoomAttachmentPicker`. /// a sheet such as `TimelineItemMenu`.
struct MenuSheetLabelStyle: LabelStyle { struct MenuSheetButtonStyle: ButtonStyle {
@Environment(\.accessibilityShowButtonShapes) private var accessibilityShowButtonShapes
func makeBody(configuration: Configuration) -> some View {
configuration.label
.labelStyle(MenuSheetLabelStyle())
.foregroundStyle(configuration.role == .destructive ? .compound.textCriticalPrimary : .compound.textActionPrimary)
.contentShape(.rect)
.opacity(configuration.isPressed ? 0.3 : 1)
.background {
if accessibilityShowButtonShapes {
RoundedRectangle(cornerRadius: 12)
.fill(Color(uiColor: .secondarySystemFill))
.opacity(configuration.isPressed ? 0.8 : 1)
.padding(4)
}
}
}
}
private struct MenuSheetLabelStyle: LabelStyle {
var spacing: CGFloat = 16 var spacing: CGFloat = 16
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
@@ -32,7 +32,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .enableTextFormatting) context.send(viewAction: .enableTextFormatting)
} label: { } label: {
Label(L10n.screenRoomAttachmentTextFormatting, icon: \.textFormatting) Label(L10n.screenRoomAttachmentTextFormatting, icon: \.textFormatting)
.labelStyle(.menuSheet)
} }
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerTextFormatting) .accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerTextFormatting)
@@ -40,7 +39,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.poll)) context.send(viewAction: .attach(.poll))
} label: { } label: {
Label(L10n.screenRoomAttachmentSourcePoll, icon: \.polls) Label(L10n.screenRoomAttachmentSourcePoll, icon: \.polls)
.labelStyle(.menuSheet)
} }
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPoll) .accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPoll)
@@ -48,7 +46,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.location)) context.send(viewAction: .attach(.location))
} label: { } label: {
Label(L10n.screenRoomAttachmentSourceLocation, icon: \.locationPin) Label(L10n.screenRoomAttachmentSourceLocation, icon: \.locationPin)
.labelStyle(.menuSheet)
} }
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerLocation) .accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerLocation)
@@ -56,7 +53,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.file)) context.send(viewAction: .attach(.file))
} label: { } label: {
Label(L10n.screenRoomAttachmentSourceFiles, icon: \.attachment) Label(L10n.screenRoomAttachmentSourceFiles, icon: \.attachment)
.labelStyle(.menuSheet)
} }
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerDocuments) .accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerDocuments)
@@ -64,7 +60,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.photoLibrary)) context.send(viewAction: .attach(.photoLibrary))
} label: { } label: {
Label(L10n.screenRoomAttachmentSourceGallery, icon: \.image) Label(L10n.screenRoomAttachmentSourceGallery, icon: \.image)
.labelStyle(.menuSheet)
} }
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPhotoLibrary) .accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPhotoLibrary)
@@ -72,7 +67,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.camera)) context.send(viewAction: .attach(.camera))
} label: { } label: {
Label(L10n.screenRoomAttachmentSourceCamera, icon: \.takePhoto) Label(L10n.screenRoomAttachmentSourceCamera, icon: \.takePhoto)
.labelStyle(.menuSheet)
} }
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerCamera) .accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerCamera)
} }
@@ -102,7 +102,13 @@ class TimelineInteractionHandler {
case .copy: case .copy:
guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { return } guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { return }
UIPasteboard.general.string = messageTimelineItem.body UIPasteboard.general.string = messageTimelineItem.body
case .edit: case .copyCaption:
guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol,
let caption = messageTimelineItem.mediaCaption else {
return
}
UIPasteboard.general.string = caption
case .edit, .addCaption, .editCaption, .editPoll:
switch timelineItem { switch timelineItem {
case let messageTimelineItem as EventBasedMessageTimelineItemProtocol: case let messageTimelineItem as EventBasedMessageTimelineItemProtocol:
processEditMessageEvent(messageTimelineItem) processEditMessageEvent(messageTimelineItem)
@@ -115,13 +121,6 @@ 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: case .removeCaption:
guard case let .event(_, eventOrTransactionID) = timelineItem.id else { guard case let .event(_, eventOrTransactionID) = timelineItem.id else {
MXLog.error("Failed removing caption, missing event ID") MXLog.error("Failed removing caption, missing event ID")
@@ -51,7 +51,7 @@ struct TimelineItemMacContextMenu: View {
} }
Section { Section {
ForEach(menuActions.debugActions) { action in ForEach(menuActions.secondaryActions) { action in
Button(role: action.isDestructive ? .destructive : nil) { Button(role: action.isDestructive ? .destructive : nil) {
send(action) send(action)
} label: { } label: {
@@ -47,7 +47,7 @@ struct TimelineItemMenu: View {
.background(Color.compound.bgSubtlePrimary) .background(Color.compound.bgSubtlePrimary)
} }
viewsForActions(actions.debugActions) viewsForActions(actions.secondaryActions)
} }
} }
} }
@@ -179,8 +179,8 @@ struct TimelineItemMenu: View {
send(action) send(action)
} label: { } label: {
action.label action.label
.labelStyle(.menuSheet)
} }
.buttonStyle(.menuSheet)
} }
} }
@@ -261,22 +261,29 @@ private extension View {
// MARK: - Previews // MARK: - Previews
struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview { struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
enum ItemType { case incomingText, outgoingMedia, outgoingMediaWithCaption }
static let viewModel = TimelineViewModel.mock static let viewModel = TimelineViewModel.mock
static let (item, actions) = makeItem() static let (item, actions) = makeActions()
static let (backupItem, _) = makeItem(authenticity: .notGuaranteed(color: .gray)) static let (backupItem, _) = makeActions(authenticity: .notGuaranteed(color: .gray))
static let (unsignedItem, _) = makeItem(authenticity: .unsignedDevice(color: .red)) static let (unsignedItem, _) = makeActions(authenticity: .unsignedDevice(color: .red))
static let (unencryptedItem, _) = makeItem(authenticity: .sentInClear(color: .red)) static let (unencryptedItem, _) = makeActions(authenticity: .sentInClear(color: .red))
static let (unknownFailureItem, _) = makeItem(deliveryStatus: .sendingFailed(.unknown)) static let (unknownFailureItem, _) = makeActions(deliveryStatus: .sendingFailed(.unknown))
static let (identityChangedItem, _) = makeItem(deliveryStatus: .sendingFailed(.verifiedUser(.changedIdentity(users: [ static let (identityChangedItem, _) = makeActions(deliveryStatus: .sendingFailed(.verifiedUser(.changedIdentity(users: [
"@alice:matrix.org" "@alice:matrix.org"
])))) ]))))
static let (unsignedDevicesItem, _) = makeItem(deliveryStatus: .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: [ static let (unsignedDevicesItem, _) = makeActions(deliveryStatus: .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: [
"@alice:matrix.org": ["DEVICE1", "DEVICE2"] "@alice:matrix.org": ["DEVICE1", "DEVICE2"]
])))) ]))))
static let (ownUnsignedDevicesItem, _) = makeItem(deliveryStatus: .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: [ static let (ownUnsignedDevicesItem, _) = makeActions(deliveryStatus: .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: [
RoomMemberProxyMock.mockMe.userID: ["DEVICE1"] RoomMemberProxyMock.mockMe.userID: ["DEVICE1"]
])))) ]))))
// Media
static let (mediaItem, mediaItemActions) = makeActions(itemType: .outgoingMedia)
static let (mediaItemWithCaption, mediaItemWithCaptionActions) = makeActions(itemType: .outgoingMediaWithCaption)
static var previews: some View { static var previews: some View {
TimelineItemMenu(item: item, actions: actions) TimelineItemMenu(item: item, actions: actions)
.environmentObject(viewModel.context) .environmentObject(viewModel.context)
@@ -314,26 +321,56 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
TimelineItemMenu(item: identityChangedItem, actions: actions) TimelineItemMenu(item: identityChangedItem, actions: actions)
.environmentObject(viewModel.context) .environmentObject(viewModel.context)
.previewDisplayName("Identity Changed") .previewDisplayName("Identity Changed")
// Media
TimelineItemMenu(item: mediaItem, actions: mediaItemActions)
.environmentObject(viewModel.context)
.previewDisplayName("Media")
TimelineItemMenu(item: mediaItemWithCaption, actions: mediaItemWithCaptionActions)
.environmentObject(viewModel.context)
.previewDisplayName("Media with Caption")
} }
static func makeItem(authenticity: EncryptionAuthenticity? = nil, static func makeActions(itemType: ItemType = .incomingText,
deliveryStatus: TimelineItemDeliveryStatus? = nil) -> (TextRoomTimelineItem, TimelineItemMenuActions)! { authenticity: EncryptionAuthenticity? = nil,
guard var item = RoomTimelineItemFixtures.singleMessageChunk.first as? TextRoomTimelineItem, deliveryStatus: TimelineItemDeliveryStatus? = nil) -> (EventBasedTimelineItemProtocol, TimelineItemMenuActions)! {
let actions = TimelineItemMenuActions(isReactable: true, guard var item = makeItem(itemType: itemType) else { return nil }
actions: [.copy, .edit, .reply(isThread: false), .pin, .redact], let provider = TimelineItemMenuActionProvider(timelineItem: item,
debugActions: [.viewSource], canCurrentUserRedactSelf: true,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings)) else { canCurrentUserRedactOthers: false,
return nil canCurrentUserPin: true,
} pinnedEventIDs: [],
isDM: true,
isViewSourceEnabled: true,
isCreateMediaCaptionsEnabled: true,
isPinnedEventsTimeline: false,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
guard let actions = provider.makeActions() else { return nil }
if var textItem = item as? TextRoomTimelineItem {
if let authenticity { if let authenticity {
item.properties.encryptionAuthenticity = authenticity textItem.properties.encryptionAuthenticity = authenticity
} }
if let deliveryStatus { if let deliveryStatus {
item.properties.deliveryStatus = deliveryStatus textItem.properties.deliveryStatus = deliveryStatus
}
item = textItem
} }
return (item, actions) return (item, actions)
} }
static func makeItem(itemType: ItemType) -> EventBasedTimelineItemProtocol? {
switch itemType {
case .incomingText:
RoomTimelineItemFixtures.singleMessageChunk.first as? EventBasedTimelineItemProtocol
case .outgoingMedia:
RoomTimelineItemFixtures.mediaChunk[1] as? EventBasedTimelineItemProtocol
case .outgoingMediaWithCaption:
RoomTimelineItemFixtures.mediaChunk[5] as? EventBasedTimelineItemProtocol
}
}
} }
@@ -13,18 +13,18 @@ import SwiftUI
struct TimelineItemMenuActions { struct TimelineItemMenuActions {
let reactions: [TimelineItemMenuReaction] let reactions: [TimelineItemMenuReaction]
let actions: [TimelineItemMenuAction] let actions: [TimelineItemMenuAction]
let debugActions: [TimelineItemMenuAction] let secondaryActions: [TimelineItemMenuAction]
init?(isReactable: Bool, init?(isReactable: Bool,
actions: [TimelineItemMenuAction], actions: [TimelineItemMenuAction],
debugActions: [TimelineItemMenuAction], secondaryActions: [TimelineItemMenuAction],
emojiProvider: EmojiProviderProtocol) { emojiProvider: EmojiProviderProtocol) {
if !isReactable, actions.isEmpty, debugActions.isEmpty { if !isReactable, actions.isEmpty, secondaryActions.isEmpty {
return nil return nil
} }
self.actions = actions self.actions = actions
self.debugActions = debugActions self.secondaryActions = secondaryActions
var frequentlyUsed: OrderedSet<TimelineItemMenuReaction> = [ var frequentlyUsed: OrderedSet<TimelineItemMenuReaction> = [
.init(key: "👍️", symbol: .handThumbsup), .init(key: "👍️", symbol: .handThumbsup),
@@ -56,10 +56,12 @@ struct TimelineItemMenuReaction: Hashable {
enum TimelineItemMenuAction: Identifiable, Hashable { enum TimelineItemMenuAction: Identifiable, Hashable {
case copy case copy
case copyCaption
case edit case edit
case addCaption case addCaption
case editCaption case editCaption
case removeCaption case removeCaption
case editPoll
case copyPermalink case copyPermalink
case redact case redact
case reply(isThread: Bool) case reply(isThread: Bool)
@@ -79,7 +81,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, .addCaption, .editCaption: case .reply, .edit, .addCaption, .editCaption, .editPoll:
return false return false
default: default:
return true return true
@@ -89,7 +91,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
/// Whether the action should be shown for an item that failed to send. /// Whether the action should be shown for an item that failed to send.
var canAppearInFailedEcho: Bool { var canAppearInFailedEcho: Bool {
switch self { switch self {
case .copy, .edit, .redact, .viewSource: case .copy, .edit, .redact, .viewSource, .editPoll:
return true return true
default: default:
return false return false
@@ -130,7 +132,9 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
var label: some View { var label: some View {
switch self { switch self {
case .copy: case .copy:
Label(L10n.actionCopy, icon: \.copy) Label(L10n.actionCopyText, icon: \.copy)
case .copyCaption:
Label(L10n.actionCopyCaption, icon: \.copy)
case .edit: case .edit:
Label(L10n.actionEdit, icon: \.edit) Label(L10n.actionEdit, icon: \.edit)
case .addCaption: case .addCaption:
@@ -138,7 +142,9 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
case .editCaption: case .editCaption:
Label(L10n.actionEditCaption, icon: \.edit) Label(L10n.actionEditCaption, icon: \.edit)
case .removeCaption: case .removeCaption:
Label(L10n.actionRemoveCaption, icon: \.delete) Label(L10n.actionRemoveCaption, icon: \.close)
case .editPoll:
Label(L10n.actionEditPoll, icon: \.edit)
case .copyPermalink: case .copyPermalink:
Label(L10n.actionCopyLinkToMessage, icon: \.link) Label(L10n.actionCopyLinkToMessage, icon: \.link)
case .reply(let isThread): case .reply(let isThread):
@@ -146,7 +152,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
case .forward: case .forward:
Label(L10n.actionForward, icon: \.forward) Label(L10n.actionForward, icon: \.forward)
case .redact: case .redact:
Label(L10n.actionRemove, icon: \.delete) Label(L10n.actionRemoveMessage, icon: \.delete)
case .viewSource: case .viewSource:
Label(L10n.actionViewSource, icon: \.code) Label(L10n.actionViewSource, icon: \.code)
case .retryDecryption: case .retryDecryption:
@@ -32,26 +32,12 @@ struct TimelineItemMenuActionProvider {
return nil return nil
} }
var debugActions: [TimelineItemMenuAction] = []
if isViewSourceEnabled {
debugActions.append(.viewSource)
}
if let encryptedItem = timelineItem as? EncryptedRoomTimelineItem { if let encryptedItem = timelineItem as? EncryptedRoomTimelineItem {
switch encryptedItem.encryptionType { return makeEncryptedItemActions(encryptedItem)
case .megolmV1AesSha2(let sessionID, _):
debugActions.append(.retryDecryption(sessionID: sessionID))
default:
break
}
return .init(isReactable: false,
actions: [.copyPermalink],
debugActions: debugActions,
emojiProvider: emojiProvider)
} }
var actions: [TimelineItemMenuAction] = [] var actions: [TimelineItemMenuAction] = []
var secondaryActions: [TimelineItemMenuAction] = []
if item.canBeRepliedTo { if item.canBeRepliedTo {
if let messageItem = item as? EventBasedMessageTimelineItemProtocol { if let messageItem = item as? EventBasedMessageTimelineItemProtocol {
@@ -65,58 +51,94 @@ struct TimelineItemMenuActionProvider {
actions.append(.forward(itemID: item.id)) actions.append(.forward(itemID: item.id))
} }
if item.isEditable {
if let messageItem = item as? EventBasedMessageTimelineItemProtocol, messageItem.supportsMediaCaption {
if messageItem.hasMediaCaption {
actions.append(contentsOf: [.editCaption, .removeCaption])
} else if isCreateMediaCaptionsEnabled {
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 {
actions.append(pinnedEventIDs.contains(eventID) ? .unpin : .pin) actions.append(pinnedEventIDs.contains(eventID) ? .unpin : .pin)
} }
if item.isCopyable {
actions.append(.copy)
}
if item.isRemoteMessage { if item.isRemoteMessage {
actions.append(.copyPermalink) actions.append(.copyPermalink)
} }
if item.isEditable {
if item.supportsMediaCaption {
if item.hasMediaCaption {
actions.append(.editCaption)
} else if isCreateMediaCaptionsEnabled {
actions.append(.addCaption)
}
} else if item is PollRoomTimelineItem {
actions.append(.editPoll)
} else if !(item is VoiceMessageRoomTimelineItem) {
actions.append(.edit)
}
}
if item.isCopyable {
actions.append(.copy)
} else if item.hasMediaCaption {
actions.append(.copyCaption)
}
if item.hasMediaCaption {
actions.append(.removeCaption)
}
if canRedactItem(item), let poll = item.pollIfAvailable, !poll.hasEnded, let eventID = item.id.eventID { if canRedactItem(item), let poll = item.pollIfAvailable, !poll.hasEnded, let eventID = item.id.eventID {
actions.append(.endPoll(pollStartID: eventID)) actions.append(.endPoll(pollStartID: eventID))
} }
if canRedactItem(item) { if isViewSourceEnabled {
actions.append(.redact) actions.append(.viewSource)
} }
if !item.isOutgoing { if !item.isOutgoing {
actions.append(.report) secondaryActions.append(.report)
} }
if item.hasFailedToSend { if canRedactItem(item) {
actions = actions.filter(\.canAppearInFailedEcho) secondaryActions.append(.redact)
}
if item.isRedacted {
actions = actions.filter(\.canAppearInRedacted)
} }
if isPinnedEventsTimeline { if isPinnedEventsTimeline {
actions.insert(.viewInRoomTimeline, at: 0) actions.insert(.viewInRoomTimeline, at: 0)
actions = actions.filter(\.canAppearInPinnedEventsTimeline) actions = actions.filter(\.canAppearInPinnedEventsTimeline)
secondaryActions = secondaryActions.filter(\.canAppearInPinnedEventsTimeline)
}
if item.hasFailedToSend {
actions = actions.filter(\.canAppearInFailedEcho)
secondaryActions = secondaryActions.filter(\.canAppearInFailedEcho)
}
if item.isRedacted {
actions = actions.filter(\.canAppearInRedacted)
secondaryActions = secondaryActions.filter(\.canAppearInRedacted)
} }
return .init(isReactable: isPinnedEventsTimeline ? false : item.isReactable, return .init(isReactable: isPinnedEventsTimeline ? false : item.isReactable,
actions: actions, actions: actions,
debugActions: debugActions, secondaryActions: secondaryActions,
emojiProvider: emojiProvider)
}
private func makeEncryptedItemActions(_ encryptedItem: EncryptedRoomTimelineItem) -> TimelineItemMenuActions? {
var actions: [TimelineItemMenuAction] = [.copyPermalink]
var secondaryActions: [TimelineItemMenuAction] = []
if isViewSourceEnabled {
actions.append(.viewSource)
}
switch encryptedItem.encryptionType {
case .megolmV1AesSha2(let sessionID, _):
secondaryActions.append(.retryDecryption(sessionID: sessionID))
default:
break
}
return .init(isReactable: false,
actions: actions,
secondaryActions: secondaryActions,
emojiProvider: emojiProvider) emojiProvider: emojiProvider)
} }
@@ -250,28 +250,16 @@ enum RoomTimelineItemFixtures {
static var mediaChunk: [RoomTimelineItemProtocol] { static var mediaChunk: [RoomTimelineItemProtocol] {
[ [
VideoRoomTimelineItem(id: .randomEvent, AudioRoomTimelineItem(isOutgoing: false, caption: "Listen to this!"),
timestamp: "10:47 am", AudioRoomTimelineItem(isOutgoing: true),
isOutgoing: false, FileRoomTimelineItem(isOutgoing: false),
isEditable: false, FileRoomTimelineItem(isOutgoing: true, caption: "Please check this ASAP!"),
canBeRepliedTo: true, ImageRoomTimelineItem(isOutgoing: false),
isThreaded: false, ImageRoomTimelineItem(isOutgoing: true, caption: "Isn't this pretty!"),
sender: .init(id: ""), VideoRoomTimelineItem(isOutgoing: false, caption: "Woah, it was incredible!"),
content: .init(filename: "video.mp4", VideoRoomTimelineItem(isOutgoing: true),
videoInfo: .mockVideo, VoiceMessageRoomTimelineItem(isOutgoing: false),
thumbnailInfo: .mockThumbnail, VoiceMessageRoomTimelineItem(isOutgoing: true)
blurhash: "KtI~70X5V?yss9oyrYs:t6")),
ImageRoomTimelineItem(id: .randomEvent,
timestamp: "10:47 am",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: ""),
content: .init(filename: "image.jpg",
imageInfo: .mockImage,
thumbnailInfo: nil,
blurhash: "KpE4oyayR5|GbHb];3j@of"))
] ]
} }
} }
@@ -294,3 +282,92 @@ private extension TextRoomTimelineItem {
return newSelf return newSelf
} }
} }
private extension AudioRoomTimelineItem {
init(isOutgoing: Bool, caption: String? = nil) {
self.init(id: .randomEvent,
timestamp: "10:47 am",
isOutgoing: isOutgoing,
isEditable: isOutgoing,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: isOutgoing ? "@alice:matrix.org" : "@bob:matrix.org"),
content: .init(filename: "audio.mp3",
caption: caption,
duration: 60,
waveform: nil,
source: .init(url: .mockMXCAudio, mimeType: nil),
fileSize: nil,
contentType: .mp3))
}
}
private extension FileRoomTimelineItem {
init(isOutgoing: Bool, caption: String? = nil) {
self.init(id: .randomEvent,
timestamp: "10:47 am",
isOutgoing: isOutgoing,
isEditable: isOutgoing,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: isOutgoing ? "@alice:matrix.org" : "@bob:matrix.org"),
content: .init(filename: "file.pdf",
caption: caption,
source: .init(url: .mockMXCFile, mimeType: nil),
fileSize: nil,
thumbnailSource: nil,
contentType: .pdf))
}
}
private extension ImageRoomTimelineItem {
init(isOutgoing: Bool, caption: String? = nil) {
self.init(id: .randomEvent,
timestamp: "10:47 am",
isOutgoing: isOutgoing,
isEditable: isOutgoing,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: isOutgoing ? "@alice:matrix.org" : "@bob:matrix.org"),
content: .init(filename: "image.jpg",
caption: caption,
imageInfo: .mockImage,
thumbnailInfo: nil,
blurhash: "KpE4oyayR5|GbHb];3j@of"))
}
}
private extension VideoRoomTimelineItem {
init(isOutgoing: Bool, caption: String? = nil) {
self.init(id: .randomEvent,
timestamp: "10:47 am",
isOutgoing: isOutgoing,
isEditable: isOutgoing,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: isOutgoing ? "@alice:matrix.org" : "@bob:matrix.org"),
content: .init(filename: "video.mp4",
caption: caption,
videoInfo: .mockVideo,
thumbnailInfo: .mockThumbnail,
blurhash: "KtI~70X5V?yss9oyrYs:t6"))
}
}
private extension VoiceMessageRoomTimelineItem {
init(isOutgoing: Bool) {
self.init(id: .randomEvent,
timestamp: "10:47 am",
isOutgoing: isOutgoing,
isEditable: isOutgoing,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: isOutgoing ? "@alice:matrix.org" : "@bob:matrix.org"),
content: .init(filename: "message.ogg",
duration: 10,
waveform: .mockWaveform,
source: .init(url: .mockMXCAudio, mimeType: nil),
fileSize: nil,
contentType: .audio))
}
}
@@ -35,18 +35,22 @@ extension EventBasedMessageTimelineItemProtocol {
} }
} }
var hasMediaCaption: Bool { var mediaCaption: String? {
switch contentType { switch contentType {
case .audio(let content): case .audio(let content):
content.caption != nil content.caption
case .file(let content): case .file(let content):
content.caption != nil content.caption
case .image(let content): case .image(let content):
content.caption != nil content.caption
case .video(let content): case .video(let content):
content.caption != nil content.caption
case .emote, .notice, .text, .location, .voice: case .emote, .notice, .text, .location, .voice:
false nil
} }
} }
var hasMediaCaption: Bool {
mediaCaption != nil
}
} }
@@ -99,4 +99,14 @@ extension EventBasedTimelineItemProtocol {
return true return true
} }
} }
var supportsMediaCaption: Bool {
guard let messageBasedItem = self as? EventBasedMessageTimelineItemProtocol else { return false }
return messageBasedItem.supportsMediaCaption
}
var hasMediaCaption: Bool {
guard let messageBasedItem = self as? EventBasedMessageTimelineItemProtocol else { return false }
return messageBasedItem.hasMediaCaption
}
} }
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bcc6fdf7c3f7e40f9beedb8f9779fa74b3c8d7020112398059783ff2820fa4ed oid sha256:42992a72deec6d6cc0a3268f4619db58fb44ba869b6cca0c9937ee70a0effd7e
size 135575 size 147883
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3e337818f2c9ecd0e7abfe35868857394f3b1129e798354266035b65ed9fe9ee oid sha256:a0868feab75e9ac054da551d7ebcc17d7cb8e2cfb3c4ab540f5d05753a273aea
size 142532 size 154967
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a7ba26595547bdab86bc57097fcccb0062b73c9afcccc5ee2d9481c24a5ee954 oid sha256:a57163af2dbcb6af527279ee9959aaf4ac5830424b14deede615891b8da411c4
size 134046 size 146496
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8dfdb1408f464b7570b1c587e03dec72be0cc02ad28af2475c7f13eabfecfdea
size 143529
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a8bad202aa9c390b47af7d1e35f3e65c731a3f3ae659c943c7f1ae63581590ec
size 133449
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7f1d6997b3ca132dbf486145371ccc38418ee1ccfbb05ebd670f73f621008c48 oid sha256:ccec869066425d2bcb107a1dae131d0a87bd2f1c8b345edd8fe77edfe3f779a1
size 124084 size 136131
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fbf1b0ba7b26a5bf3040ceffbfcca15cf38445fe4db7a39b81b16826517c6762 oid sha256:e4448552425a8dde78ee792f3915d76721d548d38daa3b59854e56d692b15286
size 134605 size 147056
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bdcb99ed272db7ee82b09574da7e791ee371b35107ca8104a72acd71e99b1156 oid sha256:b9845682cd8bc72244d05d872a5212735890703a5e65400667986f1d158a991d
size 129013 size 141448
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7f1d6997b3ca132dbf486145371ccc38418ee1ccfbb05ebd670f73f621008c48 oid sha256:ccec869066425d2bcb107a1dae131d0a87bd2f1c8b345edd8fe77edfe3f779a1
size 124084 size 136131
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:83ec69d4b69f3a33422b0a472665c65855a365b4f676888edb9eb054ebaf77d4 oid sha256:b2d5f335b38cdde8f4e5e8c40e7c2c95bee523d353d880e026c7c012c0f86284
size 133818 size 146268
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e173a969f56bdfad935308070f5dc1958eaf7781fbcf18e01c81b95799ce3ae1 oid sha256:5bcb1cf3a950c99e05559cb2cf2162e5b96cbccdebbe42e3b09efa2256a5c53c
size 132960 size 145195
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c5a5a1d65cd3f0c569f2ca1fb80258e8179870acade4a1d68b3e9e67a52d8d29 oid sha256:75219b465e5332f12fada6b5c507c9ee6c62c409b06167824310cb7dfbb068f7
size 142541 size 156450
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9fbc2bf481aeecb17f3be814b3bcf7147f2d8d757eb82a15b2a0db10a5ec8f40 oid sha256:e950f852539eccca04f68060e56787413f90a6c2481df251e8ab315cf5b659d1
size 144452 size 158242
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1eac1d2d29c71d9c332f26b311b50c7ef601896a6941a1f617d200e23bb88731 oid sha256:065d14de719ede0234e865a45119a2731da20e7982249db240180db9b7dafe71
size 141641 size 155522
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5c33873265e2e90314f656d95668da81ab86e7461fea51bfbff083eaea512b52
size 148202
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:001894f533a0619f577647f55a790769d50f5200d4664d027a6b4337e7c8fc25
size 136972
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:39c0cc82049d6e4c12416c457a087d99b52169ab266da1beaa22888aa933bca5 oid sha256:a9bd0e980e02351fdcc69c1e2e71c6c579ee9223b671aedbb8b906d82f0a6da6
size 125934 size 139535
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:eac68c72456505eaafd6c94948857aaa2d54accc4990c3dd3cd1cb31023c89bf oid sha256:e28d69132bf0d7c6e2380916185df3e5bc0bcf858eda4418cbf561e19b0cfa91
size 140645 size 154495
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:150daec2df86269a675887b754f64e067dcdb856dff9d1c8a36e746495745ab8 oid sha256:b0451dcad4c79972172c171622e32af923db17278bb59d8858ed538ef2e5acb8
size 133199 size 146847
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:39c0cc82049d6e4c12416c457a087d99b52169ab266da1beaa22888aa933bca5 oid sha256:a9bd0e980e02351fdcc69c1e2e71c6c579ee9223b671aedbb8b906d82f0a6da6
size 125934 size 139535
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a1d61ad248aa93c90b680e51cdf4a30d8cfd9568a062b03b559d6f9d57fffddf oid sha256:dc893c5f8cfe7a247818288ec6e551ff9ee4c02971cde61d6bb5502f7af139d5
size 140013 size 153853
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2c30cb638c2eecebb63ca66a5f0107bb85225aa786cbf32ecbca9974b42cd383 oid sha256:1d8fb938c99c65f5884636f8806bf3fe9a5de5b6877cf0a2899529df29e30679
size 139069 size 152908
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dd17e2229ac0f1c09ef301589f24de7f879364dbc468460d6e387b2393d6b584 oid sha256:914c77c473286e62ff5fabd0d62ce7bd0a71c0755a8d2e99ceabb102bdd7f7f0
size 88452 size 99760
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2e9f207e4656524273989488b2e8f1d9ee2901d500a76b89dbbf01e831a146c7 oid sha256:6224e94342cbe0303473eb91f16f97e85e67e43ab74be0d6b68868e3a428d1ea
size 91341 size 103095
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:46f40c81c046bb1593d0ac0d1b396558e75ab4d9a85ce9f460610c623835711a oid sha256:db0cc945ae881ff0979376ebe01eda05899100557c0778164a0b43f54f8ae6c5
size 87463 size 98124
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:10701f9bcc6d6906d8b1063b145fd754c0af3928e5d5664cb35b1aa4d79aa999
size 95921
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:01dd4d77411e87302bb5b4b152de7e5eb761c7f4b367a84f36a31fc5e141fa9d
size 87399
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b1a6d6103056a883e4fa9344b65766867ef28355f359c428c6f8fc7b8a5b1dd4 oid sha256:841d4f5f4f5a4e3eac938f2fcd4b0bb94561eebd6179c91cb84816f3ae7df8b8
size 78383 size 89445
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:12b9450065c9aa924ac7b833d2c98a8ea011ea3ec470438869f08591590a5576 oid sha256:6383d2c6351a4833a6ce83a71c6c57c2297d3a8b19c2a818211e21ce61cb7c96
size 88346 size 99003
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:05ae1dfcf89ec7c5329c570e29b876935d261470a6fc074d961452f75e4ab4e3 oid sha256:b3068613a53a7634432c201ee51c5bd5e066d93a524019ee7f0b7c6b7199842b
size 81155 size 92207
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b1a6d6103056a883e4fa9344b65766867ef28355f359c428c6f8fc7b8a5b1dd4 oid sha256:841d4f5f4f5a4e3eac938f2fcd4b0bb94561eebd6179c91cb84816f3ae7df8b8
size 78383 size 89445
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:100ebc998da4d0625307771a9fda2dc78becccf2fe0e58cb514967cc26b77e46 oid sha256:a0dac8094cdadc60b5bf9fb5057b910a51d34598ca207acedac4af329ebe61b3
size 86917 size 97569
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5d2451f6dfdb5d0342c13d9bba95f035610649f5e0c6c026b6b5c02a3f5ea792 oid sha256:f008b5f6b5015caa5102ee2e3368072b89dc8d369b43d229776b2c4d3a7ec119
size 84457 size 95442
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a7e0833f33a5372ac34cb1ffc904b3113c292ddfac1798f2ec49263f72487b8b oid sha256:2214c2b47d33f7e71ebe4b113d8c57461977d0e5558b4851e54be67bfc613bc1
size 104147 size 120917
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9dd681b7005cad7f3172e46704ce15f35348741ab4f61c25c7e25dbc976dd47f oid sha256:c0ca91f7036a9b588ae5cfaa98df46fad4a0c546a815646bcc93b60f5cc04b96
size 96872 size 114129
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:66a515af35f86cc79842515e6f200a8ac9e66393bafa3d08dd73c94b55a0e273 oid sha256:224c0a2a1a180bb0f769690986615b42670eaeba5da36c136a194a9b649392a4
size 102463 size 119359
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3ff91b2679413fe8a068f82306eeaa78de5db89edacc910443fd03a9200ac42c
size 111302
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0f083235302db13e888d8c1fdd9cf6746acba7c6b25e6066f225514b78963bc7
size 99585
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f9f354421510618dc4c3ebdd4ed3f6aeb274236bd4d680948127a21737882a35 oid sha256:28feeb86b0c09df9a9bc37b3523adbd9145c24372ba3f0d1e46e3b7811d56339
size 83970 size 100926
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:20be265e58a5ce19848a1d9c9221587656efbeb1eefa982bf598021ed91b1976 oid sha256:68ba46257a66431778c6fd648be80e65bd724f451cc4bbd05a92c8dd6eca3ab3
size 104759 size 121585
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bbcb5ce6d51e66aa74d7ebb6cdaf574a80613cf5cbc2a52285f2db5758a260c8 oid sha256:4a3212f9f5ccb198f4bb9770ea232b700cf635a0a3d37ecb7b8297c0496c12f4
size 88609 size 105456
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f9f354421510618dc4c3ebdd4ed3f6aeb274236bd4d680948127a21737882a35 oid sha256:28feeb86b0c09df9a9bc37b3523adbd9145c24372ba3f0d1e46e3b7811d56339
size 83970 size 100926
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cb4cba6c55ed6d5a399729817e3bbcf0109e940022009ec2b994d744a0e26b9e oid sha256:9e084c9628684fe7b383b72a2f5906e47f9d65ba2de12a374b728ece3be37c68
size 100765 size 117656
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e808c53766810d2cef79e239295f55b151a151cb42a7d7fd24f83ef889964ae1 oid sha256:ec4a30d3db9d84514c116964209d741a6179b11c0992b0e92e9e98a8d5e050eb
size 95987 size 112532