Skip to content

Commit 6c5cfeb

Browse files
nuno-vieiraStream BotStream Bot
authored
Adds Thread List UI Component (#621)
* 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]>
1 parent 9a9e8a1 commit 6c5cfeb

File tree

60 files changed

+2352
-218
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+2352
-218
lines changed

Brewfile.lock.json

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
{
2+
"entries": {
3+
"brew": {
4+
"mint": {
5+
"version": "0.17.5",
6+
"bottle": {
7+
"rebuild": 0,
8+
"root_url": "https://ghcr.io/v2/homebrew/core",
9+
"files": {
10+
"arm64_sequoia": {
11+
"cellar": ":any_skip_relocation",
12+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:a754e28b7b9e4e13c31af783857de64d2550b8866c2c9eb3ac9216154ab0f25a",
13+
"sha256": "a754e28b7b9e4e13c31af783857de64d2550b8866c2c9eb3ac9216154ab0f25a"
14+
},
15+
"arm64_sonoma": {
16+
"cellar": ":any_skip_relocation",
17+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:ada351985ef562807e7460f869c527bb314600311738a944219225226f43addf",
18+
"sha256": "ada351985ef562807e7460f869c527bb314600311738a944219225226f43addf"
19+
},
20+
"arm64_ventura": {
21+
"cellar": ":any_skip_relocation",
22+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:250948fe6fc14179d7c381d084a90d6796861ba9a8456617cadda9ac62cbc2b8",
23+
"sha256": "250948fe6fc14179d7c381d084a90d6796861ba9a8456617cadda9ac62cbc2b8"
24+
},
25+
"arm64_monterey": {
26+
"cellar": ":any_skip_relocation",
27+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:6546b80b980a45036415162189dd340b1f8d3f4e82a80d40a24e7b5dd672eb04",
28+
"sha256": "6546b80b980a45036415162189dd340b1f8d3f4e82a80d40a24e7b5dd672eb04"
29+
},
30+
"arm64_big_sur": {
31+
"cellar": ":any_skip_relocation",
32+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:39f9d254b248a44bb44e399081b7e50a6c598834e2bf86bb7de3ebc349f11e0d",
33+
"sha256": "39f9d254b248a44bb44e399081b7e50a6c598834e2bf86bb7de3ebc349f11e0d"
34+
},
35+
"sonoma": {
36+
"cellar": ":any_skip_relocation",
37+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:154b8b94602d6d38249cfa936f7d071d9113935b3756d5781021fe04c3971e29",
38+
"sha256": "154b8b94602d6d38249cfa936f7d071d9113935b3756d5781021fe04c3971e29"
39+
},
40+
"ventura": {
41+
"cellar": ":any_skip_relocation",
42+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:068f9984e81b578f2ed6cef4cc9659835a689bdecf121651ea24ebcfefd49339",
43+
"sha256": "068f9984e81b578f2ed6cef4cc9659835a689bdecf121651ea24ebcfefd49339"
44+
},
45+
"monterey": {
46+
"cellar": ":any_skip_relocation",
47+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:f8b09a640942548a151c7450c85f33d40162c7540049666131740d49c68e61e6",
48+
"sha256": "f8b09a640942548a151c7450c85f33d40162c7540049666131740d49c68e61e6"
49+
},
50+
"big_sur": {
51+
"cellar": ":any_skip_relocation",
52+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:528ea907912e8002cd3a769e8ddda4556cf2482122c3f848a7d923146df37101",
53+
"sha256": "528ea907912e8002cd3a769e8ddda4556cf2482122c3f848a7d923146df37101"
54+
},
55+
"x86_64_linux": {
56+
"cellar": "/home/linuxbrew/.linuxbrew/Cellar",
57+
"url": "https://ghcr.io/v2/homebrew/core/mint/blobs/sha256:7c8dd63f0310a46f67550f92ee48a370fadfc1a4d884b8a3904a36b7b610b3f2",
58+
"sha256": "7c8dd63f0310a46f67550f92ee48a370fadfc1a4d884b8a3904a36b7b610b3f2"
59+
}
60+
}
61+
}
62+
},
63+
"sonar-scanner": {
64+
"version": "6.2.1.4610",
65+
"bottle": {
66+
"rebuild": 1,
67+
"root_url": "https://ghcr.io/v2/homebrew/core",
68+
"files": {
69+
"all": {
70+
"cellar": ":any_skip_relocation",
71+
"url": "https://ghcr.io/v2/homebrew/core/sonar-scanner/blobs/sha256:5e24f759a690b4abb55737325a6c9e94d42b2235abd0e93021306d6016d967e6",
72+
"sha256": "5e24f759a690b4abb55737325a6c9e94d42b2235abd0e93021306d6016d967e6"
73+
}
74+
}
75+
}
76+
}
77+
}
78+
},
79+
"system": {
80+
"macos": {
81+
"sonoma": {
82+
"HOMEBREW_VERSION": "4.4.0",
83+
"HOMEBREW_PREFIX": "/opt/homebrew",
84+
"Homebrew/homebrew-core": "api",
85+
"CLT": "16.0.0.0.1.1724870825",
86+
"Xcode": "15.4",
87+
"macOS": "14.7"
88+
}
89+
}
90+
}
91+
}

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6+
### ✅ Added
7+
- New Thread List UI Component [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621)
8+
- Handles marking a thread read in `ChatChannelViewModel` [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621)
9+
- Adds `ViewFactory.makeChannelListItemBackground` [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621)
10+
### 🐞 Fixed
11+
- Fix Channel List loading view shimmering effect not working [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621)
12+
- Fix Channel List not preselecting the Channel on iPad [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621)
613
### 🔄 Changed
14+
- Channel List Item has now a background color when it is selected on iPad [#621](https://github.com/GetStream/stream-chat-swiftui/pull/621)
715

816
# [4.64.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.64.0)
917
_October 03, 2024_

DemoAppSwiftUI/DemoAppSwiftUIApp.swift

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ struct DemoAppSwiftUIApp: App {
1414

1515
@ObservedObject var appState = AppState.shared
1616
@ObservedObject var notificationsHandler = NotificationsHandler.shared
17-
17+
1818
var channelListController: ChatChannelListController? {
1919
appState.channelListController
2020
}
@@ -27,18 +27,14 @@ struct DemoAppSwiftUIApp: App {
2727
case .notLoggedIn:
2828
LoginView()
2929
case .loggedIn:
30-
if notificationsHandler.notificationChannelId != nil {
31-
ChatChannelListView(
32-
viewFactory: DemoAppFactory.shared,
33-
channelListController: channelListController,
34-
selectedChannelId: notificationsHandler.notificationChannelId
35-
)
36-
} else {
37-
ChatChannelListView(
38-
viewFactory: DemoAppFactory.shared,
39-
channelListController: channelListController
40-
)
41-
}
30+
TabView {
31+
channelListView()
32+
.tabItem { Label("Chat", systemImage: "message") }
33+
.badge(appState.unreadCount.channels)
34+
threadListView()
35+
.tabItem { Label("Threads", systemImage: "text.bubble") }
36+
.badge(appState.unreadCount.threads)
37+
}
4238
}
4339
}
4440
.onChange(of: appState.userState) { newValue in
@@ -57,13 +53,33 @@ struct DemoAppSwiftUIApp: App {
5753
appState.channelListController = chatClient.channelListController(query: channelListQuery)
5854
}
5955
*/
56+
appState.currentUserController = chatClient.currentUserController()
6057
notificationsHandler.setupRemoteNotifications()
6158
}
6259
}
6360
}
61+
62+
func channelListView() -> ChatChannelListView<DemoAppFactory> {
63+
if notificationsHandler.notificationChannelId != nil {
64+
ChatChannelListView(
65+
viewFactory: DemoAppFactory.shared,
66+
channelListController: channelListController,
67+
selectedChannelId: notificationsHandler.notificationChannelId
68+
)
69+
} else {
70+
ChatChannelListView(
71+
viewFactory: DemoAppFactory.shared,
72+
channelListController: channelListController
73+
)
74+
}
75+
}
76+
77+
func threadListView() -> ChatThreadListView<DemoAppFactory> {
78+
ChatThreadListView(viewFactory: DemoAppFactory.shared)
79+
}
6480
}
6581

66-
class AppState: ObservableObject {
82+
class AppState: ObservableObject, CurrentChatUserControllerDelegate {
6783

6884
@Published var userState: UserState = .launchAnimation {
6985
willSet {
@@ -72,12 +88,30 @@ class AppState: ObservableObject {
7288
}
7389
}
7490
}
75-
91+
92+
@Published var unreadCount: UnreadCount = .noUnread
93+
7694
var channelListController: ChatChannelListController?
95+
var currentUserController: CurrentChatUserController? {
96+
didSet {
97+
currentUserController?.delegate = self
98+
currentUserController?.synchronize()
99+
}
100+
}
77101

78102
static let shared = AppState()
79103

80104
private init() {}
105+
106+
func currentUserController(_ controller: CurrentChatUserController, didChangeCurrentUserUnreadCount: UnreadCount) {
107+
self.unreadCount = didChangeCurrentUserUnreadCount
108+
let totalUnreadBadge = unreadCount.channels + unreadCount.threads
109+
if #available(iOS 16.0, *) {
110+
UNUserNotificationCenter.current().setBadgeCount(totalUnreadBadge)
111+
} else {
112+
UIApplication.shared.applicationIconBadgeNumber = totalUnreadBadge
113+
}
114+
}
81115
}
82116

83117
enum UserState {

Sources/StreamChatSwiftUI/ChatChannel/ChannelInfo/PinnedMessagesView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ struct PinnedMessageView: View {
8686
if message.poll != nil {
8787
return "📊 \(L10n.Channel.Item.poll)"
8888
}
89-
return channel.attachmentPreviewText(for: message) ?? message.adjustedText
89+
let messageFormatter = InjectedValues[\.utils].messagePreviewFormatter
90+
return messageFormatter.formatAttachmentContent(for: message) ?? message.adjustedText
9091
}
9192
}

Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,9 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
238238
if canMarkRead {
239239
sendReadEventIfNeeded(for: first)
240240
}
241+
if shouldMarkThreadRead {
242+
sendThreadReadEvent()
243+
}
241244
}
242245

243246
public func scrollToLastMessage() {
@@ -346,6 +349,9 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
346349
sendReadEventIfNeeded(for: message)
347350
}
348351
}
352+
if index == 0 && shouldMarkThreadRead {
353+
sendThreadReadEvent()
354+
}
349355
}
350356

351357
open func groupMessages() {
@@ -655,7 +661,24 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource {
655661
}
656662
}
657663
}
658-
664+
665+
private var shouldMarkThreadRead: Bool {
666+
guard UIApplication.shared.applicationState == .active else {
667+
return false
668+
}
669+
guard messageController?.replies.isEmpty == false else {
670+
return false
671+
}
672+
673+
return channelDataSource.hasLoadedAllNextMessages
674+
}
675+
676+
private func sendThreadReadEvent() {
677+
throttler.throttle { [weak self] in
678+
self?.messageController?.markThreadRead()
679+
}
680+
}
681+
659682
private func handleDateChange() {
660683
guard showScrollToLatestButton == true, let currentDate = currentDate else {
661684
currentDateString = nil

Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,36 @@ extension MessageAction {
110110
messageActions.append(copyAction)
111111
}
112112

113+
if message.isRootOfThread {
114+
let messageController = InjectedValues[\.utils]
115+
.channelControllerFactory
116+
.makeMessageController(for: message.id, channelId: channel.cid)
117+
// At the moment, this is the only way to know if we are inside a thread.
118+
// This should be optimised in the future and provide the view context.
119+
let isInsideThreadView = messageController.replies.count > 0
120+
if isInsideThreadView {
121+
let markThreadUnreadAction = markThreadAsUnreadAction(
122+
messageController: messageController,
123+
message: message,
124+
onFinish: onFinish,
125+
onError: onError
126+
)
127+
messageActions.append(markThreadUnreadAction)
128+
}
129+
} else if !message.isSentByCurrentUser {
130+
if !message.isPartOfThread || message.showReplyInChannel {
131+
let markUnreadAction = markAsUnreadAction(
132+
for: message,
133+
channel: channel,
134+
chatClient: chatClient,
135+
onFinish: onFinish,
136+
onError: onError
137+
)
138+
139+
messageActions.append(markUnreadAction)
140+
}
141+
}
142+
113143
if message.isSentByCurrentUser {
114144
if message.poll == nil {
115145
let editAction = editMessageAction(
@@ -130,18 +160,6 @@ extension MessageAction {
130160

131161
messageActions.append(deleteAction)
132162
} else {
133-
if !message.isPartOfThread || message.showReplyInChannel {
134-
let markUnreadAction = markAsUnreadAction(
135-
for: message,
136-
channel: channel,
137-
chatClient: chatClient,
138-
onFinish: onFinish,
139-
onError: onError
140-
)
141-
142-
messageActions.append(markUnreadAction)
143-
}
144-
145163
if channel.canFlagMessage {
146164
let flagAction = flagMessageAction(
147165
for: message,
@@ -512,6 +530,38 @@ extension MessageAction {
512530
return unreadAction
513531
}
514532

533+
private static func markThreadAsUnreadAction(
534+
messageController: ChatMessageController,
535+
message: ChatMessage,
536+
onFinish: @escaping (MessageActionInfo) -> Void,
537+
onError: @escaping (Error) -> Void
538+
) -> MessageAction {
539+
let action = {
540+
messageController.markThreadUnread() { error in
541+
if let error {
542+
onError(error)
543+
} else {
544+
onFinish(
545+
MessageActionInfo(
546+
message: message,
547+
identifier: MessageActionId.markUnread
548+
)
549+
)
550+
}
551+
}
552+
}
553+
let unreadAction = MessageAction(
554+
id: MessageActionId.markUnread,
555+
title: L10n.Message.Actions.markUnread,
556+
iconName: "message.badge",
557+
action: action,
558+
confirmationPopup: nil,
559+
isDestructive: false
560+
)
561+
562+
return unreadAction
563+
}
564+
515565
private static func muteAction(
516566
for message: ChatMessage,
517567
channel: ChatChannel,

Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ public struct ChannelList<Factory: ViewFactory>: View {
108108

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

112113
private var factory: Factory
113114
var channels: LazyCachedMapCollection<ChatChannel>
@@ -170,6 +171,10 @@ public struct ChannelsLazyVStack<Factory: ViewFactory>: View {
170171
trailingSwipeLeftButtonTapped: trailingSwipeLeftButtonTapped,
171172
leadingSwipeButtonTapped: leadingSwipeButtonTapped
172173
)
174+
.background(factory.makeChannelListItemBackground(
175+
channel: channel,
176+
isSelected: selectedChannel?.channel.id == channel.id
177+
))
173178
.onAppear {
174179
if let index = channels.firstIndex(where: { chatChannel in
175180
chatChannel.id == channel.id

0 commit comments

Comments
 (0)