Skip to content

Conversation

laevandus
Copy link
Contributor

@laevandus laevandus commented Sep 30, 2025

🔗 Issue Links

Resolves: https://linear.app/stream/issue/IOS-1171

🎯 Goal

Use MainActor in StreamChat, Images, Fonts, Appearance, Utils

📝 Summary

  • Use MainActor in StreamChat, Images, Fonts, Appearance, Utils
  • Add MainActor to properties and types using injected values
  • Refactor ChannelAvatarsMerger to take in utils related data in function arguments (utils in MainActor, this type runs on a background thread)

🛠 Implementation

🎨 Showcase

🧪 Manual Testing Notes

☑️ 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 September 30, 2025 10:19
@laevandus laevandus added ✅ Feature An issue or PR related to a feature 💥 Breaking Changes A PR that contains breaking changes labels Sep 30, 2025
Copy link

github-actions bot commented Sep 30, 2025

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

@Stream-SDK-Bot
Copy link
Collaborator

Stream-SDK-Bot commented Sep 30, 2025

SDK Size

title develop branch diff status
StreamChatSwiftUI 9.48 MB 9.74 MB +265 KB 🟡

@laevandus laevandus force-pushed the stream-chat-main-actor branch from a988c75 to cb562f5 Compare September 30, 2025 10:51
Copy link

github-actions bot commented Sep 30, 2025

Public Interface

+ public final class ChannelAvatarsMergerOptions: Sendable  
+ 
+   public let imageProcessor: ImageProcessor
+   public let imageMerger: ImageMerging
+   public let placeholder1: UIImage
+   public let placeholder2: UIImage
+   public let placeholder3: UIImage
+   public let placeholder4: UIImage
+   
+ 
+   @MainActor public convenience init()
+   public init(imageProcessor: ImageProcessor,imageMerger: ImageMerging,placeholder1: UIImage,placeholder2: UIImage,placeholder3: UIImage,placeholder4: UIImage)



- @propertyWrapper public struct Injected  
+ @MainActor @propertyWrapper public struct Injected  

 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 class Appearance  
+ @MainActor public class Appearance  
-   public static var localizationProvider: (_ key: String, _ table: String) -> String
+   public static var localizationProvider: @Sendable (_ key: String, _ table: String) -> String

- public protocol AudioSessionFeedbackGenerator
+ @MainActor public protocol AudioSessionFeedbackGenerator

- public enum AssetType  
+ public enum AssetType: Sendable  

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

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

 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)

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

 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 class PhotoAssetLoader: NSObject, ObservableObject
+ @MainActor public class PhotoAssetLoader: NSObject, ObservableObject

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

- public struct MessageActionInfo  
+ public struct MessageActionInfo: Sendable  

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

- public protocol CommandHandler
+ @MainActor public protocol CommandHandler

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

- public struct InjectedChannelInfo  
+ public struct InjectedChannelInfo: Sendable  

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

- public struct AudioRecordingInfo: Equatable  
+ public struct AudioRecordingInfo: 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 MessageActionsViewModel: ObservableObject  
+ @MainActor open class MessageActionsViewModel: ObservableObject  

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

 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)

- public struct Fonts  
+ @MainActor public struct Fonts  

- public struct PollsConfig  
+ public struct PollsConfig: Sendable  

- public class ChannelAvatarsMerger: ChannelAvatarsMerging  
+ public final class ChannelAvatarsMerger: ChannelAvatarsMerging  
-   public func createMergedAvatar(from avatars: [UIImage])-> UIImage?
+   public func createMergedAvatar(from avatars: [UIImage],options: ChannelAvatarsMergerOptions)-> UIImage?

- public struct TypingSuggestion  
+ public struct TypingSuggestion: Sendable  

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

 public struct MessageDisplayOptions  
-   public let messageLinkDisplayResolver: (ChatMessage) -> [NSAttributedString.Key: Any]
+   public let messageLinkDisplayResolver: @MainActor (ChatMessage) -> [NSAttributedString.Key: Any]
-   public static var defaultLinkDisplay: (ChatMessage) -> [NSAttributedString.Key: Any]
+   public static var defaultLinkDisplay: @MainActor (ChatMessage) -> [NSAttributedString.Key: Any]
-   public init(showAvatars: Bool = true,showAvatarsInGroups: Bool? = nil,showMessageDate: Bool = true,showAuthorName: Bool = true,animateChanges: Bool = true,overlayDateLabelSize: CGFloat = 40,lastInGroupHeaderSize: CGFloat = 0,newMessagesSeparatorSize: CGFloat = 50,minimumSwipeGestureDistance: CGFloat = 20,currentUserMessageTransition: AnyTransition = .identity,otherUserMessageTransition: AnyTransition = .identity,shouldAnimateReactions: Bool = true,reactionsPlacement: ReactionsPlacement = .top,showOriginalTranslatedButton: Bool = false,messageLinkDisplayResolver: @escaping (ChatMessage) -> [NSAttributedString.Key: Any] = MessageDisplayOptions
+   public init(showAvatars: Bool = true,showAvatarsInGroups: Bool? = nil,showMessageDate: Bool = true,showAuthorName: Bool = true,animateChanges: Bool = true,overlayDateLabelSize: CGFloat = 40,lastInGroupHeaderSize: CGFloat = 0,newMessagesSeparatorSize: CGFloat = 50,minimumSwipeGestureDistance: CGFloat = 20,currentUserMessageTransition: AnyTransition = .identity,otherUserMessageTransition: AnyTransition = .identity,shouldAnimateReactions: Bool = true,reactionsPlacement: ReactionsPlacement = .top,showOriginalTranslatedButton: Bool = false,messageLinkDisplayResolver: @escaping @MainActor (ChatMessage) -> [NSAttributedString.Key: Any] = MessageDisplayOptions

 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 Images  
+ @MainActor public class Images  

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

- public struct PaddingsConfig  
+ public struct PaddingsConfig: Sendable  

- public protocol MarkdownFormatter
+ @MainActor public protocol MarkdownFormatter

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

 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 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 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 struct ChannelList: View  
-   public init(factory: Factory,channels: LazyCachedMapCollection<ChatChannel>,selectedChannel: Binding<ChannelSelectionInfo?>,swipedChannelId: Binding<String?>,scrolledChannelId: Binding<String?> = .constant(nil),scrollable: Bool = true,onlineIndicatorShown: ((ChatChannel) -> Bool)? = nil,imageLoader: ((ChatChannel) -> UIImage)? = nil,onItemTap: @escaping (ChatChannel) -> Void,onItemAppear: @escaping (Int) -> Void,channelNaming: ((ChatChannel) -> String)? = nil,channelDestination: @escaping (ChannelSelectionInfo) -> Factory.ChannelDestination,trailingSwipeRightButtonTapped: @escaping (ChatChannel) -> Void = { _ in },trailingSwipeLeftButtonTapped: @escaping (ChatChannel) -> Void = { _ in },leadingSwipeButtonTapped: @escaping (ChatChannel) -> Void = { _ in })
+   public init(factory: Factory,channels: LazyCachedMapCollection<ChatChannel>,selectedChannel: Binding<ChannelSelectionInfo?>,swipedChannelId: Binding<String?>,scrolledChannelId: Binding<String?> = .constant(nil),scrollable: Bool = true,onlineIndicatorShown: (@MainActor (ChatChannel) -> Bool)? = nil,imageLoader: (@MainActor (ChatChannel) -> UIImage)? = nil,onItemTap: @escaping @MainActor (ChatChannel) -> Void,onItemAppear: @escaping @MainActor (Int) -> Void,channelNaming: (@MainActor (ChatChannel) -> String)? = nil,channelDestination: @escaping @MainActor (ChannelSelectionInfo) -> Factory.ChannelDestination,trailingSwipeRightButtonTapped: @escaping @MainActor (ChatChannel) -> Void = { _ in },trailingSwipeLeftButtonTapped: @escaping @MainActor (ChatChannel) -> Void = { _ in },leadingSwipeButtonTapped: @escaping @MainActor (ChatChannel) -> Void = { _ in })

 public struct ChatChannelListView: View  
-   public init(viewFactory: Factory = DefaultViewFactory.shared,viewModel: ChatChannelListViewModel? = nil,channelListController: ChatChannelListController? = nil,title: String = "Stream Chat",onItemTap: ((ChatChannel) -> Void)? = nil,selectedChannelId: String? = nil,handleTabBarVisibility: Bool = true,embedInNavigationView: Bool = true,searchType: ChannelListSearchType = .messages)
+   public init(viewFactory: Factory = DefaultViewFactory.shared,viewModel: ChatChannelListViewModel? = nil,channelListController: ChatChannelListController? = nil,title: String = "Stream Chat",onItemTap: (@MainActor (ChatChannel) -> Void)? = nil,selectedChannelId: String? = nil,handleTabBarVisibility: Bool = true,embedInNavigationView: Bool = true,searchType: ChannelListSearchType = .messages)

- public protocol ChannelAvatarsMerging
+ public protocol ChannelAvatarsMerging: Sendable

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

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

- 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 ChannelListSearchType: Equatable  
+ public struct ChannelListSearchType: Equatable, Sendable  
-   public static var channels
+   public static let channels
-   public static var messages
+   public static let messages

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

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

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

- public class ViewModelsFactory  
+ @MainActor public class ViewModelsFactory  

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

- public struct PollsEntryConfig  
+ public struct PollsEntryConfig: Sendable  

- public struct ChatThreadListItemViewModel  
+ @MainActor public struct ChatThreadListItemViewModel  

- public protocol ImageMerging
+ public protocol ImageMerging: Sendable

 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)

- open class MessagePreviewFormatter  
+ @MainActor open class MessagePreviewFormatter  

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

- public struct ConfirmationPopup  
+ public struct ConfirmationPopup: Sendable  

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

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

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

- public class StreamChat  
+ @MainActor public class StreamChat  

 public struct MediaItem: Identifiable  
-   public var mediaAttachment: MediaAttachment?
+   @MainActor public var mediaAttachment: MediaAttachment?

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

- public protocol SnapshotCreator
+ @MainActor public protocol SnapshotCreator

- open class NukeImageProcessor: ImageProcessor  
+ open class NukeImageProcessor: ImageProcessor, @unchecked Sendable  

 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)

- public struct InjectedValues  
+ @MainActor public struct InjectedValues  

 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 struct SearchResultsView: View  
-   public init(factory: Factory,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)
+   public init(factory: Factory,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)

- public protocol InjectionKey
+ @MainActor public protocol InjectionKey

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

- open class DefaultImageMerger: ImageMerging  
+ open class DefaultImageMerger: ImageMerging, @unchecked Sendable  

 public struct ChatChannelListContentView: View  
-   public init(viewFactory: Factory,viewModel: ChatChannelListViewModel,onItemTap: ((ChatChannel) -> Void)? = nil)
+   public init(viewFactory: Factory,viewModel: ChatChannelListViewModel,onItemTap: (@MainActor (ChatChannel) -> Void)? = nil)

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

 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 class CommandsHandler: CommandHandler  
-   public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping (Error?) -> Void)
+   public func executeOnMessageSent(composerCommand: ComposerCommand,completion: @escaping @MainActor (Error?) -> Void)

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

- public struct ColorPalette  
+ @MainActor public struct ColorPalette  

 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)

- public protocol ImageProcessor
+ public protocol ImageProcessor: Sendable

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

 public class InstantCommandsHandler: 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)

/// Provider for custom localization which is dependent on App Bundle.
nonisolated(unsafe)
public static var localizationProvider: @Sendable(_ key: String, _ table: String) -> String = { key, table in
public nonisolated(unsafe)
Copy link
Contributor

Choose a reason for hiding this comment

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

is the nonisolated(unsafe) still needed here?

Copy link
Contributor Author

@laevandus laevandus Oct 2, 2025

Choose a reason for hiding this comment

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

Removed it, but internally we will have StreamConcurrency.onMain call for avoiding making too much MainActor (L10n is used so many places and it was hard to handle it in UI-tests)

/// Provides the default value of the `Appearance` class.
public struct AppearanceKey: EnvironmentKey {
public static var defaultValue: Appearance { Appearance() }
public static var defaultValue: Appearance { StreamConcurrency.onMain { Appearance() } }
Copy link
Contributor

Choose a reason for hiding this comment

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

can't we make the AppearanceKey struct main actor instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Only when we bump to Swift 6.2. Issue here is that EnvironmentKey protocol is nonisolated so I can't make this MainActor. Swift 6.2 allows writing public struct AppearanceKey: @MainActor EnvironmentKey (especially for solving issues like that)

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 we need to live with this.

@laevandus laevandus force-pushed the stream-chat-main-actor branch from 5a93672 to cb562f5 Compare September 30, 2025 11:40
@laevandus laevandus marked this pull request as draft October 1, 2025 08:09
@laevandus laevandus force-pushed the stream-chat-main-actor branch from c54925d to fa6eca0 Compare October 2, 2025 07:04
@laevandus laevandus marked this pull request as ready for review October 2, 2025 07:04
@laevandus laevandus force-pushed the stream-chat-main-actor branch from fa6eca0 to d02596d Compare October 2, 2025 07:17
@laevandus laevandus force-pushed the stream-chat-main-actor branch from 7a033b2 to 398cf19 Compare October 2, 2025 12:29
Copy link

sonarqubecloud bot commented Oct 2, 2025

Quality Gate Failed Quality Gate failed

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

See analysis details on SonarQube Cloud

@martinmitrevski martinmitrevski merged commit 8edb22c into v5 Oct 7, 2025
10 of 11 checks passed
@martinmitrevski martinmitrevski deleted the stream-chat-main-actor branch October 7, 2025 08:09
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 ✅ Feature An issue or PR related to a feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants