diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift new file mode 100644 index 000000000..81cd08759 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift @@ -0,0 +1,55 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +struct AudioRoomTimelineView: View { + let timelineItem: AudioRoomTimelineItem + + var body: some View { + TimelineStyler(timelineItem: timelineItem) { + HStack { + Image(systemName: "waveform") + .foregroundColor(.element.primaryContent) + FormattedBodyText(text: timelineItem.body) + } + .padding(.vertical, 12) + .padding(.horizontal, 6) + } + } +} + +struct AudioRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + + static var previews: some View { + body.environmentObject(viewModel.context) + body.timelineStyle(.plain).environmentObject(viewModel.context) + } + + static var body: some View { + AudioRoomTimelineView(timelineItem: AudioRoomTimelineItem(id: UUID().uuidString, + body: "audio.ogg", + timestamp: "Now", + groupState: .single, + isOutgoing: false, + isEditable: false, + sender: .init(id: "Bob"), + duration: 300, + source: nil)) + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index fd678c61e..6f81dce99 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -101,7 +101,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol { func processItemDisappearance(_ itemID: String) { } - // swiftlint:disable:next cyclomatic_complexity func processItemTap(_ itemID: String) async -> RoomTimelineControllerAction { guard let timelineItem = timelineItems.first(where: { $0.id == itemID }) else { return .none @@ -111,33 +110,36 @@ class RoomTimelineController: RoomTimelineControllerProtocol { case let item as ImageRoomTimelineItem: await loadFileForImageTimelineItem(item) guard let index = timelineItems.firstIndex(where: { $0.id == itemID }), - let item = timelineItems[index] as? ImageRoomTimelineItem else { + let item = timelineItems[index] as? ImageRoomTimelineItem, + let fileURL = item.cachedFileURL else { return .none } - if let fileURL = item.cachedFileURL { - return .displayFile(fileURL: fileURL, title: item.body) - } - return .none + return .displayFile(fileURL: fileURL, title: item.body) case let item as VideoRoomTimelineItem: await loadVideoForTimelineItem(item) guard let index = timelineItems.firstIndex(where: { $0.id == itemID }), - let item = timelineItems[index] as? VideoRoomTimelineItem else { + let item = timelineItems[index] as? VideoRoomTimelineItem, + let videoURL = item.cachedVideoURL else { return .none } - if let videoURL = item.cachedVideoURL { - return .displayVideo(videoURL: videoURL, title: item.body) - } - return .none + return .displayVideo(videoURL: videoURL, title: item.body) case let item as FileRoomTimelineItem: await loadFileForTimelineItem(item) guard let index = timelineItems.firstIndex(where: { $0.id == itemID }), - let item = timelineItems[index] as? FileRoomTimelineItem else { + let item = timelineItems[index] as? FileRoomTimelineItem, + let fileURL = item.cachedFileURL else { return .none } - if let fileURL = item.cachedFileURL { - return .displayFile(fileURL: fileURL, title: item.body) + return .displayFile(fileURL: fileURL, title: item.body) + case let item as AudioRoomTimelineItem: + await loadAudioForTimelineItem(item) + guard let index = timelineItems.firstIndex(where: { $0.id == itemID }), + let item = timelineItems[index] as? AudioRoomTimelineItem, + let audioURL = item.cachedAudioURL else { + return .none } - return .none + // For now we are just displaying audio messages with the File preview until we create a timeline player for them. + return .displayFile(fileURL: audioURL, title: item.body) default: return .none } @@ -437,4 +439,33 @@ class RoomTimelineController: RoomTimelineControllerProtocol { break } } + + private func loadAudioForTimelineItem(_ timelineItem: AudioRoomTimelineItem) async { + if timelineItem.cachedAudioURL != nil { + // already cached + return + } + + guard let source = timelineItem.source else { + return + } + + // This is not great. We could better estimate file extension from the mimetype. + guard let fileExtension = timelineItem.body.split(separator: ".").last else { + return + } + + switch await mediaProvider.loadFileFromSource(source, fileExtension: String(fileExtension)) { + case .success(let audioURL): + guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }), + var item = timelineItems[index] as? AudioRoomTimelineItem else { + return + } + + item.cachedAudioURL = audioURL + timelineItems[index] = item + case .failure: + break + } + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/CustomStringConvertible.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/CustomStringConvertible.swift index 9d6f5d676..0ef77ac14 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/CustomStringConvertible.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/CustomStringConvertible.swift @@ -53,3 +53,9 @@ extension VideoMessageContent: CustomStringConvertible { String(describing: Self.self) } } + +extension AudioMessageContent: CustomStringConvertible { + public var description: String { + String(describing: Self.self) + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift index 40281992c..ceb033166 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemContent/MessageTimelineItem.swift @@ -149,3 +149,16 @@ extension MessageTimelineItem where Content == MatrixRustSDK.FileMessageContent return .init(source: src) } } + +extension MatrixRustSDK.AudioMessageContent: MessageContentProtocol { } + +/// A timeline item that represents an `m.room.message` event with a `msgtype` of `m.audio`. +extension MessageTimelineItem where Content == MatrixRustSDK.AudioMessageContent { + var source: MediaSourceProxy { + .init(source: content.source) + } + + var duration: UInt64 { + content.info?.duration ?? 0 + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index 244474815..b9423f2d6 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -33,7 +33,7 @@ enum TimelineItemGroupState: Hashable { } } -protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol { +protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol, CustomStringConvertible { var body: String { get } var timestamp: String { get } var shouldShowSenderDetails: Bool { get } @@ -71,4 +71,8 @@ extension EventBasedTimelineItemProtocol { } } } + + var description: String { + "\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)" + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift new file mode 100644 index 000000000..7f4244ddc --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift @@ -0,0 +1,33 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct AudioRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hashable { + let id: String + let body: String + let timestamp: String + let groupState: TimelineItemGroupState + let isOutgoing: Bool + let isEditable: Bool + let sender: TimelineItemSender + + let duration: UInt64 + let source: MediaSourceProxy? + var cachedAudioURL: URL? + + var properties = RoomTimelineItemProperties() +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift index 56ed1ab76..cf5974890 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift @@ -29,11 +29,3 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash var properties = RoomTimelineItemProperties() } - -// MARK: - Redact content from logs - -extension EmoteRoomTimelineItem: CustomStringConvertible { - var description: String { - "\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)" - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift index 4c30a2ad5..4abf3d0f3 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift @@ -32,11 +32,3 @@ struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha var properties = RoomTimelineItemProperties() } - -// MARK: - Redact content from logs - -extension FileRoomTimelineItem: CustomStringConvertible { - var description: String { - "\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)" - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift index bc0070d94..7d57060ca 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift @@ -38,11 +38,3 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash var properties = RoomTimelineItemProperties() } - -// MARK: - Redact content from logs - -extension ImageRoomTimelineItem: CustomStringConvertible { - var description: String { - "\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)" - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift index 353a3e132..f73a56926 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift @@ -29,11 +29,3 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Has var properties = RoomTimelineItemProperties() } - -// MARK: - Redact content from logs - -extension NoticeRoomTimelineItem: CustomStringConvertible { - var description: String { - "\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)" - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift index 6a50de3bc..21491d8f2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift @@ -29,11 +29,3 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha var properties = RoomTimelineItemProperties() } - -// MARK: - Redact content from logs - -extension TextRoomTimelineItem: CustomStringConvertible { - var description: String { - "\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)" - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift index 728e2f04f..7b2886172 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift @@ -38,11 +38,3 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash var properties = RoomTimelineItemProperties() } - -// MARK: - Redact content from logs - -extension VideoRoomTimelineItem: CustomStringConvertible { - var description: String { - "\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)" - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index c2c80fcf4..0acac1dce 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -72,15 +72,15 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { case .file(let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) - case .notice(content: let content): + case .notice(let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) - case .emote(content: let content): + case .emote(let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) - case .audio: - #warning("Audio timeline items not currently supported") - return nil + case .audio(let content): + let message = MessageTimelineItem(item: eventItemProxy.item, content: content) + return buildAudioTimelineItem(eventItemProxy, message, isOutgoing, groupState) case .none: return buildFallbackTimelineItem(eventItemProxy, isOutgoing, groupState) } @@ -273,6 +273,21 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { deliveryStatus: eventItemProxy.deliveryStatus)) } + private func buildAudioTimelineItem(_ eventItemProxy: EventTimelineItemProxy, + _ message: MessageTimelineItem, + _ isOutgoing: Bool, + _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + AudioRoomTimelineItem(id: eventItemProxy.id, + body: message.body, + timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), + groupState: groupState, + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + sender: eventItemProxy.sender, + duration: message.duration, + source: message.source) + } + private func buildFileTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, _ isOutgoing: Bool, diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift index 008f17ab7..703d5a805 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift @@ -26,6 +26,8 @@ struct RoomTimelineViewFactory: RoomTimelineViewFactoryProtocol { return .image(item) case let item as VideoRoomTimelineItem: return .video(item) + case let item as AudioRoomTimelineItem: + return .audio(item) case let item as FileRoomTimelineItem: return .file(item) case let item as SeparatorRoomTimelineItem: diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift index 7d56b428d..2e4d73a3b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift @@ -22,6 +22,7 @@ enum RoomTimelineViewProvider: Identifiable, Hashable { case separator(SeparatorRoomTimelineItem) case image(ImageRoomTimelineItem) case video(VideoRoomTimelineItem) + case audio(AudioRoomTimelineItem) case file(FileRoomTimelineItem) case emote(EmoteRoomTimelineItem) case notice(NoticeRoomTimelineItem) @@ -44,6 +45,8 @@ enum RoomTimelineViewProvider: Identifiable, Hashable { return item.id case .video(let item): return item.id + case .audio(let item): + return item.id case .file(let item): return item.id case .emote(let item): @@ -72,7 +75,7 @@ enum RoomTimelineViewProvider: Identifiable, Hashable { /// Whether or not it is possible to send a reaction to this timeline item. var isReactable: Bool { switch self { - case .text, .image, .video, .file, .emote, .notice, .sticker: + case .text, .image, .video, .audio, .file, .emote, .notice, .sticker: return true case .redacted, .encrypted, .unsupported, .state: // Event based items that aren't reactable return false @@ -93,6 +96,8 @@ extension RoomTimelineViewProvider: View { ImageRoomTimelineView(timelineItem: item) case .video(let item): VideoRoomTimelineView(timelineItem: item) + case .audio(let item): + AudioRoomTimelineView(timelineItem: item) case .file(let item): FileRoomTimelineView(timelineItem: item) case .emote(let item): diff --git a/NSE/Sources/Other/NotificationItemProxy+NSE.swift b/NSE/Sources/Other/NotificationItemProxy+NSE.swift index 26a0ad396..e96544f63 100644 --- a/NSE/Sources/Other/NotificationItemProxy+NSE.swift +++ b/NSE/Sources/Other/NotificationItemProxy+NSE.swift @@ -117,6 +117,11 @@ extension NotificationItemProxy { senderId: senderId, roomId: roomId, mediaProvider: mediaProvider) + case .audio(content: let content): + return try await processAudio(content: content, + senderId: senderId, + roomId: roomId, + mediaProvider: mediaProvider) case .none: return nil } @@ -222,4 +227,16 @@ extension NotificationItemProxy { return notification } + + private func processAudio(content: AudioMessageContent, + senderId: String, + roomId: String, + mediaProvider: MediaProviderProtocol?) async throws -> UNMutableNotificationContent { + let notification = try await processCommon(senderId: senderId, + roomId: roomId, + mediaProvider: mediaProvider) + notification.body = "🔊 " + content.body + + return notification + } } diff --git a/changelog.d/594.feature b/changelog.d/594.feature new file mode 100644 index 000000000..9aad93475 --- /dev/null +++ b/changelog.d/594.feature @@ -0,0 +1 @@ +Added support for audio messages in the timeline as previewable files. \ No newline at end of file