diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift index fba1b5f..0bd95e8 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift @@ -61,6 +61,7 @@ public actor FakeSessionBridge: SessionBridge { senderName: "Alice", text: "Hello from fake TDLib", date: Self.baseMessageDate, + media: .photo(PhotoMedia(fileId: 100, width: 1280, height: 720)), isOutgoing: false, isRead: false ) @@ -72,6 +73,7 @@ public actor FakeSessionBridge: SessionBridge { senderName: "Mikhail", text: "Bridge smoke is green", date: Self.baseMessageDate + 60, + media: .voice(VoiceMedia(fileId: 200, duration: 12, mimeType: "audio/ogg")), isOutgoing: true ) ], diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift index 33fb900..2660bf2 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift @@ -91,12 +91,67 @@ public struct Reaction: Equatable, Sendable { } } +public enum MediaDownloadState: Equatable, Sendable { + case notDownloaded + case downloading + case downloaded(path: String) + case error(String) +} + +public struct PhotoMedia: Equatable, Sendable { + public var fileId: Int32 + public var width: Int32 + public var height: Int32 + public var downloadState: MediaDownloadState + + public init( + fileId: Int32, + width: Int32, + height: Int32, + downloadState: MediaDownloadState = .notDownloaded + ) { + self.fileId = fileId + self.width = width + self.height = height + self.downloadState = downloadState + } +} + +public struct VoiceMedia: Equatable, Sendable { + public var fileId: Int32 + public var duration: Int32 + public var mimeType: String? + public var waveform: String? + public var downloadState: MediaDownloadState + + public init( + fileId: Int32, + duration: Int32, + mimeType: String? = nil, + waveform: String? = nil, + downloadState: MediaDownloadState = .notDownloaded + ) { + self.fileId = fileId + self.duration = duration + self.mimeType = mimeType + self.waveform = waveform + self.downloadState = downloadState + } +} + +public enum MessageMedia: Equatable, Sendable { + case photo(PhotoMedia) + case voice(VoiceMedia) +} + public struct Message: Identifiable, Equatable, Sendable { public var id: Int64 public var chatId: Int64 public var senderName: String public var text: String public var date: Int32 + public var mediaAlbumId: Int64? + public var media: MessageMedia? public var isOutgoing: Bool public var isRead: Bool public var editDate: Int32? @@ -110,6 +165,8 @@ public struct Message: Identifiable, Equatable, Sendable { senderName: String, text: String, date: Int32 = 0, + mediaAlbumId: Int64? = nil, + media: MessageMedia? = nil, isOutgoing: Bool, isRead: Bool = true, editDate: Int32? = nil, @@ -122,6 +179,8 @@ public struct Message: Identifiable, Equatable, Sendable { self.senderName = senderName self.text = text self.date = date + self.mediaAlbumId = mediaAlbumId + self.media = media self.isOutgoing = isOutgoing self.isRead = isRead self.editDate = editDate diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/UniFfiSessionBridge.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/UniFfiSessionBridge.swift index b941bdf..f53c37d 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/UniFfiSessionBridge.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/UniFfiSessionBridge.swift @@ -199,6 +199,41 @@ public actor UniFfiSessionBridge: SessionBridge { Reaction(emoji: reaction.emoji, count: reaction.count, isChosen: reaction.isChosen) } + private static func mapDownloadState(_ state: IosDownloadState) -> MediaDownloadState { + switch state { + case .notDownloaded: + .notDownloaded + case .downloading: + .downloading + case let .downloaded(path): + .downloaded(path: path) + case let .error(message): + .error(message) + } + } + + private static func mapMedia(_ media: IosMedia) -> MessageMedia? { + switch media.kind { + case "photo": + .photo(PhotoMedia( + fileId: media.fileId, + width: media.width ?? 0, + height: media.height ?? 0, + downloadState: mapDownloadState(media.downloadState) + )) + case "voice": + .voice(VoiceMedia( + fileId: media.fileId, + duration: media.duration ?? 0, + mimeType: media.mimeType, + waveform: media.waveform, + downloadState: mapDownloadState(media.downloadState) + )) + default: + nil + } + } + private static func mapMessage(_ message: IosMessage, chatId: Int64) -> Message { Message( id: message.id, @@ -206,6 +241,8 @@ public actor UniFfiSessionBridge: SessionBridge { senderName: message.senderName, text: message.text, date: message.date, + mediaAlbumId: message.mediaAlbumId, + media: message.media.flatMap(mapMedia), isOutgoing: message.isOutgoing, isRead: message.isRead, editDate: message.editDate, diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift index c77a981..68a3536 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift @@ -657,6 +657,9 @@ public struct MessageRow: View { .font(.caption) .foregroundStyle(.secondary) } + if let media = message.media { + MediaPlaceholderView(media: media, mediaAlbumId: message.mediaAlbumId) + } Text(renderedText) .textSelection(.enabled) if !message.reactions.isEmpty { @@ -706,6 +709,81 @@ public struct MessageRow: View { }() } +public struct MediaPlaceholderView: View { + public var media: MessageMedia + public var mediaAlbumId: Int64? + + public init(media: MessageMedia, mediaAlbumId: Int64? = nil) { + self.media = media + self.mediaAlbumId = mediaAlbumId + } + + public var body: some View { + HStack(spacing: 8) { + Image(systemName: iconName) + .frame(width: 22, height: 22) + .foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(title) + .font(.subheadline) + if mediaAlbumId != nil { + Image(systemName: "square.stack.3d.up.fill") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + Text(detail) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(8) + .background(Color.gray.opacity(0.10), in: RoundedRectangle(cornerRadius: 8)) + } + + private var iconName: String { + switch media { + case .photo: + "photo" + case .voice: + "waveform" + } + } + + private var title: String { + switch media { + case .photo: + "Photo" + case .voice: + "Voice" + } + } + + private var detail: String { + switch media { + case let .photo(photo): + "\(photo.width)x\(photo.height) · \(downloadLabel(photo.downloadState))" + case let .voice(voice): + "\(voice.duration)s · \(downloadLabel(voice.downloadState))" + } + } + + private func downloadLabel(_ state: MediaDownloadState) -> String { + switch state { + case .notDownloaded: + "not downloaded" + case .downloading: + "downloading" + case .downloaded: + "downloaded" + case .error: + "error" + } + } +} + public struct ComposeBar: View { @Binding public var text: String public var replyTo: Message? diff --git a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift index 151504c..a908473 100644 --- a/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift +++ b/apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift @@ -70,6 +70,13 @@ struct TeleTuiIOSSmokeTests { precondition(viewModel.messages.count == 1) precondition(viewModel.messages[0].date == 1_700_000_000) precondition(viewModel.pinnedMessages.map(\.id) == [1]) + if case let .photo(photo) = viewModel.messages[0].media { + precondition(photo.fileId == 100) + precondition(photo.width == 1280) + precondition(photo.height == 720) + } else { + preconditionFailure("fake saved message should contain photo media") + } viewModel.composeText = "Hi from SwiftUI" await viewModel.send()