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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### πŸ”„ Changed

# [4.87.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.87.0)
_August 29, 2025_

## StreamChat
### βœ… Added
- Add support for `user.messages.deleted` event [#3792](https://github.com/GetStream/stream-chat-swift/pull/3792)
- Add upload endpoint for standalone attachments [#3788](https://github.com/GetStream/stream-chat-swift/pull/3788)
- Add option to access the total message count of a channel [#3796](https://github.com/GetStream/stream-chat-swift/pull/3796)
### 🐞 Fixed
- Fix channel getting removed from channel list which includes blocked channels [#3794](https://github.com/GetStream/stream-chat-swift/pull/3794)
- Fix system messages not incrementing and decrementing unread counts [#3795](https://github.com/GetStream/stream-chat-swift/pull/3795)

## StreamChatUI
### 🐞 Fixed
- Fix input text view's placeholder alignment in RTL [#3790](https://github.com/GetStream/stream-chat-swift/pull/3790)
- Flip directional icons in RTL [#3790](https://github.com/GetStream/stream-chat-swift/pull/3790)
- Fix swipe to reply gesture in RTL [#3790](https://github.com/GetStream/stream-chat-swift/pull/3790)
### πŸ”„ Changed
- Use chevron icons in `ChatMessageAttachmentPreviewVC` [#3790](https://github.com/GetStream/stream-chat-swift/pull/3790)

# [4.86.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.86.0)
_August 21, 2025_

Expand Down
24 changes: 24 additions & 0 deletions DemoApp/StreamChat/Components/DemoChatChannelListVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ final class DemoChatChannelListVC: ChatChannelListVC {
.equal(.blocked, to: true)
])
]))

lazy var blockedUnblockedWithHiddenChannelsQuery: ChannelListQuery = .init(
filter: .and([
.containMembers(userIds: [currentUserId]),
.or([
.and([.equal(.blocked, to: false), .equal(.hidden, to: false)]),
.and([.equal(.blocked, to: true), .equal(.hidden, to: true)])
])
])
)

lazy var unreadCountChannelsQuery: ChannelListQuery = .init(
filter: .and([
Expand Down Expand Up @@ -161,6 +171,15 @@ final class DemoChatChannelListVC: ChatChannelListVC {
self?.setAllBlockedChannelsQuery()
}
)

let blockedUnBlockedExcludingDeletedChannelsAction = UIAlertAction(
title: "Blocked Unblocked with Matching Hidden Channels",
style: .default,
handler: { [weak self] _ in
self?.title = "Blocked Unblocked with Matching Hidden Channels"
self?.setBlockedUnblockedWithHiddenChannelsQuery()
}
)

let unreadCountChannelsAction = UIAlertAction(
title: "Unread Count Channels",
Expand Down Expand Up @@ -230,6 +249,7 @@ final class DemoChatChannelListVC: ChatChannelListVC {
hasUnreadChannelsAction,
hiddenChannelsAction,
allBlockedChannelsAction,
blockedUnBlockedExcludingDeletedChannelsAction,
mutedChannelsAction,
coolChannelsAction,
pinnedChannelsAction,
Expand All @@ -248,6 +268,10 @@ final class DemoChatChannelListVC: ChatChannelListVC {
func setAllBlockedChannelsQuery() {
replaceQuery(allBlockedChannelsQuery)
}

func setBlockedUnblockedWithHiddenChannelsQuery() {
replaceQuery(blockedUnblockedWithHiddenChannelsQuery)
}

func setUnreadCountChannelsQuery() {
replaceQuery(unreadCountChannelsQuery)
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-8.08%20MB-blue"/>
<img id="stream-chat-label" alt="StreamChat" src="https://img.shields.io/badge/StreamChat-8.09%20MB-blue"/>
<img id="stream-chat-ui-label" alt="StreamChatUI" src="https://img.shields.io/badge/StreamChatUI-4.86%20MB-blue"/>
</p>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ public protocol AttachmentUploader {
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedAttachment, Error>) -> Void
)

/// Uploads a standalone attachment (not tied to message or channel), and returns the attachment with the remote information.
/// - Parameters:
/// - attachment: A standalone attachment.
/// - progress: The progress of the upload.
/// - completion: The callback with the uploaded attachment.
func uploadStandaloneAttachment<Payload>(
_ attachment: StreamAttachment<Payload>,
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedFile, Error>) -> Void
)
}

public class StreamAttachmentUploader: AttachmentUploader {
Expand All @@ -41,4 +52,16 @@ public class StreamAttachmentUploader: AttachmentUploader {
})
}
}

public func uploadStandaloneAttachment<Payload>(
_ attachment: StreamAttachment<Payload>,
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedFile, Error>) -> Void
) {
cdnClient.uploadStandaloneAttachment(
attachment,
progress: progress,
completion: completion
)
}
}
52 changes: 51 additions & 1 deletion Sources/StreamChat/APIClient/CDNClient/CDNClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,17 @@ public protocol CDNClient {
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedFile, Error>) -> Void
)

/// Uploads standalone attachment as a multipart/form-data and returns the uploaded remote file and its thumbnail.
/// - Parameters:
/// - attachment: An attachment to upload.
/// - progress: A closure that broadcasts upload progress.
/// - completion: Returns the uploaded file's information.
func uploadStandaloneAttachment<Payload>(
_ attachment: StreamAttachment<Payload>,
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedFile, Error>) -> Void
)
}

public extension CDNClient {
Expand Down Expand Up @@ -104,13 +115,52 @@ class StreamCDNClient: CDNClient {
let fileData = try? Data(contentsOf: uploadingState.localFileURL) else {
return completion(.failure(ClientError.AttachmentUploading(id: attachment.id)))
}
let endpoint = Endpoint<FileUploadPayload>.uploadAttachment(with: attachment.id.cid, type: attachment.type)

uploadAttachment(
endpoint: endpoint,
fileData: fileData,
uploadingState: uploadingState,
progress: progress,
completion: completion
)
}

func uploadStandaloneAttachment<Payload>(
_ attachment: StreamAttachment<Payload>,
progress: ((Double) -> Void)? = nil,
completion: @escaping (Result<UploadedFile, Error>) -> Void
) {
guard
let uploadingState = attachment.uploadingState,
let fileData = try? Data(contentsOf: uploadingState.localFileURL) else {
return completion(.failure(ClientError.Unknown()))
}

let endpoint = Endpoint<FileUploadPayload>.uploadAttachment(type: attachment.type)

uploadAttachment(
endpoint: endpoint,
fileData: fileData,
uploadingState: uploadingState,
progress: progress,
completion: completion
)
}

private func uploadAttachment<ResponsePayload>(
endpoint: Endpoint<ResponsePayload>,
fileData: Data,
uploadingState: AttachmentUploadingState,
progress: ((Double) -> Void)? = nil,
completion: @escaping (Result<UploadedFile, Error>) -> Void
) {
// Encode locally stored attachment into multipart form data
let multipartFormData = MultipartFormData(
fileData,
fileName: uploadingState.localFileURL.lastPathComponent,
mimeType: uploadingState.file.type.mimeType
)
let endpoint = Endpoint<FileUploadPayload>.uploadAttachment(with: attachment.id.cid, type: attachment.type)

encoder.encodeRequest(for: endpoint) { [weak self] (requestResult) in
var urlRequest: URLRequest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ import Foundation
extension Endpoint {
static func uploadAttachment(with cid: ChannelId, type: AttachmentType) -> Endpoint<FileUploadPayload> {
.init(
path: .uploadAttachment(channelId: cid.apiPath, type: type == .image ? "image" : "file"),
path: .uploadChannelAttachment(channelId: cid.apiPath, type: type == .image ? "image" : "file"),
method: .post,
queryItems: nil,
requiresConnectionId: false,
body: nil
)
}

static func uploadAttachment(type: AttachmentType) -> Endpoint<FileUploadPayload> {
.init(
path: .uploadAttachment(type == .image ? "image" : "file"),
method: .post,
queryItems: nil,
requiresConnectionId: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ extension EndpointPath {
return true
case .createChannel, .connect, .sync, .users, .guest, .members, .partialMemberUpdate, .search, .devices, .channels, .updateChannel,
.deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread,
.markAllChannelsRead, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadAttachment, .message,
.markAllChannelsRead, .channelEvent, .stopWatchingChannel, .pinnedMessages, .uploadChannelAttachment, .message,
.replies, .reactions, .messageAction, .banMember, .flagUser, .flagMessage, .muteUser, .translateMessage,
.callToken, .createCall, .deleteFile, .deleteImage, .og, .appSettings, .threads, .thread, .markThreadRead, .markThreadUnread,
.polls, .pollsQuery, .poll, .pollOption, .pollOptions, .pollVotes, .pollVoteInMessage, .pollVote,
.unread, .blockUser, .unblockUser, .drafts, .reminders, .reminder, .liveLocations:
.unread, .blockUser, .unblockUser, .drafts, .reminders, .reminder, .liveLocations, .uploadAttachment:
return false
}
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ enum EndpointPath: Codable {
case channelEvent(String)
case stopWatchingChannel(String)
case pinnedMessages(String)
case uploadAttachment(channelId: String, type: String)
case uploadChannelAttachment(channelId: String, type: String)
case uploadAttachment(String)

case sendMessage(ChannelId)
case message(MessageId)
Expand Down Expand Up @@ -125,7 +126,8 @@ enum EndpointPath: Codable {
case let .channelEvent(channelId): return "channels/\(channelId)/event"
case let .stopWatchingChannel(channelId): return "channels/\(channelId)/stop-watching"
case let .pinnedMessages(channelId): return "channels/\(channelId)/pinned_messages"
case let .uploadAttachment(channelId, type): return "channels/\(channelId)/\(type)"
case let .uploadChannelAttachment(channelId, type): return "channels/\(channelId)/\(type)"
case let .uploadAttachment(type): return "uploads/\(type)"

case let .sendMessage(channelId): return "channels/\(channelId.apiPath)/message"
case let .message(messageId): return "messages/\(messageId)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ public enum ChannelCodingKeys: String, CodingKey, CaseIterable {
/// The team the channel belongs to.
case team
case memberCount = "member_count"
case messageCount = "message_count"
/// Cooldown duration for the channel, if it's in slow mode.
/// This value will be 0 if the channel is not in slow mode.
case cooldownDuration = "cooldown"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ struct ChannelDetailPayload {
let members: [MemberPayload]?

let memberCount: Int

let messageCount: Int?

/// A list of users to invite in the channel.
let invitedMembers: [MemberPayload] = [] // TODO?
Expand Down Expand Up @@ -192,6 +194,7 @@ extension ChannelDetailPayload: Decodable {
isHidden: try container.decodeIfPresent(Bool.self, forKey: .hidden),
members: try container.decodeArrayIfPresentIgnoringFailures([MemberPayload].self, forKey: .members),
memberCount: try container.decodeIfPresent(Int.self, forKey: .memberCount) ?? 0,
messageCount: try container.decodeIfPresent(Int.self, forKey: .messageCount),
team: try container.decodeIfPresent(String.self, forKey: .team),
cooldownDuration: try container.decodeIfPresent(Int.self, forKey: .cooldownDuration) ?? 0
)
Expand Down
20 changes: 20 additions & 0 deletions Sources/StreamChat/ChatClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,26 @@ public class ChatClient {
loadAppSettings { continuation.resume(with: $0) }
}
}

// MARK: - Upload attachments

/// Uploads an attachment to the specified CDN.
///
/// - Parameters:
/// - attachment: the attachment to be uploaded.
/// - progress: the progress of the upload.
/// - completion: called when the attachment is uploaded.
public func upload<Payload>(
_ attachment: StreamAttachment<Payload>,
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedFile, Error>) -> Void
) {
apiClient.attachmentUploader.uploadStandaloneAttachment(
attachment,
progress: progress,
completion: completion
)
}

// MARK: - Internal

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -790,11 +790,20 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel
// MARK: - EventsControllerDelegate

public func eventsController(_ controller: EventsController, didReceiveEvent event: Event) {
guard let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid else {
return
if let channelEvent = event as? ChannelSpecificEvent, channelEvent.cid == cid {
handleChannelEvent(event)
}

handleChannelEvent(event)
// User deleted messages event is a global event, not tied to a channel.
if let userMessagesDeletedEvent = event as? UserMessagesDeletedEvent {
let userId = userMessagesDeletedEvent.user.id
if userMessagesDeletedEvent.hardDelete {
hardDeleteMessages(from: userId)
} else {
let deletedAt = userMessagesDeletedEvent.createdAt
softDeleteMessages(from: userId, deletedAt: deletedAt)
}
}
}

// MARK: - AppStateObserverDelegate
Expand Down Expand Up @@ -823,9 +832,6 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel
paginationStateHandler.begin(pagination: pagination)
}

let endpoint: Endpoint<ChannelPayload> =
.updateChannel(query: channelQuery)

let requestCompletion: (Result<ChannelPayload, Error>) -> Void = { [weak self] result in
self?.callback { [weak self] in
guard let self = self else { return }
Expand Down Expand Up @@ -1085,6 +1091,24 @@ public class LivestreamChannelController: DataStoreProvider, EventsControllerDel
messages.removeAll { $0.id == deletedMessage.id }
}

private func softDeleteMessages(from userId: UserId, deletedAt: Date) {
let messagesWithDeletedMessages = messages.map { message in
if message.author.id == userId {
return message.changing(
deletedAt: deletedAt
)
}
return message
}
messages = messagesWithDeletedMessages
}

private func hardDeleteMessages(from userId: UserId) {
messages.removeAll { message in
message.author.id == userId
}
}

private func handleNewReaction(_ reactionEvent: ReactionNewEvent) {
updateMessage(reactionEvent.message)
}
Expand Down
8 changes: 6 additions & 2 deletions Sources/StreamChat/Database/DTOs/ChannelDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class ChannelDTO: NSManagedObject {

@NSManaged var watcherCount: Int64
@NSManaged var memberCount: Int64
@NSManaged var messageCount: NSNumber?

@NSManaged var isFrozen: Bool
@NSManaged var cooldownDuration: Int
Expand Down Expand Up @@ -261,6 +262,10 @@ extension NSManagedObjectContext {
dto.defaultSortingAt = (payload.lastMessageAt ?? payload.createdAt).bridgeDate
dto.lastMessageAt = payload.lastMessageAt?.bridgeDate
dto.memberCount = Int64(clamping: payload.memberCount)

if let messageCount = payload.messageCount {
dto.messageCount = NSNumber(value: messageCount)
}

// Because `truncatedAt` is used, client side, for both truncation and channel hiding cases, we need to avoid using the
// value returned by the Backend in some cases.
Expand All @@ -285,8 +290,6 @@ extension NSManagedObjectContext {
// for blocked 1:1 channels on channel list query
if let isBlocked = payload.isBlocked {
dto.isBlocked = isBlocked
} else {
dto.isBlocked = false
}

// Backend only returns a boolean for hidden state
Expand Down Expand Up @@ -642,6 +645,7 @@ extension ChatChannel {
unreadCount: unreadCount,
watcherCount: Int(dto.watcherCount),
memberCount: Int(dto.memberCount),
messageCount: dto.messageCount?.intValue,
reads: reads,
cooldownDuration: Int(dto.cooldownDuration),
extraData: extraData,
Expand Down
Loading
Loading