Skip to content

Commit a100bd4

Browse files
Add media metadata to composer attachments for custom CDN (#1255)
1 parent 851da5c commit a100bd4

File tree

9 files changed

+328
-18
lines changed

9 files changed

+328
-18
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6-
### 🔄 Changed
6+
### ✅ Added
7+
- `AddedAsset` now has `originalWidth`, `originalHeight`, and `duration` (videos), set at selection time and passed into image/video attachment payloads for custom CDN uploads [#1255](https://github.com/GetStream/stream-chat-swiftui/pull/1255)
78

89
# [4.98.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.98.0)
910
_February 26, 2026_

Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerModels.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,20 @@ public final class AddedAsset: Identifiable, Equatable {
2020
public static func == (lhs: AddedAsset, rhs: AddedAsset) -> Bool {
2121
lhs.id == rhs.id
2222
}
23-
23+
2424
public let image: UIImage
2525
public let id: String
2626
public let url: URL
2727
public let type: AssetType
2828
public var extraData: [String: RawJSON] = [:]
2929

30+
/// Original width in pixels (for images and videos). Available at selection time without extra computation.
31+
public var originalWidth: Double?
32+
/// Original height in pixels (for images and videos). Available at selection time without extra computation.
33+
public var originalHeight: Double?
34+
/// Duration in seconds (for videos only). Available at selection time without extra computation.
35+
public var duration: TimeInterval?
36+
3037
/// The payload of the attachment, in case the attachment has been uploaded to server already.
3138
/// This is mostly used when editing an existing message that contains attachments.
3239
public var payload: AttachmentPayload?
@@ -37,13 +44,19 @@ public final class AddedAsset: Identifiable, Equatable {
3744
url: URL,
3845
type: AssetType,
3946
extraData: [String: RawJSON] = [:],
47+
originalWidth: Double? = nil,
48+
originalHeight: Double? = nil,
49+
duration: TimeInterval? = nil,
4050
payload: AttachmentPayload? = nil
4151
) {
4252
self.image = image
4353
self.id = id
4454
self.url = url
4555
self.type = type
4656
self.extraData = extraData
57+
self.originalWidth = originalWidth
58+
self.originalHeight = originalHeight
59+
self.duration = duration
4760
self.payload = payload
4861
}
4962
}
@@ -53,10 +66,20 @@ extension AddedAsset {
5366
if let payload = self.payload {
5467
return AnyAttachmentPayload(payload: payload)
5568
}
69+
var localMetadata: AnyAttachmentLocalMetadata?
70+
if originalWidth != nil || originalHeight != nil || duration != nil {
71+
var meta = AnyAttachmentLocalMetadata()
72+
if let w = originalWidth, let h = originalHeight {
73+
meta.originalResolution = (width: w, height: h)
74+
}
75+
meta.duration = duration
76+
localMetadata = meta
77+
}
5678
return try AnyAttachmentPayload(
5779
localFileURL: url,
5880
attachmentType: type == .video ? .video : .image,
59-
extraData: extraData
81+
localMetadata: localMetadata,
82+
extraData: extraData.isEmpty ? nil : extraData
6083
)
6184
}
6285
}

Sources/StreamChatSwiftUI/ChatChannel/Composer/ImagePickerView.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,14 @@ final class ImagePickerCoordinator: NSObject, UIImagePickerControllerDelegate, U
5656
) {
5757
if let uiImage = info[.originalImage] as? UIImage,
5858
let imageURL = try? uiImage.saveAsJpgToTemporaryUrl() {
59+
let scale = uiImage.scale
5960
let addedImage = AddedAsset(
6061
image: uiImage,
6162
id: UUID().uuidString,
6263
url: imageURL,
63-
type: .image
64+
type: .image,
65+
originalWidth: Double(uiImage.size.width * scale),
66+
originalHeight: Double(uiImage.size.height * scale)
6467
)
6568
parent.onAssetPicked(addedImage)
6669
} else if let videoURL = info[UIImagePickerController.InfoKey.mediaURL] as? URL {
@@ -73,11 +76,30 @@ final class ImagePickerCoordinator: NSObject, UIImagePickerControllerDelegate, U
7376
actualTime: nil
7477
)
7578
let thumbnail = UIImage(cgImage: cgImage)
79+
let durationSeconds = CMTimeGetSeconds(asset.duration)
80+
let size: (Double, Double)? = {
81+
guard let track = asset.tracks(withMediaType: .video).first else { return nil }
82+
let size = track.naturalSize
83+
let transform = track.preferredTransform
84+
let width: Double
85+
let height: Double
86+
if transform.a == 0 && abs(transform.b) == 1 && abs(transform.c) == 1 && transform.d == 0 {
87+
width = Double(size.height)
88+
height = Double(size.width)
89+
} else {
90+
width = Double(size.width)
91+
height = Double(size.height)
92+
}
93+
return (width, height)
94+
}()
7695
let addedVideo = AddedAsset(
7796
image: thumbnail,
7897
id: UUID().uuidString,
7998
url: videoURL,
80-
type: .video
99+
type: .video,
100+
originalWidth: size?.0,
101+
originalHeight: size?.1,
102+
duration: durationSeconds.isFinite && !durationSeconds.isNaN ? durationSeconds : nil
81103
)
82104
parent.onAssetPicked(addedVideo)
83105
} catch {

Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -533,11 +533,14 @@ open class MessageComposerViewModel: ObservableObject {
533533
log.error("Failed to write image to local temporary file")
534534
return
535535
}
536+
let scale = image.scale
536537
let addedImage = AddedAsset(
537538
image: image,
538539
id: UUID().uuidString,
539540
url: imageURL,
540-
type: .image
541+
type: .image,
542+
originalWidth: Double(image.size.width * scale),
543+
originalHeight: Double(image.size.height * scale)
541544
)
542545
addedAssets.append(addedImage)
543546
}
@@ -1086,7 +1089,11 @@ class MessageAttachmentsConverter {
10861089
id: videoAttachment.id.rawValue,
10871090
url: localUrl,
10881091
type: .video,
1089-
extraData: videoAttachment.extraData ?? [:]
1092+
extraData: videoAttachment.extraData ?? [:],
1093+
originalWidth: videoAttachment.originalWidth,
1094+
originalHeight: videoAttachment.originalHeight,
1095+
duration: videoAttachment.duration,
1096+
payload: nil
10901097
)
10911098
}
10921099

@@ -1096,6 +1103,9 @@ class MessageAttachmentsConverter {
10961103
url: videoAttachment.videoURL,
10971104
type: .video,
10981105
extraData: videoAttachment.extraData ?? [:],
1106+
originalWidth: videoAttachment.originalWidth,
1107+
originalHeight: videoAttachment.originalHeight,
1108+
duration: videoAttachment.duration,
10991109
payload: videoAttachment.payload
11001110
)
11011111
}
@@ -1116,7 +1126,10 @@ class MessageAttachmentsConverter {
11161126
id: imageAttachment.id.rawValue,
11171127
url: localFileUrl,
11181128
type: .image,
1119-
extraData: imageAttachment.extraData ?? [:]
1129+
extraData: imageAttachment.extraData ?? [:],
1130+
originalWidth: imageAttachment.originalWidth,
1131+
originalHeight: imageAttachment.originalHeight,
1132+
payload: nil
11201133
)
11211134
completion(imageAsset)
11221135
return
@@ -1135,6 +1148,8 @@ class MessageAttachmentsConverter {
11351148
url: imageAttachment.imageURL,
11361149
type: .image,
11371150
extraData: imageAttachment.extraData ?? [:],
1151+
originalWidth: imageAttachment.originalWidth,
1152+
originalHeight: imageAttachment.originalHeight,
11381153
payload: imageAttachment.payload
11391154
)
11401155
completion(imageAsset)

Sources/StreamChatSwiftUI/ChatChannel/Composer/PhotoAttachmentPickerView.swift

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,19 @@ public struct PhotoAttachmentCell: View {
103103
.onTapGesture {
104104
withAnimation {
105105
if let assetURL = asset.mediaType == .image ? assetJpgURL() : assetURL {
106+
let width = Double(asset.pixelWidth)
107+
let height = Double(asset.pixelHeight)
108+
let durationSeconds: TimeInterval? = asset.mediaType == .video ? asset.duration : nil
106109
onImageTap(
107110
AddedAsset(
108111
image: image,
109112
id: asset.localIdentifier,
110113
url: assetURL,
111114
type: assetType,
112-
extraData: asset
113-
.mediaType == .video ? ["duration": .string(asset.durationString)] : [:]
115+
extraData: asset.mediaType == .video ? ["duration": .string(asset.durationString)] : [:],
116+
originalWidth: width > 0 ? width : nil,
117+
originalHeight: height > 0 ? height : nil,
118+
duration: durationSeconds
114119
)
115120
)
116121
}

Sources/StreamChatSwiftUI/ChatChannel/MessageList/VideoAttachmentView.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ public struct VideoAttachmentsContainer<Factory: ViewFactory>: View {
1212
let width: CGFloat
1313
@Binding var scrolledId: String?
1414

15+
public init(
16+
factory: Factory,
17+
message: ChatMessage,
18+
width: CGFloat,
19+
scrolledId: Binding<String?>
20+
) {
21+
self.factory = factory
22+
self.message = message
23+
self.width = width
24+
_scrolledId = scrolledId
25+
}
26+
1527
public var body: some View {
1628
VStack(spacing: 0) {
1729
if let quotedMessage = message.quotedMessage {

StreamChatSwiftUI.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1462,8 +1462,8 @@
14621462
isa = XCRemoteSwiftPackageReference;
14631463
repositoryURL = "https://github.com/GetStream/stream-chat-swift.git";
14641464
requirement = {
1465-
kind = upToNextMajorVersion;
1466-
minimumVersion = 4.98.0;
1465+
branch = develop;
1466+
kind = branch;
14671467
};
14681468
};
14691469
E3A1C01A282BAC66002D1E26 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {

StreamChatSwiftUITests/Tests/ChatChannel/MessageAttachmentsConverter_Tests.swift

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,8 +189,11 @@ class MessageAttachmentsConverter_Tests: StreamChatTestCase {
189189
XCTAssertEqual(videoAsset?.type, .video)
190190
XCTAssertNotNil(videoAsset?.image) // Should have thumbnail
191191
XCTAssertNil(videoAsset?.payload) // Should not include payload when using local URL
192+
XCTAssertEqual(videoAsset?.originalWidth, 1920)
193+
XCTAssertEqual(videoAsset?.originalHeight, 1080)
194+
XCTAssertEqual(videoAsset?.duration, 90.5)
192195
}
193-
196+
194197
func test_attachmentsToAssets_videoAttachmentWithoutLocalURL() {
195198
// Given
196199
let attachments = [createVideoAttachmentWithoutLocalURL()]
@@ -235,8 +238,10 @@ class MessageAttachmentsConverter_Tests: StreamChatTestCase {
235238
XCTAssertEqual(imageAsset?.type, .image)
236239
XCTAssertNotNil(imageAsset?.image)
237240
XCTAssertNil(imageAsset?.payload) // Should not include payload when using local URL
241+
XCTAssertEqual(imageAsset?.originalWidth, 640)
242+
XCTAssertEqual(imageAsset?.originalHeight, 480)
238243
}
239-
244+
240245
func test_attachmentsToAssets_imageAttachmentWithoutLocalURL() {
241246
// Given
242247
let attachments = [createImageAttachmentWithoutLocalURL()]
@@ -259,6 +264,22 @@ class MessageAttachmentsConverter_Tests: StreamChatTestCase {
259264
XCTAssertNotNil(imageAsset?.image)
260265
XCTAssertNotNil(imageAsset?.payload)
261266
}
267+
268+
func test_attachmentsToAssets_imageAttachmentWithoutLocalURL_preservesPayloadMetadata() {
269+
let attachments = [createImageAttachmentWithoutLocalURL(originalWidth: 1024, originalHeight: 768)]
270+
let expectation = XCTestExpectation(description: "Image attachment conversion completion")
271+
var result: ComposerAssets?
272+
273+
converter.attachmentsToAssets(attachments) { composerAssets in
274+
result = composerAssets
275+
expectation.fulfill()
276+
}
277+
278+
wait(for: [expectation], timeout: 1.0)
279+
let imageAsset = result?.mediaAssets.first
280+
XCTAssertEqual(imageAsset?.originalWidth, 1024)
281+
XCTAssertEqual(imageAsset?.originalHeight, 768)
282+
}
262283

263284
// MARK: - Helper Methods
264285

@@ -318,7 +339,11 @@ class MessageAttachmentsConverter_Tests: StreamChatTestCase {
318339
).asAnyAttachment
319340
}
320341

321-
private func createVideoAttachmentWithLocalURL() -> AnyChatMessageAttachment {
342+
private func createVideoAttachmentWithLocalURL(
343+
originalWidth: Double? = 1920,
344+
originalHeight: Double? = 1080,
345+
duration: TimeInterval? = 90.5
346+
) -> AnyChatMessageAttachment {
322347
let attachmentFile = AttachmentFile(type: .mp4, size: 2048, mimeType: "video/mp4")
323348
let uploadingState = AttachmentUploadingState(
324349
localFileURL: mockVideoURL,
@@ -333,6 +358,9 @@ class MessageAttachmentsConverter_Tests: StreamChatTestCase {
333358
title: "Test Video",
334359
videoRemoteURL: URL(string: "https://example.com/video.mp4")!,
335360
thumbnailURL: TestImages.yoda.url,
361+
originalWidth: originalWidth,
362+
originalHeight: originalHeight,
363+
duration: duration,
336364
file: attachmentFile,
337365
extraData: ["test": "value"]
338366
),
@@ -359,7 +387,7 @@ class MessageAttachmentsConverter_Tests: StreamChatTestCase {
359387
).asAnyAttachment
360388
}
361389

362-
private func createImageAttachmentWithLocalURL() -> AnyChatMessageAttachment {
390+
private func createImageAttachmentWithLocalURL(originalWidth: Double? = 640, originalHeight: Double? = 480) -> AnyChatMessageAttachment {
363391
let attachmentFile = AttachmentFile(type: .png, size: 512, mimeType: "image/png")
364392
let uploadingState = AttachmentUploadingState(
365393
localFileURL: mockImageURL,
@@ -373,6 +401,8 @@ class MessageAttachmentsConverter_Tests: StreamChatTestCase {
373401
payload: ImageAttachmentPayload(
374402
title: "Test Image",
375403
imageRemoteURL: URL(string: "https://example.com/image.png")!,
404+
originalWidth: originalWidth,
405+
originalHeight: originalHeight,
376406
extraData: ["test": "value"]
377407
),
378408
downloadingState: nil,
@@ -401,7 +431,10 @@ class MessageAttachmentsConverter_Tests: StreamChatTestCase {
401431
).asAnyAttachment
402432
}
403433

404-
private func createImageAttachmentWithoutLocalURL() -> AnyChatMessageAttachment {
434+
private func createImageAttachmentWithoutLocalURL(
435+
originalWidth: Double? = nil,
436+
originalHeight: Double? = nil
437+
) -> AnyChatMessageAttachment {
405438
let attachmentFile = AttachmentFile(type: .png, size: 512, mimeType: "image/png")
406439

407440
return ChatMessageImageAttachment(
@@ -410,6 +443,8 @@ class MessageAttachmentsConverter_Tests: StreamChatTestCase {
410443
payload: ImageAttachmentPayload(
411444
title: "Test Image",
412445
imageRemoteURL: URL(string: "https://example.com/image.png")!,
446+
originalWidth: originalWidth,
447+
originalHeight: originalHeight,
413448
extraData: ["test": "value"]
414449
),
415450
downloadingState: nil,

0 commit comments

Comments
 (0)