diff --git a/CHANGELOG.md b/CHANGELOG.md index f768fd77..695fa1e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming -### 🔄 Changed +### ✅ Added +- Mark message as unread +- Jump to first unread message +- Factory method to swap jump to unread button # [4.44.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.44.0) _December 01, 2023_ diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift index c0841664..daecacfd 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelDataSource.swift @@ -41,6 +41,9 @@ protocol ChannelDataSource: AnyObject { /// Determines whether all new messages have been fetched. var hasLoadedAllNextMessages: Bool { get } + + /// Returns the first unread message id. + var firstUnreadMessageId: String? { get } /// Loads the previous messages. /// - Parameters: @@ -89,6 +92,10 @@ class ChatChannelDataSource: ChannelDataSource, ChatChannelControllerDelegate { var hasLoadedAllNextMessages: Bool { controller.hasLoadedAllNextMessages } + + var firstUnreadMessageId: String? { + controller.firstUnreadMessageId + } init(controller: ChatChannelController) { self.controller = controller @@ -160,6 +167,10 @@ class MessageThreadDataSource: ChannelDataSource, ChatMessageControllerDelegate var hasLoadedAllNextMessages: Bool { messageController.hasLoadedAllNextReplies } + + var firstUnreadMessageId: String? { + channelController.firstUnreadMessageId + } init( channelController: ChatChannelController, diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift index 0647ad9a..ec94aaef 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelView.swift @@ -57,6 +57,7 @@ public struct ChatChannelView: View, KeyboardReadable { shouldShowTypingIndicator: viewModel.shouldShowTypingIndicator, scrollPosition: $viewModel.scrollPosition, loadingNextMessages: viewModel.loadingNextMessages, + firstUnreadMessageId: $viewModel.firstUnreadMessageId, onMessageAppear: viewModel.handleMessageAppear(index:scrollDirection:), onScrollToBottom: viewModel.scrollToLastMessage, onLongPress: { displayInfo in diff --git a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift index 56d86304..bea8d3e9 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/ChatChannelViewModel.swift @@ -17,7 +17,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { private var cancellables = Set() private var lastRefreshThreshold = 200 private let refreshThreshold = 200 - private let newerMessagesLimit: Int = { + private static let newerMessagesLimit: Int = { if #available(iOS 17, *) { // On iOS 17 we can maintain the scroll position. return 25 @@ -35,6 +35,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { private var isActive = true private var readsString = "" + private var canMarkRead = true private let messageListDateOverlay: DateFormatter = DateFormatter.messageListDateOverlay @@ -47,6 +48,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { private var disableDateIndicator = false private var channelName = "" private var onlineIndicatorShown = false + private var lastReadMessageId: String? private let throttler = Throttler(interval: 3, broadcastLatestEvent: true) public var channelController: ChatChannelController @@ -108,6 +110,13 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { @Published public var shouldShowTypingIndicator = false @Published public var scrollPosition: String? @Published public private(set) var loadingNextMessages: Bool = false + @Published public var firstUnreadMessageId: String? { + didSet { + if oldValue != nil && firstUnreadMessageId == nil && (channel?.unreadCount.messages ?? 0) > 0 { + channelController.markRead() + } + } + } public var channel: ChatChannel? { channelController.channel @@ -179,6 +188,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { channelName = channel?.name ?? "" checkHeaderType() + checkUnreadCount() } @objc @@ -198,7 +208,9 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { @objc private func applicationWillEnterForeground() { guard let first = messages.first else { return } - maybeSendReadEvent(for: first) + if canMarkRead { + sendReadEventIfNeeded(for: first) + } } public func scrollToLastMessage() { @@ -212,6 +224,24 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } public func jumpToMessage(messageId: String) -> Bool { + if messageId == .unknownMessageId { + if firstUnreadMessageId == nil, let lastReadMessageId { + channelDataSource.loadPageAroundMessageId(lastReadMessageId) { [weak self] error in + if error != nil { + log.error("Error loading messages around message \(messageId)") + return + } + if let firstUnread = self?.channelDataSource.firstUnreadMessageId, + let message = self?.channelController.dataStore.message(id: firstUnread) { + self?.firstUnreadMessageId = message.messageId + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self?.scrolledId = message.messageId + } + } + } + } + return false + } if messageId == messages.first?.messageId { scrolledId = nil return true @@ -221,9 +251,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { return true } let alreadyLoaded = messages.map(\.id).contains(baseId) - if alreadyLoaded { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.scrolledId = nil + if alreadyLoaded && baseId != messageId { + if scrolledId == nil { + scrolledId = messageId + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.scrolledId = nil } return true } else { @@ -246,8 +279,12 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { log.error("Error loading messages around message \(messageId)") return } + var toJumpId = messageId + if toJumpId == baseId, let message = self?.channelController.dataStore.message(id: toJumpId) { + toJumpId = message.messageId + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self?.scrolledId = messageId + self?.scrolledId = toJumpId self?.loadingMessagesAround = false } } @@ -267,13 +304,16 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } else { checkForNewerMessages(index: index) } + if let firstUnreadMessageId, firstUnreadMessageId.contains(message.id) { + canMarkRead = true + } if utils.messageListConfig.dateIndicatorPlacement == .overlay { save(lastDate: message.createdAt) } if index == 0 { let isActive = UIApplication.shared.applicationState == .active - if isActive { - maybeSendReadEvent(for: message) + if isActive && canMarkRead { + sendReadEventIfNeeded(for: message) } } } @@ -350,11 +390,15 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } - maybeRefreshMessageList() + refreshMessageListIfNeeded() if !showScrollToLatestButton && scrolledId == nil && !loadingNextMessages { updateScrolledIdToNewestMessage() } + + if lastReadMessageId != nil && firstUnreadMessageId == nil { + firstUnreadMessageId = channelDataSource.firstUnreadMessageId + } } func dataSource( @@ -382,6 +426,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { public func onViewAppear() { setActive() messages = channelDataSource.messages + firstUnreadMessageId = channelDataSource.firstUnreadMessageId checkNameChange() } @@ -427,7 +472,7 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { scrollPosition = messages[index].messageId } - channelDataSource.loadNextMessages(limit: newerMessagesLimit) { [weak self] _ in + channelDataSource.loadNextMessages(limit: Self.newerMessagesLimit) { [weak self] _ in guard let self = self else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.loadingNextMessages = false @@ -452,16 +497,19 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { ) } - private func maybeSendReadEvent(for message: ChatMessage) { + private func sendReadEventIfNeeded(for message: ChatMessage) { if message.id != lastMessageRead { lastMessageRead = message.id throttler.throttle { [weak self] in self?.channelController.markRead() + DispatchQueue.main.async { + self?.firstUnreadMessageId = nil + } } } } - private func maybeRefreshMessageList() { + private func refreshMessageListIfNeeded() { let count = messages.count if count > lastRefreshThreshold { lastRefreshThreshold = lastRefreshThreshold + refreshThreshold @@ -552,6 +600,19 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { } } + private func checkUnreadCount() { + guard !isMessageThread else { return } + if channelController.channel?.unreadCount.messages ?? 0 > 0 { + if channelController.firstUnreadMessageId != nil { + firstUnreadMessageId = channelController.firstUnreadMessageId + canMarkRead = false + } else if channelController.lastReadMessageId != nil { + lastReadMessageId = channelController.lastReadMessageId + canMarkRead = false + } + } + } + private func handleDateChange() { guard showScrollToLatestButton == true, let currentDate = currentDate else { currentDateString = nil @@ -630,6 +691,9 @@ open class ChatChannelViewModel: ObservableObject, MessagesDataSource { if messageController == nil { utils.channelControllerFactory.clearCurrentController() ImageCache.shared.trim(toCost: utils.messageListConfig.cacheSizeOnChatDismiss) + if !channelDataSource.hasLoadedAllNextMessages { + channelDataSource.loadFirstPage { _ in } + } } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/JumpToUnreadButton.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/JumpToUnreadButton.swift new file mode 100644 index 00000000..294a03a6 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/JumpToUnreadButton.swift @@ -0,0 +1,36 @@ +// +// Copyright © 2023 Stream.io Inc. All rights reserved. +// + +import StreamChat +import SwiftUI + +struct JumpToUnreadButton: View { + + @Injected(\.colors) var colors + + var unreadCount: Int + var onTap: () -> Void + var onClose: () -> Void + + var body: some View { + HStack { + Button { + onTap() + } label: { + Text(L10n.Message.Unread.count(unreadCount)) + .font(.caption) + } + Button { + onClose() + } label: { + Image(systemName: "xmark") + .font(.caption.weight(.bold)) + } + } + .padding(.all, 10) + .foregroundColor(.white) + .background(Color(colors.textLowEmphasis)) + .cornerRadius(16) + } +} diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift index fad72564..9cefffe1 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListConfig.swift @@ -24,7 +24,7 @@ public struct MessageListConfig { cacheSizeOnChatDismiss: Int = 1024 * 1024 * 100, iPadSplitViewEnabled: Bool = true, scrollingAnchor: UnitPoint = .bottom, - showNewMessagesSeparator: Bool = false, + showNewMessagesSeparator: Bool = true, handleTabBarVisibility: Bool = true, messageListAlignment: MessageListAlignment = .standard ) { diff --git a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift index 6d4540b3..b3bee36d 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageListView.swift @@ -19,6 +19,7 @@ public struct MessageListView: View, KeyboardReadable { @Binding var showScrollToLatestButton: Bool @Binding var quotedMessage: ChatMessage? @Binding var scrollPosition: String? + @Binding var firstUnreadMessageId: MessageId? var loadingNextMessages: Bool var currentDateString: String? var listId: String @@ -34,7 +35,7 @@ public struct MessageListView: View, KeyboardReadable { @State private var keyboardShown = false @State private var pendingKeyboardUpdate: Bool? @State private var scrollDirection = ScrollDirection.up - @State private var newMessagesStartId: String? + @State private var unreadMessagesBannerShown = false private var messageRenderingUtil = MessageRenderingUtil.shared private var skipRenderingMessageIds = [String]() @@ -75,6 +76,7 @@ public struct MessageListView: View, KeyboardReadable { shouldShowTypingIndicator: Bool = false, scrollPosition: Binding = .constant(nil), loadingNextMessages: Bool = false, + firstUnreadMessageId: Binding = .constant(nil), onMessageAppear: @escaping (Int, ScrollDirection) -> Void, onScrollToBottom: @escaping () -> Void, onLongPress: @escaping (MessageDisplayInfo) -> Void, @@ -97,6 +99,7 @@ public struct MessageListView: View, KeyboardReadable { _showScrollToLatestButton = showScrollToLatestButton _quotedMessage = quotedMessage _scrollPosition = scrollPosition + _firstUnreadMessageId = firstUnreadMessageId if !messageRenderingUtil.hasPreviousMessageSet || self.showScrollToLatestButton == false || self.scrolledId != nil @@ -110,14 +113,6 @@ public struct MessageListView: View, KeyboardReadable { map: { $0 } ) } - if messageListConfig.showNewMessagesSeparator && channel.unreadCount.messages > 0 { - let index = channel.unreadCount.messages - 1 - if index < messages.count { - _newMessagesStartId = .init(wrappedValue: messages[index].id) - } - } else { - _newMessagesStartId = .init(wrappedValue: nil) - } } public var body: some View { @@ -136,7 +131,10 @@ public struct MessageListView: View, KeyboardReadable { ForEach(messages, id: \.messageId) { message in var index: Int? = messageListDateUtils.indexForMessageDate(message: message, in: messages) let messageDate: Date? = messageListDateUtils.showMessageDate(for: index, in: messages) - let showUnreadSeparator = message.id == newMessagesStartId + let messageIsFirstUnread = firstUnreadMessageId?.contains(message.id) == true + let showUnreadSeparator = messageListConfig.showNewMessagesSeparator && + messageIsFirstUnread && + !isMessageThread let showsLastInGroupInfo = showsLastInGroupInfo(for: message, channel: channel) factory.makeMessageContainerView( channel: channel, @@ -179,9 +177,15 @@ public struct MessageListView: View, KeyboardReadable { showUnreadSeparator ? factory.makeNewMessagesIndicatorView( - newMessagesStartId: $newMessagesStartId, + newMessagesStartId: $firstUnreadMessageId, count: newMessagesCount(for: index, message: message) ) + .onAppear { + unreadMessagesBannerShown = true + } + .onDisappear { + unreadMessagesBannerShown = false + } : nil showsLastInGroupInfo ? @@ -287,6 +291,18 @@ public struct MessageListView: View, KeyboardReadable { pendingKeyboardUpdate = nil } }) + .overlay( + (channel.unreadCount.messages > 0 && !unreadMessagesBannerShown && !isMessageThread) ? + factory.makeJumpToUnreadButton( + channel: channel, + onJumpToMessage: { + _ = onJumpToMessage?(firstUnreadMessageId ?? .unknownMessageId) + }, + onClose: { + firstUnreadMessageId = nil + } + ) : nil + ) .modifier(factory.makeMessageListContainerModifier()) .modifier(HideKeyboardOnTapGesture(shouldAdd: keyboardShown)) .onDisappear { @@ -295,7 +311,7 @@ public struct MessageListView: View, KeyboardReadable { .accessibilityElement(children: .contain) .accessibilityIdentifier("MessageListView") } - + private func additionalTopPadding(showsLastInGroupInfo: Bool, showUnreadSeparator: Bool) -> CGFloat { var padding = showsLastInGroupInfo ? lastInGroupHeaderSize : 0 if showUnreadSeparator { @@ -311,13 +327,7 @@ public struct MessageListView: View, KeyboardReadable { } private func newMessagesCount(for index: Int?, message: ChatMessage) -> Int { - if let index = index { - return index + 1 - } else if let index = messageListDateUtils.index(for: message, in: messages) { - return index + 1 - } else { - return channel.unreadCount.messages - } + channel.unreadCount.messages } private func showsAllData(for message: ChatMessage) -> Bool { @@ -424,9 +434,6 @@ public struct NewMessagesIndicator: View { .frame(maxWidth: .infinity) .background(Color(colors.background8)) .padding(.top, 4) - .onDisappear { - newMessagesStartId = nil - } } } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift index 0f7f53ae..7f42fc0d 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/DefaultMessageActions.swift @@ -128,6 +128,18 @@ 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) + } + let flagAction = flagMessageAction( for: message, channel: channel, @@ -433,6 +445,42 @@ extension MessageAction { return flagMessage } + + private static func markAsUnreadAction( + for message: ChatMessage, + channel: ChatChannel, + chatClient: ChatClient, + onFinish: @escaping (MessageActionInfo) -> Void, + onError: @escaping (Error) -> Void + ) -> MessageAction { + let channelController = InjectedValues[\.utils] + .channelControllerFactory + .makeChannelController(for: channel.cid) + let action = { + channelController.markUnread(from: message.id) { result in + if case let .failure(error) = result { + 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, @@ -618,4 +666,5 @@ public enum MessageActionId { public static let pin = "pin_message_action" public static let unpin = "unpin_message_action" public static let resend = "resend_message_action" + public static let markUnread = "mark_unread_action" } diff --git a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift index c04ca73f..2bc1aba3 100644 --- a/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift +++ b/Sources/StreamChatSwiftUI/ChatChannel/Reactions/MessageActions/MessageActionsResolver.swift @@ -39,6 +39,8 @@ public class MessageActionsResolver: MessageActionsResolving { viewModel.editedMessage = info.message viewModel.quotedMessage = nil } + } else if info.identifier == MessageActionId.markUnread { + viewModel.firstUnreadMessageId = info.message.messageId } viewModel.reactionsShown = false diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift index f65913f9..cbad3949 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelList.swift @@ -194,7 +194,7 @@ extension ChatChannel: Identifiable { } public var id: String { - "\(cid.id)-\(lastMessageAt ?? createdAt)-\(lastActiveMembersCount)-\(mutedString)-\(typingUsersString)-\(readUsersId)" + "\(cid.id)-\(lastMessageAt ?? createdAt)-\(lastActiveMembersCount)-\(mutedString)-\(typingUsersString)-\(readUsersId)-\(unreadCount.messages)" } private var readUsersId: String { diff --git a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift index 2dbd1f40..c250e9d6 100644 --- a/Sources/StreamChatSwiftUI/DefaultViewFactory.swift +++ b/Sources/StreamChatSwiftUI/DefaultViewFactory.swift @@ -876,6 +876,23 @@ extension ViewFactory { count: count ) } + + public func makeJumpToUnreadButton( + channel: ChatChannel, + onJumpToMessage: @escaping () -> Void, + onClose: @escaping () -> Void + ) -> some View { + VStack { + JumpToUnreadButton( + unreadCount: channel.unreadCount.messages, + onTap: onJumpToMessage, + onClose: onClose + ) + .padding(.all, 8) + + Spacer() + } + } } /// Default class conforming to `ViewFactory`, used throughout the SDK. diff --git a/Sources/StreamChatSwiftUI/Generated/L10n.swift b/Sources/StreamChatSwiftUI/Generated/L10n.swift index da42f2a1..c9a8a28b 100644 --- a/Sources/StreamChatSwiftUI/Generated/L10n.swift +++ b/Sources/StreamChatSwiftUI/Generated/L10n.swift @@ -271,6 +271,8 @@ internal enum L10n { internal static var flag: String { L10n.tr("Localizable", "message.actions.flag") } /// Reply internal static var inlineReply: String { L10n.tr("Localizable", "message.actions.inline-reply") } + /// Mark Unread + internal static var markUnread: String { L10n.tr("Localizable", "message.actions.mark-unread") } /// Pin to conversation internal static var pin: String { L10n.tr("Localizable", "message.actions.pin") } /// Resend @@ -366,6 +368,12 @@ internal enum L10n { /// Online internal static var online: String { L10n.tr("Localizable", "message.title.online") } } + internal enum Unread { + /// Plural format key: "%#@unread@" + internal static func count(_ p1: Int) -> String { + return L10n.tr("Localizable", "message.unread.count", p1) + } + } } internal enum MessageList { diff --git a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings index 82d67739..c88cfa15 100644 --- a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings +++ b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.strings @@ -33,6 +33,7 @@ "message.actions.user-mute" = "Mute User"; "message.actions.resend" = "Resend"; "message.actions.flag" = "Flag Message"; +"message.actions.mark-unread" = "Mark Unread"; "message.actions.flag.confirmation-title" = "Flag Message"; "message.actions.flag.confirmation-message" = "Do you want to send a copy of this message to a moderator for further investigation?"; diff --git a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.stringsdict b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.stringsdict index 1e438017..a9167e5e 100644 --- a/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.stringsdict +++ b/Sources/StreamChatSwiftUI/Resources/en.lproj/Localizable.stringsdict @@ -20,6 +20,24 @@ %d Message Reactions + message.unread.count + + NSStringLocalizedFormatKey + %#@unread@ + unread + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + %d unread + one + %d unread + other + %d unread + + message.search.number-of-results NSStringLocalizedFormatKey @@ -49,11 +67,11 @@ NSStringFormatValueTypeKey d zero - %d new messages + New messages one - %d new message + New messages other - %d new messages + New messages messageList.typingIndicator.users diff --git a/Sources/StreamChatSwiftUI/Utils/StringExtensions.swift b/Sources/StreamChatSwiftUI/Utils/StringExtensions.swift index e18d80b3..f8cfc17b 100644 --- a/Sources/StreamChatSwiftUI/Utils/StringExtensions.swift +++ b/Sources/StreamChatSwiftUI/Utils/StringExtensions.swift @@ -109,4 +109,6 @@ extension String { return false } + + static let unknownMessageId = "unknown" } diff --git a/Sources/StreamChatSwiftUI/ViewFactory.swift b/Sources/StreamChatSwiftUI/ViewFactory.swift index 3a348088..9b82308c 100644 --- a/Sources/StreamChatSwiftUI/ViewFactory.swift +++ b/Sources/StreamChatSwiftUI/ViewFactory.swift @@ -870,4 +870,17 @@ public protocol ViewFactory: AnyObject { newMessagesStartId: Binding, count: Int ) -> NewMessagesIndicatorViewType + + associatedtype JumpToUnreadButtonType: View + /// Creates a jump to unread button. + /// - Parameters: + /// - channel: the current channel. + /// - onJumpToMessage: called when jump to message is tapped. + /// - onClose: called when the jump to unread should be dismissed. + /// - Returns: view shown in the jump to unread slot. + func makeJumpToUnreadButton( + channel: ChatChannel, + onJumpToMessage: @escaping () -> Void, + onClose: @escaping () -> Void + ) -> JumpToUnreadButtonType } diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index c316bf94..4f957746 100644 --- a/StreamChatSwiftUI.xcodeproj/project.pbxproj +++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj @@ -318,6 +318,7 @@ 849894952AD96CCC004ACB41 /* ChatChannelListItemView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849894942AD96CCC004ACB41 /* ChatChannelListItemView_Tests.swift */; }; 849988B02AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849988AF2AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift */; }; 849CDD942768E0E1003C7A51 /* MessageActionsResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849CDD932768E0E1003C7A51 /* MessageActionsResolver.swift */; }; + 849F6BF02B1A06D10032303E /* JumpToUnreadButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849F6BEF2B1A06D10032303E /* JumpToUnreadButton.swift */; }; 849FD5112811B05C00952934 /* ChatInfoParticipantsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849FD5102811B05C00952934 /* ChatInfoParticipantsView.swift */; }; 84A1CACD2816BC420046595A /* ChatChannelInfoHelperViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A1CACC2816BC420046595A /* ChatChannelInfoHelperViews.swift */; }; 84A1CACF2816BCF00046595A /* AddUsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A1CACE2816BCF00046595A /* AddUsersView.swift */; }; @@ -828,6 +829,7 @@ 849894942AD96CCC004ACB41 /* ChatChannelListItemView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelListItemView_Tests.swift; sourceTree = ""; }; 849988AF2AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaddingsConfig_Tests.swift; sourceTree = ""; }; 849CDD932768E0E1003C7A51 /* MessageActionsResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageActionsResolver.swift; sourceTree = ""; }; + 849F6BEF2B1A06D10032303E /* JumpToUnreadButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToUnreadButton.swift; sourceTree = ""; }; 849FD5102811B05C00952934 /* ChatInfoParticipantsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoParticipantsView.swift; sourceTree = ""; }; 84A1CACC2816BC420046595A /* ChatChannelInfoHelperViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatChannelInfoHelperViews.swift; sourceTree = ""; }; 84A1CACE2816BCF00046595A /* AddUsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddUsersView.swift; sourceTree = ""; }; @@ -1566,6 +1568,7 @@ 84E4F7CE294C69F200DD4CE3 /* MessageIdBuilder.swift */, 8417AAB72ADEC3E300445021 /* BottomReactionsView.swift */, 8417AE8F2ADED28800445021 /* ReactionsIconProvider.swift */, + 849F6BEF2B1A06D10032303E /* JumpToUnreadButton.swift */, ); path = MessageList; sourceTree = ""; @@ -2451,6 +2454,7 @@ 82D64BF02AD7E5B700C5C79E /* DataLoading.swift in Sources */, 8465FD9C2746A95700AF091E /* MessageActionsView.swift in Sources */, 82D64C092AD7E5B700C5C79E /* ImageRequestKeys.swift in Sources */, + 849F6BF02B1A06D10032303E /* JumpToUnreadButton.swift in Sources */, 8465FD6E2746A95700AF091E /* DependencyInjection.swift in Sources */, 841B64D62775FDA00016FF3B /* InstantCommandsHandler.swift in Sources */, 8465FDC92746A95700AF091E /* ChatChannelSwipeableListItem.swift in Sources */, diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift index ac57ae3d..e4e43f18 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/ChatChannelViewModel_Tests.swift @@ -455,6 +455,20 @@ class ChatChannelViewModel_Tests: StreamChatTestCase { // Then XCTAssert(shouldJump == false) } + + func test_chatChannelVM_jumpToUnknownMessage() { + // Given + let message1 = ChatMessage.mock() + let message2 = ChatMessage.mock() + let channelController = makeChannelController(messages: [message1, message2]) + let viewModel = ChatChannelViewModel(channelController: channelController) + + // When + let shouldJump = viewModel.jumpToMessage(messageId: .unknownMessageId) + + // Then + XCTAssert(shouldJump == false) + } // MARK: - private diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift index bf74a06e..a3dcc5d2 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActionsViewModel_Tests.swift @@ -24,7 +24,7 @@ class MessageActionsViewModel_Tests: StreamChatTestCase { onError: { _ in } ) let viewModel = MessageActionsViewModel(messageActions: actions) - let action = actions[4] + let action = actions[5] // When viewModel.alertAction = action diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift index 13022135..61999d32 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageActions_Tests.swift @@ -63,13 +63,14 @@ class MessageActions_Tests: StreamChatTestCase { ) // Then - XCTAssert(messageActions.count == 6) + XCTAssert(messageActions.count == 7) XCTAssert(messageActions[0].title == "Reply") XCTAssert(messageActions[1].title == "Thread Reply") XCTAssert(messageActions[2].title == "Pin to conversation") XCTAssert(messageActions[3].title == "Copy Message") - XCTAssert(messageActions[4].title == "Flag Message") - XCTAssert(messageActions[5].title == "Mute User") + XCTAssert(messageActions[4].title == "Mark Unread") + XCTAssert(messageActions[5].title == "Flag Message") + XCTAssert(messageActions[6].title == "Mute User") } func test_messageActions_currentUserPinned() { diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListView_Tests.swift b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListView_Tests.swift index 10616fd5..67895362 100644 --- a/StreamChatSwiftUITests/Tests/ChatChannel/MessageListView_Tests.swift +++ b/StreamChatSwiftUITests/Tests/ChatChannel/MessageListView_Tests.swift @@ -75,17 +75,32 @@ class MessageListView_Tests: StreamChatTestCase { // Then assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) } + + func test_messageListView_jumpToUnreadButton() { + // Given + let channelConfig = ChannelConfig(reactionsEnabled: true) + let view = makeMessageListView( + channelConfig: channelConfig, + unreadCount: .mock(messages: 3) + ) + .applyDefaultSize() + + // Then + assertSnapshot(matching: view, as: .image(perceptualPrecision: precision)) + } // MARK: - private func makeMessageListView( channelConfig: ChannelConfig, + unreadCount: ChannelUnreadCount = .noUnread, currentlyTypingUsers: Set = [] ) -> MessageListView { let reactions = [MessageReactionType(rawValue: "like"): 2] let channel = ChatChannel.mockDMChannel( config: channelConfig, - currentlyTypingUsers: currentlyTypingUsers + currentlyTypingUsers: currentlyTypingUsers, + unreadCount: unreadCount ) let temp = [ChatMessage.mock( id: .unique, diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_moreMessages.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_moreMessages.1.png index 92eac22d..2b4c272e 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_moreMessages.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_moreMessages.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_moreMessagesInBetween.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_moreMessagesInBetween.1.png index 9fbf8e1e..0af85905 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_moreMessagesInBetween.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_moreMessagesInBetween.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_singleMessage.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_singleMessage.1.png index 0559d7e1..35500078 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_singleMessage.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListViewNewMessages_Tests/test_messageListViewNewMessages_singleMessage.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListView_Tests/test_messageListView_jumpToUnreadButton.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListView_Tests/test_messageListView_jumpToUnreadButton.1.png new file mode 100644 index 00000000..44f3f7b1 Binary files /dev/null and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/MessageListView_Tests/test_messageListView_jumpToUnreadButton.1.png differ diff --git a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/ReactionsOverlayView_Tests/test_reactionsOverlay_veryLongMessage.1.png b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/ReactionsOverlayView_Tests/test_reactionsOverlay_veryLongMessage.1.png index 4fc3df7f..2dde9152 100644 Binary files a/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/ReactionsOverlayView_Tests/test_reactionsOverlay_veryLongMessage.1.png and b/StreamChatSwiftUITests/Tests/ChatChannel/__Snapshots__/ReactionsOverlayView_Tests/test_reactionsOverlay_veryLongMessage.1.png differ