Skip to content

Commit

Permalink
Adds Thread List UI Component (#621)
Browse files Browse the repository at this point in the history
* Add threads tab to demo app

* Add `NoThreadsView` implementation

* Add `ThreadList` design implementation + Add `MessagePreviewFormatter`

* Fix Channel List not preselecting a channel for iPads

* Add background selection to Channel List items on iPad

* Revert "Fix Channel List not preselecting a channel for iPads"

This reverts commit 99d2699.

* Revert "Add background selection to Channel List items on iPad"

This reverts commit 75539ac.

* Add `ChatThreadList` + `ChatThreadListNavigatableItem`

* Add `ChatThreadListLoadingView` implementation and handle empty and loading states

* Add `ChatThreadListHeaderViewModifier` implementation

* Fix Channel List shimmering effect and improve shimmering animation

* Add the possibility to customise the background of Thread List

* Add `ChatThreadListErrorBannerView`

* Add `ChatThreadListFooterView` + Loading More Theads

* Add mark thread read logic to `ChatChannelViewModel`

* Add markThreadAsUnreadAction when message is the root of a thread and inside a thread view

* Fix double mark unread action

* Add `ChatThreadListHeaderView` to display new available threads

* Add thread selection logic to iPad

* Add a modifier that wraps the thread list so that the list can be customized based on state changes

* Update CHANGELOG.md

* Add missing comments to Thread List View Model

* Add more doc comments to public views

* Add background color when a thread is selected on iPad

* Add background color when a channel is selected on iPad

* Fix Channel List not preselecting channel in iPad

* Update CHANGELOG.md

* Add Thread List Item test coverage

* Add test coverage to ChatThreadListView

* Add Thread List View Model test coverage

* Fix snapshot tests

* Remove ChatThreadListScreen since it is not needed

* Fix changelog typoe

* Do not pass colors to the view factory

* Use message preview formatter from utils

* Forgotten public inits

* Remove unused properties in Thread List View

* Remove unused colors and utils from ChatThreadListViewModel

* [CI] Snapshots

* Missing public inits

* Fix glitch in loading view

* Add missing comments to some public views

* Fix layout shift when a thread has a new unread message

* Add `ChatThreadListItemViewModel` to make it easier for customers to reuse formatting logic

* Fix thread list item view test not compiling

* [CI] Snapshots

---------

Co-authored-by: Stream Bot <[email protected]>
Co-authored-by: Stream Bot <[email protected]>
  • Loading branch information
3 people authored Oct 17, 2024
1 parent 9a9e8a1 commit 6c5cfeb
Show file tree
Hide file tree
Showing 60 changed files with 2,352 additions and 218 deletions.
91 changes: 91 additions & 0 deletions Brewfile.lock.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
64 changes: 49 additions & 15 deletions DemoAppSwiftUI/DemoAppSwiftUIApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct DemoAppSwiftUIApp: App {

@ObservedObject var appState = AppState.shared
@ObservedObject var notificationsHandler = NotificationsHandler.shared

var channelListController: ChatChannelListController? {
appState.channelListController
}
Expand All @@ -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
Expand All @@ -57,13 +53,33 @@ struct DemoAppSwiftUIApp: App {
appState.channelListController = chatClient.channelListController(query: channelListQuery)
}
*/
appState.currentUserController = chatClient.currentUserController()
notificationsHandler.setupRemoteNotifications()
}
}
}

func channelListView() -> ChatChannelListView<DemoAppFactory> {
if notificationsHandler.notificationChannelId != nil {
ChatChannelListView(
viewFactory: DemoAppFactory.shared,
channelListController: channelListController,
selectedChannelId: notificationsHandler.notificationChannelId
)
} else {
ChatChannelListView(
viewFactory: DemoAppFactory.shared,
channelListController: channelListController
)
}
}

func threadListView() -> ChatThreadListView<DemoAppFactory> {
ChatThreadListView(viewFactory: DemoAppFactory.shared)
}
}

class AppState: ObservableObject {
class AppState: ObservableObject, CurrentChatUserControllerDelegate {

@Published var userState: UserState = .launchAnimation {
willSet {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
25 changes: 24 additions & 1 deletion Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
if canMarkRead {
sendReadEventIfNeeded(for: first)
}
if shouldMarkThreadRead {
sendThreadReadEvent()
}
}

public func scrollToLastMessage() {
Expand Down Expand Up @@ -346,6 +349,9 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
sendReadEventIfNeeded(for: message)
}
}
if index == 0 && shouldMarkThreadRead {
sendThreadReadEvent()
}
}

open func groupMessages() {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public struct ChannelList<Factory: ViewFactory>: View {

/// LazyVStack displaying list of channels.
public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
@Injected(\.colors) private var colors

private var factory: Factory
var channels: LazyCachedMapCollection<ChatChannel>
Expand Down Expand Up @@ -170,6 +171,10 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: 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
Expand Down
Loading

0 comments on commit 6c5cfeb

Please sign in to comment.