diff --git a/Brewfile.lock.json b/Brewfile.lock.json new file mode 100644 index 00000000..4f277d6a --- /dev/null +++ b/Brewfile.lock.json @@ -0,0 +1,91 @@ +{ + "entries": { + "brew": { + "mint": { + "version": "0.17.5", + "bottle": { + "rebuild": 0, + "root_url": "https://ghcr.io/v2/homebrew/core", + "files": { + "arm64_sequoia": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:a754e28b7b9e4e13c31af783857de64d2550b8866c2c9eb3ac9216154ab0f25a", + "sha256": "a754e28b7b9e4e13c31af783857de64d2550b8866c2c9eb3ac9216154ab0f25a" + }, + "arm64_sonoma": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:ada351985ef562807e7460f869c527bb314600311738a944219225226f43addf", + "sha256": "ada351985ef562807e7460f869c527bb314600311738a944219225226f43addf" + }, + "arm64_ventura": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:250948fe6fc14179d7c381d084a90d6796861ba9a8456617cadda9ac62cbc2b8", + "sha256": "250948fe6fc14179d7c381d084a90d6796861ba9a8456617cadda9ac62cbc2b8" + }, + "arm64_monterey": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:6546b80b980a45036415162189dd340b1f8d3f4e82a80d40a24e7b5dd672eb04", + "sha256": "6546b80b980a45036415162189dd340b1f8d3f4e82a80d40a24e7b5dd672eb04" + }, + "arm64_big_sur": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:39f9d254b248a44bb44e399081b7e50a6c598834e2bf86bb7de3ebc349f11e0d", + "sha256": "39f9d254b248a44bb44e399081b7e50a6c598834e2bf86bb7de3ebc349f11e0d" + }, + "sonoma": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:154b8b94602d6d38249cfa936f7d071d9113935b3756d5781021fe04c3971e29", + "sha256": "154b8b94602d6d38249cfa936f7d071d9113935b3756d5781021fe04c3971e29" + }, + "ventura": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:068f9984e81b578f2ed6cef4cc9659835a689bdecf121651ea24ebcfefd49339", + "sha256": "068f9984e81b578f2ed6cef4cc9659835a689bdecf121651ea24ebcfefd49339" + }, + "monterey": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:f8b09a640942548a151c7450c85f33d40162c7540049666131740d49c68e61e6", + "sha256": "f8b09a640942548a151c7450c85f33d40162c7540049666131740d49c68e61e6" + }, + "big_sur": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:528ea907912e8002cd3a769e8ddda4556cf2482122c3f848a7d923146df37101", + "sha256": "528ea907912e8002cd3a769e8ddda4556cf2482122c3f848a7d923146df37101" + }, + "x86_64_linux": { + "cellar": "/home/linuxbrew/.linuxbrew/Cellar", + "url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:7c8dd63f0310a46f67550f92ee48a370fadfc1a4d884b8a3904a36b7b610b3f2", + "sha256": "7c8dd63f0310a46f67550f92ee48a370fadfc1a4d884b8a3904a36b7b610b3f2" + } + } + } + }, + "sonar-scanner": { + "version": "6.2.1.4610", + "bottle": { + "rebuild": 1, + "root_url": "https://ghcr.io/v2/homebrew/core", + "files": { + "all": { + "cellar": ":any_skip_relocation", + "url": "https://ghcr.io/v2/homebrew/core/sonar-scanner/blobs/sha256:5e24f759a690b4abb55737325a6c9e94d42b2235abd0e93021306d6016d967e6", + "sha256": "5e24f759a690b4abb55737325a6c9e94d42b2235abd0e93021306d6016d967e6" + } + } + } + } + } + }, + "system": { + "macos": { + "sonoma": { + "HOMEBREW_VERSION": "4.4.0", + "HOMEBREW_PREFIX": "/opt/homebrew", + "Homebrew/homebrew-core": "api", + "CLT": "16.0.0.0.1.1724870825", + "Xcode": "15.4", + "macOS": "14.7" + } + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 837d4dd6..285083a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming +### ✅ Added +- New Thread List UI Component [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621) +- Handles marking a thread read in `ChatChannelViewModel` [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621) +- Adds `ViewFactory.makeChannelListItemBackground` [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621) +### 🐞 Fixed +- Fix Channel List loading view shimmering effect not working [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621) +- Fix Channel List not preselecting the Channel on iPad [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621) ### 🔄 Changed +- Channel List Item has now a background color when it is selected on iPad [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621) # [4.64.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.64.0) _October 03, 2024_ diff --git a/DemoAppSwiftUI/DemoAppSwiftUIApp.swift b/DemoAppSwiftUI/DemoAppSwiftUIApp.swift index 42dfef34..e03d7326 100644 --- a/DemoAppSwiftUI/DemoAppSwiftUIApp.swift +++ b/DemoAppSwiftUI/DemoAppSwiftUIApp.swift @@ -14,7 +14,7 @@ struct DemoAppSwiftUIApp: App { @ObservedObject var appState = AppState.shared @ObservedObject var notificationsHandler = NotificationsHandler.shared - + var channelListController: ChatChannelListController? { appState.channelListController } @@ -27,18 +27,14 @@ struct DemoAppSwiftUIApp: App { case .notLoggedIn: LoginView() case .loggedIn: - if notificationsHandler.notificationChannelId != nil { - ChatChannelListView( - viewFactory: DemoAppFactory.shared, - channelListController: channelListController, - selectedChannelId: notificationsHandler.notificationChannelId - ) - } else { - ChatChannelListView( - viewFactory: DemoAppFactory.shared, - channelListController: channelListController - ) - } + TabView { + channelListView() + .tabItem { Label("Chat", systemImage: "message") } + .badge(appState.unreadCount.channels) + threadListView() + .tabItem { Label("Threads", systemImage: "text.bubble") } + .badge(appState.unreadCount.threads) + } } } .onChange(of: appState.userState) { newValue in @@ -57,13 +53,33 @@ struct DemoAppSwiftUIApp: App { appState.channelListController = chatClient.channelListController(query: channelListQuery) } */ + appState.currentUserController = chatClient.currentUserController() notificationsHandler.setupRemoteNotifications() } } } + + func channelListView() -> ChatChannelListView { + if notificationsHandler.notificationChannelId != nil { + ChatChannelListView( + viewFactory: DemoAppFactory.shared, + channelListController: channelListController, + selectedChannelId: notificationsHandler.notificationChannelId + ) + } else { + ChatChannelListView( + viewFactory: DemoAppFactory.shared, + channelListController: channelListController + ) + } + } + + func threadListView() -> ChatThreadListView { + ChatThreadListView(viewFactory: DemoAppFactory.shared) + } } -class AppState: ObservableObject { +class AppState: ObservableObject, CurrentChatUserControllerDelegate { @Published var userState: UserState = .launchAnimation { willSet { @@ -72,12 +88,30 @@ class AppState: ObservableObject { } } } - + + @Published var unreadCount: UnreadCount = .noUnread + var channelListController: ChatChannelListController? + var currentUserController: CurrentChatUserController? { + didSet { + currentUserController?.delegate = self + currentUserController?.synchronize() + } + } static let shared = AppState() private init() {} + + func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) { + self.unreadCount = didChangeCurrentUserUnreadCount + let totalUnreadBadge = unreadCount.channels + unreadCount.threads + if #available(iOS 16.0, *) { + UNUserNotificationCenter.current().setBadgeCount(totalUnreadBadge) + } else { + UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge + } + } } enum UserState { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesView.swift index fb68659e..35ec5cc6 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesView.swift @@ -86,6 +86,7 @@ struct PinnedMessageView: View { if message.poll != nil { return "📊 \(L10n.Channel.Item.poll)" } - return channel.attachmentPreviewText(for: message) ?? message.adjustedText + let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter + return messageFormatter.formatAttachmentContent(for: message) ?? message.adjustedText } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift index 099de9fc..547bd21a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift @@ -238,6 +238,9 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { if canMarkRead { sendReadEventIfNeeded(for: first) } + if shouldMarkThreadRead { + sendThreadReadEvent() + } } public func scrollToLastMessage() { @@ -346,6 +349,9 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { sendReadEventIfNeeded(for: message) } } + if index == 0 && shouldMarkThreadRead { + sendThreadReadEvent() + } } open func groupMessages() { @@ -655,7 +661,24 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } } - + + private var shouldMarkThreadRead: Bool { + guard UIApplication.shared.applicationState == .active else { + return false + } + guard messageController?.replies.isEmpty == false else { + return false + } + + return channelDataSource.hasLoadedAllNextMessages + } + + private func sendThreadReadEvent() { + throttler.throttle { [weak self] in + self?.messageController?.markThreadRead() + } + } + private func handleDateChange() { guard showScrollToLatestButton == true, let currentDate = currentDate else { currentDateString = nil diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift index 6b9fa5b0..81377c3f 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift @@ -110,6 +110,36 @@ extension MessageAction { messageActions.append(copyAction) } + if message.isRootOfThread { + let messageController = InjectedValues[\.utils] + .channelControllerFactory + .makeMessageController(for: message.id, channelId: channel.cid) + // At the moment, this is the only way to know if we are inside a thread. + // This should be optimised in the future and provide the view context. + let isInsideThreadView = messageController.replies.count > 0 + if isInsideThreadView { + let markThreadUnreadAction = markThreadAsUnreadAction( + messageController: messageController, + message: message, + onFinish: onFinish, + onError: onError + ) + messageActions.append(markThreadUnreadAction) + } + } else if !message.isSentByCurrentUser { + if !message.isPartOfThread || message.showReplyInChannel { + let markUnreadAction = markAsUnreadAction( + for: message, + channel: channel, + chatClient: chatClient, + onFinish: onFinish, + onError: onError + ) + + messageActions.append(markUnreadAction) + } + } + if message.isSentByCurrentUser { if message.poll == nil { let editAction = editMessageAction( @@ -130,18 +160,6 @@ extension MessageAction { messageActions.append(deleteAction) } else { - if !message.isPartOfThread || message.showReplyInChannel { - let markUnreadAction = markAsUnreadAction( - for: message, - channel: channel, - chatClient: chatClient, - onFinish: onFinish, - onError: onError - ) - - messageActions.append(markUnreadAction) - } - if channel.canFlagMessage { let flagAction = flagMessageAction( for: message, @@ -512,6 +530,38 @@ extension MessageAction { return unreadAction } + private static func markThreadAsUnreadAction( + messageController: ChatMessageController, + message: ChatMessage, + onFinish: @escaping (MessageActionInfo) -> Void, + onError: @escaping (Error) -> Void + ) -> MessageAction { + let action = { + messageController.markThreadUnread() { error in + if let error { + onError(error) + } else { + onFinish( + MessageActionInfo( + message: message, + identifier: MessageActionId.markUnread + ) + ) + } + } + } + let unreadAction = MessageAction( + id: MessageActionId.markUnread, + title: L10n.Message.Actions.markUnread, + iconName: "message.badge", + action: action, + confirmationPopup: nil, + isDestructive: false + ) + + return unreadAction + } + private static func muteAction( for message: ChatMessage, channel: ChatChannel, diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift index 34842827..a92820fd 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift @@ -108,6 +108,7 @@ public struct ChannelList: View { /// LazyVStack displaying list of channels. public struct ChannelsLazyVStack: View { + @Injected(\.colors) private var colors private var factory: Factory var channels: LazyCachedMapCollection @@ -170,6 +171,10 @@ public struct ChannelsLazyVStack: View { trailingSwipeLeftButtonTapped: trailingSwipeLeftButtonTapped, leadingSwipeButtonTapped: leadingSwipeButtonTapped ) + .background(factory.makeChannelListItemBackground( + channel: channel, + isSelected: selectedChannel?.channel.id == channel.id + )) .onAppear { if let index = channels.firstIndex(where: { chatChannel in chatChannel.id == channel.id diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift index 831a07f4..a2cf7af0 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift @@ -270,14 +270,9 @@ public struct InjectedChannelInfo { extension ChatChannel { public var lastMessageText: String? { - if let latestMessage = latestMessages.first { - if let text = pollMessageText(for: latestMessage) { - return text - } - return "\(latestMessage.author.name ?? latestMessage.author.id): \(textContent(for: latestMessage))" - } else { - return nil - } + guard let latestMessage = latestMessages.first else { return nil } + let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter + return messageFormatter.format(latestMessage) } public var shouldShowTypingIndicator: Bool { @@ -312,70 +307,4 @@ extension ChatChannel { return "" } } - - private func textContent(for previewMessage: ChatMessage) -> String { - if let attachmentPreviewText = attachmentPreviewText(for: previewMessage) { - return attachmentPreviewText - } - if let textContent = previewMessage.textContent, !textContent.isEmpty { - return textContent - } - return previewMessage.adjustedText - } - - /// The message preview text in case it contains attachments. - /// - Parameter previewMessage: The preview message of the channel. - /// - Returns: A string representing the message preview text. - func attachmentPreviewText(for previewMessage: ChatMessage) -> String? { - guard let attachment = previewMessage.allAttachments.first, !previewMessage.isDeleted else { - return nil - } - let text = previewMessage.textContent ?? previewMessage.text - switch attachment.type { - case .audio: - let defaultAudioText = L10n.Channel.Item.audio - return "🎧 \(text.isEmpty ? defaultAudioText : text)" - case .file: - guard let fileAttachment = previewMessage.fileAttachments.first else { - return nil - } - let title = fileAttachment.payload.title - return "📄 \(title ?? text)" - case .image: - let defaultPhotoText = L10n.Channel.Item.photo - return "📷 \(text.isEmpty ? defaultPhotoText : text)" - case .video: - let defaultVideoText = L10n.Channel.Item.video - return "📹 \(text.isEmpty ? defaultVideoText : text)" - case .giphy: - return "/giphy" - case .voiceRecording: - let defaultVoiceMessageText = L10n.Channel.Item.voiceMessage - return "🎧 \(text.isEmpty ? defaultVoiceMessageText : text)" - default: - return nil - } - } - - private func pollMessageText(for previewMessage: ChatMessage) -> String? { - guard let poll = previewMessage.poll, !previewMessage.isDeleted else { return nil } - var components = ["📊"] - if let latestVoter = poll.latestVotes.first?.user { - if latestVoter.id == membership?.id { - components.append(L10n.Channel.Item.pollYouVoted) - } else { - components.append(L10n.Channel.Item.pollSomeoneVoted(latestVoter.name ?? latestVoter.id)) - } - } else if let creator = poll.createdBy { - if previewMessage.isSentByCurrentUser { - components.append(L10n.Channel.Item.pollYouCreated) - } else { - components.append(L10n.Channel.Item.pollSomeoneCreated(creator.name ?? creator.id)) - } - } - if !poll.name.isEmpty { - components.append(poll.name) - } - return components.joined(separator: " ") - } } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift index 5f9dd7ca..ccfc166e 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListView.swift @@ -72,46 +72,31 @@ public struct ChatChannelListView: View { } public var body: some View { - container() - .overlay(viewModel.customAlertShown ? customViewOverlay() : nil) - .accentColor(colors.tintColor) - .if(isIphone || !utils.messageListConfig.iPadSplitViewEnabled, transform: { view in - view.navigationViewStyle(.stack) - }) - .background( - isIphone && handleTabBarVisibility ? - Color.clear.background( - TabBarAccessor { tabBar in - self.tabBar = tabBar - } - ) - .allowsHitTesting(false) - : nil - ) - .onReceive(viewModel.$hideTabBar) { newValue in - if isIphone && handleTabBarVisibility { - self.setupTabBarAppeareance() - self.tabBar?.isHidden = newValue - } - } - .accessibilityIdentifier("ChatChannelListView") - } - - @ViewBuilder - private func container() -> some View { - if embedInNavigationView == true { - if #available(iOS 16, *), isIphone { - NavigationStack { - content() - } - } else { - NavigationView { - content() - } - } - } else { + NavigationContainerView(embedInNavigationView: embedInNavigationView) { content() } + .overlay(viewModel.customAlertShown ? customViewOverlay() : nil) + .accentColor(colors.tintColor) + .if(isIphone || !utils.messageListConfig.iPadSplitViewEnabled, transform: { view in + view.navigationViewStyle(.stack) + }) + .background( + isIphone && handleTabBarVisibility ? + Color.clear.background( + TabBarAccessor { tabBar in + self.tabBar = tabBar + } + ) + .allowsHitTesting(false) + : nil + ) + .onReceive(viewModel.$hideTabBar) { newValue in + if isIphone && handleTabBarVisibility { + self.setupTabBarAppeareance() + self.tabBar?.isHidden = newValue + } + } + .accessibilityIdentifier("ChatChannelListView") } @ViewBuilder @@ -261,9 +246,7 @@ public struct ChatChannelListContentView: View { leadingSwipeButtonTapped: { _ in /* No leading button by default. */ } ) .onAppear { - if horizontalSizeClass == .regular { - viewModel.preselectChannelIfNeeded() - } + viewModel.preselectChannelIfNeeded() } } diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadList.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadList.swift new file mode 100644 index 00000000..866813a2 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadList.swift @@ -0,0 +1,122 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +import SwiftUI + +/// Stateless component for the channel list. +/// If used directly, you should provide the thread list. +public struct ThreadList: View { + var threads: LazyCachedMapCollection + private var factory: Factory + private var threadDestination: (ChatThread) -> Factory.ThreadDestination + @Binding private var selectedThread: ThreadSelectionInfo? + + private var onItemTap: (ChatThread) -> Void + private var onItemAppear: (Int) -> Void + + @ViewBuilder + private var headerView: () -> HeaderView + + @ViewBuilder + private var footerView: () -> FooterView + + public init( + factory: Factory, + threads: LazyCachedMapCollection, + threadDestination: @escaping (ChatThread) -> Factory.ThreadDestination, + selectedThread: Binding, + onItemTap: @escaping (ChatThread) -> Void, + onItemAppear: @escaping (Int) -> Void, + headerView: @escaping () -> HeaderView, + footerView: @escaping () -> FooterView + ) { + self.factory = factory + self.threads = threads + self.threadDestination = threadDestination + self._selectedThread = selectedThread + self.onItemTap = onItemTap + self.onItemAppear = onItemAppear + self.headerView = headerView + self.footerView = footerView + } + + public var body: some View { + ScrollView { + headerView() + ThreadsLazyVStack( + factory: factory, + threads: threads, + threadDestination: threadDestination, + selectedThread: $selectedThread, + onItemTap: onItemTap, + onItemAppear: onItemAppear + ) + footerView() + } + } +} + +/// LazyVStack displaying list of threads. +public struct ThreadsLazyVStack: View { + @Injected(\.colors) private var colors + + private var factory: Factory + var threads: LazyCachedMapCollection + private var threadDestination: (ChatThread) -> Factory.ThreadDestination + @Binding private var selectedThread: ThreadSelectionInfo? + private var onItemTap: (ChatThread) -> Void + private var onItemAppear: (Int) -> Void + + public init( + factory: Factory, + threads: LazyCachedMapCollection, + threadDestination: @escaping (ChatThread) -> Factory.ThreadDestination, + selectedThread: Binding, + onItemTap: @escaping (ChatThread) -> Void, + onItemAppear: @escaping (Int) -> Void + ) { + self.factory = factory + self.threads = threads + self.threadDestination = threadDestination + self.onItemTap = onItemTap + self.onItemAppear = onItemAppear + self._selectedThread = selectedThread + } + + public var body: some View { + LazyVStack(spacing: 0) { + ForEach(threads) { thread in + factory.makeThreadListItem( + thread: thread, + threadDestination: threadDestination, + selectedThread: $selectedThread + ) + .background(factory.makeThreadListItemBackground( + thread: thread, + isSelected: selectedThread?.id == thread.id) + ) + .contentShape(Rectangle()) + .onTapGesture { + onItemTap(thread) + } + .onAppear { + if let index = threads.firstIndex(where: { chatThread in + chatThread.id == thread.id + }) { + onItemAppear(index) + } + } + factory.makeThreadListDividerItem() + } + } + } +} + +/// Determines the uniqueness of the channel list item. +extension ChatThread: Identifiable { + public var id: String { + parentMessageId + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListErrorBannerView.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListErrorBannerView.swift new file mode 100644 index 00000000..69fe3ed3 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListErrorBannerView.swift @@ -0,0 +1,25 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +/// A banner view that is displayed when there is an error loading the thread list. +public struct ChatThreadListErrorBannerView: View { + @Injected(\.colors) private var colors + @Injected(\.images) private var images + + let action: () -> Void + + public init(action: @escaping () -> Void) { + self.action = action + } + + public var body: some View { + ActionBannerView( + text: L10n.Thread.Error.message, + image: images.restart, + action: action + ) + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListFooterView.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListFooterView.swift new file mode 100644 index 00000000..a49c0f6c --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListFooterView.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +/// The default footer view of the thread list. +/// +/// By default shows a loading spinner when loading more threads. +public struct ChatThreadListFooterView: View { + @ObservedObject private var viewModel: ChatThreadListViewModel + + public init( + viewModel: ChatThreadListViewModel + ) { + self.viewModel = viewModel + } + + public var body: some View { + Group { + if viewModel.isLoadingMoreThreads { + LoadingView() + .frame(height: 40) + } else { + EmptyView() + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListHeaderView.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListHeaderView.swift new file mode 100644 index 00000000..9fa48df0 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListHeaderView.swift @@ -0,0 +1,40 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +/// The default header view of the thread list. +/// +/// By default it shows a loading spinner if it is loading the initial threads, +/// or shows a banner notifying that there are new threads to be fetched. +public struct ChatThreadListHeaderView: View { + @Injected(\.colors) private var colors + @Injected(\.images) private var images + + @ObservedObject private var viewModel: ChatThreadListViewModel + + public init( + viewModel: ChatThreadListViewModel + ) { + self.viewModel = viewModel + } + + public var body: some View { + Group { + if viewModel.isReloading { + LoadingView() + .frame(height: 40) + } else if viewModel.hasNewThreads { + ActionBannerView( + text: L10n.Thread.newThreads(viewModel.newThreadsCount), + image: images.restart + ) { + viewModel.loadThreads() + } + } else { + EmptyView() + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListHeaderViewModifier.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListHeaderViewModifier.swift new file mode 100644 index 00000000..3cf16a45 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListHeaderViewModifier.swift @@ -0,0 +1,22 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +struct ChatThreadListHeaderViewModifier: ViewModifier { + @Injected(\.fonts) private var fonts + + let title: String + + func body(content: Content) -> some View { + content + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(title) + .font(fonts.bodyBold) + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListItem.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListItem.swift new file mode 100644 index 00000000..6de7f607 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListItem.swift @@ -0,0 +1,185 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +import SwiftUI + +/// View for the thread list item. +public struct ChatThreadListItem: View { + var viewModel: ChatThreadListItemViewModel + + public init( + viewModel: ChatThreadListItemViewModel + ) { + self.viewModel = viewModel + } + + public var body: some View { + ChatThreadListItemContentView( + channelNameText: viewModel.channelNameText, + parentMessageText: viewModel.parentMessageText, + unreadRepliesCount: viewModel.unreadRepliesCount, + replyAuthorName: viewModel.latestReplyAuthorNameText, + replyAuthorUrl: viewModel.latestReplyAuthorImageURL, + replyAuthorIsOnline: viewModel.isLatestReplyAuthorOnline, + replyMessageText: viewModel.latestReplyMessageText, + replyTimestampText: viewModel.latestReplyTimestampText + ) + } +} + +/// The view model for the thread list item view. +/// +/// It contains the default presentation logic for the thread list item data. +public struct ChatThreadListItemViewModel { + @Injected(\.utils) private var utils + @Injected(\.chatClient) private var chatClient + + private let thread: ChatThread + + public init(thread: ChatThread) { + self.thread = thread + } + + /// The formatted thread parent message text. + public var parentMessageText: String { + var parentMessageText: String + if thread.parentMessage.isDeleted { + parentMessageText = L10n.Message.deletedMessagePlaceholder + } else if let threadTitle = thread.title { + parentMessageText = threadTitle + } else { + let formatter = InjectedValues[\.utils].messagePreviewFormatter + parentMessageText = formatter.formatContent(for: thread.parentMessage) + } + return L10n.Thread.Item.repliedTo(parentMessageText.trimmed) + } + + /// The formatted latest reply text. + public var latestReplyMessageText: String { + guard let latestReply = thread.latestReplies.last else { + return "" + } + + if latestReply.isDeleted { + return L10n.Message.deletedMessagePlaceholder + } + + let formatter = InjectedValues[\.utils].messagePreviewFormatter + return formatter.format(latestReply) + } + + /// The formatted latest reply timestamp. + public var latestReplyTimestampText: String { + utils.dateFormatter.string( + from: thread.latestReplies.last?.createdAt ?? .distantPast + ) + } + + /// The number of unread replies. + public var unreadRepliesCount: Int { + let currentUserRead = thread.reads.first( + where: { $0.user.id == chatClient.currentUserId } + ) + return currentUserRead?.unreadMessagesCount ?? 0 + } + + /// The formatted latest reply author name text. + public var latestReplyAuthorNameText: String { + latestReplyAuthor?.name ?? "" + } + + /// A boolean value indicating if the latest reply author is online. + public var isLatestReplyAuthorOnline: Bool { + latestReplyAuthor?.isOnline ?? false + } + + /// The latest reply author's image url. + public var latestReplyAuthorImageURL: URL? { + latestReplyAuthor?.imageURL + } + + /// The formatted channel name text. + public var channelNameText: String { + utils.channelNamer(thread.channel, chatClient.currentUserId) ?? "" + } + + private var latestReplyAuthor: ChatUser? { + thread.latestReplies.last?.author + } +} + +/// The layout of the thread list item view. +struct ChatThreadListItemContentView: View { + @Injected(\.fonts) private var fonts + @Injected(\.colors) private var colors + @Injected(\.utils) private var utils + @Injected(\.images) private var images + @Injected(\.chatClient) private var chatClient + + var channelNameText: String + var parentMessageText: String + var unreadRepliesCount: Int + var replyAuthorName: String + var replyAuthorUrl: URL? + var replyAuthorIsOnline: Bool + var replyMessageText: String + var replyTimestampText: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + threadContainerView + replyContainerView + } + .padding(.all, 8) + } + + var threadContainerView: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6) { + Image(uiImage: images.threadIcon) + .customizable() + .frame(width: 15, height: 15) + .foregroundColor(Color(colors.subtitleText)) + Text(channelNameText) + .lineLimit(1) + .foregroundColor(Color(colors.text)) + .font(fonts.subheadlineBold) + } + HStack(alignment: .bottom) { + SubtitleText(text: parentMessageText) + Spacer() + HStack { + if unreadRepliesCount != 0 { + UnreadIndicatorView( + unreadCount: unreadRepliesCount + ) + } + } + .frame(minHeight: 18) + } + } + } + + var replyContainerView: some View { + HStack(spacing: 8) { + MessageAvatarView( + avatarURL: replyAuthorUrl, + size: .init(width: 40, height: 40), + showOnlineIndicator: replyAuthorIsOnline + ) + VStack(alignment: .leading) { + Text(replyAuthorName) + .lineLimit(1) + .foregroundColor(Color(colors.text)) + .font(fonts.subheadlineBold) + HStack { + SubtitleText(text: replyMessageText) + Spacer() + SubtitleText(text: replyTimestampText) + } + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListLoadingView.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListLoadingView.swift new file mode 100644 index 00000000..c980308d --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListLoadingView.swift @@ -0,0 +1,35 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +/// The default thread list loading view. +public struct ChatThreadListLoadingView: View { + public var body: some View { + ScrollView { + LazyVStack { + ForEach((0..<10)) { _ in + ChatThreadListItemContentView( + channelNameText: placeholder(length: 8), + parentMessageText: placeholder(length: 50), + unreadRepliesCount: 0, + replyAuthorName: placeholder(length: 8), + replyAuthorUrl: URL(string: "url"), + replyAuthorIsOnline: false, + replyMessageText: placeholder(length: 50), + replyTimestampText: placeholder(length: 8) + ) + .shimmering(duration: 0.8, delay: 0.1) + .redacted(reason: .placeholder) + + Divider() + } + } + }.disabled(true) + } + + func placeholder(length: Int) -> String { + Array(repeating: "X", count: length).joined() + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListNavigatableItem.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListNavigatableItem.swift new file mode 100644 index 00000000..deace592 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListNavigatableItem.swift @@ -0,0 +1,71 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +import SwiftUI + +/// The thread list item that supports navigating to a destination. +/// It's generic over the thread destination. +public struct ChatThreadListNavigatableItem: View { + private var thread: ChatThread + private var threadListItem: ThreadListItem + private var threadDestination: (ChatThread) -> ThreadDestination + private var handleTabBarVisibility: Bool + @Binding private var selectedThread: ThreadSelectionInfo? + + public init( + thread: ChatThread, + threadListItem: ThreadListItem, + threadDestination: @escaping (ChatThread) -> ThreadDestination, + selectedThread: Binding, + handleTabBarVisibility: Bool + ) { + self.thread = thread + self.threadListItem = threadListItem + self.threadDestination = threadDestination + self._selectedThread = selectedThread + self.handleTabBarVisibility = handleTabBarVisibility + } + + public var body: some View { + ZStack { + threadListItem + NavigationLink( + tag: ThreadSelectionInfo(thread: thread), + selection: $selectedThread + ) { + LazyView( + threadDestination(thread) + .modifier(HideTabBarModifier( + handleTabBarVisibility: handleTabBarVisibility + )) + ) + } label: { + EmptyView() + } + } + .foregroundColor(.black) + } +} + +public struct ThreadSelectionInfo: Identifiable { + public let id: String + public let thread: ChatThread + + public init(thread: ChatThread) { + self.thread = thread + self.id = thread.id + } +} + +extension ThreadSelectionInfo: Hashable, Equatable { + + public static func == (lhs: ThreadSelectionInfo, rhs: ThreadSelectionInfo) -> Bool { + lhs.id == rhs.id + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListView.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListView.swift new file mode 100644 index 00000000..96edd96f --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListView.swift @@ -0,0 +1,123 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +import SwiftUI + +/// View for the chat thread list. +public struct ChatThreadListView: View { + @Injected(\.fonts) private var fonts + @Injected(\.colors) private var colors + @Injected(\.utils) private var utils + + @StateObject private var viewModel: ChatThreadListViewModel + + private let viewFactory: Factory + private let title: String + private var embedInNavigationView: Bool + + /// Creates a thread list view. + /// + /// - Parameters: + /// - viewFactory: The view factory used for creating views used by the thread list. + /// - viewModel: The view model instance providing the data. Default view model is created if nil. + /// - threadListController: The thread list controller managing the list of threads used as a data source for the view model. Default controller is created if nil. + /// - title: A custom title used as the navigation bar title. + /// - embedInNavigationView: True, if the thread list view should be embedded in a navigation stack. + /// + /// Changing the instance of the passed in `viewModel` or `threadListController` does not have an effect without reloading the thread list view by assigning a custom identity. The custom identity should be refreshed when either of the passed in instances have been recreated. + /// ```swift + /// ChatThreadListView( + /// viewModel: viewModel + /// ) + /// .id(myCustomViewIdentity) + /// ``` + public init( + viewFactory: Factory = DefaultViewFactory.shared, + viewModel: ChatThreadListViewModel? = nil, + threadListController: ChatThreadListController? = nil, + title: String? = nil, + embedInNavigationView: Bool = true + ) { + _viewModel = StateObject( + wrappedValue: viewModel ?? ViewModelsFactory.makeThreadListViewModel( + threadListController: threadListController + ) + ) + self.viewFactory = viewFactory + self.title = title ?? L10n.Thread.title + self.embedInNavigationView = embedInNavigationView + } + + public var body: some View { + NavigationContainerView(embedInNavigationView: embedInNavigationView) { + Group { + if viewModel.isLoading { + viewFactory.makeThreadListLoadingView() + } else if viewModel.isEmpty { + viewFactory.makeNoThreadsView() + } else { + ChatThreadListContentView( + viewFactory: viewFactory, + viewModel: viewModel + ) + } + } + .bottomBanner(isPresented: viewModel.failedToLoadThreads || viewModel.failedToLoadMoreThreads) { + viewFactory.makeThreadsListErrorBannerView { + viewModel.retryLoadThreads() + } + } + .accentColor(colors.tintColor) + .background( + viewFactory.makeThreadListBackground(colors: colors) + ) + .modifier(viewFactory.makeThreadListHeaderViewModifier(title: title)) + .modifier(viewFactory.makeThreadListContainerViewModifier(viewModel: viewModel)) + .onAppear { + viewModel.viewDidAppear() + } + } + } +} + +extension ChatThreadListView where Factory == DefaultViewFactory { + public init() { + self.init(viewFactory: DefaultViewFactory.shared) + } +} + +public struct ChatThreadListContentView: View { + private var viewFactory: Factory + @ObservedObject private var viewModel: ChatThreadListViewModel + + public init( + viewFactory: Factory, + viewModel: ChatThreadListViewModel + ) { + self.viewFactory = viewFactory + self.viewModel = viewModel + } + + public var body: some View { + ThreadList( + factory: viewFactory, + threads: viewModel.threads, + threadDestination: viewFactory.makeThreadDestination(), + selectedThread: $viewModel.selectedThread, + onItemTap: { thread in + viewModel.selectedThread = .init(thread: thread) + }, + onItemAppear: { index in + viewModel.didAppearThread(at: index) + }, + headerView: { + viewFactory.makeThreadListHeaderView(viewModel: viewModel) + }, + footerView: { + viewFactory.makeThreadListFooterView(viewModel: viewModel) + } + ) + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListViewModel.swift b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListViewModel.swift new file mode 100644 index 00000000..ae265477 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/ChatThreadListViewModel.swift @@ -0,0 +1,196 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Combine +import Foundation +import StreamChat + +/// The ViewModel for the `ChatThreadListView`. +open class ChatThreadListViewModel: ObservableObject, ChatThreadListControllerDelegate, EventsControllerDelegate { + + /// Context provided dependencies. + @Injected(\.chatClient) private var chatClient: ChatClient + + /// The controller that manages the thread list data. + private var threadListController: ChatThreadListController! + + /// The controller that manages thread list events. + private var eventsController: EventsController! + + /// A boolean value indicating if the initial threads have been loaded. + public private(set) var hasLoadedThreads = false + + /// The current selected thread. + @Published public var selectedThread: ThreadSelectionInfo? + + /// The list of threads. + @Published public var threads = LazyCachedMapCollection() + + /// A boolean indicating if it is loading data from the server and no local cache is available. + @Published public var isLoading = false + + /// A boolean indicating if it is reloading data from the server. + @Published public var isReloading = false + + /// A boolean indicating that there is no data from server. + @Published public var isEmpty = false + + /// A boolean indicating if it failed loading the initial data from the server. + @Published public var failedToLoadThreads = false + + /// A boolean indicating if it failed loading threads while paginating. + @Published public var failedToLoadMoreThreads = false + + /// A boolean value indicating if the view is currently loading more threads. + @Published public var isLoadingMoreThreads: Bool = false + + /// A boolean value indicating if all the older threads are loaded. + @Published public var hasLoadedAllThreads: Bool = false + + /// The number of new threads available to be fetched. + @Published public var newThreadsCount: Int = 0 + + /// A boolean value indicating if there are new threads available to be fetched. + @Published public var hasNewThreads: Bool = false + + /// The ids of the new threads available to be fetched. + private var newAvailableThreadIds: Set = [] { + didSet { + newThreadsCount = newAvailableThreadIds.count + hasNewThreads = newThreadsCount > 0 + } + } + + /// Creates a view model for the `ChatThreadListView`. + /// + /// - Parameters: + /// - threadListController: A controller providing the list of threads. If nil, a controller with default `ThreadListQuery` is created. + /// - eventsController: The controller that manages thread list events. If nil, the default events controller will be provided. + public init( + threadListController: ChatThreadListController? = nil, + eventsController: EventsController? = nil + ) { + if let threadListController = threadListController { + self.threadListController = threadListController + } else { + makeDefaultThreadListController() + } + + if let eventsController = eventsController { + self.eventsController = eventsController + } else { + makeDefaultEventsController() + } + } + + /// Re-fetches the threads. If the initial query failed, it will load the initial page. + /// If on the other hand it was a new page that failed, it will re-fetch that page. + public func retryLoadThreads() { + if failedToLoadMoreThreads { + loadMoreThreads() + return + } + + loadThreads() + } + + /// Called when the view appears on screen. + /// + /// By default it will load the initial threads and start observing new data. + public func viewDidAppear() { + if !hasLoadedThreads { + startObserving() + loadThreads() + } + } + + /// Starts observing new data. + public func startObserving() { + threadListController.delegate = self + eventsController?.delegate = self + } + + /// Loads the initial page of threads. + public func loadThreads() { + let isEmpty = threadListController.threads.isEmpty + isLoading = isEmpty + failedToLoadThreads = false + isReloading = !isEmpty + preselectThreadIfNeeded() + threadListController.synchronize { [weak self] error in + self?.isLoading = false + self?.isReloading = false + self?.hasLoadedThreads = error == nil + self?.failedToLoadThreads = error != nil + self?.isEmpty = self?.threadListController.threads.isEmpty == true + self?.preselectThreadIfNeeded() + self?.hasLoadedAllThreads = self?.threadListController.hasLoadedAllThreads ?? false + if error == nil { + self?.newAvailableThreadIds = [] + } + } + } + + /// Called when a thread in the list is shown on screen. + public func didAppearThread(at index: Int) { + guard index >= threads.count - 5 else { + return + } + + loadMoreThreads() + } + + /// Loads the next page of threads. + public func loadMoreThreads() { + if isLoadingMoreThreads || threadListController.hasLoadedAllThreads == true { + return + } + + isLoadingMoreThreads = true + threadListController.loadMoreThreads { [weak self] result in + self?.isLoadingMoreThreads = false + self?.hasLoadedAllThreads = self?.threadListController.hasLoadedAllThreads ?? false + let threads = try? result.get() + self?.failedToLoadMoreThreads = threads == nil + } + } + + public func controller( + _ controller: ChatThreadListController, + didChangeThreads changes: [ListChange] + ) { + threads = controller.threads + } + + public func eventsController(_ controller: EventsController, didReceiveEvent event: any Event) { + switch event { + case let event as ThreadMessageNewEvent: + guard let parentId = event.message.parentMessageId else { break } + let isNewThread = threadListController.dataStore.thread(parentMessageId: parentId) == nil + if isNewThread { + newAvailableThreadIds.insert(parentId) + } + default: + break + } + } + + private func makeDefaultThreadListController() { + threadListController = chatClient.threadListController( + query: .init(watch: true) + ) + } + + private func makeDefaultEventsController() { + eventsController = chatClient.eventsController() + } + + private func preselectThreadIfNeeded() { + guard isIPad else { return } + guard let firstThread = threads.first else { return } + guard selectedThread == nil else { return } + + selectedThread = .init(thread: firstThread) + } +} diff --git a/Sources/StreamChatSwiftUI/ChatThreadList/NoThreadsView.swift b/Sources/StreamChatSwiftUI/ChatThreadList/NoThreadsView.swift new file mode 100644 index 00000000..4e982147 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatThreadList/NoThreadsView.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +/// Default SDK implementation for the view displayed when there are no threads available. +public struct NoThreadsView: View { + + public init () {} + + public var body: some View { + NoContentView( + imageName: "text.bubble", + title: nil, + description: L10n.Thread.NoContent.message, + shouldRotateImage: false + ) + .accessibilityIdentifier("NoThreadsView") + } +} diff --git a/Sources/StreamChatSwiftUI/ColorPalette.swift b/Sources/StreamChatSwiftUI/ColorPalette.swift index f5d84276..2ef98726 100644 --- a/Sources/StreamChatSwiftUI/ColorPalette.swift +++ b/Sources/StreamChatSwiftUI/ColorPalette.swift @@ -85,6 +85,10 @@ public struct ColorPalette { public lazy var composerPlaceholderColor: UIColor = subtitleText public lazy var composerInputBackground: UIColor = background public lazy var composerInputHighlightedBorder: UIColor = innerBorder + + // MARK: - Threads + + public var bannerBackgroundColor: UIColor = .streamDarkGray } // Those colors are default defined stream constants, which are fallback values if you don't implement your color theme. diff --git a/Sources/StreamChatSwiftUI/CommonViews/ActionBannerView.swift b/Sources/StreamChatSwiftUI/CommonViews/ActionBannerView.swift new file mode 100644 index 00000000..2ce341c5 --- /dev/null +++ b/Sources/StreamChatSwiftUI/CommonViews/ActionBannerView.swift @@ -0,0 +1,29 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +struct ActionBannerView: View { + @Injected(\.colors) private var colors + + let text: String + let image: UIImage + let action: () -> Void + + public var body: some View { + HStack(alignment: .center) { + Text(text) + .foregroundColor(Color(colors.staticColorText)) + Spacer() + Button(action: action) { + Image(uiImage: image) + .customizable() + .frame(width: 20, height: 20) + .foregroundColor(Color(colors.staticColorText)) + } + } + .padding(.all, 16) + .background(Color(colors.bannerBackgroundColor)) + } +} diff --git a/Sources/StreamChatSwiftUI/CommonViews/FloatingBannerViewModifier.swift b/Sources/StreamChatSwiftUI/CommonViews/FloatingBannerViewModifier.swift new file mode 100644 index 00000000..01ae570f --- /dev/null +++ b/Sources/StreamChatSwiftUI/CommonViews/FloatingBannerViewModifier.swift @@ -0,0 +1,57 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +extension View { + @ViewBuilder + func topBanner(isPresented: Bool, _ bannerView: @escaping () -> some View) -> some View { + modifier( + FloatingBannerViewModifier( + isPresented: isPresented, + alignment: .top, + bannerView + ) + ) + } + + @ViewBuilder + func bottomBanner(isPresented: Bool, _ bannerView: @escaping () -> some View) -> some View { + modifier( + FloatingBannerViewModifier( + isPresented: isPresented, + alignment: .bottom, + bannerView + ) + ) + } +} + + +struct FloatingBannerViewModifier: ViewModifier { + let alignment: Alignment + var isPresented: Bool + + @ViewBuilder + let bannerView: () -> BannerView + + init( + isPresented: Bool, + alignment: Alignment = .bottom, + _ bannerView: @escaping () -> BannerView + ) { + self.alignment = alignment + self.isPresented = isPresented + self.bannerView = bannerView + } + + func body(content: Content) -> some View { + ZStack(alignment: alignment) { + content + if isPresented { + bannerView() + } + } + } +} diff --git a/Sources/StreamChatSwiftUI/CommonViews/LoadingView.swift b/Sources/StreamChatSwiftUI/CommonViews/LoadingView.swift index fd7f80c3..26931f88 100644 --- a/Sources/StreamChatSwiftUI/CommonViews/LoadingView.swift +++ b/Sources/StreamChatSwiftUI/CommonViews/LoadingView.swift @@ -28,13 +28,13 @@ public struct RedactedLoadingView: View { searchText: .constant("") ) - VStack(spacing: 0) { + LazyVStack(spacing: 0) { ForEach(0..<20) { _ in RedactedChannelCell() + .shimmering() Divider() } } - .shimmering() } } .accessibilityIdentifier("RedactedLoadingView") @@ -52,7 +52,7 @@ struct RedactedChannelCell: View { } public var body: some View { - HStack { + HStack(alignment: .center) { Circle() .fill(redactedColor) .frame(width: circleSize, height: circleSize) @@ -81,66 +81,3 @@ struct RedactedRectangle: View { .frame(width: width, height: 16) } } - -struct Shimmer: ViewModifier { - @State private var phase: CGFloat = 0 - var duration = 1.5 - - public func body(content: Content) -> some View { - content - .modifier(AnimatedMask(phase: phase).animation( - Animation - .linear(duration: duration) - .repeatForever(autoreverses: false) - )) - .onAppear { phase = 0.8 } - } - - /// An animatable modifier to interpolate between `phase` values. - struct AnimatedMask: AnimatableModifier { - var phase: CGFloat = 0 - - var animatableData: CGFloat { - get { phase } - set { phase = newValue } - } - - func body(content: Content) -> some View { - content - .mask(GradientMask(phase: phase).scaleEffect(3)) - } - } - - /// A slanted, animatable gradient between transparent and opaque to use as mask. - /// The `phase` parameter shifts the gradient, moving the opaque band. - struct GradientMask: View { - let phase: CGFloat - let centerColor = Color.black - let edgeColor = Color.black.opacity(0.3) - - var body: some View { - LinearGradient( - gradient: - Gradient(stops: [ - .init(color: edgeColor, location: phase), - .init(color: centerColor, location: phase + 0.1), - .init(color: edgeColor, location: phase + 0.2) - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - } -} - -extension View { - /// Adds an animated shimmering effect to any view, typically to show that - /// an operation is in progress. - /// - Parameters: - /// - duration: The duration of a shimmer cycle in seconds. Default: `1.5`. - func shimmering( - duration: Double = 1.5 - ) -> some View { - modifier(Shimmer(duration: duration)) - } -} diff --git a/Sources/StreamChatSwiftUI/CommonViews/NoContentView.swift b/Sources/StreamChatSwiftUI/CommonViews/NoContentView.swift index 30afef7d..3267da3a 100644 --- a/Sources/StreamChatSwiftUI/CommonViews/NoContentView.swift +++ b/Sources/StreamChatSwiftUI/CommonViews/NoContentView.swift @@ -11,7 +11,7 @@ struct NoContentView: View { @Injected(\.colors) private var colors var imageName: String - var title: String + var title: String? var description: String var shouldRotateImage: Bool = false @@ -27,7 +27,7 @@ struct NoContentView: View { .aspectRatio(contentMode: .fit) .font(.system(size: 100)) .foregroundColor(Color(colors.textLowEmphasis)) - Text(title) + title.map { Text($0) } .font(fonts.bodyBold) Text(description) .font(fonts.body) diff --git a/Sources/StreamChatSwiftUI/CommonViews/Shimmer.swift b/Sources/StreamChatSwiftUI/CommonViews/Shimmer.swift new file mode 100644 index 00000000..d2e73e8f --- /dev/null +++ b/Sources/StreamChatSwiftUI/CommonViews/Shimmer.swift @@ -0,0 +1,49 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +struct Shimmer: ViewModifier { + /// The duration of a shimmer cycle in seconds. Default: `1.5`. + var duration: Double = 1.5 + /// The delay until the animation re-starts. + var delay: Double = 0.25 + + @State private var isInitialState = true + + public func body(content: Content) -> some View { + content + .mask( + LinearGradient( + gradient: .init(colors: [.black.opacity(0.4), .black, .black.opacity(0.4)]), + startPoint: (isInitialState ? .init(x: -0.3, y: -0.3) : .init(x: 1, y: 1)), + endPoint: (isInitialState ? .init(x: 0, y: 0) : .init(x: 1.3, y: 1.3)) + ) + ) + .animation( + .linear(duration: duration) + .delay(delay) + .repeatForever(autoreverses: false), + value: isInitialState + ) + .onAppear { + isInitialState = false + } + } +} + + +extension View { + /// Adds an animated shimmering effect to any view, typically to show that + /// an operation is in progress. + /// - Parameters: + /// - duration: The duration of a shimmer cycle in seconds. Default: `1.5`. + /// - delay: The delay until the animation re-starts. + func shimmering( + duration: Double = 1.5, + delay: Double = 0.25 + ) -> some View { + modifier(Shimmer(duration: duration, delay: delay)) + } +} diff --git a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift index 1d3140a4..d20f43d2 100644 --- a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift +++ b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift @@ -99,7 +99,19 @@ extension ViewFactory { Color(colors.background) .edgesIgnoringSafeArea(.bottom) } - + + public func makeChannelListItemBackground( + channel: ChatChannel, + isSelected: Bool + ) -> some View { + let colors = InjectedValues[\.colors] + if isSelected && isIPad { + return Color(colors.background6) + } + + return Color(colors.background) + } + public func makeChannelListDividerItem() -> some View { Divider() } @@ -226,7 +238,6 @@ extension ViewFactory { ) } } - public func makeMessageListModifier() -> some ViewModifier { EmptyViewModifier() } @@ -970,6 +981,79 @@ extension ViewFactory { public func makePollView(message: ChatMessage, poll: Poll, isFirst: Bool) -> some View { PollAttachmentView(factory: self, message: message, poll: poll, isFirst: isFirst) } + + // MARK: Threads + + public func makeThreadDestination() -> (ChatThread) -> ChatChannelView { + { [unowned self] thread in + makeMessageThreadDestination()(thread.channel, thread.parentMessage) + } + } + + public func makeThreadListItem( + thread: ChatThread, + threadDestination: @escaping (ChatThread) -> ThreadDestination, + selectedThread: Binding + ) -> some View { + ChatThreadListNavigatableItem( + thread: thread, + threadListItem: ChatThreadListItem( + viewModel: .init(thread: thread) + ), + threadDestination: threadDestination, + selectedThread: selectedThread, + handleTabBarVisibility: true + ) + } + + public func makeNoThreadsView() -> some View { + NoThreadsView() + } + + public func makeThreadsListErrorBannerView(onRefreshAction: @escaping () -> Void) -> some View { + ChatThreadListErrorBannerView(action: onRefreshAction) + } + + public func makeThreadListLoadingView() -> some View { + ChatThreadListLoadingView() + } + + public func makeThreadListContainerViewModifier(viewModel: ChatThreadListViewModel) -> some ViewModifier { + EmptyViewModifier() + } + + public func makeThreadListHeaderViewModifier(title: String) -> some ViewModifier { + ChatThreadListHeaderViewModifier(title: title) + } + + public func makeThreadListHeaderView(viewModel: ChatThreadListViewModel) -> some View { + ChatThreadListHeaderView(viewModel: viewModel) + } + + public func makeThreadListFooterView(viewModel: ChatThreadListViewModel) -> some View { + ChatThreadListFooterView(viewModel: viewModel) + } + + public func makeThreadListBackground(colors: ColorPalette) -> some View { + Color(colors.background) + .edgesIgnoringSafeArea(.bottom) + } + + public func makeThreadListItemBackground( + thread: ChatThread, + isSelected: Bool + ) -> some View { + let colors = InjectedValues[\.colors] + if isSelected && isIPad { + return Color(colors.background6) + } + + return Color(colors.background) + } + + public func makeThreadListDividerItem() -> some View { + Divider() + } } /// Default class conforming to `ViewFactory`, used throughout the SDK. diff --git a/Sources/StreamChatSwiftUI/Generated/L10n.swift b/Sources/StreamChatSwiftUI/Generated/L10n.swift index ab386152..ba863a9c 100644 --- a/Sources/StreamChatSwiftUI/Generated/L10n.swift +++ b/Sources/StreamChatSwiftUI/Generated/L10n.swift @@ -540,6 +540,29 @@ internal enum L10n { } } } + + internal enum Thread { + /// %d new threads + internal static func newThreads(_ p1: Int) -> String { + return L10n.tr("Localizable", "thread.new-threads", p1) + } + /// Threads + internal static var title: String { L10n.tr("Localizable", "thread.title") } + internal enum Error { + /// Error loading threads + internal static var message: String { L10n.tr("Localizable", "thread.error.message") } + } + internal enum Item { + /// replied to: %@ + internal static func repliedTo(_ p1: Any) -> String { + return L10n.tr("Localizable", "thread.item.replied-to", String(describing: p1)) + } + } + internal enum NoContent { + /// No threads here yet... + internal static var message: String { L10n.tr("Localizable", "thread.no-content.message") } + } + } } // MARK: - Implementation Details diff --git a/Sources/StreamChatSwiftUI/Images.swift b/Sources/StreamChatSwiftUI/Images.swift index 3508fc17..da223dda 100644 --- a/Sources/StreamChatSwiftUI/Images.swift +++ b/Sources/StreamChatSwiftUI/Images.swift @@ -273,4 +273,8 @@ public class Images { public var searchIcon: UIImage = loadImageSafely(with: "icn_search") public var searchCloseIcon: UIImage = UIImage(systemName: "multiply.circle")! + + // MARK: - Threads + + public var threadIcon: UIImage = UIImage(systemName: "text.bubble")! } diff --git a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings index 62e186e9..d415c798 100644 --- a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings +++ b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings @@ -198,3 +198,11 @@ "channel.item.video" = "Video"; "channel.item.poll" = "Poll"; "channel.item.voice-message" = "Voice Message"; + +// - MARK: Threads + +"thread.title" = "Threads"; +"thread.new-threads" = "%d new threads"; +"thread.error.message" = "Error loading threads"; +"thread.no-content.message" = "No threads here yet..."; +"thread.item.replied-to" = "replied to: %@"; diff --git a/Sources/StreamChatSwiftUI/Utils.swift b/Sources/StreamChatSwiftUI/Utils.swift index fcce5185..de01943f 100644 --- a/Sources/StreamChatSwiftUI/Utils.swift +++ b/Sources/StreamChatSwiftUI/Utils.swift @@ -8,6 +8,9 @@ import StreamChat /// Class providing implementations of several utilities used in the SDK. /// The default implementations can be replaced in the init method, or directly via the variables. public class Utils { + // TODO: Make it public in future versions. + internal var messagePreviewFormatter = MessagePreviewFormatter() + public var dateFormatter: DateFormatter public var videoPreviewLoader: VideoPreviewLoader public var imageLoader: ImageLoading diff --git a/Sources/StreamChatSwiftUI/Utils/HideTabBarModifier.swift b/Sources/StreamChatSwiftUI/Utils/HideTabBarModifier.swift new file mode 100644 index 00000000..8de7f4a5 --- /dev/null +++ b/Sources/StreamChatSwiftUI/Utils/HideTabBarModifier.swift @@ -0,0 +1,30 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +struct HideTabBarModifier: ViewModifier { + let handleTabBarVisibility: Bool + + var shouldHandleTabBarVisibility: Bool { + isIphone && handleTabBarVisibility + } + + func body(content: Content) -> some View { + if shouldHandleTabBarVisibility, #available(iOS 16.0, *) { + content + .toolbar(.hidden, for: .tabBar) + } else if shouldHandleTabBarVisibility { + content + .onAppear { + UITabBar.appearance().isHidden = true + } + .onDisappear { + UITabBar.appearance().isHidden = false + } + } else { + content + } + } +} diff --git a/Sources/StreamChatSwiftUI/Utils/MessagePreviewFormatter.swift b/Sources/StreamChatSwiftUI/Utils/MessagePreviewFormatter.swift new file mode 100644 index 00000000..9fa61e1b --- /dev/null +++ b/Sources/StreamChatSwiftUI/Utils/MessagePreviewFormatter.swift @@ -0,0 +1,90 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +import SwiftUI + +/// A formatter that converts a message to a text preview representation. +/// By default it is used to show message previews in the Channel List and Thread List. +struct MessagePreviewFormatter { + @Injected(\.chatClient) var chatClient + + init() {} + + /// Formats the message including the author's name. + func format(_ previewMessage: ChatMessage) -> String { + if let poll = previewMessage.poll { + return formatPoll(poll) + } + return "\(previewMessage.author.name ?? previewMessage.author.id): \(formatContent(for: previewMessage))" + } + + /// Formats only the content of the message without the author's name. + func formatContent(for previewMessage: ChatMessage) -> String { + if let attachmentPreviewText = formatAttachmentContent(for: previewMessage) { + return attachmentPreviewText + } + if let textContent = previewMessage.textContent, !textContent.isEmpty { + return textContent + } + return previewMessage.adjustedText + } + + /// Formats only the attachment content of the message in case it contains attachments. + func formatAttachmentContent(for previewMessage: ChatMessage) -> String? { + if let poll = previewMessage.poll { + return "📊 \(poll.name)" + } + guard let attachment = previewMessage.allAttachments.first, !previewMessage.isDeleted else { + return nil + } + let text = previewMessage.textContent ?? previewMessage.text + switch attachment.type { + case .audio: + let defaultAudioText = L10n.Channel.Item.audio + return "🎧 \(text.isEmpty ? defaultAudioText : text)" + case .file: + guard let fileAttachment = previewMessage.fileAttachments.first else { + return nil + } + let title = fileAttachment.payload.title + return "📄 \(title ?? text)" + case .image: + let defaultPhotoText = L10n.Channel.Item.photo + return "📷 \(text.isEmpty ? defaultPhotoText : text)" + case .video: + let defaultVideoText = L10n.Channel.Item.video + return "📹 \(text.isEmpty ? defaultVideoText : text)" + case .giphy: + return "/giphy" + case .voiceRecording: + let defaultVoiceMessageText = L10n.Channel.Item.voiceMessage + return "🎧 \(text.isEmpty ? defaultVoiceMessageText : text)" + default: + return nil + } + } + + /// Formats the poll, including the author's name. + private func formatPoll(_ poll: Poll) -> String { + var components = ["📊"] + if let latestVoter = poll.latestVotes.first?.user { + if latestVoter.id == chatClient.currentUserId { + components.append(L10n.Channel.Item.pollYouVoted) + } else { + components.append(L10n.Channel.Item.pollSomeoneVoted(latestVoter.name ?? latestVoter.id)) + } + } else if let creator = poll.createdBy { + if creator.id == chatClient.currentUserId { + components.append(L10n.Channel.Item.pollYouCreated) + } else { + components.append(L10n.Channel.Item.pollSomeoneCreated(creator.name ?? creator.id)) + } + } + if !poll.name.isEmpty { + components.append(poll.name) + } + return components.joined(separator: " ") + } +} diff --git a/Sources/StreamChatSwiftUI/Utils/NavigationContainerView.swift b/Sources/StreamChatSwiftUI/Utils/NavigationContainerView.swift new file mode 100644 index 00000000..9e4fb923 --- /dev/null +++ b/Sources/StreamChatSwiftUI/Utils/NavigationContainerView.swift @@ -0,0 +1,27 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SwiftUI + +/// Reusable container view to handle the navigation container logic. +struct NavigationContainerView: View { + var embedInNavigationView: Bool + var content: () -> Content + + var body: some View { + if embedInNavigationView == true { + if #available(iOS 16, *), isIphone { + NavigationStack { + content() + } + } else { + NavigationView { + content() + } + } + } else { + content() + } + } +} diff --git a/Sources/StreamChatSwiftUI/ViewFactory.swift b/Sources/StreamChatSwiftUI/ViewFactory.swift index 97c4db83..68418b51 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory.swift @@ -65,6 +65,16 @@ public protocol ViewFactory: AnyObject { /// - Returns: view shown as a background of the channel list. func makeChannelListBackground(colors: ColorPalette) -> ChannelListBackground + associatedtype ChannelListItemBackground: View + /// Creates the background for the channel list item. + /// - Parameter channel: The channel which the item view belongs to. + /// - Parameter isSelected: Whether the current item is selected or not. + /// - Returns: The view shown as a background of the channel list item. + func makeChannelListItemBackground( + channel: ChatChannel, + isSelected: Bool + ) -> ChannelListItemBackground + associatedtype ChannelListDividerItem: View /// Creates the channel list divider item. func makeChannelListDividerItem() -> ChannelListDividerItem @@ -985,4 +995,79 @@ public protocol ViewFactory: AnyObject { poll: Poll, isFirst: Bool ) -> PollViewType + + // MARK: - Threads + + associatedtype ThreadDestination: View + /// Returns a function that creates the thread destination. + func makeThreadDestination() -> (ChatThread) -> ThreadDestination + + associatedtype ThreadListItemType: View + /// Creates the thread list item. + /// - Parameters: + /// - thread: The thread being displayed. + /// - threadDestination: A closure that creates the thread destination. + /// - selectedThread: The binding of the currently selected thread. + func makeThreadListItem( + thread: ChatThread, + threadDestination: @escaping (ChatThread) -> ThreadDestination, + selectedThread: Binding + ) -> ThreadListItemType + + associatedtype NoThreads: View + /// Creates the view that is displayed when there are no threads available. + func makeNoThreadsView() -> NoThreads + + associatedtype ThreadListErrorBannerView: View + /// Creates the error view that is displayed at the bottom of the thread list. + /// - Parameter onRefreshAction: The refresh action, to reload the threads. + /// - Returns: Returns the error view shown as a banner at the bottom of the thread list. + func makeThreadsListErrorBannerView(onRefreshAction: @escaping () -> Void) -> ThreadListErrorBannerView + + associatedtype ThreadListLoadingView: View + /// Creates a loading view for the thread list. + func makeThreadListLoadingView() -> ThreadListLoadingView + + associatedtype ThreadListContainerModifier: ViewModifier + /// Creates a modifier that wraps the thread list. It can be used to handle additional state changes. + /// - Parameter viewModel: The view model that manages the state of the thread list. + func makeThreadListContainerViewModifier(viewModel: ChatThreadListViewModel) -> ThreadListContainerModifier + + associatedtype ThreadListHeaderViewModifier: ViewModifier + /// Creates the thread list navigation header view modifier. + /// - Parameter title: the title displayed in the header. + func makeThreadListHeaderViewModifier(title: String) -> ThreadListHeaderViewModifier + + associatedtype ThreadListHeaderView: View + /// Creates the header view for the thread list. + /// + /// By default it shows a loading spinner if it is loading the initial threads, + /// or shows a banner notifying that there are new threads to be fetched. + func makeThreadListHeaderView(viewModel: ChatThreadListViewModel) -> ThreadListHeaderView + + associatedtype ThreadListFooterView: View + /// Creates the footer view for the thread list. + /// + /// By default shows a loading spinner when loading more threads. + func makeThreadListFooterView(viewModel: ChatThreadListViewModel) -> ThreadListFooterView + + associatedtype ThreadListBackground: View + /// Creates the background for the thread list. + /// - Parameter colors: The colors palette used in the SDK. + /// - Returns: The view shown as a background of the thread list. + func makeThreadListBackground(colors: ColorPalette) -> ThreadListBackground + + associatedtype ThreadListItemBackground: View + /// Creates the background for the thread list item. + /// - Parameter thread: The thread which the item view belongs to. + /// - Parameter isSelected: Whether the current item is selected or not. + /// - Returns: The view shown as a background of the thread list item. + func makeThreadListItemBackground( + thread: ChatThread, + isSelected: Bool + ) -> ThreadListItemBackground + + associatedtype ThreadListDividerItem: View + /// Creates the thread list divider item. + func makeThreadListDividerItem() -> ThreadListDividerItem } diff --git a/Sources/StreamChatSwiftUI/ViewModelsFactory.swift b/Sources/StreamChatSwiftUI/ViewModelsFactory.swift index c35c8c4d..37280fb6 100644 --- a/Sources/StreamChatSwiftUI/ViewModelsFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewModelsFactory.swift @@ -91,4 +91,17 @@ public class ViewModelsFactory { ) -> MessageActionsViewModel { MessageActionsViewModel(messageActions: messageActions) } + + /// Creates the `ChatThreadListViewModel`. + /// + /// - Parameters: + /// - threadListController: The controller that manages the thread list data. + /// - Returns: `ChatThreadListViewModel`. + public static func makeThreadListViewModel( + threadListController: ChatThreadListController? = nil + ) -> ChatThreadListViewModel { + ChatThreadListViewModel( + threadListController: threadListController + ) + } } diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index 2e36588e..ac7fb9fa 100644 --- a/StreamChatSwiftUI.xcodeproj/project.pbxproj +++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj @@ -502,6 +502,26 @@ A3600B43283F664E00E1C930 /* StartPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3600B42283F664E00E1C930 /* StartPage.swift */; }; A3828EAD283F6CFE00538258 /* StartPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3828EAC283F6CFE00538258 /* StartPage.swift */; }; A3D7B0DF2840E23100E308B3 /* UIView+AccessibilityIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3D7B0DE2840E23100E308B3 /* UIView+AccessibilityIdentifier.swift */; }; + AD2DDA5D2CB033460040B8D4 /* ChatThreadList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2DDA5C2CB033460040B8D4 /* ChatThreadList.swift */; }; + AD2DDA5F2CB0361B0040B8D4 /* ChatThreadListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2DDA5E2CB0361B0040B8D4 /* ChatThreadListViewModel.swift */; }; + AD2DDA612CB040EA0040B8D4 /* NoThreadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2DDA602CB040EA0040B8D4 /* NoThreadsView.swift */; }; + AD2DDA632CB04AD60040B8D4 /* ChatThreadListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2DDA622CB04AD60040B8D4 /* ChatThreadListView.swift */; }; + AD3AB6502CB41B0D0014D4D7 /* NavigationContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB64F2CB41B0D0014D4D7 /* NavigationContainerView.swift */; }; + AD3AB6522CB498380014D4D7 /* HideTabBarModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB6512CB498380014D4D7 /* HideTabBarModifier.swift */; }; + AD3AB6542CB54F310014D4D7 /* ChatThreadListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB6532CB54F310014D4D7 /* ChatThreadListItem.swift */; }; + AD3AB6562CB54F720014D4D7 /* ChatThreadListNavigatableItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB6552CB54F720014D4D7 /* ChatThreadListNavigatableItem.swift */; }; + AD3AB65A2CB59A660014D4D7 /* MessagePreviewFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB6592CB59A660014D4D7 /* MessagePreviewFormatter.swift */; }; + AD3AB65C2CB730090014D4D7 /* Shimmer.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB65B2CB730090014D4D7 /* Shimmer.swift */; }; + AD3AB65E2CB731360014D4D7 /* ChatThreadListLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB65D2CB731360014D4D7 /* ChatThreadListLoadingView.swift */; }; + AD3AB6602CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3AB65F2CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift */; }; + ADE0F55E2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE0F55D2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift */; }; + ADE0F5602CB846EC0053B8B9 /* FloatingBannerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE0F55F2CB846EC0053B8B9 /* FloatingBannerViewModifier.swift */; }; + ADE0F5622CB8556F0053B8B9 /* ChatThreadListFooterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE0F5612CB8556F0053B8B9 /* ChatThreadListFooterView.swift */; }; + ADE0F5642CB9609E0053B8B9 /* ChatThreadListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE0F5632CB9609E0053B8B9 /* ChatThreadListHeaderView.swift */; }; + ADE0F5662CB962470053B8B9 /* ActionBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE0F5652CB962470053B8B9 /* ActionBannerView.swift */; }; + ADE442EE2CBDAAAA0066CDF7 /* ChatThreadListViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE442ED2CBDAAAA0066CDF7 /* ChatThreadListViewModel_Tests.swift */; }; + ADE442F02CBDAAB70066CDF7 /* ChatThreadListView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE442EF2CBDAAB70066CDF7 /* ChatThreadListView_Tests.swift */; }; + ADE442F22CBDAAC40066CDF7 /* ChatThreadListItemView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE442F12CBDAAC40066CDF7 /* ChatThreadListItemView_Tests.swift */; }; C14A465B284665B100EF498E /* SDKIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14A465A284665B100EF498E /* SDKIdentifier.swift */; }; E3A1C01C282BAC66002D1E26 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = E3A1C01B282BAC66002D1E26 /* Sentry */; }; /* End PBXBuildFile section */ @@ -1073,6 +1093,26 @@ A3828EAC283F6CFE00538258 /* StartPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartPage.swift; sourceTree = ""; }; A3828EB0283F73EE00538258 /* StreamChatSwiftUITestsApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = StreamChatSwiftUITestsApp.xctestplan; sourceTree = ""; }; A3D7B0DE2840E23100E308B3 /* UIView+AccessibilityIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+AccessibilityIdentifier.swift"; sourceTree = ""; }; + AD2DDA5C2CB033460040B8D4 /* ChatThreadList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadList.swift; sourceTree = ""; }; + AD2DDA5E2CB0361B0040B8D4 /* ChatThreadListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListViewModel.swift; sourceTree = ""; }; + AD2DDA602CB040EA0040B8D4 /* NoThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoThreadsView.swift; sourceTree = ""; }; + AD2DDA622CB04AD60040B8D4 /* ChatThreadListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListView.swift; sourceTree = ""; }; + AD3AB64F2CB41B0D0014D4D7 /* NavigationContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationContainerView.swift; sourceTree = ""; }; + AD3AB6512CB498380014D4D7 /* HideTabBarModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HideTabBarModifier.swift; sourceTree = ""; }; + AD3AB6532CB54F310014D4D7 /* ChatThreadListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListItem.swift; sourceTree = ""; }; + AD3AB6552CB54F720014D4D7 /* ChatThreadListNavigatableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListNavigatableItem.swift; sourceTree = ""; }; + AD3AB6592CB59A660014D4D7 /* MessagePreviewFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagePreviewFormatter.swift; sourceTree = ""; }; + AD3AB65B2CB730090014D4D7 /* Shimmer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shimmer.swift; sourceTree = ""; }; + AD3AB65D2CB731360014D4D7 /* ChatThreadListLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListLoadingView.swift; sourceTree = ""; }; + AD3AB65F2CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListHeaderViewModifier.swift; sourceTree = ""; }; + ADE0F55D2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListErrorBannerView.swift; sourceTree = ""; }; + ADE0F55F2CB846EC0053B8B9 /* FloatingBannerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingBannerViewModifier.swift; sourceTree = ""; }; + ADE0F5612CB8556F0053B8B9 /* ChatThreadListFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListFooterView.swift; sourceTree = ""; }; + ADE0F5632CB9609E0053B8B9 /* ChatThreadListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListHeaderView.swift; sourceTree = ""; }; + ADE0F5652CB962470053B8B9 /* ActionBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBannerView.swift; sourceTree = ""; }; + ADE442ED2CBDAAAA0066CDF7 /* ChatThreadListViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListViewModel_Tests.swift; sourceTree = ""; }; + ADE442EF2CBDAAB70066CDF7 /* ChatThreadListView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListView_Tests.swift; sourceTree = ""; }; + ADE442F12CBDAAC40066CDF7 /* ChatThreadListItemView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListItemView_Tests.swift; sourceTree = ""; }; C14A465A284665B100EF498E /* SDKIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKIdentifier.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1621,6 +1661,7 @@ 8465FD612746A95700AF091E /* DefaultViewFactory.swift */, 8465FD4C2746A95600AF091E /* ChatChannelList */, 8465FCFC2746A95600AF091E /* ChatChannel */, + AD2DDA5B2CB0332C0040B8D4 /* ChatThreadList */, 8465FCF92746A95600AF091E /* CommonViews */, 8465FD312746A95600AF091E /* Utils */, 8465FCEC2746A95600AF091E /* Generated */, @@ -1660,6 +1701,8 @@ children = ( 8465FCFA2746A95600AF091E /* ActionItemView.swift */, 4F6D83502C1079A00098C298 /* AlertBannerViewModifier.swift */, + ADE0F5652CB962470053B8B9 /* ActionBannerView.swift */, + ADE0F55F2CB846EC0053B8B9 /* FloatingBannerViewModifier.swift */, 4F077EF72C85E05700F06D83 /* DelayedRenderingViewModifier.swift */, 84AB7B1C2771F4AA00631A10 /* DiscardButtonView.swift */, 84F2908D276B92A40045472D /* GalleryHeaderView.swift */, @@ -1668,6 +1711,7 @@ 8421BCED27A43E14000F977D /* SearchBar.swift */, 8434E582277088D9001E1B83 /* TitleWithCloseButton.swift */, 846608E2278C303800D3D7B3 /* TypingIndicatorView.swift */, + AD3AB65B2CB730090014D4D7 /* Shimmer.swift */, ); path = CommonViews; sourceTree = ""; @@ -1808,7 +1852,9 @@ 8465FD312746A95600AF091E /* Utils */ = { isa = PBXGroup; children = ( + AD3AB6592CB59A660014D4D7 /* MessagePreviewFormatter.swift */, 8465FD322746A95600AF091E /* ViewExtensions.swift */, + AD3AB64F2CB41B0D0014D4D7 /* NavigationContainerView.swift */, 8465FD332746A95600AF091E /* NukeImageLoader.swift */, 8465FD342746A95600AF091E /* Modifiers.swift */, 8465FD352746A95600AF091E /* LazyView.swift */, @@ -1823,6 +1869,7 @@ 84F130C02AEAA957006E7B52 /* StreamLazyImage.swift */, 4FEAB3172BFF71F70057E511 /* SwiftUI+UIAlertController.swift */, 842ADEA828EB018C00F2BE36 /* LazyImageExtensions.swift */, + AD3AB6512CB498380014D4D7 /* HideTabBarModifier.swift */, 8465FD382746A95600AF091E /* Common */, ); path = Utils; @@ -2029,6 +2076,7 @@ 84E6EC24279AEE9F0017207B /* StreamChatTestCase.swift */, 84C94C7D27567CC2007FE2B9 /* ChatChannelList */, 84C94D472758BDB2007FE2B9 /* ChatChannel */, + ADE442EC2CBDAA320066CDF7 /* ChatThreadList */, 4F6D83522C108D470098C298 /* CommonViews */, 84C94D52275A135F007FE2B9 /* Utils */, ); @@ -2198,6 +2246,34 @@ path = "Base TestCase"; sourceTree = ""; }; + AD2DDA5B2CB0332C0040B8D4 /* ChatThreadList */ = { + isa = PBXGroup; + children = ( + AD2DDA622CB04AD60040B8D4 /* ChatThreadListView.swift */, + AD2DDA5C2CB033460040B8D4 /* ChatThreadList.swift */, + AD2DDA5E2CB0361B0040B8D4 /* ChatThreadListViewModel.swift */, + AD3AB6552CB54F720014D4D7 /* ChatThreadListNavigatableItem.swift */, + AD3AB65F2CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift */, + AD3AB6532CB54F310014D4D7 /* ChatThreadListItem.swift */, + AD3AB65D2CB731360014D4D7 /* ChatThreadListLoadingView.swift */, + ADE0F5632CB9609E0053B8B9 /* ChatThreadListHeaderView.swift */, + ADE0F5612CB8556F0053B8B9 /* ChatThreadListFooterView.swift */, + ADE0F55D2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift */, + AD2DDA602CB040EA0040B8D4 /* NoThreadsView.swift */, + ); + path = ChatThreadList; + sourceTree = ""; + }; + ADE442EC2CBDAA320066CDF7 /* ChatThreadList */ = { + isa = PBXGroup; + children = ( + ADE442ED2CBDAAAA0066CDF7 /* ChatThreadListViewModel_Tests.swift */, + ADE442EF2CBDAAB70066CDF7 /* ChatThreadListView_Tests.swift */, + ADE442F12CBDAAC40066CDF7 /* ChatThreadListItemView_Tests.swift */, + ); + path = ChatThreadList; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -2512,6 +2588,7 @@ 82D64BE92AD7E5B700C5C79E /* TaskLoadData.swift in Sources */, 847CEFEE27C38ABE00606257 /* MessageCachingUtils.swift in Sources */, 82D64BF62AD7E5B700C5C79E /* ImageProcessors+CoreImage.swift in Sources */, + ADE0F55E2CB838420053B8B9 /* ChatThreadListErrorBannerView.swift in Sources */, 8451C4912BD7096000849955 /* PollAttachmentView.swift in Sources */, 8465FD792746A95700AF091E /* DeletedMessageView.swift in Sources */, 8492975227B156D100A8EEB0 /* SlowModeView.swift in Sources */, @@ -2538,6 +2615,7 @@ 8465FDB22746A95700AF091E /* InputTextView.swift in Sources */, 8465FDB32746A95700AF091E /* NSLayoutConstraint+Extensions.swift in Sources */, 82D64BCF2AD7E5B700C5C79E /* LazyImageState.swift in Sources */, + ADE0F5642CB9609E0053B8B9 /* ChatThreadListHeaderView.swift in Sources */, 8465FD912746A95700AF091E /* MessageComposerView.swift in Sources */, 82D64BE52AD7E5B700C5C79E /* ImagePipelineCache.swift in Sources */, 8465FD6A2746A95700AF091E /* L10n.swift in Sources */, @@ -2547,6 +2625,7 @@ 84471C182BE98BC400D6721E /* PollAllOptionsView.swift in Sources */, 82D64C032AD7E5B700C5C79E /* RateLimiter.swift in Sources */, 84B738402BE8EE1800EC66EC /* PollCommentsView.swift in Sources */, + AD3AB6562CB54F720014D4D7 /* ChatThreadListNavigatableItem.swift in Sources */, 82D64BEA2AD7E5B700C5C79E /* TaskFetchOriginalImageData.swift in Sources */, 82D64BDB2AD7E5B700C5C79E /* UIImageView.swift in Sources */, 82D64BD02AD7E5B700C5C79E /* NukeVideoPlayerView.swift in Sources */, @@ -2557,11 +2636,13 @@ 84289BEB2807239B00282ABE /* MediaAttachmentsViewModel.swift in Sources */, 8465FD902746A95700AF091E /* ComposerHelperViews.swift in Sources */, 82D64C142AD7E5B700C5C79E /* ImageDecoderRegistry.swift in Sources */, + AD3AB6602CB7403C0014D4D7 /* ChatThreadListHeaderViewModifier.swift in Sources */, 82D64C182AD7E5B700C5C79E /* ImageCache.swift in Sources */, 849CDD942768E0E1003C7A51 /* MessageActionsResolver.swift in Sources */, 84EADEB72B28A17B0046B50C /* RecordingConstants.swift in Sources */, 84F2908E276B92A40045472D /* GalleryHeaderView.swift in Sources */, 8465FD7B2746A95700AF091E /* GiphyBadgeView.swift in Sources */, + AD2DDA5F2CB0361B0040B8D4 /* ChatThreadListViewModel.swift in Sources */, 8465FDBD2746A95700AF091E /* ChatChannelHelperViews.swift in Sources */, 8465FD8D2746A95700AF091E /* AddedImageAttachmentsView.swift in Sources */, 8465FDAA2746A95700AF091E /* DateFormatter+Extensions.swift in Sources */, @@ -2569,6 +2650,7 @@ 841B64D42775F5540016FF3B /* GiphyCommandHandler.swift in Sources */, 82D64BDD2AD7E5B700C5C79E /* AnimatedImageView.swift in Sources */, 8434E58127707F19001E1B83 /* GridPhotosView.swift in Sources */, + ADE0F5662CB962470053B8B9 /* ActionBannerView.swift in Sources */, 84BB4C4C2841104700CBE004 /* MessageListDateUtils.swift in Sources */, 82D64BE72AD7E5B700C5C79E /* ImageTask.swift in Sources */, 8465FD742746A95700AF091E /* ViewFactory.swift in Sources */, @@ -2595,6 +2677,7 @@ 84DEC8EC27611CAE00172876 /* SendInChannelView.swift in Sources */, 84F130C12AEAA957006E7B52 /* StreamLazyImage.swift in Sources */, 82D64BD12AD7E5B700C5C79E /* Image.swift in Sources */, + ADE0F5602CB846EC0053B8B9 /* FloatingBannerViewModifier.swift in Sources */, 82D64BD52AD7E5B700C5C79E /* AnimatedFrame.swift in Sources */, 8465FD9F2746A95700AF091E /* ChatChannelExtensions.swift in Sources */, 844D1D6628510304000CCCB9 /* ChannelControllerFactory.swift in Sources */, @@ -2645,10 +2728,14 @@ 84DEC8E82760EABC00172876 /* ChatChannelDataSource.swift in Sources */, 8465FDC62746A95700AF091E /* ChannelHeaderLoader.swift in Sources */, 8465FD872746A95700AF091E /* AttachmentPickerTypeView.swift in Sources */, + AD3AB65E2CB731360014D4D7 /* ChatThreadListLoadingView.swift in Sources */, + AD2DDA5D2CB033460040B8D4 /* ChatThreadList.swift in Sources */, + AD3AB65A2CB59A660014D4D7 /* MessagePreviewFormatter.swift in Sources */, 91CC203A283C3E7F0049A146 /* URLExtensions.swift in Sources */, 845CFD782BDA6BFD0058F691 /* PollResultsView.swift in Sources */, 8465FD892746A95700AF091E /* ComposerTextInputView.swift in Sources */, 8465FDBC2746A95700AF091E /* ChannelAvatarsMerger.swift in Sources */, + ADE0F5622CB8556F0053B8B9 /* ChatThreadListFooterView.swift in Sources */, 8465FDB82746A95700AF091E /* ImageMerger.swift in Sources */, 82D64BF22AD7E5B700C5C79E /* ImageProcessors+RoundedCorners.swift in Sources */, 841B64C427744DB60016FF3B /* ComposerModels.swift in Sources */, @@ -2657,6 +2744,7 @@ 8465FD832746A95700AF091E /* LinkAttachmentView.swift in Sources */, 82D64C082AD7E5B700C5C79E /* Operation.swift in Sources */, 82D64BEB2AD7E5B700C5C79E /* ImagePipelineTask.swift in Sources */, + AD2DDA612CB040EA0040B8D4 /* NoThreadsView.swift in Sources */, 82D64BF92AD7E5B700C5C79E /* ImageProcessors+Resize.swift in Sources */, 8465FDC22746A95700AF091E /* ChatChannelNavigatableListItem.swift in Sources */, 8465FDAD2746A95700AF091E /* ImageCDN.swift in Sources */, @@ -2697,6 +2785,7 @@ 8465FD6F2746A95700AF091E /* StreamChat.swift in Sources */, 84EADEC12B2AFA690046B50C /* MessageComposerViewModel+Recording.swift in Sources */, 8465FD8F2746A95700AF091E /* AttachmentUploadingStateView.swift in Sources */, + AD2DDA632CB04AD60040B8D4 /* ChatThreadListView.swift in Sources */, 8465FD732746A95700AF091E /* ActionItemView.swift in Sources */, 82D64BD42AD7E5B700C5C79E /* GIFAnimatable.swift in Sources */, 844CC60E2811378D0006548D /* ComposerConfig.swift in Sources */, @@ -2753,6 +2842,8 @@ 82D64BE82AD7E5B700C5C79E /* TaskFetchDecodedImage.swift in Sources */, 8465FDB02746A95700AF091E /* DateUtils.swift in Sources */, 84DEC8E12760D24100172876 /* MessageRepliesView.swift in Sources */, + AD3AB65C2CB730090014D4D7 /* Shimmer.swift in Sources */, + AD3AB6522CB498380014D4D7 /* HideTabBarModifier.swift in Sources */, 8423C33F277C9A5F0092DCF1 /* UnmuteCommandHandler.swift in Sources */, 8421BCEE27A43E14000F977D /* SearchBar.swift in Sources */, 84EADEBB2B28BAE40046B50C /* RecordingWaveform.swift in Sources */, @@ -2775,8 +2866,10 @@ 82D64C072AD7E5B700C5C79E /* ImagePublisher.swift in Sources */, 8465FD9D2746A95700AF091E /* MessageActionsViewModel.swift in Sources */, 82D64BE12AD7E5B700C5C79E /* LazyImageView.swift in Sources */, + AD3AB6542CB54F310014D4D7 /* ChatThreadListItem.swift in Sources */, 8465FD7E2746A95700AF091E /* VideoAttachmentView.swift in Sources */, 82D64BF52AD7E5B700C5C79E /* ImageProcessors+GaussianBlur.swift in Sources */, + AD3AB6502CB41B0D0014D4D7 /* NavigationContainerView.swift in Sources */, 841BA9F32BCD6FCB000C73E4 /* CreatePollViewModel.swift in Sources */, 82D64BDE2AD7E5B700C5C79E /* Internal.swift in Sources */, 8465FD8E2746A95700AF091E /* PhotoAssetsUtils.swift in Sources */, @@ -2872,10 +2965,12 @@ 84C94D0727578BF2007FE2B9 /* RandomDispatchQueue.swift in Sources */, 847110B628611033004A46D6 /* MessageActions_Tests.swift in Sources */, 84C94D1227578BF2007FE2B9 /* JSONEncoder+Extensions.swift in Sources */, + ADE442EE2CBDAAAA0066CDF7 /* ChatThreadListViewModel_Tests.swift in Sources */, 84E04797284A444E00BAFA17 /* WebSocketPingControllerMock.swift in Sources */, 8423C34C277DDD250092DCF1 /* MuteCommandHandler_Tests.swift in Sources */, 84C94D1127578BF2007FE2B9 /* ChannelId.swift in Sources */, 84B2B5D628196FD100479CEE /* MediaAttachmentsView_Tests.swift in Sources */, + ADE442F02CBDAAB70066CDF7 /* ChatThreadListView_Tests.swift in Sources */, 84AB7B21277203EF00631A10 /* GalleryView_Tests.swift in Sources */, 84E0478D284A444E00BAFA17 /* ImageLoader_Mock.swift in Sources */, 8423C348277DBBDA0092DCF1 /* InstantCommandsHandler_Tests.swift in Sources */, @@ -2898,6 +2993,7 @@ 84CC3732290B0A4000689B73 /* StreamChatModel.xcdatamodeld in Sources */, 84E04790284A444E00BAFA17 /* CDNClient_Mock.swift in Sources */, 84E04796284A444E00BAFA17 /* EventBatcherMock.swift in Sources */, + ADE442F22CBDAAC40066CDF7 /* ChatThreadListItemView_Tests.swift in Sources */, 84E57C5B28103822002213C1 /* TestDataModel.xcdatamodeld in Sources */, 84E04792284A444E00BAFA17 /* MockBackgroundTaskScheduler.swift in Sources */, 84C94D1327578BF2007FE2B9 /* XCTestCase+MockJSON.swift in Sources */, diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift index 0c591e09..8c98ce1a 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift @@ -105,7 +105,7 @@ class ChatChannelViewModel_Tests: StreamChatTestCase { // Then let dateString = viewModel.currentDateString - XCTAssert(dateString == expectedDate) + XCTAssertEqual(dateString, expectedDate) } func test_chatChannelVM_showReactionsOverlay() { diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift index 98988b36..8f908d0b 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannelList/ChatChannelListItemView_Tests.swift @@ -155,7 +155,7 @@ final class ChatChannelListItemView_Tests: StreamChatTestCase { func test_channelListItem_pollMessage_youVoted() throws { // Given - let currentUserId = UserId.unique + let currentUserId = Self.currentUserId let message = try mockPollMessage(isSentByCurrentUser: false, latestVotes: [ .mock(pollId: .unique, optionId: .unique, user: .mock(id: currentUserId)), .unique, @@ -179,7 +179,7 @@ final class ChatChannelListItemView_Tests: StreamChatTestCase { func test_channelListItem_pollMessage_someoneVoted() throws { // Given - let currentUserId = UserId.unique + let currentUserId = Self.currentUserId let message = try mockPollMessage(isSentByCurrentUser: false, latestVotes: [ .mock(pollId: .unique, optionId: .unique, user: .mock(id: .unique, name: "Steve Jobs")), .unique, @@ -331,7 +331,7 @@ final class ChatChannelListItemView_Tests: StreamChatTestCase { isSentByCurrentUser: isSentByCurrentUser, poll: .mock( name: "Test poll", - createdBy: .mock(id: "test", name: "test"), + createdBy: .mock(id: isSentByCurrentUser ? Self.currentUserId : "test", name: "test"), latestVotes: latestVotes ) ) diff --git a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/LoadingView_Tests/test_redactedLoadingView_snapshot.1.png b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/LoadingView_Tests/test_redactedLoadingView_snapshot.1.png index 9c2ba800..34e3f241 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/LoadingView_Tests/test_redactedLoadingView_snapshot.1.png and b/StreamChatSwiftUITests/Tests/ChatChannelList/__Snapshots__/LoadingView_Tests/test_redactedLoadingView_snapshot.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListItemView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListItemView_Tests.swift new file mode 100644 index 00000000..9438604c --- /dev/null +++ b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListItemView_Tests.swift @@ -0,0 +1,139 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SnapshotTesting +@testable import StreamChat +@testable import StreamChatSwiftUI +@testable import StreamChatTestTools +import StreamSwiftTestHelpers +import XCTest + +final class ChatThreadListItemView_Tests: StreamChatTestCase { + var mockThread: ChatThread! + + var mockYoda = ChatUser.mock(id: .unique, name: "Yoda", imageURL: nil) + var currentUser: ChatUser! + + override func setUp() { + super.setUp() + + let circleImage = UIImage.circleImage + streamChat?.utils.channelHeaderLoader.placeholder1 = circleImage + streamChat?.utils.channelHeaderLoader.placeholder2 = circleImage + streamChat?.utils.channelHeaderLoader.placeholder3 = circleImage + streamChat?.utils.channelHeaderLoader.placeholder4 = circleImage + + currentUser = ChatUser.mock(id: StreamChatTestCase.currentUserId, name: "Vader", imageURL: nil) + + mockThread = .mock( + parentMessage: .mock(text: "Parent Message", author: mockYoda), + channel: .mock(cid: .unique, name: "Star Wars Channel"), + createdBy: currentUser, + replyCount: 3, + participantCount: 2, + threadParticipants: [ + .mock(user: mockYoda), + .mock(user: currentUser) + ], + lastMessageAt: .unique, + createdAt: .unique, + updatedAt: .unique, + title: nil, + latestReplies: [ + .mock(text: "First Message", author: mockYoda), + .mock(text: "Second Message", author: currentUser), + .mock(text: "Third Message", author: mockYoda) + ], + reads: [], + extraData: [:] + ) + } + + func test_threadListItem_default() throws { + let view = ChatThreadListItem(thread: mockThread) + .frame(width: defaultScreenSize.width) + + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_threadListItem_withUnreads() throws { + let thread = mockThread + .with(reads: [.mock(user: currentUser, lastReadAt: .unique, unreadMessagesCount: 4)]) + + let view = ChatThreadListItem(thread: thread) + .frame(width: defaultScreenSize.width) + + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_threadListItem_withTitle() throws { + let thread = mockThread + .with(title: "Thread title") + + let view = ChatThreadListItem(thread: thread) + .frame(width: defaultScreenSize.width) + + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_threadListItem_withParentMessageDeleted() throws { + let thread = mockThread + .with(parentMessage: .mock(text: "Parent Message", deletedAt: .unique)) + + let view = ChatThreadListItem(thread: thread) + .frame(width: defaultScreenSize.width) + + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_threadListItem_withLastReplyDeleted() throws { + let thread = mockThread + .with(latestReplies: [ + .mock(text: "First Message", author: mockYoda), + .mock(text: "Second Message", author: currentUser), + .mock(text: "Third Message", author: mockYoda, deletedAt: .unique) + ]) + + let view = ChatThreadListItem(thread: thread) + .frame(width: defaultScreenSize.width) + + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_threadListItem_whenAttachments() throws { + let thread = mockThread + .with( + parentMessage: .mock(text: "", attachments: [.dummy(type: .giphy)]), + latestReplies: [ + .mock(text: "", author: mockYoda, attachments: [.dummy(type: .audio)]) + ] + ) + + let view = ChatThreadListItem(thread: thread) + .frame(width: defaultScreenSize.width) + + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_threadListItem_whenAttachmentIsPoll() throws { + let thread = mockThread + .with( + parentMessage: .mock(text: "", poll: .mock(name: "Who is better?")), + latestReplies: [ + .mock(text: "", author: mockYoda, poll: .mock(name: "Who is worse?")) + ] + ) + + let view = ChatThreadListItem(thread: thread) + .frame(width: defaultScreenSize.width) + + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } +} + +extension ChatThreadListItem { + init(thread: ChatThread) { + self.init(viewModel: ChatThreadListItemViewModel(thread: thread)) + } +} diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListViewModel_Tests.swift new file mode 100644 index 00000000..0b3e98d0 --- /dev/null +++ b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListViewModel_Tests.swift @@ -0,0 +1,185 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatSwiftUI +@testable import StreamChatTestTools +import XCTest + +class ChatThreadListViewModel_Tests: StreamChatTestCase { + + func test_viewDidAppear_thenLoadsThreads() { + let mockThreadListController = ChatThreadListController_Mock.mock( + query: .init(watch: true) + ) + let viewModel = ChatThreadListViewModel( + threadListController: mockThreadListController + ) + + viewModel.viewDidAppear() + XCTAssertEqual(mockThreadListController.synchronize_callCount, 1) + } + + func test_viewDidAppear_whenAlreadyLoadedThreads_thenDoesNotLoadsThreads() { + let mockThreadListController = ChatThreadListController_Mock.mock( + query: .init(watch: true) + ) + let viewModel = ChatThreadListViewModel( + threadListController: mockThreadListController + ) + + viewModel.viewDidAppear() + mockThreadListController.synchronize_completion?(nil) + viewModel.viewDidAppear() + + XCTAssertEqual(mockThreadListController.synchronize_callCount, 1) + } + + func test_loadThreads_whenInitialEmptyData_whenSuccess() { + let mockThreadListController = ChatThreadListController_Mock.mock( + query: .init(watch: true) + ) + mockThreadListController.threads_mock = [] + let viewModel = ChatThreadListViewModel( + threadListController: mockThreadListController + ) + + viewModel.loadThreads() + + XCTAssertEqual(viewModel.isLoading, true) + XCTAssertEqual(viewModel.isReloading, false) + XCTAssertEqual(viewModel.failedToLoadThreads, false) + XCTAssertEqual(viewModel.hasLoadedThreads, false) + + mockThreadListController.threads_mock = [.mock()] + mockThreadListController.synchronize_completion?(nil) + + XCTAssertEqual(viewModel.isLoading, false) + XCTAssertEqual(viewModel.isReloading, false) + XCTAssertEqual(viewModel.failedToLoadThreads, false) + XCTAssertEqual(viewModel.hasLoadedThreads, true) + XCTAssertEqual(viewModel.isEmpty, false) + } + + func test_loadThreads_whenCacheAvailable_whenSuccess() { + let mockThreadListController = ChatThreadListController_Mock.mock( + query: .init(watch: true) + ) + mockThreadListController.threads_mock = [.mock()] + let viewModel = ChatThreadListViewModel( + threadListController: mockThreadListController + ) + + viewModel.loadThreads() + + XCTAssertEqual(viewModel.isLoading, false) + XCTAssertEqual(viewModel.isReloading, true) + XCTAssertEqual(viewModel.failedToLoadThreads, false) + XCTAssertEqual(viewModel.hasLoadedThreads, false) + + mockThreadListController.threads_mock = [.mock(), .mock()] + mockThreadListController.synchronize_completion?(nil) + + XCTAssertEqual(viewModel.isLoading, false) + XCTAssertEqual(viewModel.isReloading, false) + XCTAssertEqual(viewModel.failedToLoadThreads, false) + XCTAssertEqual(viewModel.hasLoadedThreads, true) + XCTAssertEqual(viewModel.isEmpty, false) + } + + func test_loadThreads_whenError() { + let mockThreadListController = ChatThreadListController_Mock.mock( + query: .init(watch: true) + ) + mockThreadListController.threads_mock = [] + let viewModel = ChatThreadListViewModel( + threadListController: mockThreadListController + ) + + viewModel.loadThreads() + mockThreadListController.threads_mock = [.mock()] + mockThreadListController.synchronize_completion?(ClientError("ERROR")) + + XCTAssertEqual(viewModel.isLoading, false) + XCTAssertEqual(viewModel.isReloading, false) + XCTAssertEqual(viewModel.failedToLoadThreads, true) + XCTAssertEqual(viewModel.failedToLoadMoreThreads, false) + XCTAssertEqual(viewModel.hasLoadedThreads, false) + } + + func test_didAppearThread_whenInsideThreshold_thenLoadMoreThreads() { + let mockThreadListController = ChatThreadListController_Mock.mock( + query: .init(watch: true) + ) + let viewModel = ChatThreadListViewModel( + threadListController: mockThreadListController + ) + let mockedThreads: [ChatThread] = [ + .mock(), .mock(), .mock(), .mock(), .mock(), .mock(), .mock() + ] + mockedThreads.forEach { thread in + viewModel.threads.append(thread) + } + + XCTAssertEqual(viewModel.isLoadingMoreThreads, false) + + viewModel.didAppearThread(at: 5) + + XCTAssertEqual(viewModel.isLoadingMoreThreads, true) + } + + func test_didAppearThread_whenNotInThreshold_thenDoNotLoadMoreThreads() { + let mockThreadListController = ChatThreadListController_Mock.mock( + query: .init(watch: true) + ) + let viewModel = ChatThreadListViewModel( + threadListController: mockThreadListController + ) + let mockedThreads: [ChatThread] = [ + .mock(), .mock(), .mock(), .mock(), .mock(), .mock(), .mock() + ] + mockedThreads.forEach { thread in + viewModel.threads.append(thread) + } + + XCTAssertEqual(viewModel.isLoadingMoreThreads, false) + + viewModel.didAppearThread(at: 0) + + XCTAssertEqual(viewModel.isLoadingMoreThreads, false) + } + + func test_didReceiveThreadMessageNewEvent() { + let mockThreadListController = ChatThreadListController_Mock.mock( + query: .init(watch: true) + ) + let viewModel = ChatThreadListViewModel( + threadListController: mockThreadListController + ) + let eventController = mockThreadListController.client.eventsController() + + // 2 Events + viewModel.eventsController( + eventController, + didReceiveEvent: ThreadMessageNewEvent( + message: .mock(parentMessageId: .unique), + channel: .mock(cid: .unique), + unreadCount: .noUnread, + createdAt: .unique + ) + ) + viewModel.eventsController( + eventController, + didReceiveEvent: ThreadMessageNewEvent( + message: .mock(parentMessageId: .unique), + channel: .mock(cid: .unique), + unreadCount: .noUnread, + createdAt: .unique + ) + ) + + XCTAssertEqual(viewModel.newThreadsCount, 2) + XCTAssertTrue(viewModel.hasNewThreads) + } +} diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift new file mode 100644 index 00000000..253e47f8 --- /dev/null +++ b/StreamChatSwiftUITests/Tests/ChatThreadList/ChatThreadListView_Tests.swift @@ -0,0 +1,278 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import SnapshotTesting +@testable import StreamChat +@testable import StreamChatSwiftUI +@testable import StreamChatTestTools +import StreamSwiftTestHelpers +import SwiftUI +import XCTest + +class ChatThreadListView_Tests: StreamChatTestCase { + + func test_chatThreadListView_empty() { + let view = makeView(.empty()) + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_chatThreadListView_loading() { + let view = makeView(.loading()) + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_chatThreadListView_withThreads() { + let view = makeView(.withThreads()) + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_chatThreadListView_loadingMoreThreads() { + let view = makeView(.loadingMoreThreads()) + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_chatThreadListView_reloadingThreads() { + let view = makeView(.reloadingThreads()) + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_chatThreadListView_whenNewThreadsAvailable() { + let view = makeView(.newThreadsAvailable()) + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_chatThreadListView_errorLoadingThreads() { + let view = makeView(.errorLoadingThreads()) + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + func test_chatThreadListView_errorLoadingMoreThreads() { + let view = makeView(.errorLoadingMoreThreads()) + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } + + private func makeView(_ viewModel: MockChatThreadListViewModel) -> some View { + ChatThreadListView( + viewFactory: DefaultViewFactory.shared, + viewModel: viewModel + ) + .applyDefaultSize() + } +} + +class CustomFactory: ViewFactory { + @Injected(\.chatClient) public var chatClient + + func makeThreadListLoadingView() -> some View { + LoadingView() + } +} + +private class MockChatThreadListViewModel: ChatThreadListViewModel { + static func empty() -> MockChatThreadListViewModel { + return MockChatThreadListViewModel( + threads: [], + isLoading: false, + isReloading: false, + isEmpty: true, + failedToLoadThreads: false, + failedToLoadMoreThreads: false, + isLoadingMoreThreads: false, + hasLoadedAllThreads: false, + newThreadsCount: 0, + hasNewThreads: false + ) + } + + static func loading() -> MockChatThreadListViewModel { + return MockChatThreadListViewModel( + threads: [], + isLoading: true, + isReloading: false, + isEmpty: false, + failedToLoadThreads: false, + failedToLoadMoreThreads: false, + isLoadingMoreThreads: false, + hasLoadedAllThreads: false, + newThreadsCount: 0, + hasNewThreads: false + ) + } + + static func withThreads() -> MockChatThreadListViewModel { + return MockChatThreadListViewModel( + threads: mockThreads, + isLoading: false, + isReloading: false, + isEmpty: false, + failedToLoadThreads: false, + failedToLoadMoreThreads: false, + isLoadingMoreThreads: false, + hasLoadedAllThreads: false, + newThreadsCount: 0, + hasNewThreads: false + ) + } + + static func loadingMoreThreads() -> MockChatThreadListViewModel { + return MockChatThreadListViewModel( + threads: mockThreads, + isLoading: false, + isReloading: false, + isEmpty: false, + failedToLoadThreads: false, + failedToLoadMoreThreads: false, + isLoadingMoreThreads: true, + hasLoadedAllThreads: false, + newThreadsCount: 0, + hasNewThreads: false + ) + } + + static func reloadingThreads() -> MockChatThreadListViewModel { + return MockChatThreadListViewModel( + threads: mockThreads, + isLoading: false, + isReloading: true, + isEmpty: false, + failedToLoadThreads: false, + failedToLoadMoreThreads: false, + isLoadingMoreThreads: false, + hasLoadedAllThreads: false, + newThreadsCount: 0, + hasNewThreads: false + ) + } + + static func newThreadsAvailable() -> MockChatThreadListViewModel { + return MockChatThreadListViewModel( + threads: mockThreads, + isLoading: false, + isReloading: false, + isEmpty: false, + failedToLoadThreads: false, + failedToLoadMoreThreads: false, + isLoadingMoreThreads: false, + hasLoadedAllThreads: false, + newThreadsCount: 2, + hasNewThreads: true + ) + } + + static func errorLoadingThreads() -> MockChatThreadListViewModel { + return MockChatThreadListViewModel( + threads: [], + isLoading: false, + isReloading: false, + isEmpty: true, + failedToLoadThreads: true, + failedToLoadMoreThreads: false, + isLoadingMoreThreads: false, + hasLoadedAllThreads: false, + newThreadsCount: 0, + hasNewThreads: false + ) + } + + static func errorLoadingMoreThreads() -> MockChatThreadListViewModel { + return MockChatThreadListViewModel( + threads: mockThreads, + isLoading: false, + isReloading: false, + isEmpty: false, + failedToLoadThreads: false, + failedToLoadMoreThreads: true, + isLoadingMoreThreads: false, + hasLoadedAllThreads: false, + newThreadsCount: 0, + hasNewThreads: false + ) + } + + convenience init( + threads: [ChatThread], + isLoading: Bool, + isReloading: Bool, + isEmpty: Bool, + failedToLoadThreads: Bool, + failedToLoadMoreThreads: Bool, + isLoadingMoreThreads: Bool, + hasLoadedAllThreads: Bool, + newThreadsCount: Int, + hasNewThreads: Bool + ) { + self.init(threadListController: nil, eventsController: nil) + self.threads = LazyCachedMapCollection(elements: threads) + self.isLoading = isLoading + self.isReloading = isReloading + self.isEmpty = isEmpty + self.failedToLoadThreads = failedToLoadThreads + self.failedToLoadMoreThreads = failedToLoadMoreThreads + self.isLoadingMoreThreads = isLoadingMoreThreads + self.hasLoadedAllThreads = hasLoadedAllThreads + self.newThreadsCount = newThreadsCount + self.hasNewThreads = hasNewThreads + } + + override func viewDidAppear() {} + override func loadThreads() {} + override func loadMoreThreads() {} + override func controller( + _ controller: ChatThreadListController, + didChangeThreads changes: [ListChange] + ) {} + + + static var mockYoda = ChatUser.mock(id: .unique, name: "Yoda") + static var mockVader = ChatUser.mock(id: .unique, name: "Vader") + + static var mockThreads: [ChatThread] { + [ + .mock( + parentMessage: .mock(text: "Parent Message", author: mockYoda), + channel: .mock(cid: .unique, name: "Star Wars Channel"), + createdBy: mockVader, + replyCount: 3, + participantCount: 2, + threadParticipants: [ + .mock(user: mockYoda), + .mock(user: mockVader) + ], + lastMessageAt: .unique, + createdAt: .unique, + updatedAt: .unique, + title: nil, + latestReplies: [ + .mock(text: "First Message", author: mockYoda), + .mock(text: "Second Message", author: mockVader), + .mock(text: "Third Message", author: mockYoda) + ], + reads: [ + .mock(user: mockYoda, unreadMessagesCount: 6) + ], + extraData: [:] + ), + .mock( + parentMessage: .mock(text: "Parent Message 2", author: mockYoda), + channel: .mock(cid: .unique, name: "Marvel Channel"), + createdBy: mockVader, + replyCount: 3, + participantCount: 2, + threadParticipants: [ + .mock(user: mockYoda), + .mock(user: mockVader) + ], + lastMessageAt: .unique, + createdAt: .unique, + updatedAt: .unique, + title: nil, + latestReplies: [ + .mock(text: "First Message", author: mockVader) + ], + reads: [], + extraData: [:] + ) + ] + } +} diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_default.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_default.1.png new file mode 100644 index 00000000..d8a5d5ef Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_default.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenAttachmentIsPoll.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenAttachmentIsPoll.1.png new file mode 100644 index 00000000..93c6bc91 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenAttachmentIsPoll.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenAttachments.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenAttachments.1.png new file mode 100644 index 00000000..94236eaf Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_whenAttachments.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withLastReplyDeleted.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withLastReplyDeleted.1.png new file mode 100644 index 00000000..2cd882e5 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withLastReplyDeleted.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withParentMessageDeleted.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withParentMessageDeleted.1.png new file mode 100644 index 00000000..7c659c78 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withParentMessageDeleted.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withTitle.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withTitle.1.png new file mode 100644 index 00000000..d832d52b Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withTitle.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withUnreads.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withUnreads.1.png new file mode 100644 index 00000000..c96e6655 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListItemView_Tests/test_threadListItem_withUnreads.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_empty.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_empty.1.png new file mode 100644 index 00000000..9869216a Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_empty.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_errorLoadingMoreThreads.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_errorLoadingMoreThreads.1.png new file mode 100644 index 00000000..ddd0b703 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_errorLoadingMoreThreads.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_errorLoadingThreads.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_errorLoadingThreads.1.png new file mode 100644 index 00000000..f6702259 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_errorLoadingThreads.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_loading.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_loading.1.png new file mode 100644 index 00000000..5ef5b595 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_loading.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_loadingMoreThreads.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_loadingMoreThreads.1.png new file mode 100644 index 00000000..079eda23 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_loadingMoreThreads.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_reloadingThreads.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_reloadingThreads.1.png new file mode 100644 index 00000000..9deacb47 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_reloadingThreads.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_whenNewThreadsAvailable.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_whenNewThreadsAvailable.1.png new file mode 100644 index 00000000..8d70ca8b Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_whenNewThreadsAvailable.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_withThreads.1.png b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_withThreads.1.png new file mode 100644 index 00000000..508b3723 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatThreadList/__Snapshots__/ChatThreadListView_Tests/test_chatThreadListView_withThreads.1.png differ diff --git a/yeetd-normal.pkg b/yeetd-normal.pkg new file mode 100644 index 00000000..e7fe6d35 Binary files /dev/null and b/yeetd-normal.pkg differ diff --git a/yeetd-normal.pkg.1 b/yeetd-normal.pkg.1 new file mode 100644 index 00000000..e7fe6d35 Binary files /dev/null and b/yeetd-normal.pkg.1 differ