diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListEmptyView.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListEmptyView.png new file mode 100644 index 0000000000..07423a1909 Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListEmptyView.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListEmptyView_Custom.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListEmptyView_Custom.png new file mode 100644 index 0000000000..0e40ef2603 Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListEmptyView_Custom.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListErrorView.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListErrorView.png new file mode 100644 index 0000000000..baf308b6c8 Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListErrorView.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListErrorView_Custom.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListErrorView_Custom.png new file mode 100644 index 0000000000..66b4b8854c Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListErrorView_Custom.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListItem.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListItem.png new file mode 100644 index 0000000000..c0196ee526 Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListItem.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListItem_Custom.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListItem_Custom.png new file mode 100644 index 0000000000..c44baed731 Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListItem_Custom.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListLoadingView.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListLoadingView.png new file mode 100644 index 0000000000..9b44d8755f Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListLoadingView.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListLoadingView_Custom.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListLoadingView_Custom.png new file mode 100644 index 0000000000..778534e07b Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ChatThreadListLoadingView_Custom.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ThreadListHeaderBannerView.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ThreadListHeaderBannerView.png new file mode 100644 index 0000000000..e545cf0566 Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ThreadListHeaderBannerView.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ThreadListHeaderBannerView_CustomAdvanced.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ThreadListHeaderBannerView_CustomAdvanced.png new file mode 100644 index 0000000000..8009dd47ea Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ThreadListHeaderBannerView_CustomAdvanced.png differ diff --git a/docusaurus/docs/iOS/assets/thread-list-swiftui/ThreadListHeaderBannerView_CustomBasic.png b/docusaurus/docs/iOS/assets/thread-list-swiftui/ThreadListHeaderBannerView_CustomBasic.png new file mode 100644 index 0000000000..32cc88c7cc Binary files /dev/null and b/docusaurus/docs/iOS/assets/thread-list-swiftui/ThreadListHeaderBannerView_CustomBasic.png differ diff --git a/docusaurus/docs/iOS/swiftui/thread-list.md b/docusaurus/docs/iOS/swiftui/thread-list.md new file mode 100644 index 0000000000..13d754cdc9 --- /dev/null +++ b/docusaurus/docs/iOS/swiftui/thread-list.md @@ -0,0 +1,499 @@ +--- +title: Thread List +--- + +The `ChatThreadListView` is the UI component that displays the list of threads that the current user is participating in. + +:::note +The Thread List component is available on the SwiftUI SDK since version [4.65.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.65.0). +::: + +## Basic Usage + +You can show this component in your app by creating a `ChatThreadListView` instance, here is a simple example of an app with two tabs, one for the channel list and another for the thread list: + +```swift +struct ChatApp: App { + var body: some Scene { + WindowGroup { + TabView { + ChatChannelListView() + .tabItem { Label("Chat", systemImage: "message") } + ChatThreadListView() + .tabItem { Label("Threads", systemImage: "text.bubble") } + } + } + } +} +``` + +The `ChatThreadListView` has the following parameters: +- `viewFactory`: The view factory used for creating views used by the thread list. +- `viewModel`: The view model used for managing the thread list presentation logic. A default view model is provided if not specified. +- `threadListController`: The thread list controller managing the list of threads. A custom `ChatThreadListQuery` can be provided here to customize the list of threads. +- `title`: A custom title used as the navigation bar title. +- `embedInNavigationView`: A boolean indicating whether to embed the view in a `NavigationView` or not. The default is `true`. + +## Thread List Query + +The `ThreadListQuery` is responsible to configure the list of threads that will be displayed in the `ChatThreadListController`. These are the available parameters: + +- `watch`: A boolean indicating whether to watch for changes in the thread list or not. +- `limit`: The amount of threads fetched per page. The default is 20. +- `replyLimit`: The amount of replies fetched per thread. The default is 3. +- `participantLimit`: The amount of participants fetched per thread. The default is 10. +- `next`: The pagination token from the previous response to fetch the next page. + +All the parameters are customizable and you can change them according to your needs. The default values are a good compromise between performance and user experience. + +If you are using the `ChatThreadListView` component, you don't need to worry about the `next` parameter, since pagination is handled for you. If not, you can use the `next` parameter from the previous response to fetch the next page of threads. + +## UI Customization + +You can customize the Thread List component by providing a custom `ViewFactory`. The view factory is responsible for creating the views used by the thread list. Below there is all the views and modifiers related to the thread list that can be customized: +- `makeThreadDestination()` - Creates the destination view for the thread detail view. By default it shows the thread view. +- `makeThreadListItem(thread:threadDestination:selectedThread:)` - Creates the view for each thread in the list. +- `makeNoThreadsView()` - Creates the view displayed when the thread list is empty. +- `makeThreadsListErrorBannerView(onRefreshAction:)` - Creates the view displayed when an error occurs while loading the thread list. +- `makeThreadListLoadingView()` - Creates the view displayed while loading the thread list. +- `makeThreadListContainerViewModifier(viewModel:)` - Creates the view modifier applied to the thread list container view. It can be used to provide additional state or behavior to the thread list. +- `makeThreadListHeaderViewModifier(title:)` - Responsible for the navigation header of the thread list. It can be used to customize the navigation bar title, buttons, and other properties. +- `makeThreadListHeaderView(viewModel:)` - Creates the header view for the thread list. By default it shows a loading spinner if it is refetching the threads or shows a banner notifying that there are new threads to be fetched. +- `makeThreadListFooterView(viewModel:)` - Creates the footer view for the thread list. By default shows a loading spinner when loading more threads. +- `makeThreadListBackground()` - Creates the background view for the thread list. +- `makeThreadListItemBackground(thread:isSelected:)` - Creates the background view for each thread in the list. +- `makeThreadListDividerItem()` - Creates the divider view between threads in the list. + +### Thread List Navigation Header + +The navigation header of the thread list can be configured through the `makeThreadListHeaderViewModifier()` view factory method. Here is sample example on how to change the header to a large title navigation style: + +```swift +class AppFactory: ViewFactory { + + public static let shared = AppFactory() + + func makeThreadListHeaderViewModifier(title: String) -> some ViewModifier { + ThreadListLargeTitleViewModifier( + title: title + ) + } +} + +struct ThreadListLargeTitleViewModifier: ViewModifier { + let title: String + + func body(content: Content) -> some View { + content + .navigationBarTitleDisplayMode(.large) + .navigationTitle(title) + } +} +``` + +As you can see, the customization is quite similar to the Channel List header modifier. If you need a more advanced customization, you can take a look at the [`ChannelListHeaderViewModifier`](../swiftui/channel-list-components/channel-list-header.md) customization. + +### Thread List Header View + +By default, the Thread List header view shows a loading spinner when re-fetching the threads or a banner when there are new threads to be fetched. You can customize this view by providing a custom implementation of `makeThreadListHeaderView()`. + +As an example customization, lets keep the same loading spinner but change the header when there are new threads to be fetched: + +```swift +struct CustomChatThreadListHeaderView: View { + @Injected(\.colors) private var colors + @Injected(\.images) private var images + + @ObservedObject private var viewModel: ChatThreadListViewModel + + init( + viewModel: ChatThreadListViewModel + ) { + self.viewModel = viewModel + } + + var body: some View { + Group { + if viewModel.isReloading { + loadingView + } else if viewModel.hasNewThreads { + newThreadsBannerView + } else { + EmptyView() + } + } + } + + var loadingView: some View { + VStack { + Spacer() + ProgressView() + Spacer() + } + .frame(height: 40) + } + + var newThreadsBannerView: some View { + HStack(alignment: .center) { + Spacer() + Text("\(viewModel.newThreadsCount) new threads") + .foregroundColor(Color(colors.staticColorText)) + Spacer() + } + .padding(.all, 12) + .background(colors.tintColor) + .onTapGesture { + viewModel.loadThreads() + } + } +} +``` + +Then, don't forget to provide the custom view in the view factory: +```swift +func makeThreadListHeaderView(viewModel: ChatThreadListViewModel) -> some View { + CustomChatThreadListHeaderView(viewModel: viewModel) +} +``` + +**Result:** + +| Before| After | +| ------------- | ------------- | +| ![Before](../assets/thread-list-swiftui/ThreadListHeaderBannerView.png) | ![After](../assets/thread-list-swiftui/ThreadListHeaderBannerView_CustomBasic.png) | + +#### Advanced Customization + +Let's imagine that you do not want the new threads banner to appear in the thread list header view, but instead you want to show a floating banner on the top of the thread list. You can do this by first, changing the `makeThreadListHeaderView()` method to return only the loading view when it is reloading, and then provide a custom `makeThreadListContainerViewModifier()` to add the floating banner on top of the thread list. + +First, we change the thread list header view to only show the loading spinner: +```swift +struct CustomChatThreadListHeaderView: View { + @ObservedObject private var viewModel: ChatThreadListViewModel + + init( + viewModel: ChatThreadListViewModel + ) { + self.viewModel = viewModel + } + + var body: some View { + Group { + if viewModel.isReloading { + loadingView + } else { + EmptyView() + } + } + } + + var loadingView: some View { + VStack { + Spacer() + ProgressView() + Spacer() + } + .frame(height: 40) + } +} +``` + +Then, we provide a custom view modifier to add the floating banner on top of the thread list: +```swift +struct CustomChatThreadListContainerModifier: ViewModifier { + @Injected(\.colors) private var colors + @Injected(\.images) private var images + + @ObservedObject private var viewModel: ChatThreadListViewModel + + init( + viewModel: ChatThreadListViewModel + ) { + self.viewModel = viewModel + } + + func body(content: Content) -> some View { + if viewModel.hasNewThreads && !viewModel.isReloading { + ZStack(alignment: .top) { + content + newThreadsBannerView + } + } else { + content + } + } + + var newThreadsBannerView: some View { + VStack(alignment: .center) { + HStack(alignment: .center) { + Text("\(viewModel.newThreadsCount) new threads") + .font(.footnote.bold()) + .foregroundColor(Color(colors.staticColorText)) + Image(uiImage: images.restart) + .customizable() + .frame(width: 20, height: 20) + .foregroundColor(Color(colors.staticColorText)) + } + .frame(width: 140, height: 25) + .padding(.all, 8) + .background(colors.tintColor) + .clipShape(.capsule) + .shadow(radius: 4) + .onTapGesture { + viewModel.loadThreads() + } + } + .padding(.top, 20) + } +} +``` + +Make sure you set the custom view modifier in the view factory: +```swift +func makeThreadListContainerViewModifier(viewModel: ChatThreadListViewModel) -> some ViewModifier { + CustomChatThreadListContainerModifier(viewModel: viewModel) +} +``` + +**Result:** + +| Before| After | +| ------------- | ------------- | +| ![Before](../assets/thread-list-swiftui/ThreadListHeaderBannerView.png) | ![After](../assets/thread-list-swiftui/ThreadListHeaderBannerView_CustomAdvanced.png) | + +### Thread List States + +The Thread List component comes with three states: **loading**, **empty**, and **error**. The state logic is handled automatically by this component. You can override the appearance of these states by providing a custom `ViewFactory` and customizing the views related to these states: +- `makeNoThreadsView()` - The empty state view. +- `makeThreadsListErrorBannerView()` - The error banner state view. +- `makeThreadListLoadingView()` - The loading state view. + +| Empty | Error | Loading | +| ------------- | ------------- | ------------- | +| ![Empty View](../assets/thread-list-swiftui/ChatThreadListEmptyView.png) | ![Error View](../assets/thread-list-swiftui/ChatThreadListErrorView.png) | ![Loading View](../assets/thread-list-swiftui/ChatThreadListLoadingView.png) | + +The loading view uses a redacted effect to simulate the loading state based on our thread list item layout. You can customize the loading view by providing your own redacted effect or just a simple progress view like the example below: + +```swift +struct CustomThreadListLoadingView: View { + var body: some View { + VStack { + Spacer() + ProgressView() + Spacer() + }.frame(maxWidth: .infinity) + } +} + +// view factory replacement +func makeThreadListLoadingView() -> some View { + CustomThreadListLoadingView() +} +``` + +Below is an example of a custom empty view: +```swift +struct CustomNoThreadsView: View { + var body: some View { + VStack { + Spacer() + Image(systemName: "tray") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + .foregroundColor(.gray) + Text("No threads found.") + .font(.title) + .foregroundColor(.gray) + Spacer() + } + .frame(maxWidth: .infinity) + } +} + +// view factory replacement +func makeNoThreadsView() -> some View { + CustomNoThreadsView() +} +``` + +For the loading view, let's change the default banner to be a floating red banner: +```swift +struct CustomThreadListErrorBannerView: View { + var onRefreshAction: () -> Void + + var body: some View { + HStack { + Spacer() + Button(action: onRefreshAction) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Retry") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + .shadow(radius: 4) + } + Spacer() + } + .padding(.bottom, 16) + } +} + +// view factory replacement +func makeThreadsListErrorBannerView(onRefreshAction: @escaping () -> Void) -> some View { + CustomThreadListErrorBannerView(onRefreshAction: onRefreshAction) +} +``` + +With the customizations above, the thread list states will look like this: + +| Empty | Error | Loading | +| ------------- | ------------- | ------------- | +| ![Empty View](../assets/thread-list-swiftui/ChatThreadListEmptyView_Custom.png) | ![Error View](../assets/thread-list-swiftui/ChatThreadListErrorView_Custom.png) | ![Loading View](../assets/thread-list-swiftui/ChatThreadListLoadingView_Custom.png) | + +#### Advanced Customization + +You can have more control on how you display the different states of the thread list by making use of the thread list container view modifier. For example, instead of showing a floating error button, you can show a full screen error view. Here is an example on how it is possible to completely take control of the thread list states: + +```swift +struct CustomChatThreadListContainerModifier: ViewModifier { + @ObservedObject private var viewModel: ChatThreadListViewModel + + init(viewModel: ChatThreadListViewModel) { + self.viewModel = viewModel + } + + func body(content: Content) -> some View { + if viewModel.isLoading { + CustomThreadListLoadingView() + } else if viewModel.failedToLoadThreads { + CustomErrorView() + } else if viewModel.isEmpty { + CustomNoThreadsView() + } else { + content + } + } +} +``` + +Then, you would need to change the view factory to return empty views for the existing state views, and add the custom container modifier: + +```swift +func makeNoThreadsView() -> some View { + EmptyView() +} + +func makeThreadListLoadingView() -> some View { + EmptyView() +} + +func makeThreadsListErrorBannerView(onRefreshAction: @escaping () -> Void) -> some View { + EmptyView() +} + +func makeThreadListContainerViewModifier(viewModel: ChatThreadListViewModel) -> some ViewModifier { + CustomChatThreadListContainerModifier(viewModel: viewModel) +} +``` + +### Thread List Item + +The thread list item view is responsible for displaying each thread in the list. You can customize the thread list item view by providing a custom implementation of `makeThreadListItem(thread:threadDestination:selectedThread:)`. + +By default, the navigation is automatically handled for you, as long as you wrap your custom channel list item inside our `ChatThreadListNavigatableItem` container view. Here is how you provide your custom thread list item in the view factory: + +```swift +func makeThreadListItem( + thread: ChatThread, + threadDestination: @escaping (ChatThread) -> ChatChannelView, + selectedThread: Binding +) -> some View{ + ChatThreadListNavigatableItem( + thread: thread, + threadListItem: CustomChatThreadListItem(thread: thread), + threadDestination: threadDestination, + selectedThread: selectedThread, + handleTabBarVisibility: true + ) +} +``` + +If you want to handle the navigation yourself, you can provider a custom wrapper view that handles the navigation. + +Below, you can see an example of a custom thread list item view that makes the layout more compact and similar to Slack: + +```swift +struct CustomChatThreadListItem: View { + let thread: ChatThread + let viewModel: ChatThreadListItemViewModel + + init(thread: ChatThread) { + self.thread = thread + self.viewModel = ChatThreadListItemViewModel(thread: thread) + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + HStack(spacing: 2) { + Text("#") + .font(.title3.bold()) + Text(threadTitle) + .lineLimit(1) + .font(.headline) + .foregroundColor(.primary) + } + Spacer() + if viewModel.unreadRepliesCount > 0 { + UnreadIndicatorView(unreadCount: viewModel.unreadRepliesCount) + } + } + HStack(alignment: .center, spacing: 4) { + MessageAvatarView( + avatarURL: viewModel.latestReplyAuthorImageURL, + size: .init(width: 20, height: 20) + ) + Text(viewModel.latestReplyMessageText) + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + Text(viewModel.latestReplyTimestampText) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding(.all, 8) + } + + var threadTitle: String { + let title = thread.title ?? thread.parentMessage.text + if title.isEmpty { + if let lastAttachment = thread.parentMessage.allAttachments.last?.type.rawValue.capitalized { + return lastAttachment + } + return thread.parentMessage.poll?.name ?? title + } + return title + } +} +``` + +As you can see above, you can use our `ChatThreadListItemViewModel` to reuse some of the default presentation logic. You can also access the `ChatThread` directly or even create your own view model. Just like every other custom view, you can use our reusable components like the `MessageAvatarView` and `UnreadIndicatorView` to help you build your custom layout. + +**Result:** + +| Before | After | +| ------------- | ------------- | +| ![Default](../assets/thread-list-swiftui/ChatThreadListItem.png) | ![Custom](../assets/thread-list-swiftui/ChatThreadListItem_Custom.png) | + +## Thread Events + +By default the UI components handle automatically the thread events, like new replies and thread updates. But, in case your app needs additional logic, these are the available thread events: + +- `ThreadMessageNewEvent`: Triggered when a new reply is added to a thread. +- `ThreadUpdatedEvent`: Triggered when a thread is updated, like the thread title or custom data. \ No newline at end of file diff --git a/docusaurus/shared b/docusaurus/shared new file mode 120000 index 0000000000..cfc005269b --- /dev/null +++ b/docusaurus/shared @@ -0,0 +1 @@ +/Users/nunovieira/.nvm/versions/node/v18.12.1/bin/../lib/node_modules/stream-chat-docusaurus-cli/shared \ No newline at end of file diff --git a/docusaurus/sidebars-ios.json b/docusaurus/sidebars-ios.json index 5c7fbbf70e..145ede0d20 100644 --- a/docusaurus/sidebars-ios.json +++ b/docusaurus/sidebars-ios.json @@ -43,11 +43,11 @@ ] }, "uikit/components/thread-list", - "uikit/navigation", - "uikit/localization", - "uikit/data-formatting", "uikit/components/voice-recording", - "uikit/polls" + "uikit/polls", + "uikit/data-formatting", + "uikit/navigation", + "uikit/localization" ] }, { @@ -87,10 +87,11 @@ "swiftui/chat-channel-components/composer-commands" ] }, - "swiftui/localization", - "swiftui/dependency-injection", + "swiftui/thread-list", "swiftui/voice-recording", - "swiftui/polls" + "swiftui/polls", + "swiftui/dependency-injection", + "swiftui/localization" ] }, {