From 7085579b72fe9420888cf49473817f264c7c74b9 Mon Sep 17 00:00:00 2001 From: Anka Date: Thu, 21 Aug 2025 04:42:51 +0000 Subject: [PATCH] Release - swiftui/1.1.1 --- CHANGELOG.md | 13 +- Package.swift | 10 +- .../project.pbxproj | 24 +- Sample/project.yml | 6 +- .../Configuration/SBUConfig.CodingKeys.swift | 1 + .../SBUConfig.GroupChannel.swift | 11 +- Sources/uikit/Constant/SBUStringSet.swift | 18 +- Sources/uikit/Enums/SBUIconSetType.swift | 3 + .../Extension/Shared/UIView+SBUIKit.swift | 24 +- .../SBUGroupChannelModule.List.swift | 335 +++++++++++++++++- .../GroupChannel/SBUGroupChannelModule.swift | 4 + .../Channel/SBUBaseChannelModule.List.swift | 59 ++- .../SBUMessageThreadModule.List.swift | 18 +- .../iconMarkAsUnread.imageset/Contents.json | 44 +++ .../feature-chat 1.png | Bin 0 -> 597 bytes .../feature-chat.png | Bin 0 -> 592 bytes .../feature-chat@2x 1.png | Bin 0 -> 1161 bytes .../feature-chat@2x.png | Bin 0 -> 1104 bytes .../feature-chat@3x 1.png | Bin 0 -> 1678 bytes .../feature-chat@3x.png | Bin 0 -> 1556 bytes Sources/uikit/Theme/SBUIconSet.swift | 5 + Sources/uikit/Theme/SBUTheme.swift | 55 ++- .../CellView/SBUUnreadMessageNewLine.swift | 96 +++++ .../CellView/SBUUserMessageTextView.swift | 11 +- .../SBUAdminMessageCellParams.swift | 10 +- .../SBUBaseMessageCellParams.swift | 9 +- .../SBUFileMessageCellParams.swift | 6 +- .../SBUMultipleFilesMessageCellParams.swift | 6 +- .../SBUUserMessageCellParams.swift | 6 +- .../MessageCell/SBUBaseMessageCell.swift | 28 +- .../SBUContentBaseMessageCell.swift | 2 + .../MessageCell/SBUUserMessageCell.swift | 9 +- .../SBUUnreadMessageInfoView.swift | 137 +++++++ .../SBUBaseChannelViewController.swift | 8 +- .../SBUGroupChannelViewController.swift | 235 ++++++++++++ .../SBUMessageThreadViewController.swift | 7 + .../SBUParentMessageInfoView.swift | 10 +- .../Channel/SBUBaseChannelViewModel.swift | 25 +- .../Channel/SBUGroupChannelViewModel.swift | 255 ++++++++++++- .../SBUMessageThreadViewModel.swift | 2 +- 40 files changed, 1409 insertions(+), 83 deletions(-) create mode 100644 Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/Contents.json create mode 100644 Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat 1.png create mode 100644 Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat.png create mode 100644 Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat@2x 1.png create mode 100644 Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat@2x.png create mode 100644 Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat@3x 1.png create mode 100644 Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat@3x.png create mode 100644 Sources/uikit/View/Channel/CellView/SBUUnreadMessageNewLine.swift create mode 100644 Sources/uikit/View/Channel/NewMessageInfo/SBUUnreadMessageInfoView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c38dd22..499190f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ -### New Interfaces -- Added `func topView (@ViewBuilder content: @escaping (MessageInputViewContent.TopView.ViewConfig) -> Content) -> Self` in `GroupChannelView` -- Added `QuoteMessageInputView` +## Improvements +- Updated the SDK build base to Xcode 16.4. -### Improvements -We have fixed warnings caused by the underlying autolayouts. +## ⚠️ Compatibility Notice + +**SendbirdSwiftUI v1.1.0 and earlier are not compatible with SendbirdChatSDK v4.28.1 or higher.** +Please use `SendbirdChatSDK ≤ 4.28.0` with SendbirdSwiftUI versions **up to v1.1.0**. + +To use `SendbirdChatSDK ≥ 4.28.1`, please upgrade to `SendbirdSwiftUI v1.1.1` or later, which is built with Xcode 16.4. diff --git a/Package.swift b/Package.swift index efc4ab3..16f7817 100644 --- a/Package.swift +++ b/Package.swift @@ -17,20 +17,20 @@ let package = Package( .package( name: "SendbirdChatSDK", url: "https://github.com/sendbird/sendbird-chat-sdk-ios", - from: "4.26.0" + from: "4.29.0" ), ], targets: [ .binaryTarget( name: "SendbirdSwiftUI", - url: "https://github.com/sendbird/sendbird-swiftui-ios/releases/download/1.1.0/SendbirdSwiftUI.xcframework.zip", // SendbirdSwiftUI_URL - checksum: "9e307422893d166726484e0aace0805bb7048810e0e074eddeb1e4b86aa33a6d" // SendbirdSwiftUI_CHECKSUM + url: "https://github.com/sendbird/sendbird-swiftui-ios/releases/download/1.1.1/SendbirdSwiftUI.xcframework.zip", // SendbirdSwiftUI_URL + checksum: "cc2815f523db320c30092e97a48bf0575496742f5a074130d5bf4dbc7f4e4d80" // SendbirdSwiftUI_CHECKSUM ), .binaryTarget( name: "SendbirdUIMessageTemplate", - url: "https://github.com/sendbird/sendbird-uikit-ios/releases/download/3.31.0/SendbirdUIMessageTemplate.xcframework.zip", // SendbirdUIMessageTemplate_URL - checksum: "c5943e894d0d5bfc15485614a929d6e630fe3b2f830ea6efe99468d66688c41e" // SendbirdUIMessageTemplate_CHECKSUM + url: "https://github.com/sendbird/sendbird-uikit-ios/releases/download/3.32.2/SendbirdUIMessageTemplate.xcframework.zip", // SendbirdUIMessageTemplate_URL + checksum: "380b1948377c740febd0b324e21b181e2fc3e3717eaf08886eb3d9196c0b9602" // SendbirdUIMessageTemplate_CHECKSUM ), .target( name: "SendbirdSwiftUITarget", diff --git a/Sample/QuickStartSwiftUI.xcodeproj/project.pbxproj b/Sample/QuickStartSwiftUI.xcodeproj/project.pbxproj index 685720d..082e2bf 100644 --- a/Sample/QuickStartSwiftUI.xcodeproj/project.pbxproj +++ b/Sample/QuickStartSwiftUI.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -515,6 +515,7 @@ 772E4D392B5550DD3FDAD535 /* InviteUserViewConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA23C8AD569D84167F4DD526 /* InviteUserViewConverter.swift */; }; 773421C3BDBEAAA97B75EA80 /* CreateGroupChannelView+SubViewBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B719C1C7BCA50103B2FD33F /* CreateGroupChannelView+SubViewBuilder.swift */; }; 77804AA5E6072297F429A920 /* SBUMessageFormChipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4295DC2364062B88F929D80E /* SBUMessageFormChipView.swift */; }; + 7788754D0D288A31CBD190D8 /* SBUUnreadMessageNewLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD328583808CC526F8235AF3 /* SBUUnreadMessageNewLine.swift */; }; 77AA724DB557C412388E3750 /* CustomGroupChannelRegisterOperator.ViewConverter.Header.rightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9C266B8A2DF6EB7F1504782 /* CustomGroupChannelRegisterOperator.ViewConverter.Header.rightView.swift */; }; 77AA82E38B8808BC31CF1A33 /* SBUMessageThreadModule.Header+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F41FB77D91238CE800D9F43 /* SBUMessageThreadModule.Header+SwiftUI.swift */; }; 77F45EB93F523CDDECF16107 /* UIStackView.SBUIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD34FF666C940B94555A93F5 /* UIStackView.SBUIKit.swift */; }; @@ -1067,6 +1068,7 @@ F82E518CE6E0946B168BAA18 /* UIScrollView+SBUIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B75F4EBD4914A4295678613 /* UIScrollView+SBUIKit.swift */; }; F877982811AC508943632CCE /* SBUSelectablePhotoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A936C2B0F4BEFCCE17689484 /* SBUSelectablePhotoViewController.swift */; }; F8C970D71462EFD8D116E260 /* CustomOpenChannel.ViewConverter.Header.titleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D5AFF1C0FFAF7FB44385303 /* CustomOpenChannel.ViewConverter.Header.titleView.swift */; }; + F96CCBD1D2F86EBCA1F3782A /* SBUUnreadMessageInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A4E7790BAF5E7CEC138BEF6 /* SBUUnreadMessageInfoView.swift */; }; F9C2912608CC5EAA33B09C3C /* SBUMessageDateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 744D3897AF413516BB6A02CB /* SBUMessageDateView.swift */; }; F9CF4B42BDC33490C9BB589C /* CustomTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5251C5F71C7B463C54D6474C /* CustomTheme.swift */; }; F9E8A5C906219EB9DA3F6453 /* SBUGroupChannelModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = D91401F0CEDB5A733BE00C20 /* SBUGroupChannelModule.swift */; }; @@ -1242,6 +1244,7 @@ 19F7CEEE95BBF13CB214CF41 /* SBUGroupChannelViewController.Deprecated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBUGroupChannelViewController.Deprecated.swift; sourceTree = ""; }; 1A01964E8998FC11F82F7F27 /* SBUGroupChannelModule.Deprecated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBUGroupChannelModule.Deprecated.swift; sourceTree = ""; }; 1A44E48D1D24AA8A93786370 /* CustomGroupOperatorList.SwiftUI.View.CustomMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomGroupOperatorList.SwiftUI.View.CustomMain.swift; sourceTree = ""; }; + 1A4E7790BAF5E7CEC138BEF6 /* SBUUnreadMessageInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBUUnreadMessageInfoView.swift; sourceTree = ""; }; 1A8DD774054C5168961DF04B /* GroupChannelViewConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChannelViewConverter.swift; sourceTree = ""; }; 1AE76A1AE751AA26FFB13901 /* GroupChannelSettingsViewConverter.Header.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChannelSettingsViewConverter.Header.swift; sourceTree = ""; }; 1AF8CB593FE2D4951A3ED114 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; @@ -2201,6 +2204,7 @@ FC3E876887601735308273D9 /* SBUVoiceMessageInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBUVoiceMessageInputView.swift; sourceTree = ""; }; FCB7E275124DF45ADB981B0E /* SBUTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBUTheme.swift; sourceTree = ""; }; FD18A49461FC8A53036E3A08 /* SBUToastType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBUToastType.swift; sourceTree = ""; }; + FD328583808CC526F8235AF3 /* SBUUnreadMessageNewLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBUUnreadMessageNewLine.swift; sourceTree = ""; }; FD34FF666C940B94555A93F5 /* UIStackView.SBUIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIStackView.SBUIKit.swift; sourceTree = ""; }; FD8219759D395864DBC89499 /* SBURegisterOperatorModule.List.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBURegisterOperatorModule.List.swift; sourceTree = ""; }; FDA3D3D02D5E297D699CD2C2 /* SBUBaseSelectUserViewController.Deprecated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SBUBaseSelectUserViewController.Deprecated.swift; sourceTree = ""; }; @@ -4385,6 +4389,7 @@ 2E9AABB7CE646ADCDF873887 /* SBUNotificationTimelineView.swift */, 6B424207542E1A82DAC82A0D /* SBUOpenChannelMessageWebView.swift */, B07065B5EEFA8858ADAD76CA /* SBUSelectableStackView.swift */, + FD328583808CC526F8235AF3 /* SBUUnreadMessageNewLine.swift */, 608274ACB42DDAE6A5976FC6 /* SBUUserMessageTextView.swift */, 08C31448CE461810A569DE1E /* SBUUserNameView.swift */, ); @@ -5948,6 +5953,7 @@ children = ( EF67B852DBB45B4929297E29 /* SBUNewMessageInfo.swift */, 058F7755FCA9CC99DBB5B468 /* SBUNewNotificationInfo.swift */, + 1A4E7790BAF5E7CEC138BEF6 /* SBUUnreadMessageInfoView.swift */, ); path = NewMessageInfo; sourceTree = ""; @@ -6494,7 +6500,7 @@ 4F892C552F42D67B95791C9C /* XCRemoteSwiftPackageReference "sendbird-chat-sdk-ios" */, BA2412B4C520C201A8099145 /* XCRemoteSwiftPackageReference "sendbird-uikit-ios-spm" */, ); - preferredProjectObjectVersion = 54; + preferredProjectObjectVersion = 77; projectDirPath = ""; projectRoot = ""; targets = ( @@ -7537,6 +7543,8 @@ E1AE7A7067D6B8B6970F08DA /* SBUUnderLineTextField.swift in Sources */, 544EEA8C932F1096A5F2C3B4 /* SBUUnknownMessageCell.swift in Sources */, AEF0C3B8FA3F9DFB7A4D7D98 /* SBUUnknownMessageCellParams.swift in Sources */, + F96CCBD1D2F86EBCA1F3782A /* SBUUnreadMessageInfoView.swift in Sources */, + 7788754D0D288A31CBD190D8 /* SBUUnreadMessageNewLine.swift in Sources */, 9CEE3F03D7428180C46396FF /* SBUUser.swift in Sources */, 749F372DA7755F9B0CD00F9F /* SBUUserCell.swift in Sources */, A813DE7327B146C9A9A5676C /* SBUUserListModule.Deprecated.swift in Sources */, @@ -7639,7 +7647,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.sendbird.swiftui.sample; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -7671,7 +7679,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.sendbird.swiftui.sample.SwiftUINotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -7761,7 +7769,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.sendbird.swiftui.sample; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -7848,7 +7856,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.1.0; + MARKETING_VERSION = 1.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.sendbird.swiftui.sample.SwiftUINotificationService; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -7897,7 +7905,7 @@ repositoryURL = "https://github.com/sendbird/sendbird-chat-sdk-ios"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 4.26.0; + minimumVersion = 4.29.0; }; }; BA2412B4C520C201A8099145 /* XCRemoteSwiftPackageReference "sendbird-uikit-ios-spm" */ = { @@ -7905,7 +7913,7 @@ repositoryURL = "https://github.com/sendbird/sendbird-uikit-ios-spm"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 3.31.0; + minimumVersion = 3.32.2; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Sample/project.yml b/Sample/project.yml index 0734bcf..2afa265 100644 --- a/Sample/project.yml +++ b/Sample/project.yml @@ -12,10 +12,10 @@ options: packages: SendbirdChatSDK: url: https://github.com/sendbird/sendbird-chat-sdk-ios - from: 4.26.0 + from: 4.29.0 SendbirdUIKit: url: https://github.com/sendbird/sendbird-uikit-ios-spm - from: 3.31.0 + from: 3.32.2 schemes: QuickStartSwiftUI: @@ -43,7 +43,7 @@ settingGroups: FRAMEWORK_SEARCH_PATHS: '' IPHONEOS_DEPLOYMENT_TARGET: '15.0' LD_RUNPATH_SEARCH_PATHS: ["$(inherited)", "@executable_path/Frameworks", "@loader_path/Frameworks"] - MARKETING_VERSION: '1.1.0' + MARKETING_VERSION: '1.1.1' PRODUCT_NAME: "$(TARGET_NAME)" SDKROOT: iphoneos SWIFT_VERSION: '5.0' diff --git a/Sources/uikit/Configuration/SBUConfig.CodingKeys.swift b/Sources/uikit/Configuration/SBUConfig.CodingKeys.swift index 3028e23..896d1f0 100644 --- a/Sources/uikit/Configuration/SBUConfig.CodingKeys.swift +++ b/Sources/uikit/Configuration/SBUConfig.CodingKeys.swift @@ -59,6 +59,7 @@ extension SBUConfig.GroupChannel.Channel { case replyType case threadReplySelectType case input + case isMarkAsUnreadEnabled = "enableMarkAsUnread" // 3.32.0 } } diff --git a/Sources/uikit/Configuration/SBUConfig.GroupChannel.swift b/Sources/uikit/Configuration/SBUConfig.GroupChannel.swift index 4c501d3..e1ad76a 100644 --- a/Sources/uikit/Configuration/SBUConfig.GroupChannel.swift +++ b/Sources/uikit/Configuration/SBUConfig.GroupChannel.swift @@ -102,6 +102,11 @@ extension SBUConfig.GroupChannel { set { _isSuperGroupReactionsEnabled = newValue } } + /// Enable the feature to mark message as unread. + /// + /// - Since: 3.32.0 + @SBUPrioritizedConfig public var isMarkAsUnreadEnabled: Bool = false + /// Enable the feature to mention specific members in a message for notification. /// /// - NOTE: If it's `true`, it sets new ``SBUUserMentionConfiguration`` instance to ``SBUGlobals/userMentionConfig`` if needed. If it's `false`, ``SBUGlobals/userMentionConfig`` is set to `nil` @@ -183,7 +188,8 @@ extension SBUConfig.GroupChannel { self._isFormTypeMessageEnabled.setDashboardValue(channel.isFormTypeMessageEnabled) self._isFeedbackEnabled.setDashboardValue(channel.isFeedbackEnabled) self._isMarkdownForUserMessageEnabled.setDashboardValue(channel.isMarkdownForUserMessageEnabled) - + self._isMarkAsUnreadEnabled.setDashboardValue(channel.isMarkAsUnreadEnabled) + self.input.updateWithDashboardData(channel.input) } @@ -207,6 +213,7 @@ extension SBUConfig.GroupChannel { SendbirdUI.config.groupChannel.channel.isFeedbackEnabled self.isMarkdownForUserMessageEnabled = (try? container.decode(Bool.self, forKey: .isMarkdownForUserMessageEnabled)) ?? SendbirdUI.config.groupChannel.channel.isMarkdownForUserMessageEnabled + self.isMarkAsUnreadEnabled = (try? container.decode(Bool.self, forKey: .isMarkAsUnreadEnabled)) ?? SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled self.input = try container.decode(SBUConfig.GroupChannel.Channel.Input.self, forKey: .input) } @@ -227,6 +234,8 @@ extension SBUConfig.GroupChannel { try container.encode(self._isSuperGroupReactionsEnabled, forKey: .isSuperGroupReactionsEnabled) try container.encode(self.isFeedbackEnabled, forKey: .isFeedbackEnabled) try container.encode(self.isMarkdownForUserMessageEnabled, forKey: .isMarkdownForUserMessageEnabled) + try container.encode(self.isMarkAsUnreadEnabled, forKey: .isMarkAsUnreadEnabled) + try container.encode(self.input, forKey: .input) } } diff --git a/Sources/uikit/Constant/SBUStringSet.swift b/Sources/uikit/Constant/SBUStringSet.swift index a84815d..3b08568 100644 --- a/Sources/uikit/Constant/SBUStringSet.swift +++ b/Sources/uikit/Constant/SBUStringSet.swift @@ -34,6 +34,7 @@ public class SBUStringSet { public static var Settings = "Settings" public static var Reply = "Reply" public static var Submit = "Submit" // 3.11.0 + public static var MarkAsUnread = "Mark as unread" // 3.32.0 // MARK: - Alert public static var Alert_Delete = "Are you sure you want to delete?" @@ -109,7 +110,22 @@ public class SBUStringSet { return "" } } - public static var Channel_State_Banner_Frozen = "Channel frozen" + public static var Channel_State_Banner_Frozen = "Channel is frozen" + /// - Since: 3.32.0 + public static var Channel_Unread_Message: (UInt) -> String = { count in + switch count { + case 1: + return "1 unread message" + case 2...99: + return "\(count) unread messages" + case 100...: + return "99+ unread messages" + default: + return "" + } + } + /// - Since: 3.32.0 + public static var Channel_Unread_Message_Newline = "New messages" // MARK: - Open Channel public static var Open_Channel_Name_Default = "Open Channel" diff --git a/Sources/uikit/Enums/SBUIconSetType.swift b/Sources/uikit/Enums/SBUIconSetType.swift index 3f80d0c..f99aaad 100644 --- a/Sources/uikit/Enums/SBUIconSetType.swift +++ b/Sources/uikit/Enums/SBUIconSetType.swift @@ -72,6 +72,7 @@ public enum SBUIconSetType: String, Hashable { case iconStop case iconGood // 3.15.0 case iconBad // 3.15.0 + case iconMarkAsUnread // 3.32.0 // MARK: - Metric @@ -209,6 +210,7 @@ public enum SBUIconSetType: String, Hashable { case .iconStop: SBUIconSet.iconStop = SBUIconSetType.iconStop.load() case .iconBad: SBUIconSet.iconBad = SBUIconSetType.iconBad.load() case .iconGood: SBUIconSet.iconGood = SBUIconSetType.iconGood.load() + case .iconMarkAsUnread: SBUIconSet.iconMarkAsUnread = SBUIconSetType.iconMarkAsUnread.load() } SBUIconSetType.customizedIcons.remove(self) @@ -282,6 +284,7 @@ public enum SBUIconSetType: String, Hashable { case .iconStop: return SBUIconSet.iconStop case .iconGood: return SBUIconSet.iconGood case .iconBad: return SBUIconSet.iconBad + case .iconMarkAsUnread: return SBUIconSet.iconMarkAsUnread } } } diff --git a/Sources/uikit/Extension/Shared/UIView+SBUIKit.swift b/Sources/uikit/Extension/Shared/UIView+SBUIKit.swift index ffd6a59..8c97022 100644 --- a/Sources/uikit/Extension/Shared/UIView+SBUIKit.swift +++ b/Sources/uikit/Extension/Shared/UIView+SBUIKit.swift @@ -1282,7 +1282,7 @@ extension UIView { } } -// Round Corners +// MARK: - Round Corners extension UIView { func roundCorners(corners: UIRectCorner, radius: CGFloat) { self.clipsToBounds = true @@ -1298,6 +1298,28 @@ extension UIView { } } +// Shadows +extension UIView { + func applyMultipleShadows(cornerRadius: CGFloat = 20, backgroundColor: UIColor, shadowConfigs: [(radius: CGFloat, offset: CGSize, color: UIColor)]) { + // Clear existing shadow sublayers + self.layer.sublayers?.removeAll { $0.name == "shadowLayer" } + + for shadowConfig in shadowConfigs { + let shadowLayer = CALayer() + shadowLayer.name = "shadowLayer" + shadowLayer.frame = self.bounds + shadowLayer.backgroundColor = backgroundColor.cgColor + shadowLayer.cornerRadius = cornerRadius + shadowLayer.shadowColor = shadowConfig.color.cgColor + shadowLayer.shadowOffset = shadowConfig.offset + shadowLayer.shadowRadius = shadowConfig.radius + shadowLayer.shadowOpacity = 1 + shadowLayer.shadowPath = UIBezierPath(roundedRect: self.bounds, cornerRadius: cornerRadius).cgPath + self.layer.insertSublayer(shadowLayer, at: 0) + } + } +} + // MARK: - Image extension UIView { func asImage() -> UIImage { diff --git a/Sources/uikit/Module/Channel/GroupChannel/SBUGroupChannelModule.List.swift b/Sources/uikit/Module/Channel/GroupChannel/SBUGroupChannelModule.List.swift index 99945b0..d80da21 100644 --- a/Sources/uikit/Module/Channel/GroupChannel/SBUGroupChannelModule.List.swift +++ b/Sources/uikit/Module/Channel/GroupChannel/SBUGroupChannelModule.List.swift @@ -40,6 +40,12 @@ public protocol SBUGroupChannelModuleListDelegate: SBUBaseChannelModuleListDeleg /// - user: The`SBUUser` object from the tapped mention. func groupChannelModule(_ listComponent: SBUGroupChannelModule.List, didTapMentionUser user: SBUUser) + /// Called when URL link in a message cell is tapped. + /// - Parameters: + /// - URL: The`URL` object from the tapped URL link. + /// - Since: 3.32.0 + func groupChannelModule(_ listComponent: SBUGroupChannelModule.List, didTapURL url: URL) + /// Called when tapped the thread info in the cell /// - Parameter threadInfoView: The `SBUThreadInfoView` object from the tapped thread info. /// - Since: 3.3.0 @@ -138,6 +144,33 @@ public protocol SBUGroupChannelModuleListDelegate: SBUBaseChannelModuleListDeleg shouldHandleUncachedTemplateImages cacheData: [String: String], messageCell: SBUBaseMessageCell ) + + /// Called when the unreadMessageNewLine comes on-screen. + /// - Parameters: + /// - listComponent: `SBUGroupChannelModule.List` object. + /// - messageCell: The message cell that the unreadMessageNewLine belongs to. + /// - Since: 3.32.0 + func groupChannelModule( + _ listComponent: SBUGroupChannelModule.List, + didScrollToUnreadMessageNewLine messageCell: SBUBaseMessageCell + ) + + /// Called when the button of unreadMessageInfoView is tapped. + /// - Parameters: + /// - listComponent: `SBUGroupChannelModule.List` object. + /// - didTapUnreadMessageInfoView: The `SBUUnreadMessageInfoView` object. + /// - Since: 3.32.0 + func groupChannelModule( + _ listComponent: SBUGroupChannelModule.List, + didTapUnreadMessageInfoView: Bool + ) + + /// Called when a user selects the *mark as unread* menu item of a `message` in the `listComponent`. + /// - Parameters: + /// - listComponent: A ``SBUBaseChannelModule/List`` object. + /// - message: The message that the selected menu item belongs to. + /// - Since: 3.32.0 + func groupChannelModule(_ listComponent: SBUBaseChannelModule.List, didTapMarkAsUnread message: BaseMessage) } /// Methods to get data source for list component in a group channel. @@ -156,6 +189,16 @@ public protocol SBUGroupChannelModuleListDataSource: SBUBaseChannelModuleListDat _ listComponent: SBUGroupChannelModule.List, didHandleUncachedTemplateKeys templateKeys: [String] ) -> Bool? + + /// Ask data source to return the first unread message. + /// - Returns: A `BaseMessage` instance if there is a first unread message, `nil` if there is none. + /// - Since: 3.32.0 + func groupChannelModuleFirstUnreadMessage(_ listComponent: SBUGroupChannelModule.List) -> BaseMessage? + + /// Ask data source whether messages should be marked as read when scrolling. + /// - Returns: `true` if messages should be marked as read on scroll, `false` otherwise. + /// - Since: 3.32.0 + func groupChannelModuleAllowsAutoMarkAsReadOnScroll(_ listComponent: SBUGroupChannelModule.List) -> Bool } extension SBUGroupChannelModule { @@ -225,6 +268,31 @@ extension SBUGroupChannelModule { public var voicePlayer: SBUVoicePlayer? + /// The first of all unread messages in the channel. + /// - Since: 3.32.0 + public var firstUnreadMessage: BaseMessage? { + return self.dataSource?.groupChannelModuleFirstUnreadMessage(self) + } + + /// Whether messages should be marked as read when scrolling. + /// - Since: 3.32.0 + public var allowsAutoMarkAsReadOnScroll: Bool { + self.dataSource?.groupChannelModuleAllowsAutoMarkAsReadOnScroll(self) ?? false + } + + /// A boolean flag that shows whether a unreadMessageNewLine has been on-screen or not. + /// - Since: 3.32.0 + public var hasSeenNewLine: Bool = false + + /// A boolean flag that shows whether an unread message existed in the channel before receiving new messages. + /// - Since: 3.32.0 + public var didUnreadMessageExist: Bool = false + + /// A set of strings that keep track of previously on-screen newlines. + /// It is used to detect the moment the newline goes off-screen. + /// - Since: 3.32.0 + public var previouslyVisibleNewLines: Set = [] + // MARK: default views override func createDefaultEmptyView() -> SBUEmptyView { @@ -252,6 +320,26 @@ extension SBUGroupChannelModule { SBUNewMessageInfo.createDefault(Self.NewMessageInfo) } + override func createDefaultUnreadMessageInfoView() -> SBUUnreadMessageInfoView? { + let view = SBUUnreadMessageInfoView.createDefault(Self.UnreadMessageInfoView) + view.actionHandler = { [weak self] in + guard let self else { return } + + // vc -> vm.markAsRead() + self.delegate?.groupChannelModule( + self, + didTapUnreadMessageInfoView: true + ) + + // Hide unreadMessageInfoView, newMessageInfoView. + self.unreadMessageInfoView?.isHidden = true + self.newMessageInfoView?.isHidden = true + + self.hasSeenNewLine = true + } + return view + } + // MARK: Private properties var voiceFileInfos: [String: SBUVoiceFileInfo] = [:] var currentVoiceFileInfo: SBUVoiceFileInfo? @@ -324,11 +412,32 @@ extension SBUGroupChannelModule { self.register(messageCellType: customMessageCellType) } - // Add subviews + // setup topStackView, channelStateBanner, unreadMessageInfoView + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + if isMarkAsUnreadEnabled { + if self.unreadMessageInfoView == nil { + self.unreadMessageInfoView = self.createDefaultUnreadMessageInfoView() + } + } + + if let channelStateBanner = self.channelStateBanner { + topStackView.addArrangedSubview(channelStateBanner) + } + + if let unreadMessageInfoView = self.unreadMessageInfoView { + topStackView.addArrangedSubview(unreadMessageInfoView) + } + + self.addSubview(topStackView) + if let newMessageInfoView = self.newMessageInfoView { newMessageInfoView.isHidden = true self.addSubview(newMessageInfoView) } + + if let unreadMessageInfoView = self.unreadMessageInfoView { + unreadMessageInfoView.isHidden = true + } if let scrollBottomView = self.scrollBottomView { scrollBottomView.isHidden = true @@ -341,9 +450,13 @@ extension SBUGroupChannelModule { open override func setupLayouts() { super.setupLayouts() - self.channelStateBanner? + self.topStackView .sbu_constraint(equalTo: self, leading: 8, trailing: -8, top: 8) - .sbu_constraint(height: 24) + + channelStateBanner?.sbu_constraint(height: 24) + channelStateBanner?.sbu_constraint(equalTo: topStackView, leading: 0, trailing: 0) + + self.unreadMessageInfoView?.sbu_constraint(height: 38) (self.newMessageInfoView as? SBUNewMessageInfo)? .sbu_constraint(equalTo: self, bottom: 8, centerX: 0) @@ -378,11 +491,113 @@ extension SBUGroupChannelModule { guard self.scrollBottomView?.isHidden != isHidden else { return } self.scrollBottomView?.isHidden = isHidden } - + open override func scrollViewDidScroll(_ scrollView: UIScrollView) { super.scrollViewDidScroll(scrollView) self.setScrollBottomView(hidden: isScrollNearByBottom) + + // If markAsUnread feature is enabled, + // handle markAsRead, markAsUnread, and unreadMessageInfoView + // depending on whether `unreadMessageNewLine` comes on-screen or goes off-screen. + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + guard isMarkAsUnreadEnabled else { return } + + // Get all visible cells + guard let tableView = scrollView as? UITableView else { return } + let visibleCells = tableView.visibleCells + var currentlyVisibleNewLines: Set = [] + for case let messageCell as SBUBaseMessageCell in visibleCells { + guard let newline = messageCell.unreadMessageNewLine else { return } + + // Detect whether the messagecell with a newline completely came on-screen. + checkIfNewLineCameOnScreen(messageCell: messageCell, newline: newline) + + // Keep track of visible newlines + currentlyVisibleNewLines = recordVisibleNewLines(messageCell: messageCell, newline: newline, currentlyVisibleNewLines: currentlyVisibleNewLines) + } + + // Detect whether a newline went completely off-screen. + checkIfNewLineWentOffScreen(currentlyVisibleNewLines: currentlyVisibleNewLines) + + // Update state for next scroll event + previouslyVisibleNewLines = currentlyVisibleNewLines + } + + /// If the message cell with the newline starts coming on-screen, update UI states. + /// - Since: 3.32.0 + public func checkIfNewLineCameOnScreen(messageCell: SBUBaseMessageCell, newline: UIView) { + let newlineInTable = newline.convert(newline.bounds, to: tableView) + let visibleRect = tableView.bounds + + // Check if the message cell with the newline starts coming on-screen (intersects with visible area). + if newline.isHidden == false, visibleRect.intersects(newlineInTable) { + // unreadMessageNewLine is starting to come on-screen or is partially/entirely on-screen + + if self.allowsAutoMarkAsReadOnScroll { + // User has never explicitly called markAsUnread. + if self.hasSeenNewLine == false { + self.delegate?.groupChannelModule(self, didScrollToUnreadMessageNewLine: messageCell) + self.hasSeenNewLine = true + } + } else { + // User has explicitly called markAsUnread. + // Only hide unreadMessageInfoView. + // Do not call markAsRead() + self.unreadMessageInfoView?.isHidden = true + } + } else { + // unreadMessageNewLine is completely off-screen + } + } + + /// Track visible newlines for off-screen detection. + /// - Since: 3.32.0 + public func recordVisibleNewLines(messageCell: SBUBaseMessageCell, newline: UIView, currentlyVisibleNewLines: Set) -> Set { + var tempCurrentlyVisibleNewLines = currentlyVisibleNewLines + + // Convert the newline's bounds into the tableView’s coordinate space + let newlineInTable = newline.convert(newline.bounds, to: tableView) + + // tableView.bounds is the visible area in its own coordinates + let visibleRect = tableView.bounds + + let newlineKey: String + if let message = messageCell.message { + newlineKey = "newline_\(message.messageId)" + } else { + newlineKey = "newline_cell_\(messageCell.hashValue)" + } + + // Check if the newline is actually visible on screen + if !newline.isHidden && visibleRect.intersects(newlineInTable) { + // newline is visible on screen + tempCurrentlyVisibleNewLines.insert(newlineKey) + } + + return tempCurrentlyVisibleNewLines + } + + /// Detects the moment when newline goes off-screen. + /// - Since: 3.32.0 + public func checkIfNewLineWentOffScreen(currentlyVisibleNewLines: Set ) { + // Find newlines that went off-screen + let newlinesGoneOffScreen = previouslyVisibleNewLines.subtracting(currentlyVisibleNewLines) + + for newlineKey in newlinesGoneOffScreen { + SBULog.info("Newline '\(newlineKey)' just went off-screen.") + + // This is the moment the newline goes off-screen. + // If unreMessagesCount > 0, update unreadMessageInfoView to be visible. + if let unreadMessageCount = channel?.unreadMessageCount, unreadMessageCount > 0 { + self.unreadMessageInfoView?.isHidden = false + + (self.unreadMessageInfoView as? SBUUnreadMessageInfoView)?.updateCount(replaceCount: unreadMessageCount) + + // Break after first detection, since we only want to handle one at a time + break + } + } } // MARK: - EmptyView @@ -426,12 +641,42 @@ extension SBUGroupChannelModule { let reply = self.createReplyMenuItem(for: message) items.append(reply) } + default: break } return items } + /// Creates a markAsUnread menu item. + /// - Parameters: + /// - message: The `BaseMessage` object that corresponds to the message of the menu item to show. + /// - isThreadMessage: Whether it is for thread message screen or not. + /// - Since: 3.32.0 + open override func createMarkAsUnreadMenuItem(for message: BaseMessage, isThreadMessage: Bool) -> SBUMenuItem? { + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + guard isMarkAsUnreadEnabled else { return nil } + + guard isThreadMessage == false else { return nil } + + let iconImage = SBUIconSetType.iconMarkAsUnread.image( + with: SBUTheme.componentTheme.alertButtonColor, + to: SBUIconSetType.Metric.iconActionSheetItem + ) + + let menuItem = SBUMenuItem( + title: SBUStringSet.MarkAsUnread, + color: self.theme?.menuTextColor, + image: iconImage + ) { [weak self, message] in + guard let self = self else { return } + // call channel.markAsUnread() + self.delegate?.groupChannelModule(self, didTapMarkAsUnread: message) + } + + return menuItem + } + open override func showMessageContextMenu(for message: BaseMessage, cell: UITableViewCell, forRowAt indexPath: IndexPath) { let messageMenuItems = self.createMessageMenuItems(for: message) guard !messageMenuItems.isEmpty else { return } @@ -671,13 +916,23 @@ extension SBUGroupChannelModule { indexPath: indexPath ) + // Update isFirstUnreadMessage only if markAsUnread feature is enabled. + var isFirstUnreadMessage = false + if SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled { + if let firstUnreadMessage = self.firstUnreadMessage, + firstUnreadMessage.messageId == message.messageId { + isFirstUnreadMessage = true + } + } + switch (message, messageCell) { // Admin message case let (adminMessage, adminMessageCell) as (AdminMessage, SBUAdminMessageCell): let configuration = SBUAdminMessageCellParams( message: adminMessage, hideDateView: isSameDay, - isThreadMessage: false + isThreadMessage: false, + isFirstUnreadMessage: isFirstUnreadMessage ) adminMessageCell.configure(with: configuration) self.setMessageCellAnimation(adminMessageCell, message: adminMessage, indexPath: indexPath) @@ -717,7 +972,8 @@ extension SBUGroupChannelModule { messageOffsetTimestamp: self.channel?.messageOffsetTimestamp ?? 0, shouldHideSuggestedReplies: shouldHideSuggestedReplies, shouldHideFormTypeMessage: false, - enableEmojiLongPress: enableEmojiLongPress + enableEmojiLongPress: enableEmojiLongPress, + isFirstUnreadMessage: isFirstUnreadMessage ) configuration.shouldHideFeedback = message.myFeedbackStatus == .notApplicable userMessageCell.configure(with: configuration) @@ -741,7 +997,8 @@ extension SBUGroupChannelModule { joinedAt: self.channel?.joinedAt ?? 0, messageOffsetTimestamp: self.channel?.messageOffsetTimestamp ?? 0, voiceFileInfo: voiceFileInfo, - enableEmojiLongPress: enableEmojiLongPress + enableEmojiLongPress: enableEmojiLongPress, + isFirstUnreadMessage: isFirstUnreadMessage ) configuration.shouldHideFeedback = message.myFeedbackStatus == .notApplicable @@ -774,7 +1031,8 @@ extension SBUGroupChannelModule { useMessagePosition: true, receiptState: receiptState, useReaction: true, - enableEmojiLongPress: enableEmojiLongPress + enableEmojiLongPress: enableEmojiLongPress, + isFirstUnreadMessage: isFirstUnreadMessage ) configuration.shouldHideFeedback = message.myFeedbackStatus == .notApplicable multipleFilesMessageCell.configure(with: configuration) @@ -854,6 +1112,11 @@ extension SBUGroupChannelModule { self.delegate?.groupChannelModule(self, didTapMentionUser: user) } + messageCell.urlTapHandler = { [weak self] url in + guard let self = self else { return } + self.delegate?.groupChannelModule(self, didTapURL: url) + } + messageCell.suggestedReplySelectHandler = { [weak self] optionView in guard let self = self else { return } self.delegate?.groupChannelModule(self, didSelect: optionView) @@ -1273,3 +1536,59 @@ extension SBUGroupChannelModule.List { } } } + +// - MARK: MarkAsRead +extension SBUGroupChannelModule.List { + /// Checks to call markAsRead(), if `unreadMessageNewLine` is displayed upon entering the channel. + /// - Since: 3.32.0 + public func checkForMarkAsRead() { + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + guard isMarkAsUnreadEnabled else { return } + + // Set initial shouldShowUnreadMessageInfoView value. + var shouldShowUnreadMessageInfoView: Bool = false + if let unreadCount = self.channel?.unreadMessageCount, unreadCount > 0 { + shouldShowUnreadMessageInfoView = true + } + + // Check all visible message cells. + let visibleCells = tableView.visibleCells + for case let messageCell as SBUBaseMessageCell in visibleCells { + guard let newline = messageCell.unreadMessageNewLine else { return } + + // Convert the newline bounds into the tableView’s coordinate space + let newlineInTable = newline.convert(newline.bounds, to: tableView) + + // tableView.bounds is the visible area in its own coordinates + let visibleRect = tableView.bounds + + // Check for full containment + if self.allowsAutoMarkAsReadOnScroll, + newline.isHidden == false, + visibleRect.contains(newlineInTable) { + // unreadMessageNewLine is entirely on-screen. + self.delegate?.groupChannelModule(self, didScrollToUnreadMessageNewLine: messageCell) + + shouldShowUnreadMessageInfoView = false + hasSeenNewLine = true + } else { + // unreadMessageNewLine is partially or completely off-screen + } + } + + if shouldShowUnreadMessageInfoView { + if let unreadCount = self.channel?.unreadMessageCount, unreadCount > 0 { + SBULog.info("Show unreadMessageInfoView") + self.unreadMessageInfoView?.isHidden = false + (self.unreadMessageInfoView as? SBUUnreadMessageInfoView)?.updateCount(replaceCount: unreadCount) + } + } + + // check for didUnreadMessageExist + guard let channel = self.channel else { + self.didUnreadMessageExist = false + return + } + self.didUnreadMessageExist = channel.myLastRead < (channel.lastMessage?.createdAt ?? -1) + } +} diff --git a/Sources/uikit/Module/Channel/GroupChannel/SBUGroupChannelModule.swift b/Sources/uikit/Module/Channel/GroupChannel/SBUGroupChannelModule.swift index da0ab6a..452fe64 100644 --- a/Sources/uikit/Module/Channel/GroupChannel/SBUGroupChannelModule.swift +++ b/Sources/uikit/Module/Channel/GroupChannel/SBUGroupChannelModule.swift @@ -87,6 +87,10 @@ extension SBUGroupChannelModule.List { /// Represents the type of profile view on the group channel module. /// - Since: 3.28.0 public static var UserProfileView: SBUUserProfileView.Type = SBUUserProfileView.self + + /// Represents the type of unread message info view in the group channel module. + /// - Since: 3.32.0 + public static var UnreadMessageInfoView: SBUUnreadMessageInfoView.Type = SBUUnreadMessageInfoView.self } // MARK: Input diff --git a/Sources/uikit/Module/Channel/SBUBaseChannelModule.List.swift b/Sources/uikit/Module/Channel/SBUBaseChannelModule.List.swift index 3effbc4..fb7cd50 100644 --- a/Sources/uikit/Module/Channel/SBUBaseChannelModule.List.swift +++ b/Sources/uikit/Module/Channel/SBUBaseChannelModule.List.swift @@ -294,6 +294,21 @@ extension SBUBaseChannelModule { /// - NOTE: You can use the customized view and a view that inherits `SBUNewMessageInfo`. public var newMessageInfoView: UIView? + /// A view that indicates the number of unread messages. + /// - Since: 3.32.0 + public var unreadMessageInfoView: UIView? + + /// Stack view that contains channelStateBanner and unreadMessageInfoView. + /// - Since: 3.32.0 + public lazy var topStackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 8 + stackView.alignment = .center + stackView.distribution = .fill + return stackView + }() + /// A view that scrolls table view to the bottom. /// The default view type is ``UIView``. public var scrollBottomView: UIView? @@ -339,6 +354,10 @@ extension SBUBaseChannelModule { SBUNewMessageInfo.createDefault(SBUNewMessageInfo.self) } + func createDefaultUnreadMessageInfoView() -> SBUUnreadMessageInfoView? { + SBUUnreadMessageInfoView.createDefault(SBUUnreadMessageInfoView.self) + } + // MARK: - Logic properties (Public) /// The current channel object from `baseChannelModule(_:channelForTableView:)` data source method. @@ -409,10 +428,6 @@ extension SBUBaseChannelModule { self.channelStateBanner = self.createDefaultChannelStateBanner() } - if let channelStateBanner = self.channelStateBanner { - self.addSubview(channelStateBanner) - } - if self.userProfileView == nil { self.userProfileView = self.createDefaultUserProfileView() } @@ -680,32 +695,51 @@ extension SBUBaseChannelModule { /// - Parameter message: The `BaseMessage` object that refers to the message of the menu to display. /// - Returns: The array of ``SBUMenuItem`` objects for a `message` open func createMessageMenuItems(for message: BaseMessage) -> [SBUMenuItem] { + return createMessageMenuItems(for: message, isThreadMessage: false) + } + + /// Creates an array of ``SBUMenuItem`` objects for a `message`. + /// - Parameter message: The `BaseMessage` object that refers to the message of the menu to display. + /// - Returns: The array of ``SBUMenuItem`` objects for a `message` + /// - Since: 3.32.0 + open func createMessageMenuItems(for message: BaseMessage, isThreadMessage: Bool) -> [SBUMenuItem] { let isSentByMe = message.sender?.userId == SBUGlobals.currentUser?.userId var items: [SBUMenuItem] = [] switch message { case is UserMessage: - // UserMessage: copy, (edit), (delete) + // UserMessage: copy, (edit), (markAsUnread), (delete) let copy = self.createCopyMenuItem(for: message) items.append(copy) if isSentByMe { let edit = self.createEditMenuItem(for: message) - let delete = self.createDeleteMenuItem(for: message) items.append(edit) + } + if let markAsUnread = self.createMarkAsUnreadMenuItem(for: message, isThreadMessage: isThreadMessage) { + items.append(markAsUnread) + } + if isSentByMe { + let delete = self.createDeleteMenuItem(for: message) items.append(delete) } case let fileMessage as FileMessage: - // FileMessage: save, (delete) + // FileMessage: save, markAsUnread, (delete) let save = self.createSaveMenuItem(for: message) if SBUUtils.getFileType(by: fileMessage) != .voice { items.append(save) } + if let markAsUnread = self.createMarkAsUnreadMenuItem(for: message, isThreadMessage: isThreadMessage) { + items.append(markAsUnread) + } if isSentByMe { let delete = self.createDeleteMenuItem(for: message) items.append(delete) } case is MultipleFilesMessage: - // MultipleFilesMessage: delete + // MultipleFilesMessage: (markAsUnread), delete + if let markAsUnread = self.createMarkAsUnreadMenuItem(for: message, isThreadMessage: isThreadMessage) { + items.append(markAsUnread) + } if isSentByMe { let delete = self.createDeleteMenuItem(for: message) items.append(delete) @@ -846,6 +880,15 @@ extension SBUBaseChannelModule { return menuItem } + /// Creates a markAsUnread menu item. + /// - Parameters: + /// - message: The `BaseMessage` object that corresponds to the message of the menu item to show. + /// - isThreadMessage: Whether it is for thread message screen or not. + /// - Since: 3.32.0 + open func createMarkAsUnreadMenuItem(for message: BaseMessage, isThreadMessage: Bool) -> SBUMenuItem? { + return nil + } + // MARK: - Actions /// Sets up the cell's tap gesture for handling the message. diff --git a/Sources/uikit/Module/MessageThread/SBUMessageThreadModule.List.swift b/Sources/uikit/Module/MessageThread/SBUMessageThreadModule.List.swift index c26a1a1..4f3e0b6 100644 --- a/Sources/uikit/Module/MessageThread/SBUMessageThreadModule.List.swift +++ b/Sources/uikit/Module/MessageThread/SBUMessageThreadModule.List.swift @@ -35,6 +35,12 @@ public protocol SBUMessageThreadModuleListDelegate: SBUBaseChannelModuleListDele /// - user: The`SBUUser` object from the tapped mention. func messageThreadModule(_ listComponent: SBUMessageThreadModule.List, didTapMentionUser user: SBUUser) + /// Called when URL link in a message cell is tapped. + /// - Parameters: + /// - URL: The`URL` object from the tapped URL link. + /// - Since: 3.32.0 + func messageThreadModule(_ listComponent: SBUMessageThreadModule.List, didTapURL url: URL) + /// Called when one of the files is selected in the multiple file message cell. /// - Parameters: /// - listComponent: `SBUMessageThreadModule.List ` object. @@ -344,6 +350,11 @@ extension SBUMessageThreadModule { self.delegate?.messageThreadModule(self, didTapMentionUser: user) } + self.parentMessageInfoView.urlTapHandler = { [weak self] url in + guard let self = self else { return } + self.delegate?.messageThreadModule(self, didTapURL: url) + } + self.parentMessageInfoView.errorHandler = { [weak self] error in guard let self = self else { return } self.delegate?.didReceiveError(error, isBlocker: false) @@ -376,7 +387,7 @@ extension SBUMessageThreadModule { } open override func createMessageMenuItems(for message: BaseMessage) -> [SBUMenuItem] { - let items = super.createMessageMenuItems(for: message) + let items = super.createMessageMenuItems(for: message, isThreadMessage: true) return items } @@ -449,6 +460,11 @@ extension SBUMessageThreadModule { guard let self = self else { return } self.delegate?.messageThreadModule(self, didTapMentionUser: user) } + + cell.urlTapHandler = { [weak self] url in + guard let self = self else { return } + self.delegate?.messageThreadModule(self, didTapURL: url) + } } // MARK: - TableView diff --git a/Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/Contents.json b/Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/Contents.json new file mode 100644 index 0000000..860a945 --- /dev/null +++ b/Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/Contents.json @@ -0,0 +1,44 @@ +{ + "images" : [ + { + "filename" : "feature-chat.png", + "idiom" : "universal", + "language-direction" : "left-to-right", + "scale" : "1x" + }, + { + "filename" : "feature-chat 1.png", + "idiom" : "universal", + "language-direction" : "right-to-left", + "scale" : "1x" + }, + { + "filename" : "feature-chat@2x.png", + "idiom" : "universal", + "language-direction" : "left-to-right", + "scale" : "2x" + }, + { + "filename" : "feature-chat@2x 1.png", + "idiom" : "universal", + "language-direction" : "right-to-left", + "scale" : "2x" + }, + { + "filename" : "feature-chat@3x.png", + "idiom" : "universal", + "language-direction" : "left-to-right", + "scale" : "3x" + }, + { + "filename" : "feature-chat@3x 1.png", + "idiom" : "universal", + "language-direction" : "right-to-left", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat 1.png b/Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat 1.png new file mode 100644 index 0000000000000000000000000000000000000000..9f9c5731231cf114f1586a487c4b5eb19af926d3 GIT binary patch literal 597 zcmV-b0;>IqP)ZX^dj0F;;_Yyw| zmhcRn!4TI70nccAH0V6h00d4n0D%(?K;T3J5IE5Q1Wq&nfz!kQ=hypm7}oq&wXDT+ z10Qe`UdJ%r6qUmz-Q|{+rh3Y*UqWF{@EXlyxtyAomQtQ?dztUQvGT|>& zeSL|Z&<)nuxw~%(%a}6H90eR_gNdjw9;^7wGN!wpxe9d8mIhhRn}w1z#LDCC7xrzABqRB#&3z9>)2p{s)ldc3=o#sM(Z5oAQ6T$GjIrZ zHW&|qFbFf?W55(>JPEiN@NNcTF(B0PYvhT=0EI9-nt@mhpsAZ<7^N*oO#cCwu&iMZ z9$jwRx;_rPfP;mP=8G|K$fB$#MtMDxf|JMLQz&^&GqQ;2qN_2!cDN)UY z6?V;@q_~7;t2)D#S;pQizgHq7f&cKE}l?eTs)z`xOhT=aq+|) ei9{lim_A=KGe)3E7RB8F0000Jqb?A-U#)>7g9AD7oCy&WX{@uD_Qh z*PSSRP!yTX`fcms4n>Ki`SL-h4|k_+6TQ)%(Ac<^;f_zpbw>FMMcN8qMH}T7%2zfY z3@+hWz4KHOL-Va|i_?MGPwlWy2GY}0+mG)2XXA^k!s zx0jG~16NfUv(PaqkkF=2Tj#`VtdMhHZGU;5@l8T>E9W`ZH&-+KHv7Fa)#%=*+<58F zwHa<_-X@tSw13p!xr?QfOZXkrw=5Iij}h*yTi0)2uqMc#?c{+A5{ftW`|}2!Ox&@$ zb?JV?tF|0bp(nO*pU7?A(_Xdo{`>pktVK)L*HIvEUi%Q`3o!i86J&$i}J9qiatoFy4i zOq@+yxQseQ+&@i`er51s#oFK=>uU}ZDxm=mau?np3R_;DgK>npFrnJsxn{tU|} z2CjPlhr!UFKYGzj1E+uOpBax$e6F;z#%b3v_mk73)PIDFe*8Z-sEjfCf`nGfc`hm4 zrNS0pi<@;;8U%0zF>PRtZZr&)P+(a(*=4Wu<%7xk^)jL&QI6@)S3bGrx@TMPI?X#_ z8ETy>wQ^tnC96n&oZRkgF+pwaYm4Ur8+g9hFw4KO&~`resPfJ=?mv}59KHRK?F&VeoYIb6Mw<&;$UW;qUzb literal 0 HcmV?d00001 diff --git a/Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat@2x.png b/Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..be8d7490c0d0aa6d5f4b168ba99224072d3aa1ed GIT binary patch literal 1104 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrU=jCpaSW+oe0#UM@QI5^>%+s! z4DAX^3`!2tjA~3)%qJ4sICunmIvehC=qM$0CRj1gIk-TtH+ygN?%P@Z`%S+8UzN8r zCU5%Fo$f)|a=M&AgBchiKDJ)CbT(#t@n3`M<P-;4h)d&+UKwSZ&y&-i$#0sQ*7~MD9(SD<%2-m;c*Y7dEjSZ8bdZb$fGgs{QJ_&G&cl zb#Yh!v!A*>d%0Jh>Rg`d3w8)S|6^*pYJ=l8wJP~*j)m(UerpZ>)XQb%VI{dPu<-b? z+ZD^5*gVw_mxj#A-cfr#`vO*%8zS_sWe3DpdKR@!h)qckQBsD zZo5w1ImclogUzXV_h!l0NJ~$s5^czpOk(D6uxmE!V32TU`1J9We~F$Oh(3S&1_RTC z{_y&6dpolRh9{zTA8Y-#2C=@n)y@QIiu?ZRxrxA`7s<^)_QIcg_b`an$|@)@{9If0 zfctaQPb~$;6X&yk9`W;De~nLtA+=?3L%6i3ytp!ux$}Vb)%gN#r!Ul=(idnb+ViUI z!1=F}g#;Tm9g>SwX7R5-t}V!@p>OiMdg^T9mh$+$D}FLEt~h-DYwxEx*$UnEKOerm z*IF*Qye5DCwww!}dLF9gf4{f-!}0xg$2u9Fm}FGW`4{v@`J21$^l6KYynn_peEJmC z$MPMR8jA1Ec^&v(tW&G3L!II8hpZ1z1SdN@<~Z>6rz!;HutEg!M)ctwGR zx6)g5ZV9dvaZ^na+7Y|hNXN>PVcX-MyAEwX^~FVi@kZZ;FU>~DdrrMn zCdlxZqoI)HfCUpJ!VPadep>8^x1PX|b9h1O+xc&@*7FPe>y*pibZ4{U%he5Q_Ekro zWoUWo^UW!9vXI!cHKkk&etuQCyJeLdLq?r#oOZt0*Ci!DX6(azjCN1Oz1SL_T^CiW zJGp(0wkc=Iw)pASx!;M40CT{FKUI5nr1nIvFMas8gX4giZ`8M61vmD;NE2EkvWxd$ z%>S&LuhmT-eEB3TTf6Gpp)`w&HJ9gP?922sxo@IAD}R>oS@W}<&t^Y!`RrS~=;ty^ zVx|ATs+nqFw&%M29?e4i8B;bnv4V3i1H%I8|I9P;K8E@v{Z|DEdAjKA-pJ{rchk%jbDMadxMUDZ>$P0D!Xf zZdqPsU#L%Yw1ao)yPNLp%ntr+Q4y?W zOX;H=rF9G`zTKRJp|z-Yo7E00uXD$zl_YbLuDlAumdtZnkVB{6NfmONVQ)iYV$%+)~238y@n zZaFpiBZS6^1A!oQbMaq&r{`Q~6lk|&W0<5lxfvP6oJd43vvGSyVQm~n^>c#KfeUkjc{xSQXjsT7ZoHsgEIV_)o@M_r75vUs*X{H<%RM;8wNl0oc7^cciIa!{!ue1Jwvz2NUn4i z$~`dm+!E+PM%7-$JNK0|R6AfFe7#oAHkQStFCL@gGv*6;VA z*ew&5bYl(bA#r#uzq(bfDj6G5r#3${j!fgCXsk*19gztDqx=;O&xHHF?%Zjo=#VCc z5v%S)3+uIr9Q6FYq|;sSpHd@JVIs` z0~fHoSfL{!R?L-&IH7~~Doa36@K=^A+NHw5HV)}EjWqGTU}koJz7>OQtDg^UaTj@1 z*=G#3HtuZ6h4g=V3`+pp&`?VI9hiXKee(#h^e(ghj5Eh*1T(A}qSAo7Wg}QAhYLzQ z;VLKbpK3^<)_D)Ndg?(@;OJ6e3+cz+!% zfJMq9_>?o0=-G1DYz@VQ2EjF-{Y8y_PwUixc@JmKGUnwMq_5_KkfASS3H~X|d6fs> zH1HRU6W-6*eRzCD{v#_lnV734U<}4kDtnFBmuW)AnhfE@tnIkyY#camTeR^KWGpmN zH^3088Gf#C&6xAxgk4smV}m)`Da5}`J$U5?LMsr?8Pye82%<2ThE2T;yjLxi@B%uw zIg&eww3+I0Q%$A|GP~@ XDxfXMjALeem+!>d>XaqRf*Acba+&9a literal 0 HcmV?d00001 diff --git a/Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat@3x.png b/Sources/uikit/Resource/Assets.xcassets/iconMarkAsUnread.imageset/feature-chat@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..28e4d3e05f556c5f57f28a682b0f213f96560a01 GIT binary patch literal 1556 zcmcgsi#O8?9RKZCi?&gbTi4@Kp&=yPxOO+>k#)_;V~EHtHu7kaifu_AmDCaUGLK1` zHIs_w6;m8XuMJ(xb8RUl%ln!cySnF|?muwP{hssven02)Ip_O5=kq=1n~lf0tHHEk z005|Y9CYzn>%6b2x?!#Q49BPd0CdR1#mP4rA|1)~)^XSEoEg6gkN=4f5XJ;=!kksS zRUVlE0YD9K=6a$*BgjZQY)bQWj2-W=i=XPV6(L&6g$_aSS)E4gMC6 z@X(D1=kAm(E}!KpwUxm#u}JzSd5F)Y)W`>5Rd|Ff0Yj(6${Xo+Gv}2Pk>(80O12vd zUy0~jAeWZ~s2emN*q|)W#+X$luqkK~0gPDwttr0bgYzee|L(5SeXMp)PcPT-RiD&y ziX~Yr)svk`=`J>rP9dF5w%zH|jcyX~-(R4%S@p3b<~=G2SK8)}L^UyX#8<9(mYD8= zaOG!>qly^&o>DO-rY26qvoXG3paZkTq$0+Hzd2;P+xw%}&2ht|4LRH5YMU-my6ivr zKt)z3hVtSpY&>&>1BI|U|B!9RqBU(s&y|a$oAYK53MbPhg4}}XZMLEK5n&*+gCnpk z;%D1+-RQdsDp*bT9x}u>?Z1-sGS#&Qvrv*P?~dBy@H)ak#gOPr+qEBsu=t&0^PcbO zPnC3<3&#AyYQXCB;yW=^f08@uHr zfQ@IRiTGJyoylnT9&t}OS&NcL2kTC9P;4hfp=&B)A_D2S?2KUs8O!!Bm-)SfCn0IM z0)Bwr3D+tM*&>q0tk3XL!P+0%+IFTQoSkPRYcbwVCJ>XOXLqSNsmqtV=_Rdgx^w50 z3)n&tGi%Midn#)N0?}K6ek4HBnu;l70Vu@fO3wUBW7Z@RDA15M;=`B90lT_}5&NE} zUQNf{A@N8rXc@@xu$rHV>TrXPjn~r@X&C~2V#CGeH;o1`+OXtL!R7Wr9Pz@aPcM~SL&+hcwKkZ2;+qP%tUz2^z@` zp@Nz5SNg(>7nuOk6>%Q1+W1{cIUMS!&Rx#VojelVkk{;2c?xZjVrpRhWK(pye!nWF zdJBcHOHu143(uK;sx8j6{VxQm@3)`(4e*>-*Xh4$8FP4^F#DDCaK*XQIESVE1^Bwm AjsO4v literal 0 HcmV?d00001 diff --git a/Sources/uikit/Theme/SBUIconSet.swift b/Sources/uikit/Theme/SBUIconSet.swift index 6ff38f5..93216b6 100644 --- a/Sources/uikit/Theme/SBUIconSet.swift +++ b/Sources/uikit/Theme/SBUIconSet.swift @@ -214,6 +214,11 @@ public class SBUIconSet { public static var iconBad = SBUIconSetType.iconBad.load() { didSet { SBUIconSetType.iconBad.markCustomized() } } + + /// - Since: 3.32.0 + public static var iconMarkAsUnread: UIImage = SBUIconSetType.iconMarkAsUnread.load() { + didSet { SBUIconSetType.iconMarkAsUnread.markCustomized() } + } /// Restore all customized icons to SDK's default icons. /// diff --git a/Sources/uikit/Theme/SBUTheme.swift b/Sources/uikit/Theme/SBUTheme.swift index 5a0231a..218fa29 100644 --- a/Sources/uikit/Theme/SBUTheme.swift +++ b/Sources/uikit/Theme/SBUTheme.swift @@ -3033,6 +3033,17 @@ public class SBUComponentTheme { // Feedback theme.feedbackToastUpdateDoneColor = SBUColorSet.secondaryLight // 3.15.0 + // Unread Message (3.32.0) + theme.unreadMessageFont = SBUFontSet.body2 + theme.unreadMessageLabelTintColor = SBUColorSet.onLightTextMidEmphasis + theme.unreadMessageButtonTintColor = SBUColorSet.primaryMain + theme.unreadMessageBackground = SBUColorSet.background50 + + // Unread Message New Line (3.32.0) + theme.newLineLabelFont = SBUFontSet.caption3 + theme.newLineLabelTintColor = SBUColorSet.primaryMain + theme.newLineTintColor = SBUColorSet.primaryMain + return theme } @@ -3150,6 +3161,17 @@ public class SBUComponentTheme { theme.feedbackToastUpdateDoneColor = SBUColorSet.secondaryMain // 3.15.0 + // Unread Message (3.32.0) + theme.unreadMessageFont = SBUFontSet.body2 + theme.unreadMessageLabelTintColor = SBUColorSet.onDarkTextMidEmphasis + theme.unreadMessageButtonTintColor = SBUColorSet.primaryLight + theme.unreadMessageBackground = SBUColorSet.background400 + + // Unread Message New Line (3.32.0) + theme.newLineLabelFont = SBUFontSet.caption3 + theme.newLineLabelTintColor = SBUColorSet.primaryLight + theme.newLineTintColor = SBUColorSet.primaryLight + return theme } @@ -3347,7 +3369,16 @@ public class SBUComponentTheme { loadingSpinnerColor: UIColor = SBUColorSet.primaryMain, toastContainerColor: UIColor = SBUColorSet.background700, // 3.15.0 toastTitleColor: UIColor = SBUColorSet.onDarkTextHighEmphasis, // 3.15.0 - feedbackToastUpdateDoneColor: UIColor = SBUColorSet.secondaryLight // 3.15.0 + feedbackToastUpdateDoneColor: UIColor = SBUColorSet.secondaryLight, // 3.15.0 + + unreadMessageFont: UIFont = SBUFontSet.body2, + unreadMessageLabelTintColor: UIColor = SBUColorSet.onLightTextMidEmphasis, + unreadMessageBackground: UIColor = SBUColorSet.background50, + unreadMessageButtonTintColor: UIColor = SBUColorSet.primaryMain, + + newLineLabelFont: UIFont = SBUFontSet.caption3, + newLineLabelTintColor: UIColor = SBUColorSet.primaryMain, + newLineTintColor: UIColor = SBUColorSet.primaryMain ) { self.emptyViewBackgroundColor = emptyViewBackgroundColor @@ -3440,6 +3471,17 @@ public class SBUComponentTheme { // Feedback toast self.feedbackToastUpdateDoneColor = feedbackToastUpdateDoneColor // 3.15.0 + + // Unread Message (3.32.0) + self.unreadMessageFont = unreadMessageFont + self.unreadMessageLabelTintColor = unreadMessageLabelTintColor + self.unreadMessageButtonTintColor = unreadMessageButtonTintColor + self.unreadMessageBackground = unreadMessageBackground + + // Unread Message New line (3.32.0) + self.newLineLabelFont = newLineLabelFont + self.newLineLabelTintColor = newLineLabelTintColor + self.newLineTintColor = newLineTintColor } // EmptyView @@ -3558,6 +3600,17 @@ public class SBUComponentTheme { // Feedback public var feedbackToastUpdateDoneColor: UIColor // 3.15.0 + + // Unread Message + public var unreadMessageFont: UIFont // 3.32.0 + public var unreadMessageLabelTintColor: UIColor // 3.32.0 + public var unreadMessageButtonTintColor: UIColor // 3.32.0 + public var unreadMessageBackground: UIColor // 3.32.0 + + // Unread Message New Line + public var newLineLabelFont: UIFont // 3.32.0 + public var newLineLabelTintColor: UIColor // 3.32.0 + public var newLineTintColor: UIColor // 3.32.0 } // MARK: - Message Search Theme diff --git a/Sources/uikit/View/Channel/CellView/SBUUnreadMessageNewLine.swift b/Sources/uikit/View/Channel/CellView/SBUUnreadMessageNewLine.swift new file mode 100644 index 0000000..ac9f0d8 --- /dev/null +++ b/Sources/uikit/View/Channel/CellView/SBUUnreadMessageNewLine.swift @@ -0,0 +1,96 @@ +// +// SBUUnreadMessageNewLine.swift +// SendbirdUIKit +// +// Created by Celine Moon on 5/13/25. +// + +import UIKit + +/// The view that shows the new line for first unread message of the channel. +/// - Since: 3.32.0 +public class SBUUnreadMessageNewLine: SBUView { + private let label: UILabel = { + let label = UILabel() + label.text = SBUStringSet.Channel_Unread_Message_Newline + label.translatesAutoresizingMaskIntoConstraints = false + + // prevent the label from stretching + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .horizontal) + return label + }() + + private let leftLine: UIView = { + let line = UIView() + line.translatesAutoresizingMaskIntoConstraints = false + line.heightAnchor.constraint(equalToConstant: 1).isActive = true + line.setContentHuggingPriority(.defaultLow, for: .horizontal) + line.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return line + }() + + private let rightLine: UIView = { + let line = UIView() + line.translatesAutoresizingMaskIntoConstraints = false + line.backgroundColor = .systemGray3 + line.heightAnchor.constraint(equalToConstant: 1).isActive = true + line.setContentHuggingPriority(.defaultLow, for: .horizontal) + line.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + return line + }() + + private lazy var stackView: SBUStackView = { + let stackView = SBUStackView(axis: .horizontal, alignment: .center, spacing: 4) + stackView.distribution = .fill + stackView.translatesAutoresizingMaskIntoConstraints = false + return stackView + }() + + // MARK: - Properties (Private) + @SBUThemeWrapper(theme: SBUTheme.componentTheme) + var theme: SBUComponentTheme + + public override init() { + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError() + } + + public override func setupViews() { + super.setupViews() + + self.stackView.setHStack([ + self.leftLine, + self.label, + self.rightLine + ]) + self.addSubview(self.stackView) + } + + public override func setupLayouts() { + super.setupLayouts() + + self.leftLine.widthAnchor.constraint(equalTo: self.rightLine.widthAnchor).isActive = true + + self.stackView.sbu_constraint( + equalTo: self, + left: 7.5, + right: 7.5, + top: 0, + bottom: 0 + ) + } + + public override func setupStyles() { + super.setupStyles() + + label.font = theme.newLineLabelFont + label.textColor = theme.newLineLabelTintColor + + leftLine.backgroundColor = theme.newLineTintColor + rightLine.backgroundColor = theme.newLineTintColor + } +} diff --git a/Sources/uikit/View/Channel/CellView/SBUUserMessageTextView.swift b/Sources/uikit/View/Channel/CellView/SBUUserMessageTextView.swift index e964150..791b672 100644 --- a/Sources/uikit/View/Channel/CellView/SBUUserMessageTextView.swift +++ b/Sources/uikit/View/Channel/CellView/SBUUserMessageTextView.swift @@ -15,6 +15,13 @@ public protocol SBUUserMessageTextViewDelegate: AnyObject { /// textView: `SBUUserMessageTextView` object that contains the message text. /// user: The user corresponding to tapped mention. func userMessageTextView(_ textView: SBUUserMessageTextView, didTapMention user: SBUUser) + + /// Called when the URL link in message is tapped. + /// - Parameters: + /// textView: `SBUUserMessageTextView` object that contains the message text. + /// URL: The URL link that is tapped. + /// - Since: 3.32.0 + func userMessageTextView(_ textView: SBUUserMessageTextView, didTapURL url: URL) } open class SBUUserMessageTextView: SBUView { @@ -247,7 +254,7 @@ extension SBUUserMessageTextView: UITextViewDelegate { self.longPressHandler?(URL) } else if interaction == .invokeDefaultAction { // URL link tapped - URL.open() + self.delegate?.userMessageTextView(self, didTapURL: URL) } return false @@ -272,7 +279,7 @@ extension SBUUserMessageTextView: UITextViewDelegate { } else if let tappedURL = textView.textStorage.attribute(.link, at: characterRange.location, effectiveRange: nil) as? URL { // URL link tapped return UIAction(title: "Link Tapped") { _ in - tappedURL.open() + self.delegate?.userMessageTextView(self, didTapURL: tappedURL) } } diff --git a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUAdminMessageCellParams.swift b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUAdminMessageCellParams.swift index c5edc52..29581e7 100644 --- a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUAdminMessageCellParams.swift +++ b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUAdminMessageCellParams.swift @@ -13,14 +13,20 @@ public class SBUAdminMessageCellParams: SBUBaseMessageCellParams { self.message as? AdminMessage } - public init(message: AdminMessage, hideDateView: Bool, isThreadMessage: Bool = false) { + public init( + message: AdminMessage, + hideDateView: Bool, + isThreadMessage: Bool = false, + isFirstUnreadMessage: Bool = false + ) { super.init( message: message, hideDateView: hideDateView, messagePosition: .center, groupPosition: .none, receiptState: SBUMessageReceiptState.none, - isThreadMessage: isThreadMessage + isThreadMessage: isThreadMessage, + isFirstUnreadMessage: isFirstUnreadMessage ) } } diff --git a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUBaseMessageCellParams.swift b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUBaseMessageCellParams.swift index e617e68..e092a5d 100644 --- a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUBaseMessageCellParams.swift +++ b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUBaseMessageCellParams.swift @@ -53,6 +53,9 @@ public class SBUBaseMessageCellParams { /// - Since: 3.28.0 var isThreadMessage: Bool = false + /// - Since: 3.32.0 + public var isFirstUnreadMessage: Bool = false + /** - Parameters: - messagePosition: Cell position (left / right / center) @@ -67,7 +70,9 @@ public class SBUBaseMessageCellParams { isThreadMessage: Bool = false, joinedAt: Int64 = 0, shouldHideFeedback: Bool = true, - messageOffsetTimestamp: Int64 = 0) { + messageOffsetTimestamp: Int64 = 0, + isFirstUnreadMessage: Bool = false + ) { self.message = message self.hideDateView = hideDateView self.messagePosition = messagePosition @@ -94,5 +99,7 @@ public class SBUBaseMessageCellParams { self.joinedAt = joinedAt self.messageOffsetTimestamp = messageOffsetTimestamp + + self.isFirstUnreadMessage = isFirstUnreadMessage } } diff --git a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUFileMessageCellParams.swift b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUFileMessageCellParams.swift index ede4d42..a1d29da 100644 --- a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUFileMessageCellParams.swift +++ b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUFileMessageCellParams.swift @@ -32,7 +32,8 @@ public class SBUFileMessageCellParams: SBUBaseMessageCellParams { joinedAt: Int64 = 0, messageOffsetTimestamp: Int64 = 0, voiceFileInfo: SBUVoiceFileInfo? = nil, - enableEmojiLongPress: Bool = true + enableEmojiLongPress: Bool = true, + isFirstUnreadMessage: Bool = false ) { self.useReaction = useReaction @@ -52,7 +53,8 @@ public class SBUFileMessageCellParams: SBUBaseMessageCellParams { receiptState: receiptState, isThreadMessage: isThreadMessage, joinedAt: joinedAt, - messageOffsetTimestamp: messageOffsetTimestamp + messageOffsetTimestamp: messageOffsetTimestamp, + isFirstUnreadMessage: isFirstUnreadMessage ) self.voiceFileInfo = voiceFileInfo ?? SBUVoiceFileInfo.createVoiceFileInfo(with: message) diff --git a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUMultipleFilesMessageCellParams.swift b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUMultipleFilesMessageCellParams.swift index 5a5f144..8050a38 100644 --- a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUMultipleFilesMessageCellParams.swift +++ b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUMultipleFilesMessageCellParams.swift @@ -32,7 +32,8 @@ public class SBUMultipleFilesMessageCellParams: SBUBaseMessageCellParams { isThreadMessage: Bool = false, joinedAt: Int64 = 0, voiceFileInfo: SBUVoiceFileInfo? = nil, - enableEmojiLongPress: Bool = true + enableEmojiLongPress: Bool = true, + isFirstUnreadMessage: Bool = false ) { self.useReaction = useReaction @@ -51,7 +52,8 @@ public class SBUMultipleFilesMessageCellParams: SBUBaseMessageCellParams { groupPosition: groupPosition, receiptState: receiptState, isThreadMessage: isThreadMessage, - joinedAt: joinedAt + joinedAt: joinedAt, + isFirstUnreadMessage: isFirstUnreadMessage ) } } diff --git a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUUserMessageCellParams.swift b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUUserMessageCellParams.swift index 6ab0c5c..ec78eb4 100644 --- a/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUUserMessageCellParams.swift +++ b/Sources/uikit/View/Channel/MessageCell/MessageCellParams/SBUUserMessageCellParams.swift @@ -43,7 +43,8 @@ public class SBUUserMessageCellParams: SBUBaseMessageCellParams { messageOffsetTimestamp: Int64 = 0, shouldHideSuggestedReplies: Bool = true, shouldHideFormTypeMessage: Bool = true, - enableEmojiLongPress: Bool = true + enableEmojiLongPress: Bool = true, + isFirstUnreadMessage: Bool = false ) { self.useReaction = useReaction self.withTextView = withTextView @@ -65,7 +66,8 @@ public class SBUUserMessageCellParams: SBUBaseMessageCellParams { receiptState: receiptState, isThreadMessage: isThreadMessage, joinedAt: joinedAt, - messageOffsetTimestamp: messageOffsetTimestamp + messageOffsetTimestamp: messageOffsetTimestamp, + isFirstUnreadMessage: isFirstUnreadMessage ) } } diff --git a/Sources/uikit/View/Channel/MessageCell/SBUBaseMessageCell.swift b/Sources/uikit/View/Channel/MessageCell/SBUBaseMessageCell.swift index 9fbcd1a..c3098cd 100644 --- a/Sources/uikit/View/Channel/MessageCell/SBUBaseMessageCell.swift +++ b/Sources/uikit/View/Channel/MessageCell/SBUBaseMessageCell.swift @@ -26,6 +26,16 @@ open class SBUBaseMessageCell: SBUTableViewCell, SBUMessageCellProtocol, SBUFeed // Used to display the date separator in the message list. public lazy var dateView: UIView = SBUMessageDateView() + + /// A horizontal line that marks the first unread message. + /// - Since: 3.32.0 + public lazy var unreadMessageNewLine: UIView? = { + if SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled { + return SBUUnreadMessageNewLine() + } else { + return nil + } + }() @SBUThemeWrapper(theme: SBUTheme.messageCellTheme) public var theme: SBUMessageCellTheme @@ -71,6 +81,7 @@ open class SBUBaseMessageCell: SBUTableViewCell, SBUMessageCellProtocol, SBUFeed var moreEmojiTapHandler: (() -> Void)? var emojiLongPressHandler: ((_ emojiKey: String) -> Void)? var mentionTapHandler: ((_ user: SBUUser) -> Void)? + var urlTapHandler: ((_ url: URL) -> Void)? // 3.32.0 var errorHandler: ((_ error: SBError) -> Void)? /// The action of ``SBUSuggestedReplyView`` that is called when a ``SBUSuggestedReplyOptionView`` is selected. @@ -108,15 +119,19 @@ open class SBUBaseMessageCell: SBUTableViewCell, SBUMessageCellProtocol, SBUFeed open override func setupViews() { self.dateView.isHidden = true + self.unreadMessageNewLine?.isHidden = true - // + ------------------ + - // | dateView | - // + ------------------ + - // | messageContentView | - // + ------------------ + + // + --------------------- + + // | dateView | + // + --------------------- + + // | unreadMessageNewLine | + // + --------------------- + + // | messageContentView | + // + --------------------- + self.stackView.setVStack([ self.dateView, + self.unreadMessageNewLine, self.messageContentView ]) self.contentView.addSubview(self.stackView) @@ -175,6 +190,9 @@ open class SBUBaseMessageCell: SBUTableViewCell, SBUMessageCellProtocol, SBUFeed self.receiptState = configuration.receiptState self.shouldHideFeedback = configuration.shouldHideFeedback + // MARK: Set up Unread Message Mark View + self.unreadMessageNewLine?.isHidden = (configuration.isFirstUnreadMessage == false) + var didApplyEntireCellViewConverter = false #if SWIFTUI if self.configuration?.isThreadMessage == false { diff --git a/Sources/uikit/View/Channel/MessageCell/SBUContentBaseMessageCell.swift b/Sources/uikit/View/Channel/MessageCell/SBUContentBaseMessageCell.swift index 0012d53..2a4832d 100644 --- a/Sources/uikit/View/Channel/MessageCell/SBUContentBaseMessageCell.swift +++ b/Sources/uikit/View/Channel/MessageCell/SBUContentBaseMessageCell.swift @@ -433,6 +433,8 @@ open class SBUContentBaseMessageCell: SBUBaseMessageCell { // MARK: Group messages self.setMessageGrouping() + + // Configure --- New Message --- view } public func setupQuotedMessageView(joinedAt: Int64 = 0, messageOffsetTimestamp: Int64 = 0) { diff --git a/Sources/uikit/View/Channel/MessageCell/SBUUserMessageCell.swift b/Sources/uikit/View/Channel/MessageCell/SBUUserMessageCell.swift index 720fb6f..adbcdcb 100644 --- a/Sources/uikit/View/Channel/MessageCell/SBUUserMessageCell.swift +++ b/Sources/uikit/View/Channel/MessageCell/SBUUserMessageCell.swift @@ -197,6 +197,7 @@ open class SBUUserMessageCell: SBUContentBaseMessageCell, SBUUserMessageTextView open override func configure(with configuration: SBUBaseMessageCellParams) { guard let configuration = configuration as? SBUUserMessageCellParams else { return } guard let message = configuration.userMessage else { return } + // Set using reaction self.useReaction = configuration.useReaction self.enableEmojiLongPress = configuration.enableEmojiLongPress @@ -349,7 +350,7 @@ open class SBUUserMessageCell: SBUContentBaseMessageCell, SBUUserMessageTextView return } - url.open() + self.urlTapHandler?(url) } // MARK: - Suggested Reply @@ -455,6 +456,12 @@ open class SBUUserMessageCell: SBUContentBaseMessageCell, SBUUserMessageTextView open func userMessageTextView(_ textView: SBUUserMessageTextView, didTapMention user: SBUUser) { self.mentionTapHandler?(user) } + + // MARK: - URL + /// - Since: 3.32.0 + open func userMessageTextView(_ textView: SBUUserMessageTextView, didTapURL url: URL) { + self.urlTapHandler?(url) + } // MARK: - Suggested reply delegate diff --git a/Sources/uikit/View/Channel/NewMessageInfo/SBUUnreadMessageInfoView.swift b/Sources/uikit/View/Channel/NewMessageInfo/SBUUnreadMessageInfoView.swift new file mode 100644 index 0000000..2350054 --- /dev/null +++ b/Sources/uikit/View/Channel/NewMessageInfo/SBUUnreadMessageInfoView.swift @@ -0,0 +1,137 @@ +// +// SBUUnreadMessageInfoView.swift +// SendbirdUIKit +// +// Created by Celine Moon on 6/19/25. +// + +import UIKit + +/// The view that shows the number of unread messages in a group channel. +/// - Since: 3.32.0 +public class SBUUnreadMessageInfoView: SBUView { + public lazy var baseStackView: SBUStackView = { + let view = SBUStackView(axis: .horizontal, spacing: 4) + view.clipsToBounds = false + view.isLayoutMarginsRelativeArrangement = true + view.layoutMargins = UIEdgeInsets(top: -2, left: 0, bottom: -2, right: 0) + return view + }() + + public lazy var unreadMessageCountLabel: UILabel = { + let label = UILabel() + label.text = "" + label.font = theme.unreadMessageFont + label.textColor = theme.unreadMessageLabelTintColor + return label + }() + + public lazy var markAsReadButton: UIButton = { + let button = UIButton() + button.setImage( + SBUIconSetType.iconClose.image( + with: theme.unreadMessageButtonTintColor, + to: SBUIconSetType.Metric.defaultIconSizeSmall + ), + for: .normal + ) + button.addTarget(self, action: #selector(onClickMarkAsReadButton), for: .touchUpInside) + return button + }() + + public typealias SBUUnreadMessageInfoViewHandler = () -> Void + + public var actionHandler: SBUUnreadMessageInfoViewHandler? + + public var totalUnreadCount: UInt = 0 + + // MARK: - Properties (Private) + @SBUThemeWrapper(theme: SBUTheme.componentTheme) + var theme: SBUComponentTheme + + // MARK: - Initializers + required public override init() { + super.init() + } + + public override init(frame: CGRect) { + super.init(frame: frame) + } + + @objc + public func onClickMarkAsReadButton() { + self.actionHandler?() + } + + public override func setupViews() { + super.setupViews() + + self.baseStackView.setHStack([ + self.unreadMessageCountLabel, + self.markAsReadButton + ]) + + self.addSubview(self.baseStackView) + } + + public override func setupLayouts() { + super.setupLayouts() + + self.baseStackView.sbu_constraint(equalTo: self, leading: 16, trailing: -16, top: 12, bottom: 12) + } + + public override func setupStyles() { + super.setupStyles() + + self.backgroundColor = theme.unreadMessageBackground + self.layer.cornerRadius = 20 + self.clipsToBounds = false + + // Add multiple shadow layers + self.setupShadowLayers() + } + + private func setupShadowLayers() { + let shadowConfigs: [(radius: CGFloat, offset: CGSize, color: UIColor)] = [ + (radius: 3, offset: CGSize(width: 0, height: 0), color: UIColor(red: 0, green: 0, blue: 0, alpha: 0.08)), + (radius: 1, offset: CGSize(width: 0, height: 2), color: UIColor(red: 0, green: 0, blue: 0, alpha: 0.12)), + (radius: 5, offset: CGSize(width: 0, height: 1), color: UIColor(red: 0, green: 0, blue: 0, alpha: 0.04)) + ] + + self.applyMultipleShadows( + cornerRadius: 20, + backgroundColor: theme.unreadMessageBackground, + shadowConfigs: shadowConfigs + ) + } + + public override func layoutSubviews() { + super.layoutSubviews() + + // Update shadow layers when bounds change + if !self.bounds.isEmpty { + setupShadowLayers() + } + } + + /// Updates the unread message count. + public func updateCount( + addCount: UInt = 0, + replaceCount: UInt = 0 + ) { + SBULog.info("addCount=\(addCount) replaceCount=\(replaceCount)") + + if addCount > 0 { + self.totalUnreadCount += addCount + } else { + self.totalUnreadCount = replaceCount + } + unreadMessageCountLabel.text = SBUStringSet.Channel_Unread_Message(self.totalUnreadCount) + } +} + +extension SBUUnreadMessageInfoView { + static func createDefault(_ viewType: SBUUnreadMessageInfoView.Type) -> SBUUnreadMessageInfoView { + return viewType.init() + } +} diff --git a/Sources/uikit/View/Channel/SBUBaseChannelViewController.swift b/Sources/uikit/View/Channel/SBUBaseChannelViewController.swift index 503a711..b5a5086 100644 --- a/Sources/uikit/View/Channel/SBUBaseChannelViewController.swift +++ b/Sources/uikit/View/Channel/SBUBaseChannelViewController.swift @@ -559,6 +559,10 @@ open class SBUBaseChannelViewController: SBUBaseViewController, SBUBaseChannelVi /// This function increases the new message count. @discardableResult public func increaseNewMessageCount() -> Bool { + // [base calss - core logic] + // Ensures that: + // 1. Table view hasn't scrolled + // 2. Currently loading next messages guard let tableView = self.baseListComponent?.tableView, tableView.contentOffset != .zero, self.baseViewModel?.isLoadingNext == false @@ -915,6 +919,8 @@ open class SBUBaseChannelViewController: SBUBaseViewController, SBUBaseChannelVi } } + + open func baseChannelModule(_ listComponent: SBUBaseChannelModule.List, didDismissMenuForCell cell: UITableViewCell) { cell.isSelected = false } @@ -1347,7 +1353,7 @@ open class SBUBaseChannelViewController: SBUBaseViewController, SBUBaseChannelVi // MARK: - SBUBaseChannelViewModelDataSource open func baseChannelViewModel(_ viewModel: SBUBaseChannelViewModel, isScrollNearBottomInChannel channel: BaseChannel?) -> Bool { - return self.baseListComponent?.isScrollNearByBottom ?? true + self.baseListComponent?.isScrollNearByBottom ?? true } // MARK: - UIGestureRecognizerDelegate diff --git a/Sources/uikit/View/Channel/SBUGroupChannelViewController.swift b/Sources/uikit/View/Channel/SBUGroupChannelViewController.swift index f28641c..91d6d4e 100644 --- a/Sources/uikit/View/Channel/SBUGroupChannelViewController.swift +++ b/Sources/uikit/View/Channel/SBUGroupChannelViewController.swift @@ -61,6 +61,10 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup } } + public var allowsAutoMarkAsReadOnScroll: Bool { + self.viewModel?.allowsAutoMarkAsReadOnScroll ?? false + } + override var isChatInputDisabled: Bool { didSet { (self.baseInputComponent?.messageInputView as? SBUMessageInputView)?.setDisableChatInputState(isChatInputDisabled) @@ -172,10 +176,18 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup super.viewWillAppear(animated) } + open override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + DispatchQueue.main.async { + self.listComponent?.checkForMarkAsRead() + } + } + open override func viewDidLoad() { super.viewDidLoad() self.navigationController?.interactivePopGestureRecognizer?.delegate = nil } + open override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) @@ -374,6 +386,38 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup return true } + /// Increases unread message count. + /// - Since: 3.32.0 + public func increaseUnreadMessageCount(message: BaseMessage) { + guard let viewModel = viewModel else { return } + guard !baseChannelViewModel(viewModel, isScrollNearBottomInChannel: viewModel.channel) else { return } + + guard super.increaseNewMessageCount() else { return } + self.viewModel?.newMessagesList.append(message) + + if let unreadMessageInfoView = self.listComponent?.unreadMessageInfoView as? SBUUnreadMessageInfoView, + unreadMessageInfoView.isHidden == false { + unreadMessageInfoView.updateCount(addCount: 1) + } + } + + // MARK: - Mark as read + /// Calls markAsRead() if appropriate, after receiving or sending a new message. + /// - Since: 3.32.2 + private func handleMarkAsRead() { + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + if isMarkAsUnreadEnabled { + let didUnreadMessageExist = self.listComponent?.didUnreadMessageExist ?? false + let hasSeenNewLine = self.listComponent?.hasSeenNewLine ?? true + if self.allowsAutoMarkAsReadOnScroll && (didUnreadMessageExist == false || hasSeenNewLine) { + // scroll is near bottom & received new message & user never explicitly called markAsUnread() + // & there was no unread message when entering the channel. + // Thus, should markAsRead(). + self.viewModel?.markAsRead() + } + } + } + // MARK: - Message: Menu /// Calculates the `CGPoint` value that indicates where to draw the message menu in the group channel screen. @@ -848,6 +892,58 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup } } + open override func baseChannelViewModel( + _ viewModel: SBUBaseChannelViewModel, + didReceiveNewMessage message: BaseMessage, + forChannel channel: BaseChannel + ) { + if (message is UserMessage || message is FileMessage || message is MultipleFilesMessage) { + self.increaseNewMessageCount() + } + self.increaseUnreadMessageCount(message: message) + } + + /// - Since: 3.32.0 + override open func baseChannelViewModel( + _ viewModel: SBUBaseChannelViewModel, + shouldUpdateScrollInMessageList messages: [BaseMessage], + forContext context: MessageContext?, + keepsScroll: Bool + ) { + SBULog.info("Fetched : \(messages.count), keepScroll : \(keepsScroll)") + guard let baseListComponent = baseListComponent else { return } + + guard !messages.isEmpty else { + SBULog.info("Fetched empty messages.") + return + } + + switch context?.source { + case .eventMessageSent: + SBULog.info("context.source == .eventMessageSent, messages=\(messages.map { $0.message })") + if !keepsScroll { + self.baseChannelModuleDidTapScrollToButton(baseListComponent, animated: false) + } + + handleMarkAsRead() + case .eventMessageReceived: + // Source includes .eventMessageReceived + SBULog.info("context.source == .eventMessageReceived, messages=\(messages.map { $0.message })") + if !baseChannelViewModel(viewModel, isScrollNearBottomInChannel: viewModel.channel) { + self.lastSeenIndexPath = baseListComponent.keepCurrentScroll(for: messages) + } else { + // scroll is near bottom & received new message + handleMarkAsRead() + } + default: + SBULog.info("context.source is neither .eventMessageSent nor .eventMessageReceived") + // follow keepScroll flag if context is not `eventMessageReceived`. + if keepsScroll, !baseChannelViewModel(viewModel, isScrollNearBottomInChannel: viewModel.channel) { + self.lastSeenIndexPath = baseListComponent.keepCurrentScroll(for: messages) + } + } + } + open func groupChannelViewModel( _ viewModel: SBUGroupChannelViewModel, didReceiveSuggestedMentions members: [SBUUser]?) { @@ -874,6 +970,62 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup self.listComponent?.updateStreamMessage(message) } + public func groupChannelViewModel( + _ viewModel: SBUGroupChannelViewModel, + didUpdateFirstUnreadMessage message: BaseMessage? + ) { + self.listComponent?.reloadTableView() + } + + public func groupChannelViewModel( + _ viewModel: SBUGroupChannelViewModel, + updateUnreadMessageInfoViewVisibility firstUnreadMessage: BaseMessage? + ) { + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + guard isMarkAsUnreadEnabled else { return } + + guard let channel = self.channel else { return } + + // Check if there are unread messages. + if (self.channel?.unreadMessageCount ?? 0) <= 0 { + self.listComponent?.unreadMessageInfoView?.isHidden = true + return + } + + // Check if firstUnreadMessage's newline is visible on screen + guard let tableView = self.listComponent?.tableView else { return } + + let visibleCells = tableView.visibleCells + var isUnreadMarkVisible = false + + for case let messageCell as SBUBaseMessageCell in visibleCells { + if let cellMessage = messageCell.message, + cellMessage.messageId == firstUnreadMessage?.messageId { + + // Check if the newline is visible + guard let newline = messageCell.unreadMessageNewLine else { return } + if newline.isHidden == false { + let newlineInTable = newline.convert(newline.bounds, to: tableView) + let visibleRect = tableView.bounds + isUnreadMarkVisible = visibleRect.contains(newlineInTable) + } + break + } + } + + // Show unreadMessageInfoView if firstUnreadMessage's newline is not on screen + if !isUnreadMarkVisible { + self.listComponent?.unreadMessageInfoView?.isHidden = false + + // Update the count. + if let unreadMessageInfoView = self.listComponent?.unreadMessageInfoView as? SBUUnreadMessageInfoView { + unreadMessageInfoView.updateCount(replaceCount: channel.unreadMessageCount) + } + } else { + self.listComponent?.unreadMessageInfoView?.isHidden = true + } + } + // MARK: - SBUGroupChannelModuleHeaderDelegate open override func baseChannelModule(_ headerComponent: SBUBaseChannelModule.Header, didTapLeftItem leftItem: UIBarButtonItem) { self.onClickBack() @@ -1002,6 +1154,10 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup self.showUserProfile(user: user) } + open func groupChannelModule(_ listComponent: SBUGroupChannelModule.List, didTapURL url: URL) { + url.open() + } + open func groupChannelModuleDidTapThreadInfoView(_ threadInfoView: SBUThreadInfoView) { guard let message = threadInfoView.message, let channelURL = self.channel?.channelURL else { return } @@ -1132,6 +1288,44 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup } } + public func groupChannelModule( + _ listComponent: SBUGroupChannelModule.List, + didScrollToUnreadMessageNewLine messageCell: SBUBaseMessageCell + ) { + // Check if markAsUnread feature is enabled. + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + guard isMarkAsUnreadEnabled else { return } + + // Should markAsRead if there are no newMessagesList. + if let viewModel = self.viewModel, viewModel.newMessagesList.isEmpty { + // Hide unreadMessageInfoView + self.listComponent?.unreadMessageInfoView?.isHidden = true + + viewModel.markAsRead { [weak self] error in + guard let self, error == nil else { + return + } + } + } else { + if let viewModel = self.viewModel, let firstNewMessage = viewModel.newMessagesList.first { + // Internally markAsUnread + SBULog.info("Should call markAsUnread with message=\(firstNewMessage.message)") + viewModel.markMessageAsUnread(firstNewMessage) { _ in + SBULog.info("markAsUnread done") + } + } + } + } + + public func groupChannelModule( + _ listComponent: SBUGroupChannelModule.List, + didTapUnreadMessageInfoView: Bool + ) { + self.viewModel?.markAsRead() + self.viewModel?.firstUnreadMessage = nil + self.viewModel?.allowsAutoMarkAsReadOnScroll = true + } + open override func baseChannelModule(_ listComponent: SBUBaseChannelModule.List, didTapVoiceMessage fileMessage: FileMessage, cell: UITableViewCell, forRowAt indexPath: IndexPath) { super.baseChannelModule(listComponent, didTapVoiceMessage: fileMessage, cell: cell, forRowAt: indexPath) @@ -1143,6 +1337,7 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup open override func baseChannelModuleDidTapScrollToButton(_ listComponent: SBUBaseChannelModule.List, animated: Bool) { guard self.baseViewModel?.fullMessageList.isEmpty == false else { return } self.newMessagesCount = 0 + self.viewModel?.newMessagesList = [] super.baseChannelModuleDidTapScrollToButton(listComponent, animated: animated) } @@ -1163,6 +1358,7 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup } open override func baseChannelModule(_ listComponent: SBUBaseChannelModule.List, didScroll scrollView: UIScrollView) { + guard let channel = self.channel else { return } super.baseChannelModule(listComponent, didScroll: scrollView) self.lastSeenIndexPath = nil @@ -1170,6 +1366,37 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup if listComponent.isScrollNearByBottom { self.newMessagesCount = 0 self.updateNewMessageInfo(hidden: true) + + // Check isMarkAsUnreadEnabled + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + guard isMarkAsUnreadEnabled else { return } + guard let listComponent = self.listComponent else { return } + + // Call markAsRead() + if self.allowsAutoMarkAsReadOnScroll + && channel.unreadMessageCount > 0 + && (listComponent.hasSeenNewLine || listComponent.didUnreadMessageExist == false) { + + SBULog.info("Scrolled to bottom, call markAsRead()") + self.viewModel?.newMessagesList = [] + self.viewModel?.markAsRead() + self.listComponent?.unreadMessageInfoView?.isHidden = true + } + } + } + + /// - Since: 3.32.0 + open func groupChannelModule( + _ listComponent: SBUBaseChannelModule.List, + didTapMarkAsUnread message: BaseMessage + ) { + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + guard isMarkAsUnreadEnabled else { return } + + self.viewModel?.markMessageAsUnread(message) { error in + guard error == nil else { return } + // set allowsAutoMarkAsReadOnScroll to false only when the user explicitly called markAsUnread(). + self.viewModel?.allowsAutoMarkAsReadOnScroll = false } } @@ -1303,6 +1530,14 @@ open class SBUGroupChannelViewController: SBUBaseChannelViewController, SBUGroup return self.listComponent?.tableView.indexPathsForVisibleRows } + public func groupChannelModuleFirstUnreadMessage(_ listComponent: SBUGroupChannelModule.List) -> BaseMessage? { + self.viewModel?.firstUnreadMessage + } + + public func groupChannelModuleAllowsAutoMarkAsReadOnScroll(_ listComponent: SBUGroupChannelModule.List) -> Bool { + self.allowsAutoMarkAsReadOnScroll + } + // MARK: - SBUMentionManagerDataSource open func mentionManager(_ manager: SBUMentionManager, suggestedMentionUsersWith filterText: String) -> [SBUUser] { return self.viewModel?.suggestedMemberList ?? [] diff --git a/Sources/uikit/View/MessageThread/SBUMessageThreadViewController.swift b/Sources/uikit/View/MessageThread/SBUMessageThreadViewController.swift index 62951e4..aa227f0 100644 --- a/Sources/uikit/View/MessageThread/SBUMessageThreadViewController.swift +++ b/Sources/uikit/View/MessageThread/SBUMessageThreadViewController.swift @@ -644,6 +644,13 @@ open class SBUMessageThreadViewController: SBUBaseChannelViewController, SBUMess self.showUserProfile(user: user) } + open func messageThreadModule( + _ listComponent: SBUMessageThreadModule.List, + didTapURL url: URL + ) { + url.open() + } + /// - Note: This interface is beta. We do not gaurantee this interface to work properly yet. /// - Since: [NEXT_VERSION_MFM_THREAD] public func messageThreadModule( diff --git a/Sources/uikit/View/MessageThread/SBUParentMessageInfoView.swift b/Sources/uikit/View/MessageThread/SBUParentMessageInfoView.swift index 1cbce97..d376e4c 100644 --- a/Sources/uikit/View/MessageThread/SBUParentMessageInfoView.swift +++ b/Sources/uikit/View/MessageThread/SBUParentMessageInfoView.swift @@ -150,6 +150,9 @@ open class SBUParentMessageInfoView: SBUView, SBUUserMessageTextViewDelegate { public var emojiLongPressHandler: ((_ emojiKey: String) -> Void)? /// The handler that set the logic to be called when a mention is tapped. public var mentionTapHandler: ((_ user: SBUUser) -> Void)? + /// The handler that set the logic to be called when a URL link is tapped. + /// - Since: 3.32.0 + public var urlTapHandler: ((_ url: URL) -> Void)? var errorHandler: ((_ error: SBError) -> Void)? @@ -737,7 +740,7 @@ open class SBUParentMessageInfoView: SBUView, SBUUserMessageTextViewDelegate { return } - url.open() + self.urlTapHandler?(url) } /// Calls the `moreButtonTapHandlerToContent()` when the more button is tapped. @@ -750,6 +753,11 @@ open class SBUParentMessageInfoView: SBUView, SBUUserMessageTextViewDelegate { open func userMessageTextView(_ textView: SBUUserMessageTextView, didTapMention user: SBUUser) { self.mentionTapHandler?(user) } + + /// - Since: 3.32.0 + open func userMessageTextView(_ textView: SBUUserMessageTextView, didTapURL url: URL) { + self.urlTapHandler?(url) + } } // MARK: - Multiple Files Message diff --git a/Sources/uikit/ViewModel/Channel/SBUBaseChannelViewModel.swift b/Sources/uikit/ViewModel/Channel/SBUBaseChannelViewModel.swift index 77b54f2..3f83650 100644 --- a/Sources/uikit/ViewModel/Channel/SBUBaseChannelViewModel.swift +++ b/Sources/uikit/ViewModel/Channel/SBUBaseChannelViewModel.swift @@ -617,6 +617,9 @@ open class SBUBaseChannelViewModel: NSObject { self.channel?.deleteMessage(message, completionHandler: nil) } + // MARK: - Message related + public func updateFirstUnreadMessage() { } + // MARK: - List /// This function updates the messages in the list. @@ -664,11 +667,13 @@ open class SBUBaseChannelViewModel: NSObject { /// - messages: Message array to upsert /// - needUpdateNewMessage: If set to `true`, increases new message count. /// - needReload: If set to `true`, the tableview will be call reloadData. + /// - shouldUpdateFirstUnreadMessage: `true` when this method was called when loading the initial messages /// - Since: 1.2.5 public func upsertMessagesInList( messages: [BaseMessage]?, needUpdateNewMessage: Bool = false, - needReload: Bool + needReload: Bool, + shouldUpdateFirstUnreadMessage: Bool = false ) { SBULog.info("First : \(String(describing: messages?.first)), Last : \(String(describing: messages?.last))") @@ -681,7 +686,7 @@ open class SBUBaseChannelViewModel: NSObject { } guard self.messageListParams.belongsTo(message) else { - self.sortAllMessageList(needReload: needReload) + self.sortAllMessageList(needReload: needReload, shouldUpdateFirstUnreadMessage: shouldUpdateFirstUnreadMessage) return } @@ -722,15 +727,20 @@ open class SBUBaseChannelViewModel: NSObject { } let sortAllMessageListBlock = { [weak self] in - self?.sortAllMessageList(needReload: needReload) + self?.sortAllMessageList(needReload: needReload, shouldUpdateFirstUnreadMessage: shouldUpdateFirstUnreadMessage) } if needMarkAsRead, let channel = self.channel as? GroupChannel, !self.isThreadMessageMode, SendbirdChat.getConnectState() == .open { - channel.markAsRead { error in + + if SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled { sortAllMessageListBlock() + } else { + channel.markAsRead { error in + sortAllMessageListBlock() + } } } else { sortAllMessageListBlock() @@ -842,7 +852,7 @@ open class SBUBaseChannelViewModel: NSObject { /// This function sorts the all message list. (Included `presendMessages`, `messageList` and `resendableMessages`.) /// - Parameter needReload: If set to `true`, the tableview will be call reloadData and, scroll to last seen index. /// - Since: 1.2.5 - public func sortAllMessageList(needReload: Bool) { + public func sortAllMessageList(needReload: Bool, shouldUpdateFirstUnreadMessage: Bool = false) { // Generate full list for draw let pendingMessages = self.pendingMessageManager.getPendingMessages( channelURL: self.channel?.channelURL, @@ -875,6 +885,11 @@ open class SBUBaseChannelViewModel: NSObject { + typingMessageArray } + // Find first unread message. + if shouldUpdateFirstUnreadMessage { + self.updateFirstUnreadMessage() + } + self.baseDelegates.forEach { $0.shouldUpdateLoadingState(false) $0.baseChannelViewModel( diff --git a/Sources/uikit/ViewModel/Channel/SBUGroupChannelViewModel.swift b/Sources/uikit/ViewModel/Channel/SBUGroupChannelViewModel.swift index 98f6b77..41e1ace 100644 --- a/Sources/uikit/ViewModel/Channel/SBUGroupChannelViewModel.swift +++ b/Sources/uikit/ViewModel/Channel/SBUGroupChannelViewModel.swift @@ -49,6 +49,37 @@ public protocol SBUGroupChannelViewModelDelegate: SBUBaseChannelViewModelDelegat didReceiveStreamMessage message: BaseMessage, forChannel channel: GroupChannel ) + + /// Called when the first unread message is updated. + /// - Parameters: + /// - viewModel: `SBUGroupChannelViewModel` object. + /// - message: The new first unread message. + /// - Since: 3.32.0 + func groupChannelViewModel( + _ viewModel: SBUGroupChannelViewModel, + didUpdateFirstUnreadMessage message: BaseMessage? + ) + + /// Checks whether to show or hide unreadMessageInfoView. + /// - Since: 3.32.0 + func groupChannelViewModel( + _ viewModel: SBUGroupChannelViewModel, + updateUnreadMessageInfoViewVisibility firstUnreadMessage: BaseMessage? + ) +} + +extension SBUGroupChannelViewModelDelegate { + /// Default implementation + public func groupChannelViewModel( + _ viewModel: SBUGroupChannelViewModel, + didUpdateFirstUnreadMessage message: BaseMessage? + ) { } + + /// Default implementation + public func groupChannelViewModel( + _ viewModel: SBUGroupChannelViewModel, + updateUnreadMessageInfoViewVisibility firstUnreadMessage: BaseMessage? + ) { } } open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { @@ -62,7 +93,12 @@ open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { get { self.baseDataSource as? SBUGroupChannelViewModelDataSource } set { self.baseDataSource = newValue } } - + + /// A list of messages that newly arrived while the scroll is not near bottom. + /// Older messages come first in the list. + /// - Since: 3.32.0 + public internal(set) var newMessagesList: [BaseMessage] = [] + // MARK: SwiftUI (Internal) var delegates: WeakDelegateStorage { let computedDelegates = WeakDelegateStorage() @@ -428,10 +464,11 @@ open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { self.upsertMessagesInList( messages: cacheResult, - needReload: self.displaysLocalCachedListFirst + needReload: self.displaysLocalCachedListFirst, + shouldUpdateFirstUnreadMessage: true ) - - }, apiResultHandler: { [weak self] apiResult, error in + }, + apiResultHandler: { [weak self] apiResult, error in guard let self = self else { return } self.loadInitialPendingMessages() @@ -453,13 +490,17 @@ open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { return } - + if self.initPolicy == .cacheAndReplaceByApi { self.clearMessageList() } self.isInitialLoading = false - self.upsertMessagesInList(messages: apiResult, needReload: true) + self.upsertMessagesInList( + messages: apiResult, + needReload: true, + shouldUpdateFirstUnreadMessage: true + ) }) } @@ -504,7 +545,8 @@ open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { return } - guard let messages = messages, !messages.isEmpty else { return } + guard let messages = messages, + !messages.isEmpty else { return } SBULog.info("[Prev message response] \(messages.count) messages") self.delegates.forEach { @@ -515,7 +557,11 @@ open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { keepsScroll: false ) } - self.upsertMessagesInList(messages: messages, needReload: true) + self.upsertMessagesInList( + messages: messages, + needReload: true, + shouldUpdateFirstUnreadMessage: true + ) } } @@ -529,7 +575,9 @@ open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { guard let messageCollection = self.messageCollection else { return } self.isLoadingNext = true - messageCollection.loadNext { [weak self] messages, error in + messageCollection.loadNext { + [weak self] messages, + error in guard let self = self else { return } defer { self.nextLock.unlock() @@ -554,7 +602,11 @@ open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { keepsScroll: true ) } - self.upsertMessagesInList(messages: messages, needReload: true) + self.upsertMessagesInList( + messages: messages, + needReload: true, + shouldUpdateFirstUnreadMessage: true + ) } } @@ -572,13 +624,131 @@ open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { self.markAsRead(completionHandler: nil) } + /// Calls markAsRead() only if the `isMarkAsUnreadEnabled` feature is disabled. + /// - Since: 3.32.0 + public func markAsReadIfAppropriate() { + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + if isMarkAsUnreadEnabled == false { + self.markAsRead(completionHandler: nil) + } + } + + /// Calls markAsRead() with a completionHandler, only if the `isMarkAsUnreadEnabled` feature is disabled. + /// - Parameter completionHandler: A callback block that is run after markAsRead request is complete. + /// - Since: 3.32.0 + func markAsReadIfAppropriate(completionHandler: SendbirdChatSDK.SBErrorHandler?) { + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + if isMarkAsUnreadEnabled == false { + self.markAsRead(completionHandler: completionHandler) + } + } + func markAsRead(completionHandler: SendbirdChatSDK.SBErrorHandler?) { if let channel = self.channel as? GroupChannel, - SendbirdChat.getConnectState() == .open { + SendbirdChat.getConnectState() == .open { channel.markAsRead(completionHandler: completionHandler) } } + /// Marks a message as unread. + /// - Parameters: + /// - message: The message to mark as unread. + /// - completionHandler: A handler block to be executed after marking the message as unread. + /// - Since: 3.32.0 + open func markMessageAsUnread(_ message: BaseMessage, completionHandler: SendbirdChatSDK.SBErrorHandler?) { + let isMarkAsUnreadEnabled = SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled + guard isMarkAsUnreadEnabled else { + SBULog.warning("To call markAsUnread, please first enable `SendbirdUI.config.groupChannel.channel.isMarkAsUnreadEnabled` feature.") + return + } + + if let groupChannel = channel as? GroupChannel { + groupChannel.markAsUnread(message: message) { [weak self] error in + guard let self = self else { return } + if let error = error { + SBULog.error("Failed to mark message as unread. \(error)") + self.delegates.forEach { + $0.didReceiveError(error) + } + } + + self.sortAllMessageList(needReload: true) + completionHandler?(error) + } + } + } + + /// Whether automatic mark-as-read is allowed when scrolling past the newline. + /// Set to `false` when user explicitly calls markAsUnread to prevent auto-read behavior. + /// - Since: 3.32.0 + var allowsAutoMarkAsReadOnScroll: Bool = true + + /// - Since: 3.32.0 + /// If `nil`, either all messages are read or first unread message is not yet loaded in `messageList`. + var firstUnreadMessage: BaseMessage? { + didSet { + self.delegates.forEach { + $0.groupChannelViewModel(self, didUpdateFirstUnreadMessage: firstUnreadMessage) + } + } + } + + override public func updateFirstUnreadMessage() { + SBULog.info("Update firstUnreadMessage") + guard let groupChannel = self.channel as? GroupChannel else { return } + guard let collection = self.messageCollection else { return } + + let myLastRead = groupChannel.myLastRead + + // Check if we have any loaded messages + guard !self.messageList.isEmpty else { + self.firstUnreadMessage = nil + SBULog.info("No messages loaded yet. firstUnreadMessage = nil") + return + } + + if let oldestLoadedMessageTimestamp = self.messageList.last?.createdAt, + let newestLoadedMessageTimestamp = self.messageList.first?.createdAt { + let isFirstUnreadMessageInLoadedMessageList = oldestLoadedMessageTimestamp <= myLastRead && myLastRead <= newestLoadedMessageTimestamp + + // If there are unread messages but the first unread message is not in the currently loaded range + if !isFirstUnreadMessageInLoadedMessageList { + // Check if the first unread message might be before our loaded messages + if myLastRead < oldestLoadedMessageTimestamp && collection.hasPrevious { + self.firstUnreadMessage = nil + SBULog.info("The first unread message is not yet loaded in `messageList` (before current range).") + return + } + // Check if the first unread message might be after our loaded messages + else if myLastRead > newestLoadedMessageTimestamp && collection.hasNext { + self.firstUnreadMessage = nil + SBULog.info("The first unread message is not yet loaded in `messageList` (after current range).") + return + } else { + // If we don't have previous/next messages to load, proceed to find within current range + } + } + } + + // Search for the first unread message in the currently loaded messages + var tempFirstUnreadMessage: BaseMessage? + for currentMessage in self.messageList { + // A silent message is excluded as an unread message. (Also excluded from unreadMessageCount) + if currentMessage.isSilent { continue } + + if currentMessage is UserMessage || currentMessage is FileMessage || currentMessage is MultipleFilesMessage || currentMessage is AdminMessage { + if groupChannel.myLastRead < currentMessage.createdAt { + SBULog.info("Temporary firstUnreadMessage=\(currentMessage.message)") + tempFirstUnreadMessage = currentMessage + } + } + } + + self.firstUnreadMessage = tempFirstUnreadMessage + + SBULog.info("Finished finding firstUnreadMessage. firstUnreadMessage=\(self.firstUnreadMessage?.message ?? "nil")") + } + // MARK: - Typing public func startTypingMessage() { guard let channel = self.channel as? GroupChannel else { return } @@ -725,10 +895,12 @@ open class SBUGroupChannelViewModel: SBUBaseChannelViewModel { } override func reset() { - self.markAsRead() - + self.markAsReadIfAppropriate() super.reset() } + + var addedMessages = [BaseMessage]() + var addedMessageContext: MessageContext? = nil } extension SBUGroupChannelViewModel: MessageCollectionDelegate { @@ -756,9 +928,10 @@ extension SBUGroupChannelViewModel: MessageCollectionDelegate { if existInPendingMessage { return } SBULog.info("messageCollection addedMessages : \(messages.count)") + switch context.source { case .eventMessageReceived: - self.markAsRead() + self.markAsReadIfAppropriate() default: break } @@ -878,6 +1051,55 @@ extension SBUGroupChannelViewModel: MessageCollectionDelegate { } } + public func messageCollection( + _ collection: MessageCollection, + channelContext: ChannelContext, + updatedChannel: GroupChannel + ) { + switch channelContext.source { + case .eventUserDidMarkAsRead: + SBULog.info("channelContext.source == .eventDidMarkAsRead") + guard let readEventDetail = channelContext.eventDetail as? EventDetail.UserDidMarkAsRead else { + return + } + + SBULog.info("reader=\(readEventDetail.userIds[0])") + if let currentUserId = SBUGlobals.currentUser?.userId, + readEventDetail.userIds[0] == currentUserId { + SBULog.info(".eventDidMarkAsRead by CurrentUser") + } + + case .eventUserDidMarkAsUnread: + SBULog.info("channelContext.source == .eventDidMarkAsUnread") + guard let unreadEventDetail = channelContext.eventDetail as? EventDetail.UserDidMarkAsUnread else { + return + } + + SBULog.info("reader=\(unreadEventDetail.userIds[0])") + guard let currentUserId = SBUGlobals.currentUser?.userId, + unreadEventDetail.userIds[0] == currentUserId else { + return + } + + SBULog.info(".eventDidMarkAsUnread by CurrentUser") + self.updateFirstUnreadMessage() + case .eventChannelChanged: + SBULog.info("channelContext.source == .eventChannelChanged") + + // Update unreadMessageInfoView's visibility based on + // the updated channel data (myLastRead, lastMessage.createdAt). + self.delegates.forEach { + $0.groupChannelViewModel( + self, + updateUnreadMessageInfoViewVisibility: self.firstUnreadMessage + ) + } + + default: + break + } + } + open func messageCollection( _ collection: MessageCollection, context: MessageContext, @@ -1064,7 +1286,7 @@ extension SBUGroupChannelViewModel { self.messageCache?.loadNext() } - self.markAsRead { [weak self] _ in + self.markAsReadIfAppropriate() { [weak self] error in self?.refreshChannel() } } @@ -1081,8 +1303,9 @@ extension SBUGroupChannelViewModel: GroupChannelDelegate { let isScrollBottom = self.dataSource?.baseChannelViewModel(self, isScrollNearBottomInChannel: self.channel) if (self.hasNext() == true || isScrollBottom == false) && - (message is UserMessage || message is FileMessage) { + (message is UserMessage || message is FileMessage || message is MultipleFilesMessage || message is AdminMessage) { // let context = MessageContext(source: .eventMessageReceived, sendingStatus: .succeeded) + if let channel = self.channel { self.delegates.forEach { $0.baseChannelViewModel(self, didReceiveNewMessage: message, forChannel: channel) diff --git a/Sources/uikit/ViewModel/MessageThread/SBUMessageThreadViewModel.swift b/Sources/uikit/ViewModel/MessageThread/SBUMessageThreadViewModel.swift index 7b0bed1..99e8da7 100644 --- a/Sources/uikit/ViewModel/MessageThread/SBUMessageThreadViewModel.swift +++ b/Sources/uikit/ViewModel/MessageThread/SBUMessageThreadViewModel.swift @@ -893,7 +893,7 @@ open class SBUMessageThreadViewModel: SBUBaseChannelViewModel { } // MARK: - List - public override func sortAllMessageList(needReload: Bool) { + public override func sortAllMessageList(needReload: Bool, shouldUpdateFirstUnreadMessage: Bool = false) { // Generate full list for draw let pendingMessages = self.pendingMessageManager .getPendingMessages(