Skip to content

Conversation

laevandus
Copy link
Contributor

🔗 Issue Links

Resolves: IOS-736

🎯 Goal

Use Swift 6 with strict concurrency checking set to complete

📝 Summary

  • Set Swift version to 6 with strict concurrency checking complete (including demo app)
  • Drop Xcode 15 support
  • Add Sendable conformances to public types
  • Add @mainactor to view models, view factory and view model factory
  • Nuke was upgraded from 10 to 12 (brings Swift 6 support)

🛠 Implementation

Tried to make it as concise as possible, therefore in some cases there is nonisolated(unsafe), because fixing them properly leads to huge refactoring (too large scope). In many cases this is actually all right.

🧪 Manual Testing Notes

Needs full regression testing round.

☑️ Contributor Checklist

  • I have signed the Stream CLA (required)
  • This change should be manually QAed
  • Changelog is updated with client-facing changes
  • Changelog is updated with new localization keys
  • New code is covered by unit tests
  • Documentation has been updated in the docs-content repo

@laevandus laevandus requested a review from a team as a code owner August 1, 2025 09:00
@laevandus laevandus added 🤞 Ready for QA 🪧 Demo App An Issue or PR related to the Demo App ✅ Feature An issue or PR related to a feature labels Aug 1, 2025
@laevandus laevandus marked this pull request as draft August 1, 2025 09:00
@laevandus laevandus added the 💥 Breaking Changes A PR that contains breaking changes label Aug 1, 2025
Copy link

github-actions bot commented Aug 1, 2025

1 Warning
⚠️ Big PR
1 Message
📖 There seems to be app changes but CHANGELOG wasn't modified.
Please include an entry if the PR includes user-facing changes.
You can find it at CHANGELOG.md.

Generated by 🚫 Danger

Copy link

github-actions bot commented Aug 1, 2025

Public Interface

- public struct PollsConfig  
+ public struct PollsConfig: Sendable  

- open class ChatThreadListViewModel: ObservableObject, ChatThreadListControllerDelegate, EventsControllerDelegate  
+ @MainActor open class ChatThreadListViewModel: ObservableObject, ChatThreadListControllerDelegate, EventsControllerDelegate  

- public protocol ViewFactory: AnyObject
+ @MainActor public protocol ViewFactory: AnyObject

- public struct ChannelListSearchType: Equatable  
+ public struct ChannelListSearchType: Equatable, Sendable  
-   public static var channels
+   public static let channels
-   public static var messages
+   public static let messages

- public class ViewModelsFactory  
+ @MainActor public class ViewModelsFactory  

 open class TwoStepMentionCommand: CommandHandler  
-   open func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping (Error?) -> Void)
+   open func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping @MainActor(Error?) -> Void)

 public class MuteCommandHandler: TwoStepMentionCommand  
-   override public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping (Error?) -> Void)
+   override public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping @MainActor(Error?) -> Void)

 public class UnmuteCommandHandler: TwoStepMentionCommand  
-   override public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping (Error?) -> Void)
+   override public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping @MainActor(Error?) -> Void)

 extension ViewFactory  
-   public func supportedMoreChannelActions(for channel: ChatChannel,onDismiss: @escaping () -> Void,onError: @escaping (Error) -> Void)-> [ChannelAction]
+   public func supportedMoreChannelActions(for channel: ChatChannel,onDismiss: @escaping @MainActor() -> Void,onError: @escaping @MainActor(Error) -> Void)-> [ChannelAction]
-   public func makeMoreChannelActionsView(for channel: ChatChannel,swipedChannelId: Binding<String?>,onDismiss: @escaping () -> Void,onError: @escaping (Error) -> Void)-> some View
+   public func makeMoreChannelActionsView(for channel: ChatChannel,swipedChannelId: Binding<String?>,onDismiss: @escaping @MainActor() -> Void,onError: @escaping @MainActor(Error) -> Void)-> some View
-   public func makeChannelListItem(channel: ChatChannel,channelName: String,avatar: UIImage,onlineIndicatorShown: Bool,disabled: Bool,selectedChannel: Binding<ChannelSelectionInfo?>,swipedChannelId: Binding<String?>,channelDestination: @escaping (ChannelSelectionInfo) -> ChannelDestination,onItemTap: @escaping (ChatChannel) -> Void,trailingSwipeRightButtonTapped: @escaping (ChatChannel) -> Void,trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void,leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void)-> some View
+   public func makeChannelListItem(channel: ChatChannel,channelName: String,avatar: UIImage,onlineIndicatorShown: Bool,disabled: Bool,selectedChannel: Binding<ChannelSelectionInfo?>,swipedChannelId: Binding<String?>,channelDestination: @escaping @MainActor(ChannelSelectionInfo) -> ChannelDestination,onItemTap: @escaping @MainActor(ChatChannel) -> Void,trailingSwipeRightButtonTapped: @escaping @MainActor(ChatChannel) -> Void,trailingSwipeLeftButtonTapped: @escaping @MainActor(ChatChannel) -> Void,leadingSwipeButtonTapped: @escaping @MainActor(ChatChannel) -> Void)-> some View
-   public func makeTrailingSwipeActionsView(channel: ChatChannel,offsetX: CGFloat,buttonWidth: CGFloat,swipedChannelId: Binding<String?>,leftButtonTapped: @escaping (ChatChannel) -> Void,rightButtonTapped: @escaping (ChatChannel) -> Void)-> TrailingSwipeActionsView
+   public func makeTrailingSwipeActionsView(channel: ChatChannel,offsetX: CGFloat,buttonWidth: CGFloat,swipedChannelId: Binding<String?>,leftButtonTapped: @escaping @MainActor(ChatChannel) -> Void,rightButtonTapped: @escaping @MainActor(ChatChannel) -> Void)-> TrailingSwipeActionsView
-   public func makeLeadingSwipeActionsView(channel: ChatChannel,offsetX: CGFloat,buttonWidth: CGFloat,swipedChannelId: Binding<String?>,buttonTapped: (ChatChannel) -> Void)-> EmptyView
+   public func makeLeadingSwipeActionsView(channel: ChatChannel,offsetX: CGFloat,buttonWidth: CGFloat,swipedChannelId: Binding<String?>,buttonTapped: @MainActor(ChatChannel) -> Void)-> EmptyView
-   public func makeSearchResultsView(selectedChannel: Binding<ChannelSelectionInfo?>,searchResults: [ChannelSelectionInfo],loadingSearchResults: Bool,onlineIndicatorShown: @escaping (ChatChannel) -> Bool,channelNaming: @escaping (ChatChannel) -> String,imageLoader: @escaping (ChatChannel) -> UIImage,onSearchResultTap: @escaping (ChannelSelectionInfo) -> Void,onItemAppear: @escaping (Int) -> Void)-> some View
+   public func makeSearchResultsView(selectedChannel: Binding<ChannelSelectionInfo?>,searchResults: [ChannelSelectionInfo],loadingSearchResults: Bool,onlineIndicatorShown: @escaping @MainActor(ChatChannel) -> Bool,channelNaming: @escaping @MainActor(ChatChannel) -> String,imageLoader: @escaping @MainActor(ChatChannel) -> UIImage,onSearchResultTap: @escaping @MainActor(ChannelSelectionInfo) -> Void,onItemAppear: @escaping @MainActor(Int) -> Void)-> some View
-   public func makeChannelListSearchResultItem(searchResult: ChannelSelectionInfo,onlineIndicatorShown: Bool,channelName: String,avatar: UIImage,onSearchResultTap: @escaping (ChannelSelectionInfo) -> Void,channelDestination: @escaping (ChannelSelectionInfo) -> ChannelDestination)-> some View
+   public func makeChannelListSearchResultItem(searchResult: ChannelSelectionInfo,onlineIndicatorShown: Bool,channelName: String,avatar: UIImage,onSearchResultTap: @escaping @MainActor(ChannelSelectionInfo) -> Void,channelDestination: @escaping @MainActor(ChannelSelectionInfo) -> ChannelDestination)-> some View
-   public func makeChannelDestination()-> (ChannelSelectionInfo) -> ChatChannelView<Self>
+   public func makeChannelDestination()-> @MainActor(ChannelSelectionInfo) -> ChatChannelView<Self>
-   public func makeMessageThreadDestination()-> (ChatChannel, ChatMessage) -> ChatChannelView<Self>
+   public func makeMessageThreadDestination()-> @MainActor(ChatChannel, ChatMessage) -> ChatChannelView<Self>
-   public func makeMessageContainerView(channel: ChatChannel,message: ChatMessage,width: CGFloat?,showsAllInfo: Bool,isInThread: Bool,scrolledId: Binding<String?>,quotedMessage: Binding<ChatMessage?>,onLongPress: @escaping (MessageDisplayInfo) -> Void,isLast: Bool)-> some View
+   public func makeMessageContainerView(channel: ChatChannel,message: ChatMessage,width: CGFloat?,showsAllInfo: Bool,isInThread: Bool,scrolledId: Binding<String?>,quotedMessage: Binding<ChatMessage?>,onLongPress: @escaping @MainActor(MessageDisplayInfo) -> Void,isLast: Bool)-> some View
-   public func makeScrollToBottomButton(unreadCount: Int,onScrollToBottom: @escaping () -> Void)-> some View
+   public func makeScrollToBottomButton(unreadCount: Int,onScrollToBottom: @escaping @MainActor() -> Void)-> some View
-   public func makeMessageComposerViewType(with channelController: ChatChannelController,messageController: ChatMessageController?,quotedMessage: Binding<ChatMessage?>,editedMessage: Binding<ChatMessage?>,onMessageSent: @escaping () -> Void)-> MessageComposerView<Self>
+   public func makeMessageComposerViewType(with channelController: ChatChannelController,messageController: ChatMessageController?,quotedMessage: Binding<ChatMessage?>,editedMessage: Binding<ChatMessage?>,onMessageSent: @escaping @MainActor() -> Void)-> MessageComposerView<Self>
-   @ViewBuilder public func makeComposerInputView(text: Binding<String>,selectedRangeLocation: Binding<Int>,command: Binding<ComposerCommand?>,addedAssets: [AddedAsset],addedFileURLs: [URL],addedCustomAttachments: [CustomAttachment],quotedMessage: Binding<ChatMessage?>,maxMessageLength: Int?,cooldownDuration: Int,onCustomAttachmentTap: @escaping (CustomAttachment) -> Void,shouldScroll: Bool,removeAttachmentWithId: @escaping (String) -> Void)-> some View
+   @ViewBuilder public func makeComposerInputView(text: Binding<String>,selectedRangeLocation: Binding<Int>,command: Binding<ComposerCommand?>,addedAssets: [AddedAsset],addedFileURLs: [URL],addedCustomAttachments: [CustomAttachment],quotedMessage: Binding<ChatMessage?>,maxMessageLength: Int?,cooldownDuration: Int,onCustomAttachmentTap: @escaping @MainActor(CustomAttachment) -> Void,shouldScroll: Bool,removeAttachmentWithId: @escaping @MainActor(String) -> Void)-> some View
-   public func makeTrailingComposerView(enabled: Bool,cooldownDuration: Int,onTap: @escaping () -> Void)-> some View
+   public func makeTrailingComposerView(enabled: Bool,cooldownDuration: Int,onTap: @escaping @MainActor() -> Void)-> some View
-   public func makeAttachmentPickerView(attachmentPickerState: Binding<AttachmentPickerState>,filePickerShown: Binding<Bool>,cameraPickerShown: Binding<Bool>,addedFileURLs: Binding<[URL]>,onPickerStateChange: @escaping (AttachmentPickerState) -> Void,photoLibraryAssets: PHFetchResult<PHAsset>?,onAssetTap: @escaping (AddedAsset) -> Void,onCustomAttachmentTap: @escaping (CustomAttachment) -> Void,isAssetSelected: @escaping (String) -> Bool,addedCustomAttachments: [CustomAttachment],cameraImageAdded: @escaping (AddedAsset) -> Void,askForAssetsAccessPermissions: @escaping () -> Void,isDisplayed: Bool,height: CGFloat,popupHeight: CGFloat)-> some View
+   public func makeAttachmentPickerView(attachmentPickerState: Binding<AttachmentPickerState>,filePickerShown: Binding<Bool>,cameraPickerShown: Binding<Bool>,addedFileURLs: Binding<[URL]>,onPickerStateChange: @escaping @MainActor(AttachmentPickerState) -> Void,photoLibraryAssets: PHFetchResult<PHAsset>?,onAssetTap: @escaping @MainActor(AddedAsset) -> Void,onCustomAttachmentTap: @escaping @MainActor(CustomAttachment) -> Void,isAssetSelected: @escaping @MainActor(String) -> Bool,addedCustomAttachments: [CustomAttachment],cameraImageAdded: @escaping @MainActor(AddedAsset) -> Void,askForAssetsAccessPermissions: @escaping @MainActor() -> Void,isDisplayed: Bool,height: CGFloat,popupHeight: CGFloat)-> some View
-   public func makeCustomAttachmentView(addedCustomAttachments: [CustomAttachment],onCustomAttachmentTap: @escaping (CustomAttachment) -> Void)-> some View
+   public func makeCustomAttachmentView(addedCustomAttachments: [CustomAttachment],onCustomAttachmentTap: @escaping @MainActor(CustomAttachment) -> Void)-> some View
-   public func makeCustomAttachmentPreviewView(addedCustomAttachments: [CustomAttachment],onCustomAttachmentTap: @escaping (CustomAttachment) -> Void)-> some View
+   public func makeCustomAttachmentPreviewView(addedCustomAttachments: [CustomAttachment],onCustomAttachmentTap: @escaping @MainActor(CustomAttachment) -> Void)-> some View
-   public func makeAttachmentSourcePickerView(selected: AttachmentPickerState,onPickerStateChange: @escaping (AttachmentPickerState) -> Void)-> some View
+   public func makeAttachmentSourcePickerView(selected: AttachmentPickerState,onPickerStateChange: @escaping @MainActor(AttachmentPickerState) -> Void)-> some View
-   public func makePhotoAttachmentPickerView(assets: PHFetchResultCollection,onAssetTap: @escaping (AddedAsset) -> Void,isAssetSelected: @escaping (String) -> Bool)-> some View
+   public func makePhotoAttachmentPickerView(assets: PHFetchResultCollection,onAssetTap: @escaping @MainActor(AddedAsset) -> Void,isAssetSelected: @escaping @MainActor(String) -> Bool)-> some View
-   public func makeCameraPickerView(selected: Binding<AttachmentPickerState>,cameraPickerShown: Binding<Bool>,cameraImageAdded: @escaping (AddedAsset) -> Void)-> some View
+   public func makeCameraPickerView(selected: Binding<AttachmentPickerState>,cameraPickerShown: Binding<Bool>,cameraImageAdded: @escaping @MainActor(AddedAsset) -> Void)-> some View
-   public func supportedMessageActions(for message: ChatMessage,channel: ChatChannel,onFinish: @escaping (MessageActionInfo) -> Void,onError: @escaping (Error) -> Void)-> [MessageAction]
+   public func supportedMessageActions(for message: ChatMessage,channel: ChatChannel,onFinish: @escaping @MainActor(MessageActionInfo) -> Void,onError: @escaping @MainActor(Error) -> Void)-> [MessageAction]
-   public func makeMessageActionsView(for message: ChatMessage,channel: ChatChannel,onFinish: @escaping (MessageActionInfo) -> Void,onError: @escaping (Error) -> Void)-> some View
+   public func makeMessageActionsView(for message: ChatMessage,channel: ChatChannel,onFinish: @escaping @MainActor(MessageActionInfo) -> Void,onError: @escaping @MainActor(Error) -> Void)-> some View
-   public func makeBottomReactionsView(message: ChatMessage,showsAllInfo: Bool,onTap: @escaping () -> Void,onLongPress: @escaping () -> Void)-> some View
+   public func makeBottomReactionsView(message: ChatMessage,showsAllInfo: Bool,onTap: @escaping @MainActor() -> Void,onLongPress: @escaping @MainActor() -> Void)-> some View
-   public func makeMessageReactionView(message: ChatMessage,onTapGesture: @escaping () -> Void,onLongPressGesture: @escaping () -> Void)-> some View
+   public func makeMessageReactionView(message: ChatMessage,onTapGesture: @escaping @MainActor() -> Void,onLongPressGesture: @escaping @MainActor() -> Void)-> some View
-   public func makeReactionsOverlayView(channel: ChatChannel,currentSnapshot: UIImage,messageDisplayInfo: MessageDisplayInfo,onBackgroundTap: @escaping () -> Void,onActionExecuted: @escaping (MessageActionInfo) -> Void)-> some View
+   public func makeReactionsOverlayView(channel: ChatChannel,currentSnapshot: UIImage,messageDisplayInfo: MessageDisplayInfo,onBackgroundTap: @escaping @MainActor() -> Void,onActionExecuted: @escaping @MainActor(MessageActionInfo) -> Void)-> some View
-   public func makeReactionsContentView(message: ChatMessage,contentRect: CGRect,onReactionTap: @escaping (MessageReactionType) -> Void)-> some View
+   public func makeReactionsContentView(message: ChatMessage,contentRect: CGRect,onReactionTap: @escaping @MainActor(MessageReactionType) -> Void)-> some View
-   public func makeCommandsContainerView(suggestions: [String: Any],handleCommand: @escaping ([String: Any]) -> Void)-> some View
+   public func makeCommandsContainerView(suggestions: [String: Any],handleCommand: @escaping @MainActor([String: Any]) -> Void)-> some View
-   public func makeJumpToUnreadButton(channel: ChatChannel,onJumpToMessage: @escaping () -> Void,onClose: @escaping () -> Void)-> some View
+   public func makeJumpToUnreadButton(channel: ChatChannel,onJumpToMessage: @escaping @MainActor() -> Void,onClose: @escaping @MainActor() -> Void)-> some View
-   public func makeThreadDestination()-> (ChatThread) -> ChatChannelView<Self>
+   public func makeThreadDestination()-> @MainActor(ChatThread) -> ChatChannelView<Self>
-   public func makeThreadListItem(thread: ChatThread,threadDestination: @escaping (ChatThread) -> ThreadDestination,selectedThread: Binding<ThreadSelectionInfo?>)-> some View
+   public func makeThreadListItem(thread: ChatThread,threadDestination: @escaping @MainActor(ChatThread) -> ThreadDestination,selectedThread: Binding<ThreadSelectionInfo?>)-> some View
-   public func makeThreadsListErrorBannerView(onRefreshAction: @escaping () -> Void)-> some View
+   public func makeThreadsListErrorBannerView(onRefreshAction: @escaping @MainActor() -> Void)-> some View
-   public func makeAddUsersView(options: AddUsersOptions,onUserTap: @escaping (ChatUser) -> Void)-> some View
+   public func makeAddUsersView(options: AddUsersOptions,onUserTap: @escaping @MainActor(ChatUser) -> Void)-> some View

- public enum AssetType  
+ public enum AssetType: Sendable  

 public struct AppearanceKey: EnvironmentKey  
-   public static let defaultValue: Appearance
+   public static var defaultValue: Appearance

- public class PollAttachmentViewModel: ObservableObject, PollControllerDelegate  
+ @MainActor public class PollAttachmentViewModel: ObservableObject, PollControllerDelegate  

- public struct AudioRecordingInfo: Equatable  
+ public struct AudioRecordingInfo: Equatable, Sendable  

- public struct ChannelSelectionInfo: Identifiable  
+ public struct ChannelSelectionInfo: Identifiable, Sendable  

- public struct ChannelItemMutedLayoutStyle: Hashable  
+ public struct ChannelItemMutedLayoutStyle: Hashable, Sendable  
-   public static var `default`: ChannelItemMutedLayoutStyle
+   public static let `default`: ChannelItemMutedLayoutStyle
-   public static var topRightCorner: ChannelItemMutedLayoutStyle
+   public static let topRightCorner: ChannelItemMutedLayoutStyle
-   public static var afterChannelName: ChannelItemMutedLayoutStyle
+   public static let afterChannelName: ChannelItemMutedLayoutStyle

 public struct MessageListView: View, KeyboardReadable  
-   public init(factory: Factory,channel: ChatChannel,messages: LazyCachedMapCollection<ChatMessage>,messagesGroupingInfo: [String: [String]],scrolledId: Binding<String?>,showScrollToLatestButton: Binding<Bool>,quotedMessage: Binding<ChatMessage?>,currentDateString: String? = nil,listId: String,isMessageThread: Bool = false,shouldShowTypingIndicator: Bool = false,scrollPosition: Binding<String?> = .constant(nil),loadingNextMessages: Bool = false,firstUnreadMessageId: Binding<MessageId?> = .constant(nil),onMessageAppear: @escaping (Int, ScrollDirection) -> Void,onScrollToBottom: @escaping () -> Void,onLongPress: @escaping (MessageDisplayInfo) -> Void,onJumpToMessage: ((String) -> Bool)? = nil)
+   public init(factory: Factory,channel: ChatChannel,messages: LazyCachedMapCollection<ChatMessage>,messagesGroupingInfo: [String: [String]],scrolledId: Binding<String?>,showScrollToLatestButton: Binding<Bool>,quotedMessage: Binding<ChatMessage?>,currentDateString: String? = nil,listId: String,isMessageThread: Bool = false,shouldShowTypingIndicator: Bool = false,scrollPosition: Binding<String?> = .constant(nil),loadingNextMessages: Bool = false,firstUnreadMessageId: Binding<MessageId?> = .constant(nil),onMessageAppear: @escaping @MainActor(Int, ScrollDirection) -> Void,onScrollToBottom: @escaping @MainActor() -> Void,onLongPress: @escaping @MainActor(MessageDisplayInfo) -> Void,onJumpToMessage: ((String) -> Bool)? = nil)

 open class NukeImageLoader: ImageLoading  
-   open func loadImage(using urlRequest: URLRequest,cachingKey: String?,completion: @escaping ((Result<UIImage, Error>) -> Void))
+   open func loadImage(using urlRequest: URLRequest,cachingKey: String?,completion: @escaping @MainActor(Result<UIImage, Error>) -> Void)
-   open func loadImages(from urls: [URL],placeholders: [UIImage],loadThumbnails: Bool,thumbnailSize: CGSize,imageCDN: ImageCDN,completion: @escaping (([UIImage]) -> Void))
+   open func loadImages(from urls: [URL],placeholders: [UIImage],loadThumbnails: Bool,thumbnailSize: CGSize,imageCDN: ImageCDN,completion: @escaping @MainActor([UIImage]) -> Void)
-   open func loadImage(url: URL?,imageCDN: ImageCDN,resize: Bool = true,preferredSize: CGSize? = nil,completion: @escaping ((Result<UIImage, Error>) -> Void))
+   open func loadImage(url: URL?,imageCDN: ImageCDN,resize: Bool = true,preferredSize: CGSize? = nil,completion: @escaping @MainActor(Result<UIImage, Error>) -> Void)

- open class ChannelHeaderLoader: ObservableObject  
+ @MainActor open class ChannelHeaderLoader: ObservableObject  
-   public init()
+   nonisolated public init()

 public class Utils  
-   public lazy var audioSessionFeedbackGenerator: AudioSessionFeedbackGenerator
+   @MainActor public lazy var audioSessionFeedbackGenerator: AudioSessionFeedbackGenerator

- public class ChannelAvatarsMerger: ChannelAvatarsMerging  
+ public final class ChannelAvatarsMerger: ChannelAvatarsMerging  

 public class Appearance  
-   public static var localizationProvider: (_ key: String, _ table: String) -> String
+   nonisolated public static var localizationProvider: @Sendable(_ key: String, _ table: String) -> String

- public protocol CommandHandler
+ @MainActor public protocol CommandHandler

 public struct ChatChannelSwipeableListItem: View  
-   public init(factory: Factory,channelListItem: ChannelListItem,swipedChannelId: Binding<String?>,channel: ChatChannel,numberOfTrailingItems: Int = 2,widthOfTrailingItem: CGFloat = 60,trailingRightButtonTapped: @escaping (ChatChannel) -> Void,trailingLeftButtonTapped: @escaping (ChatChannel) -> Void,leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void)
+   public init(factory: Factory,channelListItem: ChannelListItem,swipedChannelId: Binding<String?>,channel: ChatChannel,numberOfTrailingItems: Int = 2,widthOfTrailingItem: CGFloat = 60,trailingRightButtonTapped: @escaping @MainActor(ChatChannel) -> Void,trailingLeftButtonTapped: @escaping @MainActor(ChatChannel) -> Void,leadingSwipeButtonTapped: @escaping @MainActor(ChatChannel) -> Void)

- public struct PollsEntryConfig  
+ public struct PollsEntryConfig: Sendable  

 extension CommandHandler  
-   public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping (Error?) -> Void)
+   public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping @MainActor(Error?) -> Void)

- public struct ChannelAction: Identifiable  
+ public struct ChannelAction: Identifiable, @unchecked Sendable  
-   public let action: () -> Void
+   public let action: @MainActor() -> Void
-   public init(title: String,iconName: String,action: @escaping () -> Void,confirmationPopup: ConfirmationPopup?,isDestructive: Bool)
+   public init(title: String,iconName: String,action: @escaping @MainActor() -> Void,confirmationPopup: ConfirmationPopup?,isDestructive: Bool)

- open class MessageComposerViewModel: ObservableObject  
+ @MainActor open class MessageComposerViewModel: ObservableObject  
-   open func sendMessage(quotedMessage: ChatMessage?,editedMessage: ChatMessage?,isSilent: Bool = false,skipPush: Bool = false,skipEnrichUrl: Bool = false,extraData: [String: RawJSON] = [:],completion: @escaping () -> Void)
+   open func sendMessage(quotedMessage: ChatMessage?,editedMessage: ChatMessage?,isSilent: Bool = false,skipPush: Bool = false,skipEnrichUrl: Bool = false,extraData: [String: RawJSON] = [:],completion: @escaping @MainActor() -> Void)

- public class PinnedMessagesViewModel: ObservableObject  
+ @MainActor public class PinnedMessagesViewModel: ObservableObject  

- public struct AddedVoiceRecording: Identifiable, Equatable  
+ public struct AddedVoiceRecording: Identifiable, Equatable, Sendable  

 public struct MessageAction: Identifiable, Equatable  
-   public let action: () -> Void
+   public let action: @MainActor() -> Void
-   public init(id: String = UUID().uuidString,title: String,iconName: String,action: @escaping () -> Void,confirmationPopup: ConfirmationPopup?,isDestructive: Bool)
+   public init(id: String = UUID().uuidString,title: String,iconName: String,action: @escaping @MainActor() -> Void,confirmationPopup: ConfirmationPopup?,isDestructive: Bool)

- open class ChatChannelListViewModel: ObservableObject, ChatChannelListControllerDelegate, ChatMessageSearchControllerDelegate  
+ @MainActor open class ChatChannelListViewModel: ObservableObject, ChatChannelListControllerDelegate, ChatMessageSearchControllerDelegate  

 extension ChannelAction  
-   public static func defaultActions(for channel: ChatChannel,chatClient: ChatClient,onDismiss: @escaping () -> Void,onError: @escaping (Error) -> Void)-> [ChannelAction]
+   @MainActor public static func defaultActions(for channel: ChatChannel,chatClient: ChatClient,onDismiss: @escaping @MainActor() -> Void,onError: @escaping @MainActor(Error) -> Void)-> [ChannelAction]

- open class MessageActionsViewModel: ObservableObject  
+ @MainActor open class MessageActionsViewModel: ObservableObject  

- open class ReactionsOverlayViewModel: ObservableObject, ChatMessageControllerDelegate  
+ @MainActor open class ReactionsOverlayViewModel: ObservableObject, ChatMessageControllerDelegate  

- public struct MessageActionInfo  
+ public struct MessageActionInfo: Sendable  

- public protocol ChannelAvatarsMerging
+ public protocol ChannelAvatarsMerging: Sendable

 open class WaveformView: UIView  
-   public struct Content: Equatable  
+   public struct Content: Equatable, Sendable  

 public class CommandsHandler: CommandHandler  
-   public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping (Error?) -> Void)
+   public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping @MainActor(Error?) -> Void)

 public final class DefaultVideoPreviewLoader: VideoPreviewLoader  
-   public func loadPreviewForVideo(at url: URL,completion: @escaping (Result<UIImage, Error>) -> Void)
+   public func loadPreviewForVideo(at url: URL,completion: @escaping @MainActor(Result<UIImage, Error>) -> Void)

- public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate  
+ @MainActor public class ChatChannelInfoViewModel: ObservableObject, ChatChannelControllerDelegate  
-   public func leaveConversationTapped(completion: @escaping () -> Void)
+   public func leaveConversationTapped(completion: @escaping @MainActor() -> Void)

 public struct ChannelsLazyVStack: View  
-   public init(factory: Factory,channels: LazyCachedMapCollection<ChatChannel>,selectedChannel: Binding<ChannelSelectionInfo?>,swipedChannelId: Binding<String?>,onlineIndicatorShown: @escaping (ChatChannel) -> Bool,imageLoader: @escaping (ChatChannel) -> UIImage,onItemTap: @escaping (ChatChannel) -> Void,onItemAppear: @escaping (Int) -> Void,channelNaming: @escaping (ChatChannel) -> String,channelDestination: @escaping (ChannelSelectionInfo) -> Factory.ChannelDestination,trailingSwipeRightButtonTapped: @escaping (ChatChannel) -> Void,trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void,leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void)
+   public init(factory: Factory,channels: LazyCachedMapCollection<ChatChannel>,selectedChannel: Binding<ChannelSelectionInfo?>,swipedChannelId: Binding<String?>,onlineIndicatorShown: @escaping @MainActor(ChatChannel) -> Bool,imageLoader: @escaping @MainActor(ChatChannel) -> UIImage,onItemTap: @escaping @MainActor(ChatChannel) -> Void,onItemAppear: @escaping @MainActor(Int) -> Void,channelNaming: @escaping @MainActor(ChatChannel) -> String,channelDestination: @escaping @MainActor(ChannelSelectionInfo) -> Factory.ChannelDestination,trailingSwipeRightButtonTapped: @escaping @MainActor(ChatChannel) -> Void,trailingSwipeLeftButtonTapped: @escaping @MainActor(ChatChannel) -> Void,leadingSwipeButtonTapped: @escaping @MainActor(ChatChannel) -> Void)

- public enum StreamChatErrorCode: Int  
+ public enum StreamChatErrorCode: Int, Sendable  

- open class MessageViewModel: ObservableObject  
+ @MainActor open class MessageViewModel: ObservableObject  

- public protocol AudioSessionFeedbackGenerator
+ @MainActor public protocol AudioSessionFeedbackGenerator

 public struct ThreadsLazyVStack: View  
-   public init(factory: Factory,threads: LazyCachedMapCollection<ChatThread>,threadDestination: @escaping (ChatThread) -> Factory.ThreadDestination,selectedThread: Binding<ThreadSelectionInfo?>,onItemTap: @escaping (ChatThread) -> Void,onItemAppear: @escaping (Int) -> Void)
+   public init(factory: Factory,threads: LazyCachedMapCollection<ChatThread>,threadDestination: @escaping @MainActor(ChatThread) -> Factory.ThreadDestination,selectedThread: Binding<ThreadSelectionInfo?>,onItemTap: @escaping (ChatThread) -> Void,onItemAppear: @escaping (Int) -> Void)

- public struct PaddingsConfig  
+ public struct PaddingsConfig: Sendable  

 open class StreamImageCDN: ImageCDN  
-   public static var streamCDNURL
+   public static let streamCDNURL

- public struct CustomAttachment: Identifiable, Equatable  
+ public struct CustomAttachment: Identifiable, Equatable, Sendable  

 public struct AttachmentPickerView: View  
-   public init(viewFactory: Factory,selectedPickerState: Binding<AttachmentPickerState>,filePickerShown: Binding<Bool>,cameraPickerShown: Binding<Bool>,addedFileURLs: Binding<[URL]>,onPickerStateChange: @escaping (AttachmentPickerState) -> Void,photoLibraryAssets: PHFetchResult<PHAsset>? = nil,onAssetTap: @escaping (AddedAsset) -> Void,onCustomAttachmentTap: @escaping (CustomAttachment) -> Void,isAssetSelected: @escaping (String) -> Bool,addedCustomAttachments: [CustomAttachment],cameraImageAdded: @escaping (AddedAsset) -> Void,askForAssetsAccessPermissions: @escaping () -> Void,isDisplayed: Bool,height: CGFloat)
+   public init(viewFactory: Factory,selectedPickerState: Binding<AttachmentPickerState>,filePickerShown: Binding<Bool>,cameraPickerShown: Binding<Bool>,addedFileURLs: Binding<[URL]>,onPickerStateChange: @escaping @MainActor(AttachmentPickerState) -> Void,photoLibraryAssets: PHFetchResult<PHAsset>? = nil,onAssetTap: @escaping @MainActor(AddedAsset) -> Void,onCustomAttachmentTap: @escaping @MainActor(CustomAttachment) -> Void,isAssetSelected: @escaping @MainActor(String) -> Bool,addedCustomAttachments: [CustomAttachment],cameraImageAdded: @escaping @MainActor(AddedAsset) -> Void,askForAssetsAccessPermissions: @escaping () -> Void,isDisplayed: Bool,height: CGFloat)

- open class ChatChannelViewModel: ObservableObject, MessagesDataSource  
+ @MainActor open class ChatChannelViewModel: ObservableObject, MessagesDataSource  

 public struct StreamChatError: Error  
-   public let additionalInfo: [String: Any]?
+   public nonisolated let additionalInfo: [String: Any]?

- public struct AddedAsset: Identifiable, Equatable  
+ public struct AddedAsset: Identifiable, Equatable, Sendable  

- open class MoreChannelActionsViewModel: ObservableObject  
+ @MainActor open class MoreChannelActionsViewModel: ObservableObject  

 public struct ComposerInputView: View, KeyboardReadable  
-   public init(factory: Factory,text: Binding<String>,selectedRangeLocation: Binding<Int>,command: Binding<ComposerCommand?>,addedAssets: [AddedAsset],addedFileURLs: [URL],addedCustomAttachments: [CustomAttachment],quotedMessage: Binding<ChatMessage?>,maxMessageLength: Int? = nil,cooldownDuration: Int,onCustomAttachmentTap: @escaping (CustomAttachment) -> Void,removeAttachmentWithId: @escaping (String) -> Void)
+   public init(factory: Factory,text: Binding<String>,selectedRangeLocation: Binding<Int>,command: Binding<ComposerCommand?>,addedAssets: [AddedAsset],addedFileURLs: [URL],addedCustomAttachments: [CustomAttachment],quotedMessage: Binding<ChatMessage?>,maxMessageLength: Int? = nil,cooldownDuration: Int,onCustomAttachmentTap: @escaping @MainActor(CustomAttachment) -> Void,removeAttachmentWithId: @escaping (String) -> Void)

 public class InstantCommandsHandler: CommandHandler  
-   public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping (Error?) -> Void)
+   public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping @MainActor(Error?) -> Void)

 extension DateFormatter  
-   public static var messageListDateOverlay: DateFormatter
+   @MainActor public static var messageListDateOverlay: DateFormatter

 public struct ComposerConfig  
-   public static var defaultAttachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload]
+   nonisolated public static var defaultAttachmentPayloadConverter: (ChatMessage) -> [AnyAttachmentPayload]

- public struct ConfirmationPopup  
+ public struct ConfirmationPopup: Sendable  

- public struct TypingSuggestion  
+ public struct TypingSuggestion: Sendable  

- public struct InjectedChannelInfo  
+ public struct InjectedChannelInfo: Sendable  

- trailing_comma
- trailing_newline
- trailing_semicolon
- trailing_whitespace
Copy link
Contributor Author

@laevandus laevandus Aug 1, 2025

Choose a reason for hiding this comment

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

This was breaking markdown tests which only happened in this PR because I did a change in MessageView_Test.swift which then led linter to lint the whole file and apply fixes. Some tests need soft linebreak which is (double space)

@Stream-SDK-Bot
Copy link
Collaborator

Stream-SDK-Bot commented Aug 4, 2025

SDK Size

title develop branch diff status
StreamChatSwiftUI 9.43 MB 9.6 MB +167 KB 🟢

@laevandus laevandus marked this pull request as ready for review August 4, 2025 08:16
Copy link
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

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

We have a bit more work here - we need to update the InjectedValues to make its usage better, plus we should think about the external dependencies.

token: token,
cid: cid
)
Task { @MainActor in
Copy link
Contributor

Choose a reason for hiding this comment

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

We should have some consistency in how we run things on the main thread. On the other PR this was done with an abstraction.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since this piece is not part of the SDK and part of the demo app, I did not want to duplicate here. So demo app uses Task, SDK uses that abstraction (mostly)


@Injected(\.utils) private var utils
@Injected(\.images) private var images
private var images: Images { InjectedValues[\.images] }
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here, we should definitely do sth about this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The case here is that ChannelAvatarsMerger is called from background threads. @injected properties have mutable state and therefore must be concurrency safe. @injected is not, InjectedValues is internally nonisolated(unsafe) which makes the compiler happy. Alternative approach would be to pass Images and Utils in with the initialiser.

private let didLoadImage = PassthroughSubject<ChannelId, Never>()

public init() {
nonisolated public init() {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why nonisolated?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Utils creates this class in its init method, but Utils does not force MainActor. It is slightly bigger change because Utils is used in model objects (ChatChannel) what is Sendable. It is going to have bigger side-effects (all the callsites must ensure MainActor). Now the question is, should we try to make Utils @mainactor, maybe, but probably makes sense to have it in a separate ticket.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If Utils is MainActor, then the high-level StreamChat must become MainActor as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For now I took a shortcut here.

Copy link
Contributor

Choose a reason for hiding this comment

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

mmm, ok. Out of scope here, but maybe we can also do StreamChat main actor - it's anyway UI SDK object.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can try


import Foundation

enum StreamConcurrency {
Copy link
Contributor

Choose a reason for hiding this comment

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

We should move this to Core maybe?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll add a note to consider it to the ticket we have about adding Stream Core to chat.

@GetStream GetStream deleted a comment from github-actions bot Aug 4, 2025
@GetStream GetStream deleted a comment from github-actions bot Aug 4, 2025
@GetStream GetStream deleted a comment from github-actions bot Aug 21, 2025
Copy link
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

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

Few things we should do here before merging - most notably the preference keys and the closures in the factory methods.

private let didLoadImage = PassthroughSubject<ChannelId, Never>()

public init() {
nonisolated public init() {
Copy link
Contributor

Choose a reason for hiding this comment

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

mmm, ok. Out of scope here, but maybe we can also do StreamChat main actor - it's anyway UI SDK object.

# Conflicts:
#	Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift
#	StreamChatSwiftUI.xcodeproj/project.pbxproj
#	StreamChatSwiftUITests/Tests/ChatChannel/ChannelInfo/ChatChannelInfoView_Tests.swift
#	StreamChatSwiftUITests/Tests/ChatChannel/MessageComposerView_Tests.swift
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
46.5% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

Copy link
Contributor

@martinmitrevski martinmitrevski left a comment

Choose a reason for hiding this comment

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

looks good to me! Did a smoke test as well. Good if @testableapple does a more thorough testing.

@martinmitrevski martinmitrevski merged commit 095caaa into v5 Sep 24, 2025
10 of 11 checks passed
@martinmitrevski martinmitrevski deleted the swift-6-simplified branch September 24, 2025 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
💥 Breaking Changes A PR that contains breaking changes 🪧 Demo App An Issue or PR related to the Demo App ✅ Feature An issue or PR related to a feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants