Skip to content

Commit a168ba8

Browse files
authored
Add support for downloading file attachments (#952)
* Add `AttachmentDownloadingStateView` similar to the uploading one * Add download and share button to `FileAttachmentView` * Add download and share button to the Files List view as well * Add feature flag to enable download of file attachments * Add test coverage * Update CHANGELOG.md * Simlify conflict resolution * Revise CHANGELOG format and add new entry Updated the changelog format and added a new entry for file attachment downloads. * Fix linting * Fix more linting
1 parent 20c6a1b commit a168ba8

20 files changed

+444
-36
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+
- Add support for downloading file attachments [#952](https://github.com/GetStream/stream-chat-swiftui/pull/952)
78

89
# [4.88.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.88.0)
910
_September 10, 2025_

DemoAppSwiftUI/AppDelegate.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ class AppDelegate: NSObject, UIApplicationDelegate {
7474
skipEditedMessageLabel: { message in
7575
message.extraData["ai_generated"]?.boolValue == true
7676
},
77-
draftMessagesEnabled: true
77+
draftMessagesEnabled: true,
78+
downloadFileAttachmentsEnabled: true
7879
),
7980
composerConfig: ComposerConfig(isVoiceRecordingEnabled: true)
8081
)

Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/FileAttachmentsView.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public struct FileAttachmentsView: View {
1212
@Injected(\.colors) private var colors
1313
@Injected(\.fonts) private var fonts
1414
@Injected(\.images) private var images
15+
@Injected(\.utils) private var utils
1516

1617
public init(channel: ChatChannel) {
1718
_viewModel = StateObject(
@@ -45,11 +46,17 @@ public struct FileAttachmentsView: View {
4546
Button {
4647
viewModel.selectedAttachment = attachment
4748
} label: {
48-
FileAttachmentDisplayView(
49-
url: url,
50-
title: attachment.title ?? url.lastPathComponent,
51-
sizeString: attachment.file.sizeString
52-
)
49+
HStack {
50+
FileAttachmentDisplayView(
51+
url: url,
52+
title: attachment.title ?? url.lastPathComponent,
53+
sizeString: attachment.file.sizeString
54+
)
55+
Spacer()
56+
if utils.messageListConfig.downloadFileAttachmentsEnabled {
57+
DownloadShareAttachmentView(attachment: attachment)
58+
}
59+
}
5360
.onAppear {
5461
viewModel.loadAdditionalAttachments(
5562
after: monthlyDataSource,
@@ -59,6 +66,7 @@ public struct FileAttachmentsView: View {
5966
.padding(.horizontal, 8)
6067
.padding(.vertical)
6168
}
69+
.withDownloadingStateIndicator(for: attachment.downloadingState, url: attachment.assetURL)
6270
.sheet(item: $viewModel.selectedAttachment) { item in
6371
FileAttachmentPreview(title: item.title, url: item.assetURL)
6472
}

Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/FileAttachmentsViewModel.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,16 @@ class FileAttachmentsViewModel: ObservableObject, ChatMessageSearchControllerDel
8989
loading = true
9090
messageSearchController.search(query: query, completion: { [weak self] _ in
9191
guard let self = self else { return }
92-
self.updateAttachments()
92+
withAnimation {
93+
self.updateAttachments()
94+
}
9395
self.loading = false
9496
})
9597
}
9698

9799
private func updateAttachments() {
98100
let messages = messageSearchController.messages
99-
withAnimation {
100-
self.attachmentsDataSource = self.loadAttachments(from: messages)
101-
}
101+
attachmentsDataSource = loadAttachments(from: messages)
102102
}
103103

104104
private func loadAttachments(from messages: LazyCachedMapCollection<ChatMessage>) -> [MonthlyFileAttachments] {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import SwiftUI
7+
8+
/// View used for displaying progress while an attachment is being downloaded.
9+
struct AttachmentDownloadingStateView: View {
10+
@Injected(\.images) private var images
11+
@Injected(\.colors) private var colors
12+
@Injected(\.fonts) private var fonts
13+
14+
var downloadState: AttachmentDownloadingState
15+
var url: URL
16+
17+
var body: some View {
18+
Group {
19+
switch downloadState.state {
20+
case let .downloading(progress: progress):
21+
BottomRightView {
22+
PercentageProgressView(progress: progress)
23+
}
24+
25+
case .downloadingFailed:
26+
BottomRightView {
27+
Image(uiImage: images.messageListErrorIndicator)
28+
.foregroundColor(Color(colors.alert))
29+
.background(Color.white)
30+
.clipShape(Circle())
31+
.offset(x: -4, y: -4)
32+
}
33+
case .downloaded:
34+
EmptyView()
35+
}
36+
}
37+
.id("\(url.absoluteString)-\(downloadState.state))")
38+
}
39+
}
40+
41+
/// View modifier enabling downloading state display.
42+
struct AttachmentDownloadingStateViewModifier: ViewModifier {
43+
var downloadState: AttachmentDownloadingState?
44+
var url: URL
45+
46+
func body(content: Content) -> some View {
47+
content
48+
.overlay(
49+
downloadState != nil ? AttachmentDownloadingStateView(downloadState: downloadState!, url: url) : nil
50+
)
51+
}
52+
}
53+
54+
extension View {
55+
/// Attaches a downloading state indicator.
56+
/// - Parameters:
57+
/// - downloadState: the download state of the attachment.
58+
/// - url: the url of the attachment.
59+
public func withDownloadingStateIndicator(for downloadState: AttachmentDownloadingState?, url: URL) -> some View {
60+
modifier(AttachmentDownloadingStateViewModifier(downloadState: downloadState, url: url))
61+
}
62+
}

Sources/StreamChatSwiftUI/ChatChannel/Composer/AttachmentUploadingStateView.swift renamed to Sources/StreamChatSwiftUI/ChatChannel/MessageList/AttachmentUploadingStateView.swift

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,7 @@ struct AttachmentUploadingStateView: View {
1919
switch uploadState.state {
2020
case let .uploading(progress: progress):
2121
BottomRightView {
22-
HStack(spacing: 4) {
23-
ProgressView()
24-
.progressViewStyle(
25-
CircularProgressViewStyle(tint: .white)
26-
)
27-
.scaleEffect(0.7)
28-
29-
Text(progressDisplay(for: progress))
30-
.font(fonts.footnote)
31-
.foregroundColor(Color(colors.staticColorText))
32-
}
33-
.padding(.all, 4)
34-
.background(Color.black.opacity(0.7))
35-
.cornerRadius(8)
36-
.padding(.all, 8)
22+
PercentageProgressView(progress: progress)
3723
}
3824

3925
case .uploadingFailed:
@@ -58,11 +44,6 @@ struct AttachmentUploadingStateView: View {
5844
}
5945
.id("\(url.absoluteString)-\(uploadState.state))")
6046
}
61-
62-
private func progressDisplay(for progress: CGFloat) -> String {
63-
let value = Int(progress * 100)
64-
return "\(value)%"
65-
}
6647
}
6748

6849
/// View modifier enabling uploading state display.

Sources/StreamChatSwiftUI/ChatChannel/MessageList/FileAttachmentView.swift

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,11 @@ public struct FileAttachmentsContainer<Factory: ViewFactory>: View {
7171
}
7272

7373
public struct FileAttachmentView: View {
74+
@Injected(\.utils) private var utils
7475
@Injected(\.images) private var images
7576
@Injected(\.fonts) private var fonts
7677
@Injected(\.colors) private var colors
78+
@Injected(\.chatClient) private var chatClient
7779

7880
@State private var fullScreenShown = false
7981

@@ -102,17 +104,30 @@ public struct FileAttachmentView: View {
102104
}
103105

104106
Spacer()
107+
108+
if utils.messageListConfig.downloadFileAttachmentsEnabled {
109+
DownloadShareAttachmentView(attachment: attachment)
110+
}
105111
}
106112
.padding(.all, 8)
107113
.background(Color(colors.background))
108114
.frame(width: width)
109115
.roundWithBorder()
110116
.withUploadingStateIndicator(for: attachment.uploadingState, url: attachment.assetURL)
117+
.withDownloadingStateIndicator(for: attachment.downloadingState, url: attachment.assetURL)
111118
.sheet(isPresented: $fullScreenShown) {
112-
FileAttachmentPreview(title: attachment.title, url: attachment.assetURL)
119+
FileAttachmentPreview(title: attachment.title, url: previewURL)
113120
}
114121
.accessibilityIdentifier("FileAttachmentView")
115122
}
123+
124+
private var previewURL: URL {
125+
if attachment.downloadingState?.state == .downloaded,
126+
let localFileURL = attachment.downloadingState?.localFileURL {
127+
return localFileURL
128+
}
129+
return attachment.assetURL
130+
}
116131
}
117132

118133
public struct FileAttachmentDisplayView: View {
@@ -157,3 +172,73 @@ public struct FileAttachmentDisplayView: View {
157172
return images.documentPreviews[iconName] ?? images.fileFallback
158173
}
159174
}
175+
176+
struct DownloadShareAttachmentView<Payload: DownloadableAttachmentPayload>: View {
177+
@Injected(\.colors) var colors
178+
@Injected(\.images) var images
179+
@Injected(\.chatClient) var chatClient
180+
181+
@State private var shareSheetShown = false
182+
183+
var attachment: ChatMessageAttachment<Payload>
184+
185+
var body: some View {
186+
Group {
187+
if shouldShowDownloadButton {
188+
downloadButton
189+
} else if shouldShowShareButton {
190+
shareButton
191+
}
192+
}
193+
.sheet(isPresented: $shareSheetShown) {
194+
if let shareURL = attachment.downloadingState?.localFileURL {
195+
ShareSheet(activityItems: [shareURL])
196+
}
197+
}
198+
}
199+
200+
private var shouldShowShareButton: Bool {
201+
attachment.downloadingState?.state == .downloaded
202+
}
203+
204+
private var shouldShowDownloadButton: Bool {
205+
(attachment.uploadingState == nil || attachment.uploadingState?.state == .uploaded) && attachment.downloadingState == nil
206+
}
207+
208+
private var downloadButton: some View {
209+
Button(action: { downloadAttachment() }) {
210+
Image(uiImage: images.download)
211+
.renderingMode(.template)
212+
.foregroundColor(colors.tintColor)
213+
.frame(width: 24, height: 24)
214+
}
215+
.accessibilityLabel("Download")
216+
}
217+
218+
private var shareButton: some View {
219+
Button(action: { shareSheetShown = true }) {
220+
Image(uiImage: images.share)
221+
.renderingMode(.template)
222+
.foregroundColor(colors.tintColor)
223+
.frame(width: 24, height: 24)
224+
}
225+
.accessibilityLabel("Share")
226+
}
227+
228+
private func downloadAttachment() {
229+
let messageId = attachment.id.messageId
230+
let cid = attachment.id.cid
231+
let messageController = chatClient.messageController(cid: cid, messageId: messageId)
232+
messageController.downloadAttachment(attachment) { _ in }
233+
}
234+
}
235+
236+
struct ShareSheet: UIViewControllerRepresentable {
237+
let activityItems: [Any]
238+
239+
func makeUIViewController(context: Context) -> UIActivityViewController {
240+
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
241+
}
242+
243+
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
244+
}

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ public struct MessageListConfig {
3535
userBlockingEnabled: Bool = false,
3636
bouncedMessagesAlertActionsEnabled: Bool = true,
3737
skipEditedMessageLabel: @escaping (ChatMessage) -> Bool = { _ in false },
38-
draftMessagesEnabled: Bool = false
38+
draftMessagesEnabled: Bool = false,
39+
downloadFileAttachmentsEnabled: Bool = false
3940
) {
4041
self.messageListType = messageListType
4142
self.typingIndicatorPlacement = typingIndicatorPlacement
@@ -64,6 +65,7 @@ public struct MessageListConfig {
6465
self.bouncedMessagesAlertActionsEnabled = bouncedMessagesAlertActionsEnabled
6566
self.skipEditedMessageLabel = skipEditedMessageLabel
6667
self.draftMessagesEnabled = draftMessagesEnabled
68+
self.downloadFileAttachmentsEnabled = downloadFileAttachmentsEnabled
6769
}
6870

6971
public let messageListType: MessageListType
@@ -102,6 +104,9 @@ public struct MessageListConfig {
102104
///
103105
/// If enabled, the SDK will save the message content as a draft when the user navigates away from the composer.
104106
public let draftMessagesEnabled: Bool
107+
108+
/// A boolean value that determines if download action is shown for file attachments.
109+
public let downloadFileAttachmentsEnabled: Bool
105110
}
106111

107112
/// Contains information about the message paddings.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import SwiftUI
6+
7+
/// A view used to show the progress of a task a long with the percentage.
8+
struct PercentageProgressView: View {
9+
@Injected(\.images) private var images
10+
@Injected(\.colors) private var colors
11+
@Injected(\.fonts) private var fonts
12+
13+
let progress: CGFloat
14+
15+
var body: some View {
16+
HStack(spacing: 4) {
17+
ProgressView()
18+
.progressViewStyle(
19+
CircularProgressViewStyle(tint: .white)
20+
)
21+
.scaleEffect(0.7)
22+
23+
Text(progressDisplay(for: progress))
24+
.font(fonts.footnote)
25+
.foregroundColor(Color(colors.staticColorText))
26+
}
27+
.padding(.all, 4)
28+
.background(Color.black.opacity(0.7))
29+
.cornerRadius(8)
30+
.padding(.all, 8)
31+
}
32+
33+
private func progressDisplay(for progress: CGFloat) -> String {
34+
let value = Int(progress * 100)
35+
return "\(value)%"
36+
}
37+
}

0 commit comments

Comments
 (0)