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
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)

Comment on lines +39 to 41
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Renaming the former .uploadAttachment(channelId:type:) breaks Codable compatibility; consider a backward-compatible strategy

Because EndpointPath is Codable, changing the case name and its payload shape (from two associated values to one) will fail to decode any previously persisted values (e.g., offline request queue, cached requests) that used the legacy case. This can silently drop queued work or crash decode paths after app upgrade.

Two viable approaches:

  • Preferred (non-breaking for persisted data): keep the original case name for the channel-scoped upload and give the new standalone endpoint a distinct name, e.g.:
    • case uploadAttachment(channelId: String, type: String) (legacy semantics, unchanged)
    • case uploadStandaloneAttachment(String) (new uploads/{type})
      Update call-sites accordingly.
  • Alternative: implement a custom init(from:) that attempts to decode the legacy representation (uploadAttachment with a keyed object {channelId,type}) and map it to the new uploadChannelAttachment, while also supporting the new single-value uploadAttachment(String) shape.

If you do intend this to be a breaking change, please call it out in the changelog and migration docs. Otherwise, consider the diff below to avoid breaking persisted payloads:

-    case uploadChannelAttachment(channelId: String, type: String)
-    case uploadAttachment(String)
+    // Keep legacy case name for channel-scoped uploads to retain Codable compatibility.
+    case uploadAttachment(channelId: String, type: String)
+    // New: standalone uploads (uploads/{type})
+    case uploadStandaloneAttachment(String)
@@
-        case let .uploadChannelAttachment(channelId, type): return "channels/\(channelId)/\(type)"
-        case let .uploadAttachment(type): return "uploads/\(type)"
+        case let .uploadAttachment(channelId, type): return "channels/\(channelId)/\(type)"
+        case let .uploadStandaloneAttachment(type): return "uploads/\(type)"

Follow-ups outside this file: switch call-sites/tests from .uploadChannelAttachment to .uploadAttachment(channelId:type:) and rename references to the new standalone case as needed. I can generate a PR-wide patch if helpful.

Run this repo scan to see if EndpointPath values are persisted (to gauge the impact) and where to update call-sites:

Also applies to: 129-131


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Look for EndpointPath encode/decode usage (persistence risk)
rg -nC2 -g '!**/DerivedData/**' -P 'JSONEncoder\.stream\.encode\(|JSONDecoder\.stream\.decode\('

# 2) Find references to uploadAttachment/uploadChannelAttachment to estimate blast radius
rg -nC2 -g '!**/DerivedData/**' -P '\bEndpointPath\.(uploadAttachment|uploadChannelAttachment)\b'
rg -nC2 -g '!**/DerivedData/**' -P '\.uploadAttachment\(' --type swift

# 3) Check for offline queue classes that may serialize endpoints
rg -nC3 -g '!**/DerivedData/**' -P 'Offline|Queue|Persistence|Store|Codable|Disk|background' --type swift

Length of output: 54439


Preserve Codable compatibility for queued endpoints in EndpointPath

The EndpointPath enum is serialized and deserialized by the offline requests queue (see OfflineRequestsRepository.swift:198–199 for JSONDecoder.stream.decode and 233–235 for JSONEncoder.stream.encode). Renaming the channel-scoped upload case and changing its associated values will break decoding of any endpoints persisted before this change, leading to silent failure or data loss in the offline queue.

Locations needing attention:

  • Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift (lines 39–41)
    Ensure the legacy .uploadAttachment(channelId:type:) case remains intact for backward compatibility.
  • Sources/StreamChat/Repositories/OfflineRequestsRepository.swift (around lines 198 & 233)
    Confirm that decoding logic continues to expect the original case, or implement a custom init(from:) in EndpointPath to handle both legacy and new representations.

Suggested diff in EndpointPath.swift:

-Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift
-    case uploadChannelAttachment(channelId: String, type: String)
-    case uploadAttachment(String)
+    // Legacy: channel-scoped uploads (retain for Codable compatibility)
+    case uploadAttachment(channelId: String, type: String)
+    // New: standalone uploads (uploads/{type})
+    case uploadStandaloneAttachment(String)
@@
-        case let .uploadChannelAttachment(channelId, type): return "channels/\(channelId)/\(type)"
-        case let .uploadAttachment(type): return "uploads/\(type)"
+        case let .uploadAttachment(channelId, type):      return "channels/\(channelId)/\(type)"
+        case let .uploadStandaloneAttachment(type):        return "uploads/\(type)"

Follow-ups:

  • Update all call-sites and tests referencing .uploadChannelAttachment to use .uploadAttachment(channelId:type:).
  • Use .uploadStandaloneAttachment for the new uploads endpoint.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case uploadChannelAttachment(channelId: String, type: String)
case uploadAttachment(String)
// --- in Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift ---
// Replace the two existing cases:
//
// case uploadChannelAttachment(channelId: String, type: String)
// case uploadAttachment(String)
//
// With these:
// Legacy: channel-scoped uploads (retain for Codable compatibility)
case uploadAttachment(channelId: String, type: String)
// New: standalone uploads (uploads/{type})
case uploadStandaloneAttachment(String)
// --- later in the same file, inside `var path: String { switch self { … } }` ---
// Replace the two branches:
//
// case let .uploadChannelAttachment(channelId, type): return "channels/\(channelId)/\(type)"
// case let .uploadAttachment(type): return "uploads/\(type)"
//
// With these:
case let .uploadAttachment(channelId, type):
return "channels/\(channelId)/\(type)"
case let .uploadStandaloneAttachment(type):
return "uploads/\(type)"

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
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 @@ -4,6 +4,39 @@

import Foundation

public struct StreamAttachment<Payload> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Stream prefix could be a bit generic, and it does not give enough context on how this is used. What about this:

Suggested change
public struct StreamAttachment<Payload> {
/// An attachment that is not assigned to any message or channel.
public struct StandaloneAttachment<Payload> {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another option is to try to just reuse the API from ChannelUpdater.uploadFile(), and not expose any new type 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About the naming, I would actually prefer to keep StreamAttachment. The reason is that on other products, all attachments would be standalone - not tied to channels, posts, etc. And it matches the API one, which is just Attachment.

/// The attachment type.
public let type: AttachmentType

/// The attachment payload.
public var payload: Payload

/// The downloading state of the attachment.
///
/// Reflects the downloading progress for attachments.
public let downloadingState: AttachmentDownloadingState?

/// The uploading state of the attachment.
///
/// Reflects uploading progress for local attachments that require file uploading.
/// Is `nil` for local attachments that don't need to be uploaded.
///
/// Becomes `nil` when the message with the current attachment is sent.
public let uploadingState: AttachmentUploadingState?

public init(
type: AttachmentType,
payload: Payload,
downloadingState: AttachmentDownloadingState?,
uploadingState: AttachmentUploadingState?
) {
self.type = type
self.payload = payload
self.downloadingState = downloadingState
self.uploadingState = uploadingState
}
}

/// A type representing a chat message attachment.
/// `ChatMessageAttachment<Payload>` is an immutable snapshot of message attachment at the given time.
@dynamicMemberLookup
Expand Down Expand Up @@ -81,6 +114,13 @@ public struct AttachmentUploadingState: Hashable {

/// The information about file size/mimeType.
public let file: AttachmentFile

/// Public init.
public init(localFileURL: URL, state: LocalAttachmentState, file: AttachmentFile) {
self.localFileURL = localFileURL
self.state = state
self.file = file
}
}

// MARK: - Type erasure/recovery
Expand Down
4 changes: 4 additions & 0 deletions StreamChat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,7 @@
84B7383E2BE8C13A00EC66EC /* PollController+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7383C2BE8C13A00EC66EC /* PollController+SwiftUI.swift */; };
84B8779E2AC30F0E009EF76A /* DemoShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B8779D2AC30F0E009EF76A /* DemoShareViewModel.swift */; };
84B877A02AC31AB8009EF76A /* StreamChat+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B8779F2AC31AB8009EF76A /* StreamChat+Extensions.swift */; };
84BC99282E5C56590000FB87 /* StreamAttachment_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BC99272E5C56590000FB87 /* StreamAttachment_Mock.swift */; };
84BE85DA2AC30E88007DD47C /* DemoShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84BE85D92AC30E88007DD47C /* DemoShareView.swift */; };
84C11BDF27FB2B4600000A9E /* ChannelPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C11BDE27FB2B4600000A9E /* ChannelPayload.swift */; };
84C11BE127FB2C2B00000A9E /* ChannelReadDTO_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C11BE027FB2C2B00000A9E /* ChannelReadDTO_Tests.swift */; };
Expand Down Expand Up @@ -3793,6 +3794,7 @@
84B7383C2BE8C13A00EC66EC /* PollController+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollController+SwiftUI.swift"; sourceTree = "<group>"; };
84B8779D2AC30F0E009EF76A /* DemoShareViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoShareViewModel.swift; sourceTree = "<group>"; };
84B8779F2AC31AB8009EF76A /* StreamChat+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StreamChat+Extensions.swift"; sourceTree = "<group>"; };
84BC99272E5C56590000FB87 /* StreamAttachment_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamAttachment_Mock.swift; sourceTree = "<group>"; };
84BE85D92AC30E88007DD47C /* DemoShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoShareView.swift; sourceTree = "<group>"; };
84C11BDE27FB2B4600000A9E /* ChannelPayload.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelPayload.swift; sourceTree = "<group>"; };
84C11BE027FB2C2B00000A9E /* ChannelReadDTO_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelReadDTO_Tests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7002,6 +7004,7 @@
A344075B27D753530044F150 /* ChatMessageLinkAttachment_Mock.swift */,
4F1FB7D72C7DEC6600C47C2A /* ChatMessageVideoAttachment_Mock.swift */,
40A2961929F8244500E0C186 /* ChatMessageVoiceRecordingAttachment_Mock.swift */,
84BC99272E5C56590000FB87 /* StreamAttachment_Mock.swift */,
);
path = Attachments;
sourceTree = "<group>";
Expand Down Expand Up @@ -11393,6 +11396,7 @@
A311B42D27E8BB7400CFCF6D /* StreamChatTestTools.swift in Sources */,
40D484022A1264F1009E4134 /* MockAudioRecorder.swift in Sources */,
82F714A72B0784D900442A74 /* UnwrapAsync.swift in Sources */,
84BC99282E5C56590000FB87 /* StreamAttachment_Mock.swift in Sources */,
A344077627D753530044F150 /* ChatMessageReaction_Mock.swift in Sources */,
A311B43B27E8BC8400CFCF6D /* ChannelWatcherListController_Delegate.swift in Sources */,
82E655332B06748400D64906 /* Spy.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ extension EndpointPath: Equatable {
case let (.channelEvent(string1), .channelEvent(string2)): return string1 == string2
case let (.stopWatchingChannel(string1), .stopWatchingChannel(string2)): return string1 == string2
case let (.pinnedMessages(string1), .pinnedMessages(string2)): return string1 == string2
case let (.uploadAttachment(channelId1, type1), .uploadAttachment(channelId2, type2)): return channelId1 == channelId2 &&
case let (.uploadChannelAttachment(channelId1, type1), .uploadChannelAttachment(channelId2, type2)): return channelId1 == channelId2 &&
type1 ==
type2
Comment on lines +31 to 33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Add Equatable branch for the new .uploadAttachment(type:) path as well

You updated the renamed channel-scoped case, but the new standalone path (.uploadAttachment(type:)) is not covered. Any equality checks involving that case will currently return false. Please add a matching branch.

Apply this diff near the existing upload case:

 case let (.uploadChannelAttachment(channelId1, type1), .uploadChannelAttachment(channelId2, type2)): return channelId1 == channelId2 &&
     type1 ==
     type2
+case let (.uploadAttachment(type1), .uploadAttachment(type2)): return type1 == type2

Run this to confirm we have tests that rely on path equality for the new case (and to ensure the new branch is exercised):


🏁 Script executed:

#!/bin/bash
# Find usages of the standalone upload path in tests
rg -nP --type=swift -C2 '\.uploadAttachment\s*\(\s*type:' Tests TestTools Sources \
  | sed -n '1,200p'
# Check Equatable extension covers standalone upload
rg -nP --type=swift 'extension\s+EndpointPath\s*:\s*Equatable' -A120 TestTools \
  | rg -nP 'uploadAttachment'

Length of output: 1143


🏁 Script executed:

#!/bin/bash
# Locate the Equatable extension file under TestTools
find TestTools -type f -name "*Equatable.swift"
# Display its first 200 lines if found
equiv_file=$(find TestTools -type f -name "*Equatable.swift" | head -n1)
if [ -n "$equiv_file" ]; then
  echo "== Showing first 200 lines of $equiv_file =="
  sed -n '1,200p' "$equiv_file"
  echo "== Searching for uploadAttachment in $equiv_file =="
  grep -n "uploadAttachment" "$equiv_file" || echo "No matches found in $equiv_file"
else
  echo "No Equatable extension file found under TestTools"
fi
# Search across TestTools for any mentions of uploadAttachment
echo "== Searching TestTools for uploadAttachment usage =="
rg -n --type swift 'uploadAttachment' TestTools || echo "No uploadAttachment references in TestTools"

Length of output: 5363


🏁 Script executed:

#!/bin/bash
# Inspect the EndpointPath Equatable extension for uploadAttachment handling
equiv="TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift"
if [ -f "$equiv" ]; then
  echo "== File exists: $equiv =="
  sed -n '1,200p' "$equiv"
  echo "== Searching for uploadChannelAttachment =="
  grep -n "uploadChannelAttachment" "$equiv" || echo "None"
  echo "== Searching for uploadAttachment =="
  grep -n "uploadAttachment" "$equiv" || echo "None"
else
  echo "$equiv not found"
fi

Length of output: 4316


Add missing Equatable branch for .uploadAttachment(type:)

Our inspection of TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift shows that while the .uploadChannelAttachment(channelId:type:) case is handled, there is no matching branch for the standalone .uploadAttachment(type:) path. As a result, any equality check between two .uploadAttachment cases will fall through to the default and return false, likely breaking tests or logic that compares upload endpoints.

Please add the following case immediately after the existing .uploadChannelAttachment branch:

 case let (.uploadChannelAttachment(channelId1, type1), .uploadChannelAttachment(channelId2, type2)): return channelId1 == channelId2 &&
     type1 ==
     type2
+case let (.uploadAttachment(type1), .uploadAttachment(type2)): return type1 == type2

This will ensure that equality between two .uploadAttachment endpoints correctly compares their type parameters.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case let (.uploadChannelAttachment(channelId1, type1), .uploadChannelAttachment(channelId2, type2)): return channelId1 == channelId2 &&
type1 ==
type2
case let (.uploadChannelAttachment(channelId1, type1), .uploadChannelAttachment(channelId2, type2)):
return channelId1 == channelId2 &&
type1 == type2
case let (.uploadAttachment(type1), .uploadAttachment(type2)):
return type1 == type2
🤖 Prompt for AI Agents
In TestTools/StreamChatTestTools/Extensions/EndpoinPath+Equatable.swift around
lines 31 to 33, add an Equatable branch for the `.uploadAttachment(type:)` enum
case immediately after the existing `.uploadChannelAttachment(channelId:type:)`
branch; match two `.uploadAttachment` cases and return the comparison of their
`type` parameters (i.e., `type1 == type2`) so equality between two standalone
upload endpoints is handled correctly.

case let (.sendMessage(channelId1), .sendMessage(channelId2)): return channelId1 == channelId2
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

@testable import StreamChat
import Foundation

public extension StreamAttachment {
/// Creates a new `ChatMessageFileAttachment` object from the provided data.
static func mock(
payload: Payload,
title: String = "Sample.png",
assetURL: URL = URL(string: "http://asset.url")!,
file: AttachmentFile = AttachmentFile(type: .png, size: 120, mimeType: "image/png"),
localState: LocalAttachmentState? = .uploaded,
localDownloadState: LocalAttachmentDownloadState? = nil,
uploadingState: AttachmentUploadingState? = nil,
extraData: [String: RawJSON]? = nil
) -> Self {
.init(
type: .image,
payload: payload,
downloadingState: localDownloadState.map {
.init(
localFileURL: $0 == .downloaded ? .newTemporaryFileURL() : nil,
state: $0,
file: file
)
},
uploadingState: uploadingState ?? localState.map {
.init(
localFileURL: assetURL,
state: $0,
file: file
)
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,22 @@ final class AttachmentUploader_Spy: AttachmentUploader, Spy {
}
}
}

func uploadStandaloneAttachment<Payload>(
_ attachment: StreamAttachment<Payload>,
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedFile, any Error>) -> Void
) {
record()

if let uploadAttachmentProgress = uploadAttachmentProgress {
progress?(uploadAttachmentProgress)
}

if let uploadAttachmentResult = uploadAttachmentResult {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
completion(uploadAttachmentResult.map { UploadedFile(fileURL: $0.remoteURL )})
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,21 @@ final class CDNClient_Spy: CDNClient, Spy {
}
}
}

func uploadStandaloneAttachment<Payload>(
_ attachment: StreamAttachment<Payload>,
progress: ((Double) -> Void)?,
completion: @escaping (Result<UploadedFile, any Error>) -> Void
) {
record()
if let uploadAttachmentProgress = uploadAttachmentProgress {
progress?(uploadAttachmentProgress)
}

if let uploadAttachmentResult = uploadAttachmentResult {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
completion(uploadAttachmentResult.map { UploadedFile(fileURL: $0) })
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ public final class CustomCDNClient: CDNClient {
progress: ((Double) -> Void)?,
completion: @escaping (Result<URL, Error>) -> Void
) {}

public func uploadStandaloneAttachment<Payload>(
_ attachment: StreamChat.StreamAttachment<Payload>,
progress: ((Double) -> Void)?,
completion: @escaping (Result<StreamChat.UploadedFile, any Error>) -> Void
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class AttachmentEndpoints_Tests: XCTestCase {

for (type, pathComponent) in testCases {
let expectedEndpoint: Endpoint<FileUploadPayload> = .init(
path: .uploadAttachment(channelId: id.cid.apiPath, type: pathComponent),
path: .uploadChannelAttachment(channelId: id.cid.apiPath, type: pathComponent),
method: .post,
queryItems: nil,
requiresConnectionId: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ final class EndpointPathTests: XCTestCase {
assertResultEncodingAndDecoding(.channelEvent("channel_idq"))
assertResultEncodingAndDecoding(.stopWatchingChannel("channel_idq"))
assertResultEncodingAndDecoding(.pinnedMessages("channel_idq"))
assertResultEncodingAndDecoding(.uploadAttachment(channelId: "channel_id", type: "file"))
assertResultEncodingAndDecoding(.uploadChannelAttachment(channelId: "channel_id", type: "file"))

assertResultEncodingAndDecoding(.sendMessage(ChannelId(type: .messaging, id: "the_id")))
assertResultEncodingAndDecoding(.message("message_idm"))
Expand Down
Loading
Loading