diff --git a/CHANGELOG.md b/CHANGELOG.md index a1ea8dcf2..2de0fed7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### ✅ Added - Add extra data to user display info [#819](https://github.com/GetStream/stream-chat-swiftui/pull/819) - Make message spacing in message list configurable [#830](https://github.com/GetStream/stream-chat-swiftui/pull/830) +- Show time, relative date, weekday, or short date for last message in channel list and search [#833](https://github.com/GetStream/stream-chat-swiftui/pull/833) + - Set `ChannelListConfig.messageRelativeDateFormatEnabled` to true for enabling it - Add `MessageViewModel` to `MessageContainerView` to make it easier to customise presentation logic [#815](https://github.com/GetStream/stream-chat-swiftui/pull/815) - Add `MessageListConfig.messaeDisplayOptions.showOriginalTranslatedButton` to enable showing original text in translated message [#815](https://github.com/GetStream/stream-chat-swiftui/pull/815) - Add `Utils.originalTranslationsStore` to keep track of messages that should show the original text [#815](https://github.com/GetStream/stream-chat-swiftui/pull/815) diff --git a/DemoAppSwiftUI/AppDelegate.swift b/DemoAppSwiftUI/AppDelegate.swift index a771cdf15..7cf4a97aa 100644 --- a/DemoAppSwiftUI/AppDelegate.swift +++ b/DemoAppSwiftUI/AppDelegate.swift @@ -63,6 +63,9 @@ class AppDelegate: NSObject, UIApplicationDelegate { #endif let utils = Utils( + channelListConfig: ChannelListConfig( + messageRelativeDateFormatEnabled: true + ), messageListConfig: MessageListConfig( messageDisplayOptions: .init(showOriginalTranslatedButton: true), dateIndicatorPlacement: .messageList, diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChannelListConfig.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelListConfig.swift new file mode 100644 index 000000000..6bd0aa495 --- /dev/null +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChannelListConfig.swift @@ -0,0 +1,17 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A configuration for channel lists. +public struct ChannelListConfig { + public init(messageRelativeDateFormatEnabled: Bool = false) { + self.messageRelativeDateFormatEnabled = messageRelativeDateFormatEnabled + } + + /// If true, the timestamp format depends on the time passed. + /// + /// Different date formats are used for today, yesterday, last 7 days, and older dates. + public var messageRelativeDateFormatEnabled: Bool +} diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift index e36f5de9f..cd14fe253 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/ChatChannelListItem.swift @@ -337,7 +337,11 @@ extension ChatChannel { public var timestampText: String { if let lastMessageAt = lastMessageAt { - return InjectedValues[\.utils].dateFormatter.string(from: lastMessageAt) + let utils = InjectedValues[\.utils] + let formatter = utils.channelListConfig.messageRelativeDateFormatEnabled ? + utils.messageRelativeDateFormatter : + utils.dateFormatter + return formatter.string(from: lastMessageAt) } else { return "" } diff --git a/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift b/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift index 455f2d8d0..ab4e6750a 100644 --- a/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift +++ b/Sources/StreamChatSwiftUI/ChatChannelList/SearchResultsView.swift @@ -158,7 +158,10 @@ struct SearchResultItem: View { private var timestampText: String { if let lastMessageAt = searchResult.channel.lastMessageAt { - return utils.dateFormatter.string(from: lastMessageAt) + let formatter = utils.channelListConfig.messageRelativeDateFormatEnabled ? + utils.messageRelativeDateFormatter : + utils.dateFormatter + return formatter.string(from: lastMessageAt) } else { return "" } diff --git a/Sources/StreamChatSwiftUI/Utils.swift b/Sources/StreamChatSwiftUI/Utils.swift index 8a27c4391..55f564a68 100644 --- a/Sources/StreamChatSwiftUI/Utils.swift +++ b/Sources/StreamChatSwiftUI/Utils.swift @@ -13,6 +13,11 @@ public class Utils { var markdownFormatter = MarkdownFormatter() public var dateFormatter: DateFormatter + + /// Date formatter where the format depends on the time passed. + /// + /// - SeeAlso: ``ChannelListConfig/messageRelativeDateFormatEnabled``. + public var messageRelativeDateFormatter: DateFormatter public var videoPreviewLoader: VideoPreviewLoader public var imageLoader: ImageLoading public var imageCDN: ImageCDN @@ -25,6 +30,7 @@ public class Utils { public var messageTypeResolver: MessageTypeResolving public var messageActionsResolver: MessageActionsResolving public var commandsConfig: CommandsConfig + public var channelListConfig: ChannelListConfig public var messageListConfig: MessageListConfig public var composerConfig: ComposerConfig public var pollsConfig: PollsConfig @@ -71,6 +77,7 @@ public class Utils { public init( dateFormatter: DateFormatter = .makeDefault(), + messageRelativeDateFormatter: DateFormatter = MessageRelativeDateFormatter(), videoPreviewLoader: VideoPreviewLoader = DefaultVideoPreviewLoader(), imageLoader: ImageLoading = NukeImageLoader(), imageCDN: ImageCDN = StreamImageCDN(), @@ -81,6 +88,7 @@ public class Utils { messageTypeResolver: MessageTypeResolving = MessageTypeResolver(), messageActionResolver: MessageActionsResolving = MessageActionsResolver(), commandsConfig: CommandsConfig = DefaultCommandsConfig(), + channelListConfig: ChannelListConfig = ChannelListConfig(), messageListConfig: MessageListConfig = MessageListConfig(), composerConfig: ComposerConfig = ComposerConfig(), pollsConfig: PollsConfig = PollsConfig(), @@ -95,6 +103,7 @@ public class Utils { shouldSyncChannelControllerOnAppear: @escaping (ChatChannelController) -> Bool = { _ in true } ) { self.dateFormatter = dateFormatter + self.messageRelativeDateFormatter = messageRelativeDateFormatter self.videoPreviewLoader = videoPreviewLoader self.imageLoader = imageLoader self.imageCDN = imageCDN @@ -107,6 +116,7 @@ public class Utils { self.messageTypeResolver = messageTypeResolver messageActionsResolver = messageActionResolver self.commandsConfig = commandsConfig + self.channelListConfig = channelListConfig self.messageListConfig = messageListConfig self.composerConfig = composerConfig self.snapshotCreator = snapshotCreator diff --git a/Sources/StreamChatSwiftUI/Utils/Common/MessageRelativeDateFormatter.swift b/Sources/StreamChatSwiftUI/Utils/Common/MessageRelativeDateFormatter.swift new file mode 100644 index 000000000..8c081dbf0 --- /dev/null +++ b/Sources/StreamChatSwiftUI/Utils/Common/MessageRelativeDateFormatter.swift @@ -0,0 +1,63 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation + +/// A formatter that converts message timestamps to a format which depends on the time passed. +public final class MessageRelativeDateFormatter: DateFormatter, @unchecked Sendable { + override public init() { + super.init() + locale = .autoupdatingCurrent + dateStyle = .short + timeStyle = .none + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func string(from date: Date) -> String { + if calendar.isDateInToday(date) { + return todayFormatter.string(from: date) + } + if calendar.isDateInYesterday(date) { + return yesterdayFormatter.string(from: date) + } + if calendar.isDateInLastWeek(date) { + return weekdayFormatter.string(from: date) + } + + return super.string(from: date) + } + + var todayFormatter: DateFormatter { + InjectedValues[\.utils].dateFormatter + } + + let yesterdayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.dateStyle = .short + formatter.timeStyle = .none + formatter.doesRelativeDateFormatting = true + return formatter + }() + + let weekdayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + formatter.setLocalizedDateFormatFromTemplate("EEEE") + return formatter + }() +} + +extension Calendar { + func isDateInLastWeek(_ date: Date) -> Bool { + guard let dateBefore7days = self.date(byAdding: .day, value: -7, to: Date()) else { + return false + } + return date > dateBefore7days + } +} diff --git a/StreamChatSwiftUI.xcodeproj/project.pbxproj b/StreamChatSwiftUI.xcodeproj/project.pbxproj index 11b00b63a..7ecc4ce6e 100644 --- a/StreamChatSwiftUI.xcodeproj/project.pbxproj +++ b/StreamChatSwiftUI.xcodeproj/project.pbxproj @@ -16,10 +16,13 @@ 4F6D83352C0F05040098C298 /* PollCommentsViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6D83342C0F05040098C298 /* PollCommentsViewModel_Tests.swift */; }; 4F6D83512C1079A00098C298 /* AlertBannerViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6D83502C1079A00098C298 /* AlertBannerViewModifier.swift */; }; 4F6D83542C1094220098C298 /* AlertBannerViewModifier_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F6D83532C1094220098C298 /* AlertBannerViewModifier_Tests.swift */; }; + 4F7613792DDCB2C900F996E3 /* MessageRelativeDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7613782DDCB2AD00F996E3 /* MessageRelativeDateFormatter.swift */; }; 4F7720AE2C58C45200BAEC02 /* OnLoadViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7720AD2C58C45000BAEC02 /* OnLoadViewModifier.swift */; }; 4F7DD9A02BFC7C6100599AA6 /* ChatClient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7DD99F2BFC7C6100599AA6 /* ChatClient+Extensions.swift */; }; 4F7DD9A22BFCB2EF00599AA6 /* ChatClientExtensions_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F7DD9A12BFCB2EF00599AA6 /* ChatClientExtensions_Tests.swift */; }; 4F889C562D7F000700A7BDAF /* ChatMessageExtensions_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F889C552D7F000700A7BDAF /* ChatMessageExtensions_Tests.swift */; }; + 4F8D64402DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F8D643F2DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift */; }; + 4F9173FD2DDDFFE8003C30B5 /* ChannelListConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F9173FC2DDDFFE3003C30B5 /* ChannelListConfig.swift */; }; 4FA3741A2D799CA400294721 /* AppConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA374192D799CA400294721 /* AppConfigurationView.swift */; }; 4FA3741D2D799FC300294721 /* AppConfigurationTranslationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA3741C2D799FC300294721 /* AppConfigurationTranslationView.swift */; }; 4FA3741F2D79A64F00294721 /* AppConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FA3741E2D79A64900294721 /* AppConfiguration.swift */; }; @@ -614,10 +617,13 @@ 4F6D83342C0F05040098C298 /* PollCommentsViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollCommentsViewModel_Tests.swift; sourceTree = ""; }; 4F6D83502C1079A00098C298 /* AlertBannerViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertBannerViewModifier.swift; sourceTree = ""; }; 4F6D83532C1094220098C298 /* AlertBannerViewModifier_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertBannerViewModifier_Tests.swift; sourceTree = ""; }; + 4F7613782DDCB2AD00F996E3 /* MessageRelativeDateFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRelativeDateFormatter.swift; sourceTree = ""; }; 4F7720AD2C58C45000BAEC02 /* OnLoadViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnLoadViewModifier.swift; sourceTree = ""; }; 4F7DD99F2BFC7C6100599AA6 /* ChatClient+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatClient+Extensions.swift"; sourceTree = ""; }; 4F7DD9A12BFCB2EF00599AA6 /* ChatClientExtensions_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClientExtensions_Tests.swift; sourceTree = ""; }; 4F889C552D7F000700A7BDAF /* ChatMessageExtensions_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageExtensions_Tests.swift; sourceTree = ""; }; + 4F8D643F2DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRelativeDateFormatter_Tests.swift; sourceTree = ""; }; + 4F9173FC2DDDFFE3003C30B5 /* ChannelListConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListConfig.swift; sourceTree = ""; }; 4FA374192D799CA400294721 /* AppConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigurationView.swift; sourceTree = ""; }; 4FA3741C2D799FC300294721 /* AppConfigurationTranslationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigurationTranslationView.swift; sourceTree = ""; }; 4FA3741E2D79A64900294721 /* AppConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfiguration.swift; sourceTree = ""; }; @@ -1941,6 +1947,7 @@ 8465FD3D2746A95600AF091E /* ImageCDN.swift */, 8465FD482746A95600AF091E /* ImageMerger.swift */, 8465FD422746A95600AF091E /* InputTextView.swift */, + 4F7613782DDCB2AD00F996E3 /* MessageRelativeDateFormatter.swift */, 8465FD432746A95600AF091E /* NSLayoutConstraint+Extensions.swift */, 8465FD492746A95600AF091E /* NukeImageProcessor.swift */, 4F7720AD2C58C45000BAEC02 /* OnLoadViewModifier.swift */, @@ -1957,22 +1964,23 @@ 8465FD4C2746A95600AF091E /* ChatChannelList */ = { isa = PBXGroup; children = ( + 8465FD4D2746A95600AF091E /* ChannelAvatarsMerger.swift */, + 8465FD572746A95700AF091E /* ChannelHeaderLoader.swift */, + 4F9173FC2DDDFFE3003C30B5 /* ChannelListConfig.swift */, + 8465FD4E2746A95600AF091E /* ChatChannelHelperViews.swift */, + 8465FD512746A95600AF091E /* ChatChannelList.swift */, + 8465FD542746A95700AF091E /* ChatChannelListHeader.swift */, + 8465FD592746A95700AF091E /* ChatChannelListItem.swift */, 8465FD552746A95700AF091E /* ChatChannelListScreen.swift */, 8465FD5C2746A95700AF091E /* ChatChannelListView.swift */, - 8465FD512746A95600AF091E /* ChatChannelList.swift */, 8465FD582746A95700AF091E /* ChatChannelListViewModel.swift */, - 8465FD542746A95700AF091E /* ChatChannelListHeader.swift */, - 8465FD5A2746A95700AF091E /* ChatChannelSwipeableListItem.swift */, 8465FD532746A95600AF091E /* ChatChannelNavigatableListItem.swift */, - 8465FD592746A95700AF091E /* ChatChannelListItem.swift */, - 8465FD4D2746A95600AF091E /* ChannelAvatarsMerger.swift */, - 8465FD4E2746A95600AF091E /* ChatChannelHelperViews.swift */, + 8465FD5A2746A95700AF091E /* ChatChannelSwipeableListItem.swift */, 8465FD502746A95600AF091E /* DefaultChannelActions.swift */, - 8465FD522746A95600AF091E /* NoChannelsView.swift */, - 8465FD572746A95700AF091E /* ChannelHeaderLoader.swift */, + 91B763A3283EB19800B458A9 /* MoreChannelActionsFullScreenWrappingView.swift */, 8465FD4F2746A95600AF091E /* MoreChannelActionsView.swift */, 8465FD5B2746A95700AF091E /* MoreChannelActionsViewModel.swift */, - 91B763A3283EB19800B458A9 /* MoreChannelActionsFullScreenWrappingView.swift */, + 8465FD522746A95600AF091E /* NoChannelsView.swift */, 8421BCEF27A44EAE000F977D /* SearchResultsView.swift */, ); path = ChatChannelList; @@ -2192,6 +2200,7 @@ 91B79FD8284E7E9C005B6E4F /* ChatUserNamer_Tests.swift */, 84C94D53275A1380007FE2B9 /* DateUtils_Tests.swift */, 84C94D5D275A3AA9007FE2B9 /* ImageCDN_Tests.swift */, + 4F8D643F2DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift */, 849988AF2AE6BE4800CC95C9 /* PaddingsConfig_Tests.swift */, 84779C762AEBCA6E000A6A68 /* ReactionsIconProvider_Tests.swift */, 84E1D8272976CCAF00060491 /* SortReactions_Tests.swift */, @@ -2801,6 +2810,7 @@ 82D64C082AD7E5B700C5C79E /* Operation.swift in Sources */, 82D64BEB2AD7E5B700C5C79E /* ImagePipelineTask.swift in Sources */, AD2DDA612CB040EA0040B8D4 /* NoThreadsView.swift in Sources */, + 4F9173FD2DDDFFE8003C30B5 /* ChannelListConfig.swift in Sources */, 82D64BF92AD7E5B700C5C79E /* ImageProcessors+Resize.swift in Sources */, 8465FDC22746A95700AF091E /* ChatChannelNavigatableListItem.swift in Sources */, 8465FDAD2746A95700AF091E /* ImageCDN.swift in Sources */, @@ -2814,6 +2824,7 @@ 8465FDA52746A95700AF091E /* Modifiers.swift in Sources */, 8465FDBB2746A95700AF091E /* LoadingView.swift in Sources */, 84D6E4F62B2CA4E300D0056C /* RecordingTipView.swift in Sources */, + 4F7613792DDCB2C900F996E3 /* MessageRelativeDateFormatter.swift in Sources */, 846608E3278C303800D3D7B3 /* TypingIndicatorView.swift in Sources */, 84A1CACF2816BCF00046595A /* AddUsersView.swift in Sources */, 82D64BF02AD7E5B700C5C79E /* DataLoading.swift in Sources */, @@ -3073,6 +3084,7 @@ 84B2B5CA281947E100479CEE /* ViewFrameUtils.swift in Sources */, 8423C342277CBA280092DCF1 /* TypingSuggester_Tests.swift in Sources */, 84507C9A281ACCD70081DDC2 /* AddUsersView_Tests.swift in Sources */, + 4F8D64402DDDCF9300026C09 /* MessageRelativeDateFormatter_Tests.swift in Sources */, 84C94D0627578BF2007FE2B9 /* UnwrapAsync.swift in Sources */, 84D6B55A27DF6EC7009C6D07 /* LoadingView_Tests.swift in Sources */, 84C94D0427578BF2007FE2B9 /* TestError.swift in Sources */, diff --git a/StreamChatSwiftUITests/Tests/Utils/MessageRelativeDateFormatter_Tests.swift b/StreamChatSwiftUITests/Tests/Utils/MessageRelativeDateFormatter_Tests.swift new file mode 100644 index 000000000..af8412a4e --- /dev/null +++ b/StreamChatSwiftUITests/Tests/Utils/MessageRelativeDateFormatter_Tests.swift @@ -0,0 +1,63 @@ +// +// Copyright © 2025 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamChat +@testable import StreamChatSwiftUI +import XCTest + +final class MessageRelativeDateFormatter_Tests: StreamChatTestCase { + private var formatter: MessageRelativeDateFormatter! + + override func setUp() { + super.setUp() + formatter = MessageRelativeDateFormatter() + formatter.locale = Locale(identifier: "en_UK") + formatter.todayFormatter.locale = Locale(identifier: "en_UK") + formatter.yesterdayFormatter.locale = Locale(identifier: "en_UK") + } + + override func tearDown() { + super.tearDown() + formatter = nil + } + + func test_showingTimeOnly() throws { + let date = try XCTUnwrap(Calendar.current.date(bySettingHour: 1, minute: 2, second: 3, of: Date())) + let result = formatter.string(from: date) + let expected = formatter.todayFormatter.string(from: date) + XCTAssertEqual(expected, result) + XCTAssertEqual("01:02", result) + } + + func test_showingYesterday() throws { + let date = try XCTUnwrap(Calendar.current.date(byAdding: .day, value: -1, to: Date())) + let result = formatter.string(from: date) + let expected = formatter.yesterdayFormatter.string(from: date) + XCTAssertEqual(expected, result) + XCTAssertEqual("Yesterday", result) + } + + func test_showingWeekday() throws { + let date = try XCTUnwrap(Calendar.current.date(byAdding: .day, value: -6, to: Date())) + let result = formatter.string(from: date) + let expected = formatter.weekdayFormatter.string(from: date) + XCTAssertEqual(expected, result) + } + + func test_showingShortDate() throws { + let components = DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2025, + month: 1, + day: 15, + hour: 3, + minute: 4, + second: 5 + ) + let date = try XCTUnwrap(Calendar.current.date(from: components)) + let result = formatter.string(from: date) + XCTAssertEqual("15/01/2025", result) + } +}