mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
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:
@@ -51,7 +51,7 @@ struct TimelineItemMacContextMenu: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(menuActions.debugActions) { action in
|
||||
ForEach(menuActions.secondaryActions) { action in
|
||||
Button(role: action.isDestructive ? .destructive : nil) {
|
||||
send(action)
|
||||
} label: {
|
||||
|
||||
@@ -47,7 +47,7 @@ struct TimelineItemMenu: View {
|
||||
.background(Color.compound.bgSubtlePrimary)
|
||||
}
|
||||
|
||||
viewsForActions(actions.debugActions)
|
||||
viewsForActions(actions.secondaryActions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,8 +179,8 @@ struct TimelineItemMenu: View {
|
||||
send(action)
|
||||
} label: {
|
||||
action.label
|
||||
.labelStyle(.menuSheet)
|
||||
}
|
||||
.buttonStyle(.menuSheet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,21 +261,28 @@ private extension View {
|
||||
// MARK: - Previews
|
||||
|
||||
struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
|
||||
enum ItemType { case incomingText, outgoingMedia, outgoingMediaWithCaption }
|
||||
|
||||
static let viewModel = TimelineViewModel.mock
|
||||
static let (item, actions) = makeItem()
|
||||
static let (backupItem, _) = makeItem(authenticity: .notGuaranteed(color: .gray))
|
||||
static let (unsignedItem, _) = makeItem(authenticity: .unsignedDevice(color: .red))
|
||||
static let (unencryptedItem, _) = makeItem(authenticity: .sentInClear(color: .red))
|
||||
static let (unknownFailureItem, _) = makeItem(deliveryStatus: .sendingFailed(.unknown))
|
||||
static let (identityChangedItem, _) = makeItem(deliveryStatus: .sendingFailed(.verifiedUser(.changedIdentity(users: [
|
||||
static let (item, actions) = makeActions()
|
||||
static let (backupItem, _) = makeActions(authenticity: .notGuaranteed(color: .gray))
|
||||
static let (unsignedItem, _) = makeActions(authenticity: .unsignedDevice(color: .red))
|
||||
static let (unencryptedItem, _) = makeActions(authenticity: .sentInClear(color: .red))
|
||||
static let (unknownFailureItem, _) = makeActions(deliveryStatus: .sendingFailed(.unknown))
|
||||
static let (identityChangedItem, _) = makeActions(deliveryStatus: .sendingFailed(.verifiedUser(.changedIdentity(users: [
|
||||
"@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"]
|
||||
]))))
|
||||
static let (ownUnsignedDevicesItem, _) = makeItem(deliveryStatus: .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: [
|
||||
static let (ownUnsignedDevicesItem, _) = makeActions(deliveryStatus: .sendingFailed(.verifiedUser(.hasUnsignedDevice(devices: [
|
||||
RoomMemberProxyMock.mockMe.userID: ["DEVICE1"]
|
||||
]))))
|
||||
|
||||
// Media
|
||||
|
||||
static let (mediaItem, mediaItemActions) = makeActions(itemType: .outgoingMedia)
|
||||
static let (mediaItemWithCaption, mediaItemWithCaptionActions) = makeActions(itemType: .outgoingMediaWithCaption)
|
||||
|
||||
static var previews: some View {
|
||||
TimelineItemMenu(item: item, actions: actions)
|
||||
@@ -314,26 +321,56 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
|
||||
TimelineItemMenu(item: identityChangedItem, actions: actions)
|
||||
.environmentObject(viewModel.context)
|
||||
.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,
|
||||
deliveryStatus: TimelineItemDeliveryStatus? = nil) -> (TextRoomTimelineItem, TimelineItemMenuActions)! {
|
||||
guard var item = RoomTimelineItemFixtures.singleMessageChunk.first as? TextRoomTimelineItem,
|
||||
let actions = TimelineItemMenuActions(isReactable: true,
|
||||
actions: [.copy, .edit, .reply(isThread: false), .pin, .redact],
|
||||
debugActions: [.viewSource],
|
||||
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings)) else {
|
||||
return nil
|
||||
}
|
||||
static func makeActions(itemType: ItemType = .incomingText,
|
||||
authenticity: EncryptionAuthenticity? = nil,
|
||||
deliveryStatus: TimelineItemDeliveryStatus? = nil) -> (EventBasedTimelineItemProtocol, TimelineItemMenuActions)! {
|
||||
guard var item = makeItem(itemType: itemType) else { return nil }
|
||||
let provider = TimelineItemMenuActionProvider(timelineItem: item,
|
||||
canCurrentUserRedactSelf: true,
|
||||
canCurrentUserRedactOthers: false,
|
||||
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 let authenticity {
|
||||
item.properties.encryptionAuthenticity = authenticity
|
||||
}
|
||||
|
||||
if let deliveryStatus {
|
||||
item.properties.deliveryStatus = deliveryStatus
|
||||
if var textItem = item as? TextRoomTimelineItem {
|
||||
if let authenticity {
|
||||
textItem.properties.encryptionAuthenticity = authenticity
|
||||
}
|
||||
|
||||
if let deliveryStatus {
|
||||
textItem.properties.deliveryStatus = deliveryStatus
|
||||
}
|
||||
item = textItem
|
||||
}
|
||||
|
||||
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 {
|
||||
let reactions: [TimelineItemMenuReaction]
|
||||
let actions: [TimelineItemMenuAction]
|
||||
let debugActions: [TimelineItemMenuAction]
|
||||
let secondaryActions: [TimelineItemMenuAction]
|
||||
|
||||
init?(isReactable: Bool,
|
||||
actions: [TimelineItemMenuAction],
|
||||
debugActions: [TimelineItemMenuAction],
|
||||
secondaryActions: [TimelineItemMenuAction],
|
||||
emojiProvider: EmojiProviderProtocol) {
|
||||
if !isReactable, actions.isEmpty, debugActions.isEmpty {
|
||||
if !isReactable, actions.isEmpty, secondaryActions.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.actions = actions
|
||||
self.debugActions = debugActions
|
||||
self.secondaryActions = secondaryActions
|
||||
|
||||
var frequentlyUsed: OrderedSet<TimelineItemMenuReaction> = [
|
||||
.init(key: "👍️", symbol: .handThumbsup),
|
||||
@@ -56,10 +56,12 @@ struct TimelineItemMenuReaction: Hashable {
|
||||
|
||||
enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
case copy
|
||||
case copyCaption
|
||||
case edit
|
||||
case addCaption
|
||||
case editCaption
|
||||
case removeCaption
|
||||
case editPoll
|
||||
case copyPermalink
|
||||
case redact
|
||||
case reply(isThread: Bool)
|
||||
@@ -79,7 +81,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
/// Whether the item should cancel a reply/edit occurring in the composer.
|
||||
var switchToDefaultComposer: Bool {
|
||||
switch self {
|
||||
case .reply, .edit, .addCaption, .editCaption:
|
||||
case .reply, .edit, .addCaption, .editCaption, .editPoll:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -89,7 +91,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
/// Whether the action should be shown for an item that failed to send.
|
||||
var canAppearInFailedEcho: Bool {
|
||||
switch self {
|
||||
case .copy, .edit, .redact, .viewSource:
|
||||
case .copy, .edit, .redact, .viewSource, .editPoll:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -130,7 +132,9 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
var label: some View {
|
||||
switch self {
|
||||
case .copy:
|
||||
Label(L10n.actionCopy, icon: \.copy)
|
||||
Label(L10n.actionCopyText, icon: \.copy)
|
||||
case .copyCaption:
|
||||
Label(L10n.actionCopyCaption, icon: \.copy)
|
||||
case .edit:
|
||||
Label(L10n.actionEdit, icon: \.edit)
|
||||
case .addCaption:
|
||||
@@ -138,7 +142,9 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
case .editCaption:
|
||||
Label(L10n.actionEditCaption, icon: \.edit)
|
||||
case .removeCaption:
|
||||
Label(L10n.actionRemoveCaption, icon: \.delete)
|
||||
Label(L10n.actionRemoveCaption, icon: \.close)
|
||||
case .editPoll:
|
||||
Label(L10n.actionEditPoll, icon: \.edit)
|
||||
case .copyPermalink:
|
||||
Label(L10n.actionCopyLinkToMessage, icon: \.link)
|
||||
case .reply(let isThread):
|
||||
@@ -146,7 +152,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
case .forward:
|
||||
Label(L10n.actionForward, icon: \.forward)
|
||||
case .redact:
|
||||
Label(L10n.actionRemove, icon: \.delete)
|
||||
Label(L10n.actionRemoveMessage, icon: \.delete)
|
||||
case .viewSource:
|
||||
Label(L10n.actionViewSource, icon: \.code)
|
||||
case .retryDecryption:
|
||||
|
||||
+66
-44
@@ -32,26 +32,12 @@ struct TimelineItemMenuActionProvider {
|
||||
return nil
|
||||
}
|
||||
|
||||
var debugActions: [TimelineItemMenuAction] = []
|
||||
if isViewSourceEnabled {
|
||||
debugActions.append(.viewSource)
|
||||
}
|
||||
|
||||
if let encryptedItem = timelineItem as? EncryptedRoomTimelineItem {
|
||||
switch encryptedItem.encryptionType {
|
||||
case .megolmV1AesSha2(let sessionID, _):
|
||||
debugActions.append(.retryDecryption(sessionID: sessionID))
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return .init(isReactable: false,
|
||||
actions: [.copyPermalink],
|
||||
debugActions: debugActions,
|
||||
emojiProvider: emojiProvider)
|
||||
return makeEncryptedItemActions(encryptedItem)
|
||||
}
|
||||
|
||||
var actions: [TimelineItemMenuAction] = []
|
||||
var secondaryActions: [TimelineItemMenuAction] = []
|
||||
|
||||
if item.canBeRepliedTo {
|
||||
if let messageItem = item as? EventBasedMessageTimelineItemProtocol {
|
||||
@@ -64,59 +50,95 @@ struct TimelineItemMenuActionProvider {
|
||||
if item.isForwardable {
|
||||
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 {
|
||||
actions.append(pinnedEventIDs.contains(eventID) ? .unpin : .pin)
|
||||
}
|
||||
|
||||
if item.isCopyable {
|
||||
actions.append(.copy)
|
||||
}
|
||||
|
||||
if item.isRemoteMessage {
|
||||
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 {
|
||||
actions.append(.endPoll(pollStartID: eventID))
|
||||
}
|
||||
|
||||
if canRedactItem(item) {
|
||||
actions.append(.redact)
|
||||
if isViewSourceEnabled {
|
||||
actions.append(.viewSource)
|
||||
}
|
||||
|
||||
|
||||
if !item.isOutgoing {
|
||||
actions.append(.report)
|
||||
secondaryActions.append(.report)
|
||||
}
|
||||
|
||||
if item.hasFailedToSend {
|
||||
actions = actions.filter(\.canAppearInFailedEcho)
|
||||
}
|
||||
|
||||
if item.isRedacted {
|
||||
actions = actions.filter(\.canAppearInRedacted)
|
||||
|
||||
if canRedactItem(item) {
|
||||
secondaryActions.append(.redact)
|
||||
}
|
||||
|
||||
if isPinnedEventsTimeline {
|
||||
actions.insert(.viewInRoomTimeline, at: 0)
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user