Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ file_name_no_space:

cyclomatic_complexity:
ignores_case_statements: true
warning: 25
error: 30
warning: 30
error: 35

custom_rules:
regular_constraints_forbidden:
Expand Down
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### 🔄 Changed

# [4.94.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.94.0)
_December 02, 2025_

## StreamChat
### ✅ Added
- Add `ChatClient.uploadAttachment(localUrl:progress:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `ChatClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `CDNClient.deleteAttachment(remoteUrl:)` [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `heic`, `heif` and `svg` formats to the supported image file types [#3883](https://github.com/GetStream/stream-chat-swift/pull/3883)
- Add `ChatChannelController.markUnread(from:completion:)` and `Chat.markUnread(from:)` where the from argument is `Date` [#3885](https://github.com/GetStream/stream-chat-swift/pull/3885)
- Add support for filter tags in channels [#3886](https://github.com/GetStream/stream-chat-swift/pull/3886)
- Add `ChatChannel.filterTags`
- Add `filterTags` channel list filtering key
- Add `filterTags` argument to `ChatChannelController` and `Chat` factory methods in `ChatClient`
- Add `filterTags` argument to `ChatChannelController.updateChannel` and `ChatChannelController.partialUpdateChannel`
- Add `filterTags` argument to `Chat.update` and `Chat.updatePartial`
### 🐞 Fixed
- Fix rare crash in `WebSocketClient.connectEndpoint` [#3882](https://github.com/GetStream/stream-chat-swift/pull/3882)
- Fix audio recordings not playing from AirPods automatically [#3884](https://github.com/GetStream/stream-chat-swift/pull/3884)
- Fix audio recordings not using AirPods mic automatically [#3884](https://github.com/GetStream/stream-chat-swift/pull/3884)

## StreamChatUI
### 🐞 Fixed
- Fix marking channel read when scrolling to the bottom without unread counts [#3881](https://github.com/GetStream/stream-chat-swift/pull/3881)

# [4.93.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.93.0)
_November 18, 2025_

Expand Down
103 changes: 66 additions & 37 deletions DemoApp/Screens/UserProfile/UserProfileViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//

import StreamChat
import StreamChatUI
import SwiftUI
import UIKit

Expand Down Expand Up @@ -152,13 +153,12 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
}

private func updateUserData() {
guard let imageURL = currentUserController.currentUser?.imageURL else { return }
DispatchQueue.global().async { [weak self] in
guard let data = try? Data(contentsOf: imageURL), let image = UIImage(data: data) else { return }
DispatchQueue.main.async {
self?.imageView.image = image
}
}
Components.default
.imageLoader
.loadImage(
into: imageView,
from: currentUserController.currentUser?.imageURL
)

if let typingIndicatorsEnabled = currentUserController.currentUser?.privacySettings.typingIndicators?.enabled {
UserConfig.shared.typingIndicatorsEnabled = typingIndicatorsEnabled
Expand Down Expand Up @@ -268,6 +268,12 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
self?.presentImagePicker(sourceType: .photoLibrary)
})

if currentUserController.currentUser?.imageURL != nil {
alertController.addAction(UIAlertAction(title: "Delete Avatar", style: .destructive) { [weak self] _ in
self?.deleteAvatar()
})
}

alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel))

if let popover = alertController.popoverPresentationController {
Expand Down Expand Up @@ -312,45 +318,20 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
}

private func uploadImageAndUpdateProfile(_ image: UIImage, completion: @escaping (Error?) -> Void) {
guard let imageData = image.pngData() else {
completion(NSError(domain: "UserProfile", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to convert image to PNG data"]))
guard let imageLocalUrl = image.tempFileURL() else {
completion(ClientError("Failed to get local url."))
return
}

// Create temporary file
let imageURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("avatar_\(UUID().uuidString).png")

do {
try imageData.write(to: imageURL)
} catch {
completion(error)
return
}

let uploadingState = AttachmentUploadingState(
localFileURL: imageURL,
state: .pendingUpload,
file: .init(type: .png, size: Int64(imageData.count), mimeType: "image/png")
)

let attachment = StreamAttachment(
type: .image,
payload: imageData,
downloadingState: nil,
uploadingState: uploadingState
)


// Upload the image
currentUserController.client.upload(attachment, progress: { progress in
currentUserController.client.uploadAttachment(localUrl: imageLocalUrl, progress: { progress in
print("Upload progress: \(progress)")
}, completion: { [weak self] result in
// Clean up temporary file
try? FileManager.default.removeItem(at: imageURL)

switch result {
case .success(let file):
// Update user profile with new image URL
self?.currentUserController.updateUserData(imageURL: file.fileURL) { error in
self?.updateUserData()
completion(error)
}
case .failure(let error):
Expand All @@ -359,6 +340,40 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
})
}

private func deleteAvatar() {
guard let imageURL = currentUserController.currentUser?.imageURL else {
return
}

loadingSpinner.startAnimating()

// Delete the attachment from CDN
currentUserController.client.deleteAttachment(remoteUrl: imageURL) { [weak self] error in
if let error = error {
self?.loadingSpinner.stopAnimating()
self?.showError(error)
} else {
// Only update user data if deletion was successful
self?.currentUserController.updateUserData(unsetProperties: ["image"]) { updateError in
self?.loadingSpinner.stopAnimating()

if let updateError = updateError {
self?.showError(updateError)
} else {
self?.updateUserData()
let alert = UIAlertController(
title: "Success",
message: "Avatar deleted successfully!",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
self?.present(alert, animated: true)
}
}
}
}
}

private func showError(_ error: Error) {
let alert = UIAlertController(
title: "Upload Failed",
Expand All @@ -379,3 +394,17 @@ class UserProfileViewController: UITableViewController, CurrentChatUserControlle
present(alert, animated: true)
}
}

extension UIImage {
func tempFileURL() -> URL? {
guard let imageData = self.pngData() else { return nil }
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory())
let imageURL = tempDir.appendingPathComponent("avatar_\(UUID().uuidString).png")
do {
try imageData.write(to: imageURL)
return imageURL
} catch {
return nil
}
}
}
26 changes: 26 additions & 0 deletions DemoApp/StreamChat/Components/DemoChatChannelListRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,24 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
}
}
}),
.init(title: "Mark channel unread with timestamp", isEnabled: true, handler: { [unowned self] _ in
self.rootViewController.presentAlert(title: "Mark messages as unread with timestamp", message: "Marks messages as unread from the last number of days", textFieldPlaceholder: "Days") { offsetInDaysString in
let calendar = Calendar.current
guard let offsetInDays = Int(offsetInDaysString ?? ""),
let date = calendar.date(byAdding: .day, value: -abs(offsetInDays), to: calendar.startOfDay(for: Date())) else {
self.rootViewController.presentAlert(title: "Timestamp offset is not valid")
return
}
channelController.markUnread(from: date) { result in
switch result {
case .failure(let error):
self.rootViewController.presentAlert(title: "Couldn't mark messages as unread \(cid)", message: "\(error)")
case .success:
break
}
}
}
}),
.init(title: "Cool channel", isEnabled: canMuteChannel, handler: { [unowned self] _ in
channelController.partialChannelUpdate(extraData: ["is_cool": true]) { error in
if let error = error {
Expand All @@ -477,6 +495,14 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
}
}
}),
.init(title: "Add Premium Tag", isEnabled: canUpdateChannel, handler: { [unowned self] _ in
channelController.partialChannelUpdate(filterTags: ["premium"]) { error in
if let error = error {
self.rootViewController.presentAlert(title: "Couldn't make the channel \(cid) premium", message: "\(error)")
}
}
}),

.init(title: "Unmute channel", isEnabled: canMuteChannel, handler: { [unowned self] _ in
channelController.unmuteChannel { error in
if let error = error {
Expand Down
18 changes: 17 additions & 1 deletion DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ final class DemoChatChannelListVC: ChatChannelListVC {
lazy var equalMembersQuery: ChannelListQuery = .init(filter:
.equal(.members, values: [currentUserId, "r2-d2"])
)

lazy var premiumTaggedChannelsQuery: ChannelListQuery = .init(filter: .in(.filterTags, values: ["premium"]))

var demoRouter: DemoChatChannelListRouter? {
router as? DemoChatChannelListRouter
Expand Down Expand Up @@ -256,6 +258,15 @@ final class DemoChatChannelListVC: ChatChannelListVC {
self?.title = "R2-D2 Channels (Equal Members)"
self?.setEqualMembersChannelsQuery()
}

let taggedChannelsAction = UIAlertAction(
title: "Premium Tagged Channels",
style: .default,
handler: { [weak self] _ in
self?.title = "Premium Tagged Channels"
self?.setPremiumTaggedChannelsQuery()
}
)

presentAlert(
title: "Filter Channels",
Expand All @@ -271,7 +282,8 @@ final class DemoChatChannelListVC: ChatChannelListVC {
pinnedChannelsAction,
archivedChannelsAction,
equalMembersAction,
channelRoleChannelsAction
channelRoleChannelsAction,
taggedChannelsAction
].sorted(by: { $0.title ?? "" < $1.title ?? "" }),
preferredStyle: .actionSheet,
sourceView: filterChannelsButton
Expand Down Expand Up @@ -327,6 +339,10 @@ final class DemoChatChannelListVC: ChatChannelListVC {
func setEqualMembersChannelsQuery() {
replaceQuery(equalMembersQuery)
}

func setPremiumTaggedChannelsQuery() {
replaceQuery(premiumTaggedChannelsQuery)
}

func setInitialChannelsQuery() {
replaceQuery(initialQuery)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<a href="https://sonarcloud.io/summary/new_code?id=GetStream_stream-chat-swift"><img src="https://sonarcloud.io/api/project_badges/measure?project=GetStream_stream-chat-swift&metric=coverage" /></a>
</p>
<p align="center">
<img id="stream-chat-label" alt="StreamChat" src="https://img.shields.io/badge/StreamChat-7.25%20MB-blue"/>
<img id="stream-chat-label" alt="StreamChat" src="https://img.shields.io/badge/StreamChat-7.28%20MB-blue"/>
<img id="stream-chat-ui-label" alt="StreamChatUI" src="https://img.shields.io/badge/StreamChatUI-4.89%20MB-blue"/>
</p>

Expand Down
7 changes: 6 additions & 1 deletion Sources/StreamChat/APIClient/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ class APIClient {
/// The attachment uploader.
let attachmentUploader: AttachmentUploader

/// The CDN Client to store and delete attachments.
let cdnClient: CDNClient

/// Queue in charge of handling incoming requests
private let operationQueue: OperationQueue = {
let operationQueue = OperationQueue()
Expand Down Expand Up @@ -63,13 +66,15 @@ class APIClient {
requestEncoder: RequestEncoder,
requestDecoder: RequestDecoder,
attachmentDownloader: AttachmentDownloader,
attachmentUploader: AttachmentUploader
attachmentUploader: AttachmentUploader,
cdnClient: CDNClient
) {
encoder = requestEncoder
decoder = requestDecoder
session = URLSession(configuration: sessionConfiguration)
self.attachmentDownloader = attachmentDownloader
self.attachmentUploader = attachmentUploader
self.cdnClient = cdnClient
}

/// Performs a network request and retries in case of network failures
Expand Down
43 changes: 42 additions & 1 deletion Sources/StreamChat/APIClient/CDNClient/CDNClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ public protocol CDNClient {
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedFile, Error>) -> Void
)

/// Deletes the attachment from the CDN, given the remote URL.
/// - Parameters:
/// - remoteUrl: The remote url of the attachment.
/// - completion: Returns an error in case the delete operation fails.
func deleteAttachment(
remoteUrl: URL,
completion: @escaping (Error?) -> Void
)
}

public extension CDNClient {
Expand Down Expand Up @@ -147,7 +156,39 @@ class StreamCDNClient: CDNClient {
completion: completion
)
}


func deleteAttachment(
remoteUrl: URL,
completion: @escaping (Error?) -> Void
) {
let isImage = AttachmentFileType(ext: remoteUrl.pathExtension).isImage
let endpoint = Endpoint<EmptyResponse>
.deleteAttachment(
url: remoteUrl,
type: isImage ? .image : .file
)

encoder.encodeRequest(for: endpoint) { [weak self] (requestResult) in
var urlRequest: URLRequest

do {
urlRequest = try requestResult.get()
} catch {
log.error(error, subsystems: .httpRequests)
completion(error)
return
}

guard let self = self else {
return
}

self.session.dataTask(with: urlRequest, completionHandler: { _, _, error in
completion(error)
}).resume()
}
}

private func uploadAttachment<ResponsePayload>(
endpoint: Endpoint<ResponsePayload>,
fileData: Data,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ extension Endpoint {
)
}

static func deleteAttachment(url: URL, type: AttachmentType) -> Endpoint<EmptyResponse> {
.init(
path: .uploadAttachment(type == .image ? "image" : "file"),
method: .delete,
queryItems: nil,
requiresConnectionId: false,
body: ["url": url.absoluteString]
)
}

static func enrichUrl(url: URL)
-> Endpoint<LinkAttachmentPayload> {
.init(
Expand Down
Loading
Loading