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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 401 additions and 199 deletions

View File

@ -36,8 +36,10 @@
"action_confirm_password" = "Confirm password";
"action_continue" = "Continue";
"action_copy" = "Copy";
"action_copy_caption" = "Copy caption";
"action_copy_link" = "Copy link";
"action_copy_link_to_message" = "Copy link to message";
"action_copy_text" = "Copy text";
"action_create" = "Create";
"action_create_a_room" = "Create a room";
"action_deactivate" = "Deactivate";
@ -84,6 +86,7 @@
"action_reject" = "Reject";
"action_remove" = "Remove";
"action_remove_caption" = "Remove caption";
"action_remove_message" = "Remove message";
"action_reply" = "Reply";
"action_reply_in_thread" = "Reply in thread";
"action_report_bug" = "Report bug";

View File

@ -104,10 +104,14 @@ internal enum L10n {
internal static var actionContinue: String { return L10n.tr("Localizable", "action_continue") }
/// 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
internal static var actionCopyLink: String { return L10n.tr("Localizable", "action_copy_link") }
/// 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
internal static var actionCreate: String { return L10n.tr("Localizable", "action_create") }
/// Create a room
@ -204,6 +208,8 @@ internal enum L10n {
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") }
/// Remove message
internal static var actionRemoveMessage: String { return L10n.tr("Localizable", "action_remove_message") }
/// Reply
internal static var actionReply: String { return L10n.tr("Localizable", "action_reply") }
/// Reply in thread

View File

@ -7,14 +7,34 @@
import SwiftUI
extension LabelStyle where Self == MenuSheetLabelStyle {
/// A label style for labels that are within a menu that is being presented as a sheet.
static var menuSheet: Self { MenuSheetLabelStyle() }
extension ButtonStyle where Self == MenuSheetButtonStyle {
/// A button style for buttons that are within a menu that is being presented as a sheet.
static var menuSheet: Self { MenuSheetButtonStyle() }
}
/// The style used for labels that are part of a menu that's presented as
/// a sheet as `TimelineItemMenu` and `RoomAttachmentPicker`.
struct MenuSheetLabelStyle: LabelStyle {
/// The style used for buttons that are part of a menu that's presented as
/// a sheet such as `TimelineItemMenu`.
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
func makeBody(configuration: Configuration) -> some View {

View File

@ -32,7 +32,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .enableTextFormatting)
} label: {
Label(L10n.screenRoomAttachmentTextFormatting, icon: \.textFormatting)
.labelStyle(.menuSheet)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerTextFormatting)
@ -40,7 +39,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.poll))
} label: {
Label(L10n.screenRoomAttachmentSourcePoll, icon: \.polls)
.labelStyle(.menuSheet)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPoll)
@ -48,7 +46,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.location))
} label: {
Label(L10n.screenRoomAttachmentSourceLocation, icon: \.locationPin)
.labelStyle(.menuSheet)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerLocation)
@ -56,7 +53,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.file))
} label: {
Label(L10n.screenRoomAttachmentSourceFiles, icon: \.attachment)
.labelStyle(.menuSheet)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerDocuments)
@ -64,7 +60,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.photoLibrary))
} label: {
Label(L10n.screenRoomAttachmentSourceGallery, icon: \.image)
.labelStyle(.menuSheet)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPhotoLibrary)
@ -72,7 +67,6 @@ struct RoomAttachmentPicker: View {
context.send(viewAction: .attach(.camera))
} label: {
Label(L10n.screenRoomAttachmentSourceCamera, icon: \.takePhoto)
.labelStyle(.menuSheet)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerCamera)
}

View File

@ -102,7 +102,13 @@ class TimelineInteractionHandler {
case .copy:
guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { return }
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 {
case let messageTimelineItem as EventBasedMessageTimelineItemProtocol:
processEditMessageEvent(messageTimelineItem)
@ -115,13 +121,6 @@ class TimelineInteractionHandler {
default:
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")

View File

@ -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: {

View File

@ -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
}
}
}

View File

@ -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:

View File

@ -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)
}

View File

@ -250,28 +250,16 @@ enum RoomTimelineItemFixtures {
static var mediaChunk: [RoomTimelineItemProtocol] {
[
VideoRoomTimelineItem(id: .randomEvent,
timestamp: "10:47 am",
isOutgoing: false,
isEditable: false,
canBeRepliedTo: true,
isThreaded: false,
sender: .init(id: ""),
content: .init(filename: "video.mp4",
videoInfo: .mockVideo,
thumbnailInfo: .mockThumbnail,
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"))
AudioRoomTimelineItem(isOutgoing: false, caption: "Listen to this!"),
AudioRoomTimelineItem(isOutgoing: true),
FileRoomTimelineItem(isOutgoing: false),
FileRoomTimelineItem(isOutgoing: true, caption: "Please check this ASAP!"),
ImageRoomTimelineItem(isOutgoing: false),
ImageRoomTimelineItem(isOutgoing: true, caption: "Isn't this pretty!"),
VideoRoomTimelineItem(isOutgoing: false, caption: "Woah, it was incredible!"),
VideoRoomTimelineItem(isOutgoing: true),
VoiceMessageRoomTimelineItem(isOutgoing: false),
VoiceMessageRoomTimelineItem(isOutgoing: true)
]
}
}
@ -294,3 +282,92 @@ private extension TextRoomTimelineItem {
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))
}
}

View File

@ -35,18 +35,22 @@ extension EventBasedMessageTimelineItemProtocol {
}
}
var hasMediaCaption: Bool {
var mediaCaption: String? {
switch contentType {
case .audio(let content):
content.caption != nil
content.caption
case .file(let content):
content.caption != nil
content.caption
case .image(let content):
content.caption != nil
content.caption
case .video(let content):
content.caption != nil
content.caption
case .emote, .notice, .text, .location, .voice:
false
nil
}
}
var hasMediaCaption: Bool {
mediaCaption != nil
}
}

View File

@ -99,4 +99,14 @@ extension EventBasedTimelineItemProtocol {
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
}
}