Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Thread List UI Component #621

Merged
merged 48 commits into from
Oct 17, 2024
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
f71c2d0
Add threads tab to demo app
nuno-vieira Oct 4, 2024
4cd882a
Add `NoThreadsView` implementation
nuno-vieira Oct 4, 2024
6e9ca92
Add `ThreadList` design implementation + Add `MessagePreviewFormatter`
nuno-vieira Oct 7, 2024
2e59963
Fix Channel List not preselecting a channel for iPads
nuno-vieira Oct 8, 2024
6714a1e
Add background selection to Channel List items on iPad
nuno-vieira Oct 8, 2024
96af171
Revert "Fix Channel List not preselecting a channel for iPads"
nuno-vieira Oct 9, 2024
e10cd60
Revert "Add background selection to Channel List items on iPad"
nuno-vieira Oct 9, 2024
6e40837
Add `ChatThreadList` + `ChatThreadListNavigatableItem`
nuno-vieira Oct 9, 2024
d9c0676
Add `ChatThreadListLoadingView` implementation and handle empty and l…
nuno-vieira Oct 9, 2024
adc9c61
Add `ChatThreadListHeaderViewModifier` implementation
nuno-vieira Oct 9, 2024
d45d28c
Fix Channel List shimmering effect and improve shimmering animation
nuno-vieira Oct 10, 2024
d745725
Add the possibility to customise the background of Thread List
nuno-vieira Oct 10, 2024
6f7b535
Add `ChatThreadListErrorBannerView`
nuno-vieira Oct 10, 2024
79f2afe
Add `ChatThreadListFooterView` + Loading More Theads
nuno-vieira Oct 10, 2024
6364a23
Add mark thread read logic to `ChatChannelViewModel`
nuno-vieira Oct 11, 2024
44b9b7d
Add markThreadAsUnreadAction when message is the root of a thread and…
nuno-vieira Oct 11, 2024
aa57d96
Fix double mark unread action
nuno-vieira Oct 11, 2024
74da45e
Add `ChatThreadListHeaderView` to display new available threads
nuno-vieira Oct 11, 2024
e1be9e4
Add thread selection logic to iPad
nuno-vieira Oct 14, 2024
5ab707b
Add a modifier that wraps the thread list so that the list can be cus…
nuno-vieira Oct 14, 2024
adacc5d
Update CHANGELOG.md
nuno-vieira Oct 14, 2024
8275785
Add missing comments to Thread List View Model
nuno-vieira Oct 14, 2024
b856aa9
Add more doc comments to public views
nuno-vieira Oct 14, 2024
5f74c95
Add background color when a thread is selected on iPad
nuno-vieira Oct 14, 2024
7032e72
Add background color when a channel is selected on iPad
nuno-vieira Oct 14, 2024
e3f1fa8
Fix Channel List not preselecting channel in iPad
nuno-vieira Oct 14, 2024
accd20f
Update CHANGELOG.md
nuno-vieira Oct 14, 2024
47a8578
Add Thread List Item test coverage
nuno-vieira Oct 14, 2024
b7dea0a
Add test coverage to ChatThreadListView
nuno-vieira Oct 15, 2024
163349b
Add Thread List View Model test coverage
nuno-vieira Oct 15, 2024
fec1995
Fix snapshot tests
nuno-vieira Oct 15, 2024
bebb31e
Remove ChatThreadListScreen since it is not needed
nuno-vieira Oct 15, 2024
1fb573b
Fix changelog typoe
nuno-vieira Oct 15, 2024
5fa98d2
Do not pass colors to the view factory
nuno-vieira Oct 15, 2024
937da68
Use message preview formatter from utils
nuno-vieira Oct 15, 2024
e954494
Forgotten public inits
nuno-vieira Oct 15, 2024
13343a0
Remove unused properties in Thread List View
nuno-vieira Oct 15, 2024
4f0eaef
Remove unused colors and utils from ChatThreadListViewModel
nuno-vieira Oct 15, 2024
cbfbc3e
[CI] Snapshots
Oct 15, 2024
149d123
Merge pull request #622 from GetStream/add/threads-v2-snapshots
nuno-vieira Oct 15, 2024
f20898a
Missing public inits
nuno-vieira Oct 15, 2024
dc60bc8
Fix glitch in loading view
nuno-vieira Oct 16, 2024
96aef0f
Add missing comments to some public views
nuno-vieira Oct 16, 2024
48b4e6c
Fix layout shift when a thread has a new unread message
nuno-vieira Oct 16, 2024
f378189
Add `ChatThreadListItemViewModel` to make it easier for customers to …
nuno-vieira Oct 16, 2024
5563c83
Fix thread list item view test not compiling
nuno-vieira Oct 16, 2024
7a91804
[CI] Snapshots
Oct 16, 2024
754ce6c
Merge pull request #623 from GetStream/add/threads-v2-snapshots
nuno-vieira Oct 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
}
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)
}
nuno-vieira marked this conversation as resolved.
Show resolved Hide resolved
} 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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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: " ")
}
}
Loading
Loading