diff --git a/.styles/config/vocabularies/Base/accept.txt b/.styles/config/vocabularies/Base/accept.txt index 71b42f66f94..8b2eed94853 100644 --- a/.styles/config/vocabularies/Base/accept.txt +++ b/.styles/config/vocabularies/Base/accept.txt @@ -46,4 +46,6 @@ swappable telehealth subclassing [Ss]ubview -scrollable \ No newline at end of file +scrollable +HContainer +VContainer \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index be6a66b22e3..8baa9c72b27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Upcoming ## StreamChat +### ✅ Added +- Add helper functions to `Poll` that extracts common domain logic [#3374](https://github.com/GetStream/stream-chat-swift/pull/3374) ### 🐞 Fixed - Fix Logger printing the incorrect thread name [#3382](https://github.com/GetStream/stream-chat-swift/pull/3382) +- Fix `PollOption.latestVotes` sorting [#3374](https://github.com/GetStream/stream-chat-swift/pull/3374) +- Fix `Poll.latestAnswers` sorting [#3374](https://github.com/GetStream/stream-chat-swift/pull/3374) +- Fix `Poll` updates not triggering message updates in `ChannelController` [#3374](https://github.com/GetStream/stream-chat-swift/pull/3374) + +## StreamChatUI +### ✅ Added +- ✨ Introducing `ViewContainerBuilder`, a new, easier way to customize views [#3374](https://github.com/GetStream/stream-chat-swift/pull/3374) (Learn more by reading the docs [here](https://getstream.io/chat/docs/sdk/ios/uikit/custom-components/)) +- Add `PollAttachmentView` component to render polls in the message list [#3374](https://github.com/GetStream/stream-chat-swift/pull/3374) +- Add `ChatUserAvatarView.shouldShowOnlineIndicator` to disable the online indicator easily [#3374](https://github.com/GetStream/stream-chat-swift/pull/3374) +### 🎭 New Localizations +- `message.polls.subtitle.selectOne` +- `message.polls.subtitle.selectOneOrMore` +- `message.polls.subtitle.selectUpTo` +- `message.polls.subtitle.voteEnded` +- `message.polls.button.endVote` +- `message.polls.button.viewResults` +- `message.preview.poll-someone-voted` +- `message.preview.poll-you-voted` +- `message.preview.poll-someone-created` +- `message.preview.poll-you-created` +- `alert.poll.end-title` +- `alert.poll.end` # [4.62.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.62.0) _August 15, 2024_ diff --git a/Sources/StreamChat/APIClient/Endpoints/Payloads/PollsPayloads.swift b/Sources/StreamChat/APIClient/Endpoints/Payloads/PollsPayloads.swift index 17c673f72dc..889016668ee 100644 --- a/Sources/StreamChat/APIClient/Endpoints/Payloads/PollsPayloads.swift +++ b/Sources/StreamChat/APIClient/Endpoints/Payloads/PollsPayloads.swift @@ -174,7 +174,7 @@ struct PollPayload: Decodable { var voteCount: Int var latestAnswers: [PollVotePayload?]? var options: [PollOptionPayload?] - var ownVotes: [PollVotePayload?] + var ownVotes: [PollVotePayload?]? var custom: [String: RawJSON]? var latestVotesByOption: [String: [PollVotePayload]]? var voteCountsByOption: [String: Int]? @@ -183,6 +183,8 @@ struct PollPayload: Decodable { var votingVisibility: String? var createdBy: UserPayload? + var fromEvent: Bool = false + init( allowAnswers: Bool, allowUserSuggestedOptions: Bool, diff --git a/Sources/StreamChat/Database/DTOs/PollDTO.swift b/Sources/StreamChat/Database/DTOs/PollDTO.swift index b3972c32e52..b13a9e10236 100644 --- a/Sources/StreamChat/Database/DTOs/PollDTO.swift +++ b/Sources/StreamChat/Database/DTOs/PollDTO.swift @@ -24,6 +24,7 @@ class PollDTO: NSManagedObject { @NSManaged var votingVisibility: String? @NSManaged var createdBy: UserDTO? @NSManaged var latestAnswers: Set + @NSManaged var ownVotes: Set @NSManaged var message: MessageDTO? @NSManaged var options: NSOrderedSet @NSManaged var latestVotesByOption: Set @@ -88,9 +89,12 @@ extension PollDTO { maxVotesAllowed: maxVotesAllowed?.intValue, votingVisibility: votingVisibility(from: votingVisibility), createdBy: createdBy?.asModel(), - latestAnswers: latestAnswers.map { try $0.asModel() }, + latestAnswers: latestAnswers + .map { try $0.asModel() } + .sorted(by: { $0.createdAt < $1.createdAt }), options: optionsArray.map { try $0.asModel() }, - latestVotesByOption: latestVotesByOption.map { try $0.asModel() } + latestVotesByOption: latestVotesByOption.map { try $0.asModel() }, + ownVotes: ownVotes.map { try $0.asModel() } ) } @@ -174,7 +178,24 @@ extension NSManagedObjectContext { } } ?? [] ) - + + if let payloadOwnVotes = payload.ownVotes, !payload.fromEvent { + pollDto.ownVotes = try Set( + payloadOwnVotes.compactMap { payload in + if let payload { + let voteDto = try savePollVote(payload: payload, query: nil, cache: cache) + voteDto.poll = pollDto + return voteDto + } else { + return nil + } + } + ) + } + + // Trigger FRC message update + pollDto.message?.poll = pollDto + return pollDto } diff --git a/Sources/StreamChat/Database/DTOs/PollOptionDTO.swift b/Sources/StreamChat/Database/DTOs/PollOptionDTO.swift index cf46e7e539d..d733e2cd5b1 100644 --- a/Sources/StreamChat/Database/DTOs/PollOptionDTO.swift +++ b/Sources/StreamChat/Database/DTOs/PollOptionDTO.swift @@ -56,7 +56,9 @@ extension PollOptionDTO { return PollOption( id: id, text: text, - latestVotes: try latestVotes.map { try $0.asModel() }, + latestVotes: try latestVotes + .map { try $0.asModel() } + .sorted(by: { $0.createdAt < $1.createdAt }), extraData: extraData ) } diff --git a/Sources/StreamChat/Database/DTOs/PollVoteDTO.swift b/Sources/StreamChat/Database/DTOs/PollVoteDTO.swift index 488b96c7c0a..e43c7d498f6 100644 --- a/Sources/StreamChat/Database/DTOs/PollVoteDTO.swift +++ b/Sources/StreamChat/Database/DTOs/PollVoteDTO.swift @@ -131,7 +131,14 @@ extension NSManagedObjectContext { let queryDTO = try saveQuery(query: query) queryDTO?.votes.insert(dto) } - + + if currentUser?.user.id == dto.user?.id { + poll.ownVotes.insert(dto) + } + + // Trigger FRC message update + poll.message?.poll = poll + return dto } @@ -187,7 +194,14 @@ extension NSManagedObjectContext { } option?.latestVotes.insert(dto) - + + if currentUser?.user.id == dto.user?.id { + poll.ownVotes.insert(dto) + } + + // Trigger FRC message update + poll.message?.poll = poll + return dto } @@ -213,8 +227,14 @@ extension NSManagedObjectContext { poll?.voteCountsByOption?[optionId] = votes } dto.option?.latestVotes.remove(dto) + if currentUser?.user.id == dto.user?.id { + dto.poll?.ownVotes.remove(dto) + } } + // Trigger FRC message update + poll?.message?.poll = poll + delete(pollVote: dto) return dto } diff --git a/Sources/StreamChat/Database/DatabaseSession.swift b/Sources/StreamChat/Database/DatabaseSession.swift index fb9c26b08f4..c0b560911be 100644 --- a/Sources/StreamChat/Database/DatabaseSession.swift +++ b/Sources/StreamChat/Database/DatabaseSession.swift @@ -695,7 +695,8 @@ extension DatabaseSession { } } - if let poll = payload.poll { + if var poll = payload.poll { + poll.fromEvent = true try savePoll(payload: poll, cache: nil) } diff --git a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents index dd35fc9f1f1..877706d3581 100644 --- a/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents +++ b/Sources/StreamChat/Database/StreamChatModel.xcdatamodeld/StreamChatModel.xcdatamodel/contents @@ -332,6 +332,7 @@ + @@ -349,6 +350,7 @@ + diff --git a/Sources/StreamChat/Models/ChatMessage.swift b/Sources/StreamChat/Models/ChatMessage.swift index 948155d79a9..ce3c1009afc 100644 --- a/Sources/StreamChat/Models/ChatMessage.swift +++ b/Sources/StreamChat/Models/ChatMessage.swift @@ -363,6 +363,7 @@ extension ChatMessage: Hashable { guard lhs.localState == rhs.localState else { return false } guard lhs.updatedAt == rhs.updatedAt else { return false } guard lhs.allAttachments == rhs.allAttachments else { return false } + guard lhs.poll == rhs.poll else { return false } guard lhs.author == rhs.author else { return false } guard lhs.currentUserReactionsCount == rhs.currentUserReactionsCount else { return false } guard lhs.text == rhs.text else { return false } diff --git a/Sources/StreamChat/Models/Poll.swift b/Sources/StreamChat/Models/Poll.swift index 98e4b9df278..d5a5d8c3c35 100644 --- a/Sources/StreamChat/Models/Poll.swift +++ b/Sources/StreamChat/Models/Poll.swift @@ -68,4 +68,62 @@ public struct Poll: Equatable { /// A list of the latest votes received for each option in the poll. public let latestVotesByOption: [PollOption] + + /// The list of the current user votes. + public let ownVotes: [PollVote] +} + +/// Poll domain logic helpers. +public extension Poll { + /// The value of the option with the most votes. + var currentMaximumVoteCount: Int { + voteCountsByOption?.values.max() ?? 0 + } + + /// Whether the poll is already closed and the provided option is the one, and **the only one** with the most votes. + func isOptionWinner(_ option: PollOption) -> Bool { + isClosed && isOptionWithMostVotes(option) + } + + /// Whether the poll is already close and the provided option is one of that has the most votes. + func isOptionOneOfTheWinners(_ option: PollOption) -> Bool { + isClosed && isOptionWithMaximumVotes(option) + } + + /// Whether the provided option is the one, and **the only one** with the most votes. + func isOptionWithMostVotes(_ option: PollOption) -> Bool { + let optionsWithMostVotes = voteCountsByOption?.filter { $0.value == currentMaximumVoteCount } + return optionsWithMostVotes?.count == 1 && optionsWithMostVotes?[option.id] != nil + } + + /// Whether the provided option is one of that has the most votes. + func isOptionWithMaximumVotes(_ option: PollOption) -> Bool { + let optionsWithMostVotes = voteCountsByOption?.filter { $0.value == currentMaximumVoteCount } + return optionsWithMostVotes?[option.id] != nil + } + + /// The vote count for the given option. + func voteCount(for option: PollOption) -> Int { + voteCountsByOption?[option.id] ?? 0 + } + + // The ratio of the votes for the given option in comparison with the number of total votes. + func voteRatio(for option: PollOption) -> Float { + if currentMaximumVoteCount == 0 { + return 0 + } + + let optionVoteCount = voteCount(for: option) + return Float(optionVoteCount) / Float(currentMaximumVoteCount) + } + + /// Returns the vote of the current user for the given option in case the user has voted. + func currentUserVote(for option: PollOption) -> PollVote? { + ownVotes.first(where: { $0.optionId == option.id }) + } + + /// Returns a Boolean value indicating whether the current user has voted the given option. + func hasCurrentUserVoted(for option: PollOption) -> Bool { + ownVotes.map(\.optionId).contains(option.id) + } } diff --git a/Sources/StreamChatUI/Appearance+Images.swift b/Sources/StreamChatUI/Appearance+Images.swift index b935e9a212c..135f000de44 100644 --- a/Sources/StreamChatUI/Appearance+Images.swift +++ b/Sources/StreamChatUI/Appearance+Images.swift @@ -79,6 +79,14 @@ public extension Appearance { public var messageDeliveryStatusRead: UIImage = loadImageSafely(with: "message_receipt_read") public var messageDeliveryStatusFailed: UIImage = loadImageSafely(with: "message_receipt_failed") + // MARK: - Polls + + public var pollVoteCheckmarkActive: UIImage = .checkmark + public var pollVoteCheckmarkInactive: UIImage = UIImage( + systemName: "circle", + withConfiguration: UIImage.SymbolConfiguration(weight: .thin) + ) ?? loadSafely(systemName: "circle", assetsFallback: "checkmark_confirm") + // MARK: - Threads public var threadIcon: UIImage = loadSafely(systemName: "text.bubble", assetsFallback: "text_bubble") diff --git a/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView.swift b/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView.swift index 13c0526dec8..0d1dbb30192 100644 --- a/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView.swift +++ b/Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView.swift @@ -150,6 +150,10 @@ open class ChatChannelListItemView: _View, ThemeProvider, SwiftUIRepresentable { } if let previewMessage = content.channel.previewMessage { + if let pollPreviewText = pollAttachmentPreviewText(for: previewMessage) { + return pollPreviewText + } + if isLastMessageVoiceRecording { return previewMessageForAudioRecordingMessage(messageText: previewMessage.text) } @@ -440,6 +444,7 @@ open class ChatChannelListItemView: _View, ThemeProvider, SwiftUIRepresentable { guard let attachment = previewMessage.allAttachments.first else { return nil } + let text = messageText switch attachment.type { case .audio: @@ -464,6 +469,31 @@ open class ChatChannelListItemView: _View, ThemeProvider, SwiftUIRepresentable { } } + /// The message preview text in case it is a Poll. + /// - Parameter previewMessage: The preview message of the channel. + /// - Returns: A string representing the message preview text. + open func pollAttachmentPreviewText(for previewMessage: ChatMessage) -> String? { + guard let poll = previewMessage.poll, !previewMessage.isDeleted else { return nil } + var components = ["📊"] + if let latestVoter = poll.latestVotesByOption.first?.latestVotes.first?.user { + if previewMessage.isSentByCurrentUser && latestVoter.id == previewMessage.author.id { + components.append(L10n.Message.Preview.pollYouVoted) + } else { + components.append(L10n.Message.Preview.pollSomeoneVoted(latestVoter.name ?? latestVoter.id)) + } + } else if let creator = poll.createdBy { + if previewMessage.isSentByCurrentUser { + components.append(L10n.Message.Preview.pollYouCreated) + } else { + components.append(L10n.Message.Preview.pollSomeoneCreated(creator.name ?? creator.id)) + } + } + if !poll.name.isEmpty { + components.append(poll.name) + } + return components.joined(separator: " ") + } + // MARK: - Channel preview when user is typing /// The formatted string containing the typing member. diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/AttachmentViewCatalog.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/AttachmentViewCatalog.swift index b618f5d33b8..2b300dd6a17 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/AttachmentViewCatalog.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/AttachmentViewCatalog.swift @@ -15,6 +15,10 @@ open class AttachmentViewCatalog { message: ChatMessage, components: Components ) -> AttachmentViewInjector.Type? { + if message.poll != nil { + return PollAttachmentViewInjector.self + } + let attachmentCounts = message.attachmentCounts if attachmentCounts.keys.contains(.image) || attachmentCounts.keys.contains(.video) { diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift index 42710702272..c0e66d208a2 100644 --- a/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/ChatMessageAttachmentPreviewVC.swift @@ -17,7 +17,7 @@ open class ChatMessageAttachmentPreviewVC: _ViewController, WKNavigationDelegate .withoutAutoresizingMaskConstraints .withAccessibilityIdentifier(identifier: "webView") - public private(set) lazy var activityIndicatorView = UIActivityIndicatorView(style: .gray) + public private(set) lazy var activityIndicatorView = UIActivityIndicatorView(style: .medium) .withAccessibilityIdentifier(identifier: "activityIndicatorView") private lazy var closeButton = UIBarButtonItem( diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentOptionListView/PollAttachmentOptionListItemView.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentOptionListView/PollAttachmentOptionListItemView.swift new file mode 100644 index 00000000000..9eb5b69cca4 --- /dev/null +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentOptionListView/PollAttachmentOptionListItemView.swift @@ -0,0 +1,169 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +import UIKit + +/// The view that displays a poll option in the poll option list view. +open class PollAttachmentOptionListItemView: _View, ThemeProvider { + public struct Content { + /// The option that this view represents. + public var option: PollOption + /// The poll that this option belongs. + public var poll: Poll + + public init(option: PollOption, poll: Poll) { + self.option = option + self.poll = poll + } + + /// Whether the current option has been voted by the current user. + public var isVotedByCurrentUser: Bool { + poll.hasCurrentUserVoted(for: option) + } + + /// The number of votes this option has. + public var voteCount: Int { + poll.voteCount(for: option) + } + + /// The ratio of the votes of this option in comparison with the number of total votes. + public var voteRatio: Float { + poll.voteRatio(for: option) + } + } + + public var content: Content? { + didSet { + updateContentIfNeeded() + } + } + + // MARK: - Action Handlers + + /// A closure that is triggered whenever the option is tapped either from the button or the item itself. + public var onOptionTap: ((PollOption) -> Void)? + + // MARK: - UI Components + + /// A label which displays the name of the option. + open private(set) lazy var optionNameLabel = UILabel() + .withoutAutoresizingMaskConstraints + .withBidirectionalLanguagesSupport + .withAdjustingFontForContentSizeCategory + .withAccessibilityIdentifier(identifier: "optionNameLabel") + + /// A label which displays the number of votes of the option. + open private(set) lazy var votesCountLabel = UILabel() + .withoutAutoresizingMaskConstraints + .withBidirectionalLanguagesSupport + .withAdjustingFontForContentSizeCategory + .withAccessibilityIdentifier(identifier: "votesCountLabel") + + /// A progress view that displays the number of votes this option + /// has in relation with the option with max votes. + open private(set) lazy var votesProgressView = UIProgressView() + .withoutAutoresizingMaskConstraints + .withAccessibilityIdentifier(identifier: "votesProgressView") + + /// A button to add or remove a vote for this option. + open private(set) lazy var voteCheckboxButton = CheckboxButton(type: .roundedRect) + .withoutAutoresizingMaskConstraints + .withAccessibilityIdentifier(identifier: "voteCheckboxView") + + /// The avatar view type used to build the avatar views displayed on the vote authors. + open lazy var latestVotesAuthorsView = StackedUserAvatarsView() + .withoutAutoresizingMaskConstraints + .withAccessibilityIdentifier(identifier: "latestVotesAuthorsView") + + // MARK: - Lifecycle + + override open func setUp() { + super.setUp() + + voteCheckboxButton.addTarget(self, action: #selector(didTapOption(sender:)), for: .touchUpInside) + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapOption(sender:))) + addGestureRecognizer(tapGestureRecognizer) + } + + override open func setUpAppearance() { + super.setUpAppearance() + + optionNameLabel.numberOfLines = 0 + optionNameLabel.font = appearance.fonts.subheadline + votesCountLabel.font = appearance.fonts.body.withSize(14) + votesCountLabel.textColor = appearance.colorPalette.text + voteCheckboxButton.contentEdgeInsets = .zero + voteCheckboxButton.imageEdgeInsets = .zero + voteCheckboxButton.titleEdgeInsets = .zero + } + + override open func setUpLayout() { + super.setUpLayout() + + HContainer(spacing: 2) { + voteCheckboxButton + .width(25) + VContainer(spacing: 4) { + HContainer(spacing: 4, alignment: .top) { + optionNameLabel + Spacer() + latestVotesAuthorsView + votesCountLabel.layout { + $0.setContentCompressionResistancePriority(.streamRequire, for: .horizontal) + } + } + votesProgressView + } + .height(greaterThanOrEqualTo: 28) + } + .embed(in: self) + } + + override open func updateContent() { + super.updateContent() + + guard let content = self.content else { + return + } + + optionNameLabel.text = content.option.text + votesCountLabel.text = "\(content.voteCount)" + latestVotesAuthorsView.content = .init(users: latestVotesAuthors) + + if content.isVotedByCurrentUser { + voteCheckboxButton.setCheckedState() + } else { + voteCheckboxButton.setUncheckedState() + } + voteCheckboxButton.isHidden = content.poll.isClosed + + if isOptionWinner { + votesProgressView.tintColor = appearance.colorPalette.alternativeActiveTint + } else { + votesProgressView.tintColor = appearance.colorPalette.accentPrimary + } + votesProgressView.progress = content.voteRatio + } + + @objc func didTapOption(sender: Any?) { + guard let option = content?.option else { + return + } + onOptionTap?(option) + } + + /// Whether the poll is closed and this option is the winner. + open var isOptionWinner: Bool { + guard let content = self.content else { return false } + return content.poll.isOptionWinner(content.option) + } + + /// The authors of the latest votes of this option. + open var latestVotesAuthors: [ChatUser] { + content?.option.latestVotes + .prefix(2) + .compactMap(\.user) ?? [] + } +} diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentOptionListView/PollAttachmentOptionListView.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentOptionListView/PollAttachmentOptionListView.swift new file mode 100644 index 00000000000..641d38eb376 --- /dev/null +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentOptionListView/PollAttachmentOptionListView.swift @@ -0,0 +1,88 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +import UIKit + +/// The options list view of the poll attachment. +open class PollAttachmentOptionListView: _View, ThemeProvider { + // MARK: - Content + + public struct Content: Equatable { + public var poll: Poll + + public init(poll: Poll) { + self.poll = poll + } + } + + public var content: Content? { + didSet { + updateContentIfNeeded() + } + } + + // MARK: - Configuration + + /// A closure that is triggered whenever the option is tapped either from the button or the item itself. + public var onOptionTap: ((PollOption) -> Void)? + + // MARK: - Views + + /// The container that holds all option item views. + open var container: UIStackView? + + /// The item views that display each option. + open var itemViews: [PollAttachmentOptionListItemView] = [] + + // MARK: - Lifecycle + + override open func setUpLayout() { + super.setUpLayout() + + container?.removeFromSuperview() + container = VContainer(spacing: 24) { + makeItemViews() + }.embed(in: self) + } + + override open func updateContent() { + super.updateContent() + + guard let content = self.content else { + return + } + + /// We only recreate the item views in case the options do not match the number of views. + /// This makes sure we only recreate the item views when needed. + if itemViews.count != content.poll.options.count { + setUpLayout() + } + + itemViews.forEach { + $0.isHidden = true + } + zip(itemViews, content.poll.options).forEach { itemView, option in + itemView.content = .init( + option: option, + poll: content.poll + ) + itemView.isHidden = false + } + } + + /// Creates the option item views based on the number of options. + open func makeItemViews() -> [PollAttachmentOptionListItemView] { + guard let content = self.content else { return [] } + guard !content.poll.options.isEmpty else { return [] } + itemViews = (0.. Void)? + + /// A closure that is triggered whenever the end poll button is tapped. + public var onEndTap: ((Poll) -> Void)? + + /// A closure that is triggered whenever the poll results button is tapped. + public var onResultsTap: ((Poll) -> Void)? + + // MARK: - UI Components + + /// A label which by default displays the title of the Poll. + open private(set) lazy var pollTitleLabel = UILabel() + .withoutAutoresizingMaskConstraints + .withBidirectionalLanguagesSupport + .withAdjustingFontForContentSizeCategory + .withAccessibilityIdentifier(identifier: "pollTitleLabel") + + /// A label which by default displays the voting state of the Poll. + open private(set) lazy var pollSubtitleLabel = UILabel() + .withoutAutoresizingMaskConstraints + .withBidirectionalLanguagesSupport + .withAdjustingFontForContentSizeCategory + .withAccessibilityIdentifier(identifier: "pollSubtitleLabel") + + /// A label which by default displays the selection rules of the Poll. + open private(set) lazy var optionListView: PollAttachmentOptionListView = components + .pollAttachmentOptionListView.init() + .withoutAutoresizingMaskConstraints + .withAccessibilityIdentifier(identifier: "optionsListView") + + /// The button that when tapped it shows the polls results. + open private(set) lazy var pollResultsButton = UIButton() + .withoutAutoresizingMaskConstraints + .withAccessibilityIdentifier(identifier: "pollResultsButton") + + /// The button that when tapped it shows the polls results. + open private(set) lazy var endPollButton = UIButton() + .withoutAutoresizingMaskConstraints + .withAccessibilityIdentifier(identifier: "endPollButton") + + /// The header view composed by the poll title and subtile labels. + open private(set) lazy var headerView: UIView = { + VContainer(spacing: 2) { + pollTitleLabel + pollSubtitleLabel + } + }() + + /// The footer view composed by a stack of buttons that can perform actions on the poll. + open private(set) lazy var footerView: UIView = { + VContainer(spacing: 2) { + pollResultsButton + endPollButton + } + }() + + // MARK: - Lifecycle + + override open func setUp() { + super.setUp() + + pollResultsButton.addTarget(self, action: #selector(didTapResultsButton(sender:)), for: .touchUpInside) + endPollButton.addTarget(self, action: #selector(didTapEndPollButton(sender:)), for: .touchUpInside) + } + + override open func setUpAppearance() { + super.setUpAppearance() + + clipsToBounds = true + pollTitleLabel.font = appearance.fonts.headlineBold + pollTitleLabel.numberOfLines = 0 + pollSubtitleLabel.font = appearance.fonts.caption1 + pollSubtitleLabel.textColor = appearance.colorPalette.textLowEmphasis + pollResultsButton.setTitleColor(appearance.colorPalette.accentPrimary, for: .normal) + pollResultsButton.titleLabel?.font = appearance.fonts.subheadline.withSize(16) + endPollButton.setTitleColor(appearance.colorPalette.accentPrimary, for: .normal) + endPollButton.titleLabel?.font = appearance.fonts.subheadline.withSize(16) + } + + override open func setUpLayout() { + super.setUpLayout() + + directionalLayoutMargins = .init(top: 12, leading: 10, bottom: 10, trailing: 12) + + VContainer(spacing: 14) { + headerView + optionListView + footerView + } + .embedToMargins(in: self) + } + + override open func updateContent() { + super.updateContent() + + guard let content = self.content else { + return + } + + pollTitleLabel.text = content.poll.name + pollSubtitleLabel.text = subtitleText + + optionListView.onOptionTap = onOptionTap + optionListView.content = .init(poll: content.poll) + + pollResultsButton.setTitle(L10n.Message.Polls.Button.viewResults, for: .normal) + endPollButton.setTitle(L10n.Message.Polls.Button.endVote, for: .normal) + + let isPollCreatedByCurrentUser = content.poll.createdBy?.id == content.currentUserId + let shouldShowEndPollButton = !content.poll.isClosed && isPollCreatedByCurrentUser + endPollButton.isHidden = !shouldShowEndPollButton + } + + @objc open func didTapResultsButton(sender: Any?) { + guard let poll = content?.poll else { return } + onResultsTap?(poll) + } + + @objc open func didTapEndPollButton(sender: Any?) { + guard let poll = content?.poll else { return } + onEndTap?(poll) + } + + /// The subtitle text. By default it displays the current voting state. + open var subtitleText: String { + guard let content = self.content else { return "" } + let poll = content.poll + if poll.isClosed == true { + return L10n.Message.Polls.Subtitle.voteEnded + } else if poll.enforceUniqueVote == true { + return L10n.Message.Polls.Subtitle.selectOne + } else if let maxVotes = poll.maxVotesAllowed, maxVotes > 0 { + return L10n.Message.Polls.Subtitle.selectUpTo(min(maxVotes, poll.options.count)) + } else { + return L10n.Message.Polls.Subtitle.selectOneOrMore + } + } +} diff --git a/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentViewInjector.swift b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentViewInjector.swift new file mode 100644 index 00000000000..cf93bc8b80e --- /dev/null +++ b/Sources/StreamChatUI/ChatMessageList/Attachments/Poll/PollAttachmentViewInjector.swift @@ -0,0 +1,74 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Foundation +import StreamChat + +/// The delegate used to handle Polls interactions in the message list. +public protocol PollAttachmentViewInjectorDelegate: ChatMessageContentViewDelegate { + /// Called when the user taps in an option of the poll. + func pollAttachmentView( + _ pollAttachmentView: PollAttachmentView, + didTapOption option: PollOption, + in message: ChatMessage + ) + + /// Called when the user ends the poll. + func pollAttachmentView( + _ pollAttachmentView: PollAttachmentView, + didTapEndPoll poll: Poll, + in message: ChatMessage + ) + + /// Called when the user taps on the button to show the poll results. + func pollAttachmentView( + _ pollAttachmentView: PollAttachmentView, + didTapPollResults poll: Poll, + in message: ChatMessage + ) +} + +public class PollAttachmentViewInjector: AttachmentViewInjector { + open lazy var pollAttachmentView: PollAttachmentView = contentView.components + .pollAttachmentView + .init() + .withoutAutoresizingMaskConstraints + + public var pollAttachmentViewDelegate: PollAttachmentViewInjectorDelegate? { + contentView.delegate as? PollAttachmentViewInjectorDelegate + } + + override open func contentViewDidLayout(options: ChatMessageLayoutOptions) { + super.contentViewDidLayout(options: options) + + contentView.bubbleContentContainer.insertArrangedSubview( + pollAttachmentView, + at: 0, + respectsLayoutMargins: false + ) + } + + override open func contentViewDidUpdateContent() { + super.contentViewDidUpdateContent() + + guard let message = contentView.content else { return } + guard let poll = message.poll else { return } + guard let currentUserId = contentView.currentUserId else { return } + + pollAttachmentView.onOptionTap = { [weak self] option in + guard let self = self else { return } + self.pollAttachmentViewDelegate?.pollAttachmentView(self.pollAttachmentView, didTapOption: option, in: message) + } + pollAttachmentView.onEndTap = { [weak self] poll in + guard let self = self else { return } + self.pollAttachmentViewDelegate?.pollAttachmentView(self.pollAttachmentView, didTapEndPoll: poll, in: message) + } + pollAttachmentView.onResultsTap = { [weak self] poll in + guard let self = self else { return } + self.pollAttachmentViewDelegate?.pollAttachmentView(self.pollAttachmentView, didTapPollResults: poll, in: message) + } + + pollAttachmentView.content = .init(poll: poll, currentUserId: currentUserId) + } +} diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift index c082378c1e7..106b6cc8daa 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessage/ChatMessageContentView.swift @@ -91,6 +91,9 @@ open class ChatMessageContentView: _View, ThemeProvider, UITextViewDelegate { didSet { updateContentIfNeeded() } } + /// The current logged in user id. + public var currentUserId: UserId? + /// A formatter that converts the message timestamp to textual representation. public lazy var timestampFormatter: MessageTimestampFormatter = appearance.formatters.messageTimestamp diff --git a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift index 4247f7b71e9..a5c03536bf8 100644 --- a/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift +++ b/Sources/StreamChatUI/ChatMessageList/ChatMessageListVC.swift @@ -16,6 +16,7 @@ open class ChatMessageListVC: _ViewController, GiphyActionContentViewDelegate, FileActionContentViewDelegate, LinkPreviewViewDelegate, + PollAttachmentViewInjectorDelegate, UITableViewDataSource, UITableViewDelegate, UIGestureRecognizerDelegate, @@ -135,6 +136,10 @@ open class ChatMessageListVC: _ViewController, .audioSessionFeedbackGenerator .init() + /// A feedbackGenerator that will be used to provide feedback when a task is successful or not. + /// You can disable the feedback generator by overriding to `nil`. + open private(set) lazy var notificationFeedbackGenerator: UINotificationFeedbackGenerator? = UINotificationFeedbackGenerator() + /// A component responsible to manage the swipe to quote reply logic. open lazy var swipeToReplyGestureHandler = SwipeToReplyGestureHandler(listView: self.listView) @@ -162,6 +167,14 @@ open class ChatMessageListVC: _ViewController, dataSource?.isFirstPageLoaded == true } + /// The poll controller to manage the poll actions. + /// There is only one controller active for the poll which the user is currently interacting. + public internal(set) var pollController: PollController? + + /// The poll options that are currently being changed. + /// It is used to avoid making duplicate calls. + public internal(set) var pollOptionsCastingVote: Set = [] + /// The message cell height caches. This makes sure that the message list doesn't /// need to recalculate the cell height every time. This improve the scrolling /// experience since the content size calculation is more precise. @@ -768,6 +781,7 @@ open class ChatMessageListVC: _ViewController, cell.messageContentView?.delegate = self cell.messageContentView?.channel = channel cell.messageContentView?.content = message + cell.messageContentView?.currentUserId = client.currentUserId /// Process cell decorations cell.setDecoration(for: .header, decorationView: delegate?.chatMessageListVC(self, headerViewForMessage: message, at: indexPath)) @@ -1063,6 +1077,112 @@ open class ChatMessageListVC: _ViewController, audioPlayer?.seek(to: timeInterval) } + // MARK: - PollAttachmentViewDelegate + + open func pollAttachmentView( + _ pollAttachmentView: PollAttachmentView, + didTapOption option: PollOption, + in message: ChatMessage + ) { + guard let poll = message.poll else { return } + + if pollOptionsCastingVote.contains(option.id) { + return + } + pollOptionsCastingVote.insert(option.id) + + notificationFeedbackGenerator?.notificationOccurred(.success) + + let pollController = makePollController(for: poll, in: message) + if let currentUserVote = poll.currentUserVote(for: option) { + pollController.removePollVote(voteId: currentUserVote.id) { [weak self] error in + self?.didRemovePollVote(currentUserVote, for: option, in: message, error: error) + } + } else { + pollController.castPollVote(answerText: nil, optionId: option.id) { [weak self] error in + self?.didCastPollVote(for: option, in: message, error: error) + } + } + } + + open func pollAttachmentView( + _ pollAttachmentView: PollAttachmentView, + didTapPollResults poll: Poll, + in message: ChatMessage + ) { + print("show view results") + } + + open func pollAttachmentView( + _ pollAttachmentView: PollAttachmentView, + didTapEndPoll poll: Poll, + in message: ChatMessage + ) { + let pollController = makePollController(for: poll, in: message) + let alert = UIAlertController( + title: nil, + message: L10n.Alert.Poll.endTitle, + preferredStyle: .actionSheet + ) + alert.addAction(.init(title: L10n.Alert.Poll.end, style: .destructive, handler: { _ in + pollController.closePoll { [weak self] error in + let isSuccess = error == nil + self?.notificationFeedbackGenerator?.notificationOccurred(isSuccess ? .success : .error) + } + })) + alert.addAction(.init(title: L10n.Alert.Actions.cancel, style: .cancel)) + present(alert, animated: true) + } + + // MARK: Poll Requests Completion Handlers + + /// Called when removing a poll vote completed. + /// - Parameters: + /// - vote: The vote that was removed. + /// - option: The option which the voted was removed from. + /// - message: The message where the Poll belongs to. + /// - error: An error in case the call failed. + open func didRemovePollVote( + _ vote: PollVote, + for option: PollOption, + in message: ChatMessage, + error: Error? + ) { + pollOptionsCastingVote.remove(option.id) + if error != nil { + notificationFeedbackGenerator?.notificationOccurred(.error) + } + } + + /// Called when adding a poll vote completed. + /// - Parameters: + /// - option: The option which the voted was added to. + /// - message: The message where the Poll belongs to. + /// - error: An error in case the call failed. + open func didCastPollVote( + for option: PollOption, + in message: ChatMessage, + error: Error? + ) { + pollOptionsCastingVote.remove(option.id) + if error != nil { + notificationFeedbackGenerator?.notificationOccurred(.error) + } + } + + /// Creates the poll controller for the poll that is being interacted at the moment. + private func makePollController(for poll: Poll, in message: ChatMessage) -> PollController { + let pollController: PollController + if let existingPollController = self.pollController, poll.id == existingPollController.pollId { + pollController = existingPollController + } else { + pollController = client.pollController(messageId: message.id, pollId: poll.id) + pollOptionsCastingVote = [] + } + self.pollController = pollController + return pollController + } + // MARK: - Deprecations /// Jump to a given message. diff --git a/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift b/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift index c50fe7241dd..2ef5aac3b77 100644 --- a/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift +++ b/Sources/StreamChatUI/CommonViews/AvatarView/ChatUserAvatarView.swift @@ -17,9 +17,16 @@ open class ChatUserAvatarView: _View, ThemeProvider { didSet { updateContentIfNeeded() } } + /// A boolean value to determine if online indicator should be shown or not. + public var shouldShowOnlineIndicator: Bool = true + override open func setUpLayout() { super.setUpLayout() embed(presenceAvatarView) + + if !shouldShowOnlineIndicator { + presenceAvatarView.onlineIndicatorView.isHidden = true + } } override open func updateContent() { @@ -32,6 +39,8 @@ open class ChatUserAvatarView: _View, ThemeProvider { ) ) - presenceAvatarView.isOnlineIndicatorVisible = content?.isOnline ?? false + if shouldShowOnlineIndicator { + presenceAvatarView.isOnlineIndicatorVisible = content?.isOnline ?? false + } } } diff --git a/Sources/StreamChatUI/CommonViews/AttachmentActionButton/AttachmentActionButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/AttachmentActionButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/AttachmentActionButton/AttachmentActionButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/AttachmentActionButton.swift diff --git a/Sources/StreamChatUI/CommonViews/AttachmentButton/AttachmentButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/AttachmentButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/AttachmentButton/AttachmentButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/AttachmentButton.swift diff --git a/Sources/StreamChatUI/CommonViews/Buttons/CheckboxButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/CheckboxButton.swift new file mode 100644 index 00000000000..ec39bf7f08f --- /dev/null +++ b/Sources/StreamChatUI/CommonViews/Buttons/CheckboxButton.swift @@ -0,0 +1,21 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +import UIKit + +/// A button for checking or unchecking an option. +open class CheckboxButton: _Button, AppearanceProvider { + /// Sets the button has checked. + open func setCheckedState() { + setImage(appearance.images.pollVoteCheckmarkActive, for: .normal) + tintColor = appearance.colorPalette.accentPrimary + } + + /// Sets the button has unchecked. + open func setUncheckedState() { + setImage(appearance.images.pollVoteCheckmarkInactive, for: .normal) + tintColor = appearance.colorPalette.inactiveTint + } +} diff --git a/Sources/StreamChatUI/CommonViews/CheckboxControl/CheckboxControl.swift b/Sources/StreamChatUI/CommonViews/Buttons/CheckboxControl.swift similarity index 96% rename from Sources/StreamChatUI/CommonViews/CheckboxControl/CheckboxControl.swift rename to Sources/StreamChatUI/CommonViews/Buttons/CheckboxControl.swift index e5cca74590c..daf3d80a477 100644 --- a/Sources/StreamChatUI/CommonViews/CheckboxControl/CheckboxControl.swift +++ b/Sources/StreamChatUI/CommonViews/Buttons/CheckboxControl.swift @@ -5,7 +5,7 @@ import StreamChat import UIKit -/// A view to check/uncheck an option. +/// A view to check/uncheck an option along with a label describing the option. open class CheckboxControl: _Control, AppearanceProvider { // MARK: - Properties diff --git a/Sources/StreamChatUI/CommonViews/CircularCloseButton/CircularCloseButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/CircularCloseButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/CircularCloseButton/CircularCloseButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/CircularCloseButton.swift diff --git a/Sources/StreamChatUI/CommonViews/CloseButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/CloseButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/CloseButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/CloseButton.swift diff --git a/Sources/StreamChatUI/CommonViews/CommandButton/CommandButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/CommandButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/CommandButton/CommandButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/CommandButton.swift diff --git a/Sources/StreamChatUI/CommonViews/ConfirmButton/ConfirmButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/ConfirmButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/ConfirmButton/ConfirmButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/ConfirmButton.swift diff --git a/Sources/StreamChatUI/CommonViews/MediaButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/MediaButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/MediaButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/MediaButton.swift diff --git a/Sources/StreamChatUI/CommonViews/PillButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/PillButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/PillButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/PillButton.swift diff --git a/Sources/StreamChatUI/CommonViews/PlayPauseButton/PlayPauseButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/PlayPauseButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/PlayPauseButton/PlayPauseButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/PlayPauseButton.swift diff --git a/Sources/StreamChatUI/CommonViews/RecordButton/RecordButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/RecordButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/RecordButton/RecordButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/RecordButton.swift diff --git a/Sources/StreamChatUI/CommonViews/SendButton/SendButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/SendButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/SendButton/SendButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/SendButton.swift diff --git a/Sources/StreamChatUI/CommonViews/ShareButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/ShareButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/ShareButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/ShareButton.swift diff --git a/Sources/StreamChatUI/CommonViews/ShrinkInputButton/ShrinkInputButton.swift b/Sources/StreamChatUI/CommonViews/Buttons/ShrinkInputButton.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/ShrinkInputButton/ShrinkInputButton.swift rename to Sources/StreamChatUI/CommonViews/Buttons/ShrinkInputButton.swift diff --git a/Sources/StreamChatUI/CommonViews/CommandLabelView/CommandLabelView.swift b/Sources/StreamChatUI/CommonViews/CommandLabelView.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/CommandLabelView/CommandLabelView.swift rename to Sources/StreamChatUI/CommonViews/CommandLabelView.swift diff --git a/Sources/StreamChatUI/CommonViews/SendButton/CooldownView.swift b/Sources/StreamChatUI/CommonViews/CooldownView.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/SendButton/CooldownView.swift rename to Sources/StreamChatUI/CommonViews/CooldownView.swift diff --git a/Sources/StreamChatUI/CommonViews/GradientView/GradientView.swift b/Sources/StreamChatUI/CommonViews/GradientView.swift similarity index 100% rename from Sources/StreamChatUI/CommonViews/GradientView/GradientView.swift rename to Sources/StreamChatUI/CommonViews/GradientView.swift diff --git a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift index 6b9fbce67a7..c120be5a069 100644 --- a/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift +++ b/Sources/StreamChatUI/CommonViews/QuotedChatMessageView/QuotedChatMessageView.swift @@ -185,6 +185,10 @@ open class QuotedChatMessageView: _View, ThemeProvider, SwiftUIRepresentable { let translatedText = content?.message.translatedText(for: currentUserLang) { textView.text = translatedText } + + if let poll = message.poll, !message.isDeleted { + textView.text = "📊 \(poll.name)" + } } /// Sets the text of the quoted message. diff --git a/Sources/StreamChatUI/CommonViews/StackedUserAvatarsView.swift b/Sources/StreamChatUI/CommonViews/StackedUserAvatarsView.swift new file mode 100644 index 00000000000..9825efb2585 --- /dev/null +++ b/Sources/StreamChatUI/CommonViews/StackedUserAvatarsView.swift @@ -0,0 +1,69 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +import UIKit + +/// A view that shows user avatar views stacked together. +open class StackedUserAvatarsView: _View, ThemeProvider { + // MARK: - Content + + public struct Content { + public var users: [ChatUser] + + public init(users: [ChatUser]) { + self.users = users + } + } + + public var content: Content? { + didSet { + updateContentIfNeeded() + } + } + + // MARK: - Configuration + + /// The maximum number of avatars. + public var maximumNumberOfAvatars = 2 + + // MARK: - Views + + /// The user avatar views. + open lazy var userAvatarViews: [ChatUserAvatarView] = { + (0...maximumNumberOfAvatars - 1).map { _ in + let avatarView = components.userAvatarView.init() + .width(20) + .height(20) + avatarView.shouldShowOnlineIndicator = false + return avatarView + } + }() + + // MARK: - Lifecycle + + override open func setUpLayout() { + super.setUpLayout() + + HContainer(spacing: -4) { + userAvatarViews + }.embed(in: self) + } + + override open func updateContent() { + super.updateContent() + + guard let content = self.content else { + return + } + + userAvatarViews.forEach { + $0.isHidden = true + } + zip(userAvatarViews, content.users).forEach { avatarView, user in + avatarView.content = user + avatarView.isHidden = false + } + } +} diff --git a/Sources/StreamChatUI/Components.swift b/Sources/StreamChatUI/Components.swift index 694c5187e7c..1c977a032c5 100644 --- a/Sources/StreamChatUI/Components.swift +++ b/Sources/StreamChatUI/Components.swift @@ -312,6 +312,17 @@ public struct Components { /// The view that displays the number of unread messages in the chat. public var messageHeaderDecorationView: ChatChannelMessageHeaderDecoratorView.Type = ChatChannelMessageHeaderDecoratorView.self + // MARK: - Polls + + /// The view that displays a poll in the message list. + public var pollAttachmentView: PollAttachmentView.Type = PollAttachmentView.self + + /// The options list view of the poll attachment in the message list. + public var pollAttachmentOptionListView: PollAttachmentOptionListView.Type = PollAttachmentOptionListView.self + + /// The view that displays a poll option in the poll option list view. + public var pollAttachmentOptionListItemView: PollAttachmentOptionListItemView.Type = PollAttachmentOptionListItemView.self + // MARK: - Reactions /// The Reaction picker VC. diff --git a/Sources/StreamChatUI/Generated/L10n.swift b/Sources/StreamChatUI/Generated/L10n.swift index e65d38f2575..2c63bd42d85 100644 --- a/Sources/StreamChatUI/Generated/L10n.swift +++ b/Sources/StreamChatUI/Generated/L10n.swift @@ -24,6 +24,12 @@ internal enum L10n { /// Ok internal static var ok: String { L10n.tr("Localizable", "alert.actions.ok") } } + internal enum Poll { + /// End + internal static var end: String { L10n.tr("Localizable", "alert.poll.end") } + /// Nobody will be able to vote in this poll anymore. + internal static var endTitle: String { L10n.tr("Localizable", "alert.poll.end-title") } + } } internal enum Attachment { @@ -269,6 +275,40 @@ internal enum L10n { /// Are you sure? internal static var title: String { L10n.tr("Localizable", "message.moderation.title") } } + internal enum Polls { + internal enum Button { + /// End Vote + internal static var endVote: String { L10n.tr("Localizable", "message.polls.button.endVote") } + /// View Results + internal static var viewResults: String { L10n.tr("Localizable", "message.polls.button.viewResults") } + } + internal enum Subtitle { + /// Select one + internal static var selectOne: String { L10n.tr("Localizable", "message.polls.subtitle.selectOne") } + /// Select one or more + internal static var selectOneOrMore: String { L10n.tr("Localizable", "message.polls.subtitle.selectOneOrMore") } + /// Select up to %d + internal static func selectUpTo(_ p1: Int) -> String { + return L10n.tr("Localizable", "message.polls.subtitle.selectUpTo", p1) + } + /// Vote ended + internal static var voteEnded: String { L10n.tr("Localizable", "message.polls.subtitle.voteEnded") } + } + } + internal enum Preview { + /// %@ created: + internal static func pollSomeoneCreated(_ p1: Any) -> String { + return L10n.tr("Localizable", "message.preview.poll-someone-created", String(describing: p1)) + } + /// %@ voted: + internal static func pollSomeoneVoted(_ p1: Any) -> String { + return L10n.tr("Localizable", "message.preview.poll-someone-voted", String(describing: p1)) + } + /// You created: + internal static var pollYouCreated: String { L10n.tr("Localizable", "message.preview.poll-you-created") } + /// You voted: + internal static var pollYouVoted: String { L10n.tr("Localizable", "message.preview.poll-you-voted") } + } internal enum Sending { /// UPLOADING FAILED internal static var attachmentUploadingFailed: String { L10n.tr("Localizable", "message.sending.attachment-uploading-failed") } diff --git a/Sources/StreamChatUI/MessageActionsPopup/MessageActionsTransitionController.swift b/Sources/StreamChatUI/MessageActionsPopup/MessageActionsTransitionController.swift index dca52b49367..24fcc7c083c 100644 --- a/Sources/StreamChatUI/MessageActionsPopup/MessageActionsTransitionController.swift +++ b/Sources/StreamChatUI/MessageActionsPopup/MessageActionsTransitionController.swift @@ -258,6 +258,7 @@ open class ChatMessageActionsTransitionController: NSObject, UIViewControllerTra messageView.setUpLayoutIfNeeded(options: messageLayoutOptions, attachmentViewInjectorType: messageAttachmentInjectorType) messageView.channel = originalView.channel messageView.content = message + messageView.currentUserId = originalView.currentUserId messageView.delegate = originalView.delegate return messageView diff --git a/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings b/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings index 8cbdeeee51f..4bf665bce81 100644 --- a/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings +++ b/Sources/StreamChatUI/Resources/en.lproj/Localizable.strings @@ -229,3 +229,32 @@ "threadList.new-threads" = "%d new threads"; /// Shown when there's an error when loading the threads. "threadList.error.message" = "Error loading threads"; + +// MARK: - Polls + +/// Shown in the message poll view to describe that only one option is selectable. +"message.polls.subtitle.selectOne" = "Select one"; +/// Shown in the message poll view to describe that multiple options are selectable. +"message.polls.subtitle.selectOneOrMore" = "Select one or more"; +/// Shown in the message poll view to describe that multiple options are selectable up until a limit. +"message.polls.subtitle.selectUpTo" = "Select up to %d"; +/// Shown in the message poll view to describe that the poll has ended. +"message.polls.subtitle.voteEnded" = "Vote ended"; +/// Shown in the message poll view on the button to end the poll. +"message.polls.button.endVote" = "End Vote"; +/// Shown in the message poll view on the button to view the poll results. +"message.polls.button.viewResults" = "View Results"; + +/// Shown in the channel list or thread list message preview in case someone voted a poll. +"message.preview.poll-someone-voted" = "%@ voted:"; +/// Shown in the channel list or thread list message preview in case the current user voted a poll. +"message.preview.poll-you-voted" = "You voted:"; +/// Shown in the channel list or thread list message preview in case someone created a poll. +"message.preview.poll-someone-created" = "%@ created:"; +/// Shown in the channel list or thread list message preview in case the current user created a poll. +"message.preview.poll-you-created" = "You created:"; + +/// Alert title when closing a poll. +"alert.poll.end-title" = "Nobody will be able to vote in this poll anymore."; +/// Alert end action when closing a poll. +"alert.poll.end" = "End"; diff --git a/Sources/StreamChatUI/ViewContainerBuilder/ViewContainerBuilder.swift b/Sources/StreamChatUI/ViewContainerBuilder/ViewContainerBuilder.swift new file mode 100644 index 00000000000..5aa76c38db6 --- /dev/null +++ b/Sources/StreamChatUI/ViewContainerBuilder/ViewContainerBuilder.swift @@ -0,0 +1,270 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import UIKit + +// MARK: - View Container Builder + +/// A result builder to create a stack view given an array of views. +/// The goal is to build UIKit layout similar to SwiftUI so that it easier to create and re-layout views. +@resultBuilder +public struct ViewContainerBuilder { + init() {} + + /// The block responsible to produce a UIStackView given multiple views. + /// Example: + /// ``` + /// HContainer { + /// threadIconView + /// replyTimestampLabel + /// } + /// ``` + public static func buildBlock(_ components: UIView?...) -> UIStackView { + UIStackView(arrangedSubviews: components.compactMap { $0 }) + } + + /// The block responsible to produce a UIStackView given an array views. + /// Example: + /// ``` + /// HContainer { + /// headerViews // -> [UIView] + /// } + /// ``` + public static func buildBlock(_ components: [UIView]) -> UIStackView { + UIStackView(arrangedSubviews: components) + } + + /// The block responsible to replace the views of a stack view. + /// Example: + /// ``` + /// container.views { + /// threadIconView + /// replyTimestampLabel + /// } + /// ``` + public static func buildBlock(_ components: UIView?...) -> [UIView] { + components.compactMap { $0 } + } + + /// The block responsible to help creating additional constraints in a container. + /// Example: + /// ``` + /// threadIconView.constraints { + /// $0.heightAnchor.pin(equalToConstant: 15) + /// $0.widthAnchor.pin(equalToConstant: 15) + /// } + /// ``` + public static func buildBlock(_ components: NSLayoutConstraint...) -> [NSLayoutConstraint] { + NSLayoutConstraint.activate(components) + return components + } + + /// A block responsible to support if-statements when building the stack views. + public static func buildEither(first component: UIStackView) -> UIStackView { + component + } + + /// A block responsible to support if-statements when building the stack views. + public static func buildEither(second component: UIStackView) -> UIStackView { + component + } +} + +/// The vertical container which represents a vertical `UIStackView`. +/// +/// - parameter spacing: The spacing between views. +/// - parameter distribution: The stack view distribution, by default it is `.fill`. +/// - parameter alignment: The stack view alignment, by default it is `.fill`. +/// - parameter content: The result builder responsible to return the stack view with the arranged views. +public func VContainer( + spacing: CGFloat = 0, + distribution: UIStackView.Distribution = .fill, + alignment: UIStackView.Alignment = .fill, + @ViewContainerBuilder content: () -> UIStackView = { UIStackView() } +) -> UIStackView { + let stack = content() + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.distribution = distribution + stack.alignment = alignment + stack.spacing = spacing + return stack +} + +/// The horizontal container which represents a horizontal `UIStackView`. +/// +/// - parameter spacing: The spacing between views. +/// - parameter distribution: The stack view distribution, by default it is `.fill`. +/// - parameter alignment: The stack view alignment. +/// - parameter content: The result builder responsible to return the stack view with the arranged views. +public func HContainer( + spacing: CGFloat = 0, + distribution: UIStackView.Distribution = .fill, + alignment: UIStackView.Alignment = .fill, + @ViewContainerBuilder content: () -> UIStackView = { UIStackView() } +) -> UIStackView { + let stack = content() + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .horizontal + stack.distribution = distribution + stack.alignment = alignment + stack.spacing = spacing + return stack +} + +/// A flexible space that expands along the major axis of its containing stack layout. +public func Spacer() -> UIView { + let view = UIStackView() + view.translatesAutoresizingMaskIntoConstraints = false + return view +} + +// MARK: - Layout & Constraints Builders + +public extension UIView { + /// Syntax sugar to be able to add additional layout changes in place when building layouts with the `@ViewContainerBuilder`. + /// Example: + /// ``` + /// replyTimestampLabel.layout { + /// $0.setContentCompressionResistancePriority(.required, for: .horizontal) + /// + /// NSLayoutConstraint.activate([ + /// $0.heightAnchor.pin(equalToConstant: 15) + /// $0.widthAnchor.pin(equalToConstant: 15) + /// ]) + /// } + /// ``` + @discardableResult + func layout(_ block: (Self) -> Void) -> Self { + block(self) + return self + } + + /// Syntax sugar to be able to set view constraints in place when building layouts with the `@ViewContainerBuilder`. + /// The constraints are automatically activated. + /// + /// Example: + /// ``` + /// threadIconView.constraints { + /// $0.heightAnchor.pin(equalToConstant: 15) + /// $0.widthAnchor.pin(equalToConstant: 15) + /// } + /// ``` + @discardableResult + func constraints(@ViewContainerBuilder block: (Self) -> [NSLayoutConstraint]) -> Self { + NSLayoutConstraint.activate(block(self)) + return self + } +} + +// MARK: - UIStackView.views {} - Helper to replace the subviews + +public extension UIStackView { + /// Result builder to allow replacing views of a container. + /// This is useful when containers have a reference. + /// + /// /// Example: + /// ``` + /// container.views { + /// threadIconView + /// replyTimestampLabel + /// } + /// ``` + @discardableResult + func views( + @ViewContainerBuilder _ subviews: () -> [UIView] + ) -> Self { + removeAllArrangedSubviews() + subviews().forEach { addArrangedSubview($0) } + return self + } +} + +// MARK: - UIView width and height helpers + +public extension UIView { + /// Creates a width constraint with the given constant value. + @discardableResult + func width(_ value: CGFloat) -> Self { + NSLayoutConstraint.activate([ + widthAnchor.pin(equalToConstant: value) + ]) + return self + } + + /// Creates a width constraint greater or equal to the given value. + @discardableResult + func width(greaterThanOrEqualTo value: CGFloat) -> Self { + NSLayoutConstraint.activate([ + widthAnchor.pin(greaterThanOrEqualToConstant: value) + ]) + return self + } + + /// Creates a width constraint less or equal to the given value. + @discardableResult + func width(lessThanOrEqualTo value: CGFloat) -> Self { + NSLayoutConstraint.activate([ + widthAnchor.pin(lessThanOrEqualToConstant: value) + ]) + return self + } +} + +public extension UIView { + /// Creates a height constraint with the given constant value. + @discardableResult + func height(_ value: CGFloat) -> Self { + NSLayoutConstraint.activate([ + heightAnchor.pin(equalToConstant: value) + ]) + return self + } + + /// Creates a height constraint greater or equal to the given value. + @discardableResult + func height(greaterThanOrEqualTo value: CGFloat) -> Self { + NSLayoutConstraint.activate([ + heightAnchor.pin(greaterThanOrEqualToConstant: value) + ]) + return self + } + + /// Creates a height constraint less or equal to the given value. + @discardableResult + func height(lessThanOrEqualTo value: CGFloat) -> Self { + NSLayoutConstraint.activate([ + heightAnchor.pin(lessThanOrEqualToConstant: value) + ]) + return self + } +} + +// MARK: - UIStackView.embed() - Helper to add container to parent view + +public extension UIStackView { + /// Embeds the container to the given view. + @discardableResult + func embed(in view: UIView) -> UIStackView { + view.addSubview(self) + pin(to: view) + return self + } + + /// Embeds the container to the given view with insets. + @discardableResult + func embed(in view: UIView, insets: NSDirectionalEdgeInsets) -> UIStackView { + view.embed(self, insets: insets) + return self + } + + /// Embeds the container to the given view respecting the layout margins guide. + /// The margins can be customised by changing the `directionalLayoutMargins`. + @discardableResult + func embedToMargins(in view: UIView) -> UIStackView { + view.addSubview(self) + pin(to: view.layoutMarginsGuide) + return self + } +} diff --git a/StreamChat.xcodeproj/project.pbxproj b/StreamChat.xcodeproj/project.pbxproj index 68f3a47d2b1..a665d8165c7 100644 --- a/StreamChat.xcodeproj/project.pbxproj +++ b/StreamChat.xcodeproj/project.pbxproj @@ -1384,6 +1384,9 @@ AD0F7F192B613EDB00914C4C /* TextLinkDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0F7F162B6139D500914C4C /* TextLinkDetector.swift */; }; AD0F7F1A2B613EDC00914C4C /* TextLinkDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0F7F162B6139D500914C4C /* TextLinkDetector.swift */; }; AD0F7F1C2B616DD000914C4C /* TextLinkDetector_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0F7F1B2B616DD000914C4C /* TextLinkDetector_Tests.swift */; }; + AD142ACA2C739D6600ABCC1F /* Poll_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD142AC92C739D6600ABCC1F /* Poll_Tests.swift */; }; + AD142ACE2C73B0C700ABCC1F /* Poll_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD142ACD2C73B0C700ABCC1F /* Poll_Mock.swift */; }; + AD142AD22C73BB7600ABCC1F /* PollAttachmentView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD142AD02C73BB2300ABCC1F /* PollAttachmentView_Tests.swift */; }; AD154C6D25DC3BA000850925 /* ChatCommandSuggestionView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD154C6C25DC3BA000850925 /* ChatCommandSuggestionView_Tests.swift */; }; AD158B6526C1873000C104CD /* ChatThreadVC+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD158B6326C1872D00C104CD /* ChatThreadVC+SwiftUI.swift */; }; AD158B6626C1876800C104CD /* ChatChannelVC+SwiftUI_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B059EC267116B40024CE90 /* ChatChannelVC+SwiftUI_Tests.swift */; }; @@ -1433,6 +1436,8 @@ AD483B962A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD483B952A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift */; }; AD483B972A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD483B952A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift */; }; AD4C15562A55874700A32955 /* ImageLoading_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4C15552A55874700A32955 /* ImageLoading_Tests.swift */; }; + AD4C8C222C5D479B00E1C414 /* StackedUserAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */; }; + AD4C8C232C5D479B00E1C414 /* StackedUserAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */; }; AD4CDD85296499160057BC8A /* ScrollViewPaginationHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4CDD81296498D20057BC8A /* ScrollViewPaginationHandler_Tests.swift */; }; AD4CDD862964991A0057BC8A /* InvertedScrollViewPaginationHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD4CDD83296498EB0057BC8A /* InvertedScrollViewPaginationHandler_Tests.swift */; }; AD4FB7152C1B758100EB73C5 /* Unread.json in Resources */ = {isa = PBXBuildFile; fileRef = AD4FB7142C1B758100EB73C5 /* Unread.json */; }; @@ -1662,6 +1667,8 @@ ADD3286E2C07CCCA00BAD0E9 /* BadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD3286B2C07CC7100BAD0E9 /* BadgeView.swift */; }; ADD328712C07CD7000BAD0E9 /* ChatThreadUnreadCountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD3286F2C07CD5300BAD0E9 /* ChatThreadUnreadCountView.swift */; }; ADD328722C07CD7200BAD0E9 /* ChatThreadUnreadCountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD3286F2C07CD5300BAD0E9 /* ChatThreadUnreadCountView.swift */; }; + ADD328762C07E9B200BAD0E9 /* ViewContainerBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD328742C07E9AC00BAD0E9 /* ViewContainerBuilder.swift */; }; + ADD328772C07E9B300BAD0E9 /* ViewContainerBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD328742C07E9AC00BAD0E9 /* ViewContainerBuilder.swift */; }; ADD4C0E12B30A98300F230FF /* UnsupportedAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD4C0DE2B30A95200F230FF /* UnsupportedAttachmentViewInjector.swift */; }; ADD4C0E22B30A98400F230FF /* UnsupportedAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD4C0DE2B30A95200F230FF /* UnsupportedAttachmentViewInjector.swift */; }; ADD738472A8D312B0011FE81 /* ChannelListMessageTimestampFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADD738462A8D312B0011FE81 /* ChannelListMessageTimestampFormatter.swift */; }; @@ -1685,6 +1692,8 @@ ADE57B8F2C3C638900DD6B88 /* ThreadMessageNew.json in Resources */ = {isa = PBXBuildFile; fileRef = ADE57B832C3C5C8700DD6B88 /* ThreadMessageNew.json */; }; ADE595782B44A2B500727CC1 /* MixedAttachmentViewInjector_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE595772B44A2B500727CC1 /* MixedAttachmentViewInjector_Tests.swift */; }; ADE88A142949453200C0F084 /* ChatMessageListRouter_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE88A132949453200C0F084 /* ChatMessageListRouter_Mock.swift */; }; + ADE8B4B52C611DEA00C26FBF /* CheckboxButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE8B4B42C611DEA00C26FBF /* CheckboxButton.swift */; }; + ADE8B4B62C611DEA00C26FBF /* CheckboxButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE8B4B42C611DEA00C26FBF /* CheckboxButton.swift */; }; ADEDA1FA2B2BC46C00020460 /* RepeatingTimer_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEDA1F92B2BC46C00020460 /* RepeatingTimer_Tests.swift */; }; ADEE651829BF712D00186129 /* ChatMessageListView_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEE651429BF711200186129 /* ChatMessageListView_Mock.swift */; }; ADEE651929BF713200186129 /* ChatMessageCell_Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEE651629BF712500186129 /* ChatMessageCell_Mock.swift */; }; @@ -1705,6 +1714,14 @@ ADF34F9E25CDD8E600AD637C /* ConnectionController+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF34F9D25CDD8E600AD637C /* ConnectionController+SwiftUI.swift */; }; ADF34FA625CDD8F600AD637C /* ConnectionController+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF34FA525CDD8F600AD637C /* ConnectionController+Combine.swift */; }; ADF3EEF62C00FC7B00DB36D6 /* NotificationMarkUnread+MissingFields.json in Resources */ = {isa = PBXBuildFile; fileRef = ADF3EEF52C00FC7B00DB36D6 /* NotificationMarkUnread+MissingFields.json */; }; + ADF5096F2C5A80EE008F95CD /* PollAttachmentOptionListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF509672C5A80EE008F95CD /* PollAttachmentOptionListItemView.swift */; }; + ADF509702C5A80EE008F95CD /* PollAttachmentOptionListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF509672C5A80EE008F95CD /* PollAttachmentOptionListItemView.swift */; }; + ADF509712C5A80EE008F95CD /* PollAttachmentOptionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF509682C5A80EE008F95CD /* PollAttachmentOptionListView.swift */; }; + ADF509722C5A80EE008F95CD /* PollAttachmentOptionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF509682C5A80EE008F95CD /* PollAttachmentOptionListView.swift */; }; + ADF509732C5A80EE008F95CD /* PollAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF5096A2C5A80EE008F95CD /* PollAttachmentView.swift */; }; + ADF509742C5A80EE008F95CD /* PollAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF5096A2C5A80EE008F95CD /* PollAttachmentView.swift */; }; + ADF509752C5A80EE008F95CD /* PollAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF5096B2C5A80EE008F95CD /* PollAttachmentViewInjector.swift */; }; + ADF509762C5A80EE008F95CD /* PollAttachmentViewInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF5096B2C5A80EE008F95CD /* PollAttachmentViewInjector.swift */; }; ADF617692A09927000E70307 /* MessagesPaginationStateHandler_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF617672A09926900E70307 /* MessagesPaginationStateHandler_Tests.swift */; }; ADF9E1F72A03E7E400109108 /* MessagesPaginationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF9E1F62A03E7E400109108 /* MessagesPaginationState.swift */; }; ADFA09C926A99E0A002A6EFA /* ChatThreadHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADFA09C726A99C71002A6EFA /* ChatThreadHeaderView.swift */; }; @@ -4080,6 +4097,9 @@ AD0F7F122B5ED64600914C4C /* ComposerLinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposerLinkPreviewView.swift; sourceTree = ""; }; AD0F7F162B6139D500914C4C /* TextLinkDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLinkDetector.swift; sourceTree = ""; }; AD0F7F1B2B616DD000914C4C /* TextLinkDetector_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextLinkDetector_Tests.swift; sourceTree = ""; }; + AD142AC92C739D6600ABCC1F /* Poll_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll_Tests.swift; sourceTree = ""; }; + AD142ACD2C73B0C700ABCC1F /* Poll_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Poll_Mock.swift; sourceTree = ""; }; + AD142AD02C73BB2300ABCC1F /* PollAttachmentView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollAttachmentView_Tests.swift; sourceTree = ""; }; AD154C6C25DC3BA000850925 /* ChatCommandSuggestionView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatCommandSuggestionView_Tests.swift; sourceTree = ""; }; AD158B6326C1872D00C104CD /* ChatThreadVC+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChatThreadVC+SwiftUI.swift"; sourceTree = ""; }; AD17CDF827E4DB2700E0D092 /* PushProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushProvider.swift; sourceTree = ""; }; @@ -4113,6 +4133,7 @@ AD470C9D26C6D9030090759A /* ChatMessageListVCDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListVCDelegate.swift; sourceTree = ""; }; AD483B952A2658970004B406 /* ChannelMemberUnbanRequestPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMemberUnbanRequestPayload.swift; sourceTree = ""; }; AD4C15552A55874700A32955 /* ImageLoading_Tests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageLoading_Tests.swift; sourceTree = ""; }; + AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StackedUserAvatarsView.swift; sourceTree = ""; }; AD4CDD81296498D20057BC8A /* ScrollViewPaginationHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewPaginationHandler_Tests.swift; sourceTree = ""; }; AD4CDD83296498EB0057BC8A /* InvertedScrollViewPaginationHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedScrollViewPaginationHandler_Tests.swift; sourceTree = ""; }; AD4FB7142C1B758100EB73C5 /* Unread.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Unread.json; sourceTree = ""; }; @@ -4266,6 +4287,7 @@ ADD328642C06B39F00BAD0E9 /* ChatThreadListItemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadListItemCell.swift; sourceTree = ""; }; ADD3286B2C07CC7100BAD0E9 /* BadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeView.swift; sourceTree = ""; }; ADD3286F2C07CD5300BAD0E9 /* ChatThreadUnreadCountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadUnreadCountView.swift; sourceTree = ""; }; + ADD328742C07E9AC00BAD0E9 /* ViewContainerBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewContainerBuilder.swift; sourceTree = ""; }; ADD4C0DE2B30A95200F230FF /* UnsupportedAttachmentViewInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsupportedAttachmentViewInjector.swift; sourceTree = ""; }; ADD5A9E725DE8AF6006DC88A /* ChatSuggestionsVC_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatSuggestionsVC_Tests.swift; sourceTree = ""; }; ADD738462A8D312B0011FE81 /* ChannelListMessageTimestampFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelListMessageTimestampFormatter.swift; sourceTree = ""; }; @@ -4281,6 +4303,7 @@ ADE57B872C3C60CB00DD6B88 /* ThreadEvents_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadEvents_Tests.swift; sourceTree = ""; }; ADE595772B44A2B500727CC1 /* MixedAttachmentViewInjector_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixedAttachmentViewInjector_Tests.swift; sourceTree = ""; }; ADE88A132949453200C0F084 /* ChatMessageListRouter_Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageListRouter_Mock.swift; sourceTree = ""; }; + ADE8B4B42C611DEA00C26FBF /* CheckboxButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckboxButton.swift; sourceTree = ""; }; ADEA7F21261D2F8C00CA2289 /* chewbacca.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = chewbacca.jpg; sourceTree = ""; }; ADEA7F22261D2F8C00CA2289 /* r2.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = r2.jpg; sourceTree = ""; }; ADEDA1F92B2BC46C00020460 /* RepeatingTimer_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatingTimer_Tests.swift; sourceTree = ""; }; @@ -4298,6 +4321,10 @@ ADF34F9D25CDD8E600AD637C /* ConnectionController+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConnectionController+SwiftUI.swift"; sourceTree = ""; }; ADF34FA525CDD8F600AD637C /* ConnectionController+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConnectionController+Combine.swift"; sourceTree = ""; }; ADF3EEF52C00FC7B00DB36D6 /* NotificationMarkUnread+MissingFields.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "NotificationMarkUnread+MissingFields.json"; sourceTree = ""; }; + ADF509672C5A80EE008F95CD /* PollAttachmentOptionListItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollAttachmentOptionListItemView.swift; sourceTree = ""; }; + ADF509682C5A80EE008F95CD /* PollAttachmentOptionListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollAttachmentOptionListView.swift; sourceTree = ""; }; + ADF5096A2C5A80EE008F95CD /* PollAttachmentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollAttachmentView.swift; sourceTree = ""; }; + ADF5096B2C5A80EE008F95CD /* PollAttachmentViewInjector.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PollAttachmentViewInjector.swift; sourceTree = ""; }; ADF617672A09926900E70307 /* MessagesPaginationStateHandler_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationStateHandler_Tests.swift; sourceTree = ""; }; ADF9E1F62A03E7E400109108 /* MessagesPaginationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessagesPaginationState.swift; sourceTree = ""; }; ADFA09C726A99C71002A6EFA /* ChatThreadHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatThreadHeaderView.swift; sourceTree = ""; }; @@ -5022,22 +5049,6 @@ path = AudioRecorder; sourceTree = ""; }; - 40824D192A1271B9003B61FD /* RecordButton */ = { - isa = PBXGroup; - children = ( - 40824D1A2A1271B9003B61FD /* RecordButton.swift */, - ); - path = RecordButton; - sourceTree = ""; - }; - 40824D1B2A1271B9003B61FD /* PlayPauseButton */ = { - isa = PBXGroup; - children = ( - 40824D1C2A1271B9003B61FD /* PlayPauseButton.swift */, - ); - path = PlayPauseButton; - sourceTree = ""; - }; 40824D252A1271D7003B61FD /* RecordButton */ = { isa = PBXGroup; children = ( @@ -5211,29 +5222,30 @@ children = ( 7908823125432C6400896F03 /* StreamChatUI.h */, 7908823225432C6400896F03 /* Info.plist */, + C14A46572846636900EF498E /* SDKIdentifier.swift */, + BD4016352638411D00F09774 /* Deprecations.swift */, + 8850B929255C286B003AED69 /* Components.swift */, + BDDD1E982632C4C900BA007B /* Components+SwiftUI.swift */, + 792DD9FA256E67C6001DB91B /* ComponentsProvider.swift */, BDDD1EA22632C50200BA007B /* Appearance.swift */, + BDDD1EA52632C6D600BA007B /* AppearanceProvider.swift */, E7166CB125BED22B00B03B07 /* Appearance+ColorPalette.swift */, E7166CB925BED29200B03B07 /* Appearance+Fonts.swift */, E7166CE125BEE20600B03B07 /* Appearance+Images.swift */, BDDD1EA92632CE3C00BA007B /* Appearance+SwiftUI.swift */, - BDDD1EA52632C6D600BA007B /* AppearanceProvider.swift */, - 792DD9FA256E67C6001DB91B /* ComponentsProvider.swift */, - 8850B929255C286B003AED69 /* Components.swift */, - BDDD1E982632C4C900BA007B /* Components+SwiftUI.swift */, - BD4016352638411D00F09774 /* Deprecations.swift */, - C14A46572846636900EF498E /* SDKIdentifier.swift */, AD99C901279B06E9009DD9C5 /* Appearance+Formatters */, + ADD328732C07E99700BAD0E9 /* ViewContainerBuilder */, ADECE08926AAEC63001AE411 /* ChatChannel */, 790882BE25486AB000896F03 /* ChatChannelList */, 7908830C2548707B00896F03 /* ChatMessageList */, ADECE08A26AAED3B001AE411 /* ChatThread */, ADD3285B2C05440B00BAD0E9 /* ChatThreadList */, - 888123E5255D51BD00070D5A /* CommonViews */, AD4EA229264ADE0100DF8EE2 /* Composer */, F833D64326393E4800651D14 /* Gallery */, 401105252A12734800F877C7 /* VoiceRecording */, 88F8364E2578D1590039AEC8 /* MessageActionsPopup */, 7973CE48265413B4004C7CE5 /* Navigation */, + 888123E5255D51BD00070D5A /* CommonViews */, 888123D0255D42F000070D5A /* Utils */, 88F0D6EA257E409E00F4B050 /* Generated */, 88F0D6ED257E446800F4B050 /* Resources */, @@ -6016,14 +6028,6 @@ path = PinnedMessages; sourceTree = ""; }; - 843F0BCB2677666B00B342CB /* AttachmentActionButton */ = { - isa = PBXGroup; - children = ( - 843F0BCC2677667000B342CB /* AttachmentActionButton.swift */, - ); - path = AttachmentActionButton; - sourceTree = ""; - }; 8440861528FFE85F0027849C /* Shared */ = { isa = PBXGroup; children = ( @@ -6077,14 +6081,6 @@ path = DemoShare; sourceTree = ""; }; - 847DD2D9267233CF0084E14B /* GradientView */ = { - isa = PBXGroup; - children = ( - 847DD2DA267233DB0084E14B /* GradientView.swift */, - ); - path = GradientView; - sourceTree = ""; - }; 847E946C269C685F00E31D0C /* EventsController */ = { isa = PBXGroup; children = ( @@ -6163,6 +6159,7 @@ ADD4C0DA2B30A78500F230FF /* VoiceRecording */, ADD4C0DB2B30A7B400F230FF /* Gallery */, ADD4C0DC2B30A7ED00F230FF /* Link */, + ADF5096C2C5A80EE008F95CD /* Poll */, ADD4C0DD2B30A91900F230FF /* Unsupported */, ); path = Attachments; @@ -6194,36 +6191,25 @@ ADD3286B2C07CC7100BAD0E9 /* BadgeView.swift */, AD96106E2C2DD874004F543C /* BannerView.swift */, 40824D182A1271B9003B61FD /* ClampedView.swift */, - 40824D172A1271B9003B61FD /* PillButton.swift */, - 4067764E2A14CB550079B05C /* MediaButton.swift */, 792DD9D8256BC542001DB91B /* BaseViews.swift */, 88BA7F5C258B6953006CE0C5 /* ChatLoadingIndicator.swift */, 22A0921625682880001FE9F0 /* ChatNavigationBar.swift */, - F80BCA1D26304FEE00F2107B /* CloseButton.swift */, A3BB3FFE261DA74D00365496 /* ContainerStackView.swift */, DBF12127258BAFC1001919C6 /* OnlyLinkTappableTextView.swift */, 84DA54DE2680C66A003A26CD /* PlayerView.swift */, - F80BCA1326304F7800F2107B /* ShareButton.swift */, F87A4865260B3516001653A8 /* SwiftUIViewRepresentable.swift */, CF62AD9828944D4700392893 /* SkeletonLoadable.swift */, - 40824D1B2A1271B9003B61FD /* PlayPauseButton */, - 40824D192A1271B9003B61FD /* RecordButton */, - 843F0BCB2677666B00B342CB /* AttachmentActionButton */, - AD87D094263C7495008B466C /* AttachmentButton */, + AD4C8C212C5D479B00E1C414 /* StackedUserAvatarsView.swift */, + 2210525E256FE16600A5F0DB /* CommandLabelView.swift */, + 847DD2DA267233DB0084E14B /* GradientView.swift */, + CF33B3AB28171BE500C84CDB /* CooldownView.swift */, + ADE8B4B32C611B6700C26FBF /* Buttons */, AD4474C1263AFD380030E583 /* Attachments */, AD7112D325F10CF300932AEE /* AvatarView */, - AD4474B8263AFCC30030E583 /* CheckboxControl */, - AD87D093263C7489008B466C /* CircularCloseButton */, - AD87D092263C747C008B466C /* CommandButton */, - AD8E6BBD2642DB520013E01E /* CommandLabelView */, - AD4475A8263B4DF90030E583 /* ConfirmButton */, - 847DD2D9267233CF0084E14B /* GradientView */, ADFB13262637610E00D321FD /* InputChatMessageView */, ADED4BBF26431CA500F4E2C8 /* InputTextView */, AD81AEEB25ED132400F17F8F /* ListCollectionViewLayout */, ADB3C478261638C500A69B66 /* QuotedChatMessageView */, - AD447496263AFC4A0030E583 /* SendButton */, - AD87D095263C74A4008B466C /* ShrinkInputButton */, ADA35730269C9B3B004AD8E9 /* TitleContainerView */, ); path = CommonViews; @@ -6648,6 +6634,7 @@ A344075E27D753530044F150 /* ChatUser_Mock.swift */, A344075427D753530044F150 /* CurrentChatUser_Mock.swift */, AD9490652BF6756200E69224 /* ChatThread_Mock.swift */, + AD142ACD2C73B0C700ABCC1F /* Poll_Mock.swift */, A344075527D753530044F150 /* Attachments */, ); path = "Models + Extensions"; @@ -7098,6 +7085,7 @@ 796CBC6425FBAD12003299B0 /* Member_Tests.swift */, 88EA9AFB25472269007EE76B /* MessageReactionType_Tests.swift */, C10B0A0B29D20DE1006517FC /* User_Tests.swift */, + AD142AC92C739D6600ABCC1F /* Poll_Tests.swift */, A364D0A127D0C8930029857A /* Attachments */, ); path = Models; @@ -7517,6 +7505,7 @@ 8479C7A82812FCC000FC8CFD /* ChatMessageListVC_Tests.swift */, E74DB0102655473300508D22 /* TypingIndicatorView_Tests.swift */, C1BFBAC029CC42CE00FC82A2 /* JumpToUnreadMessagesButton_Tests.swift */, + AD142ACF2C73BAFC00ABCC1F /* Poll */, A3960DEE27DA2D2F003AB2B0 /* Attachments */, A3960DEF27DA2D4B003AB2B0 /* ChatMessage */, A3960DF027DA2D7D003AB2B0 /* Reactions */, @@ -8274,6 +8263,14 @@ path = ReactionListController; sourceTree = ""; }; + AD142ACF2C73BAFC00ABCC1F /* Poll */ = { + isa = PBXGroup; + children = ( + AD142AD02C73BB2300ABCC1F /* PollAttachmentView_Tests.swift */, + ); + path = Poll; + sourceTree = ""; + }; AD4473DD263AC2B80030E583 /* ChatCommandSuggestionView */ = { isa = PBXGroup; children = ( @@ -8306,23 +8303,6 @@ path = ChatMentionSuggestionView; sourceTree = ""; }; - AD447496263AFC4A0030E583 /* SendButton */ = { - isa = PBXGroup; - children = ( - 22753598257C442300D1FDB6 /* SendButton.swift */, - CF33B3AB28171BE500C84CDB /* CooldownView.swift */, - ); - path = SendButton; - sourceTree = ""; - }; - AD4474B8263AFCC30030E583 /* CheckboxControl */ = { - isa = PBXGroup; - children = ( - 224165A725910A2C00ED7F78 /* CheckboxControl.swift */, - ); - path = CheckboxControl; - sourceTree = ""; - }; AD4474C1263AFD380030E583 /* Attachments */ = { isa = PBXGroup; children = ( @@ -8335,14 +8315,6 @@ path = Attachments; sourceTree = ""; }; - AD4475A8263B4DF90030E583 /* ConfirmButton */ = { - isa = PBXGroup; - children = ( - 7943382A26208D020094471F /* ConfirmButton.swift */, - ); - path = ConfirmButton; - sourceTree = ""; - }; AD4CDD80296498B10057BC8A /* ViewPaginationHandling */ = { isa = PBXGroup; children = ( @@ -8437,38 +8409,6 @@ path = ListCollectionViewLayout; sourceTree = ""; }; - AD87D092263C747C008B466C /* CommandButton */ = { - isa = PBXGroup; - children = ( - AD87D096263C7783008B466C /* CommandButton.swift */, - ); - path = CommandButton; - sourceTree = ""; - }; - AD87D093263C7489008B466C /* CircularCloseButton */ = { - isa = PBXGroup; - children = ( - AD87D0BC263C7C09008B466C /* CircularCloseButton.swift */, - ); - path = CircularCloseButton; - sourceTree = ""; - }; - AD87D094263C7495008B466C /* AttachmentButton */ = { - isa = PBXGroup; - children = ( - AD87D0A0263C7823008B466C /* AttachmentButton.swift */, - ); - path = AttachmentButton; - sourceTree = ""; - }; - AD87D095263C74A4008B466C /* ShrinkInputButton */ = { - isa = PBXGroup; - children = ( - AD87D0AA263C7A7E008B466C /* ShrinkInputButton.swift */, - ); - path = ShrinkInputButton; - sourceTree = ""; - }; AD8B7277290801B800921C31 /* ImageCDN */ = { isa = PBXGroup; children = ( @@ -8478,14 +8418,6 @@ path = ImageCDN; sourceTree = ""; }; - AD8E6BBD2642DB520013E01E /* CommandLabelView */ = { - isa = PBXGroup; - children = ( - 2210525E256FE16600A5F0DB /* CommandLabelView.swift */, - ); - path = CommandLabelView; - sourceTree = ""; - }; AD9490552BF3BA8000E69224 /* ThreadListController */ = { isa = PBXGroup; children = ( @@ -8677,6 +8609,14 @@ path = ChatThreadList; sourceTree = ""; }; + ADD328732C07E99700BAD0E9 /* ViewContainerBuilder */ = { + isa = PBXGroup; + children = ( + ADD328742C07E9AC00BAD0E9 /* ViewContainerBuilder.swift */, + ); + path = ViewContainerBuilder; + sourceTree = ""; + }; ADD4C0D82B30A6E100F230FF /* Giphy */ = { isa = PBXGroup; children = ( @@ -8767,6 +8707,28 @@ path = Thread; sourceTree = ""; }; + ADE8B4B32C611B6700C26FBF /* Buttons */ = { + isa = PBXGroup; + children = ( + 22753598257C442300D1FDB6 /* SendButton.swift */, + AD87D0AA263C7A7E008B466C /* ShrinkInputButton.swift */, + 7943382A26208D020094471F /* ConfirmButton.swift */, + AD87D096263C7783008B466C /* CommandButton.swift */, + 40824D1C2A1271B9003B61FD /* PlayPauseButton.swift */, + F80BCA1326304F7800F2107B /* ShareButton.swift */, + F80BCA1D26304FEE00F2107B /* CloseButton.swift */, + 4067764E2A14CB550079B05C /* MediaButton.swift */, + 40824D172A1271B9003B61FD /* PillButton.swift */, + 40824D1A2A1271B9003B61FD /* RecordButton.swift */, + AD87D0A0263C7823008B466C /* AttachmentButton.swift */, + 843F0BCC2677667000B342CB /* AttachmentActionButton.swift */, + ADE8B4B42C611DEA00C26FBF /* CheckboxButton.swift */, + 224165A725910A2C00ED7F78 /* CheckboxControl.swift */, + AD87D0BC263C7C09008B466C /* CircularCloseButton.swift */, + ); + path = Buttons; + sourceTree = ""; + }; ADECE08926AAEC63001AE411 /* ChatChannel */ = { isa = PBXGroup; children = ( @@ -8819,6 +8781,25 @@ path = ConnectionController; sourceTree = ""; }; + ADF509692C5A80EE008F95CD /* PollAttachmentOptionListView */ = { + isa = PBXGroup; + children = ( + ADF509682C5A80EE008F95CD /* PollAttachmentOptionListView.swift */, + ADF509672C5A80EE008F95CD /* PollAttachmentOptionListItemView.swift */, + ); + path = PollAttachmentOptionListView; + sourceTree = ""; + }; + ADF5096C2C5A80EE008F95CD /* Poll */ = { + isa = PBXGroup; + children = ( + ADF5096B2C5A80EE008F95CD /* PollAttachmentViewInjector.swift */, + ADF5096A2C5A80EE008F95CD /* PollAttachmentView.swift */, + ADF509692C5A80EE008F95CD /* PollAttachmentOptionListView */, + ); + path = Poll; + sourceTree = ""; + }; ADF617662A09925300E70307 /* MessagesPaginationStateHandling */ = { isa = PBXGroup; children = ( @@ -10279,10 +10260,12 @@ 22ADD682256C40410098EFEB /* ComposerView.swift in Sources */, E7A37B8425ADA66E0055458F /* ChatSuggestionsHeaderView.swift in Sources */, 849980F1277246DB00ABA58B /* UIScrollView+Extensions.swift in Sources */, + AD4C8C222C5D479B00E1C414 /* StackedUserAvatarsView.swift in Sources */, 8850FE91256558B200C8D534 /* NavigationRouter.swift in Sources */, CF01EB7B288A2B7200B426B8 /* ChatChannelListLoadingView.swift in Sources */, 883051C82630579D0069D731 /* ChatThreadArrowView.swift in Sources */, E798D6D325FF69120002C3B9 /* SwipeableView.swift in Sources */, + ADD328762C07E9B200BAD0E9 /* ViewContainerBuilder.swift in Sources */, E701201E2583EBD50036DACD /* CALayer+Extensions.swift in Sources */, C1FC2F7C27416E150062530F /* Combine.swift in Sources */, 84C11BE527FB459900000A9E /* ChatMessageDeliveryStatusView.swift in Sources */, @@ -10301,6 +10284,7 @@ 8803E9E726398F4E002B2A7B /* ChatMessageBubbleView.swift in Sources */, 22FF4365256E943F00133910 /* ChatSuggestionsVC.swift in Sources */, 22A0921725682880001FE9F0 /* ChatNavigationBar.swift in Sources */, + ADE8B4B52C611DEA00C26FBF /* CheckboxButton.swift in Sources */, AD87D0A1263C7823008B466C /* AttachmentButton.swift in Sources */, 22753599257C442300D1FDB6 /* SendButton.swift in Sources */, AD7BE1732C2347A3000A5756 /* ChatThreadListEmptyView.swift in Sources */, @@ -10387,12 +10371,14 @@ AD76CE332A5F1128003CA182 /* ChatMessageSearchVC.swift in Sources */, F80BCA1426304F7800F2107B /* ShareButton.swift in Sources */, CF38F5AF287DB53E00E24D10 /* ChatChannelListErrorView.swift in Sources */, + ADF509752C5A80EE008F95CD /* PollAttachmentViewInjector.swift in Sources */, C1FC2F6F27416E150062530F /* ImagePipelineConfiguration.swift in Sources */, ADE57B792C36DB2000DD6B88 /* ChatThreadListErrorView.swift in Sources */, F838F6B32636D42B0025E1F5 /* ZoomAnimator.swift in Sources */, ADCB578328A42D7700B81AE8 /* StagedChangeset.swift in Sources */, 64B059E22670EFFE0024CE90 /* ChatChannelVC+SwiftUI.swift in Sources */, BDDD1EA32632C50200BA007B /* Appearance.swift in Sources */, + ADF5096F2C5A80EE008F95CD /* PollAttachmentOptionListItemView.swift in Sources */, 88CABC4425933EE70061BB67 /* ChatMessageDefaultReactionsBubbleView.swift in Sources */, ADD328662C06B3A700BAD0E9 /* ChatThreadListItemCell.swift in Sources */, C1788F5829B8C1B400149883 /* ChatMessageHeaderDecoratorView.swift in Sources */, @@ -10515,6 +10501,7 @@ A3C50228284F9CF70048753E /* SwiftyMarkdown+macOS.swift in Sources */, 8850B92A255C286B003AED69 /* Components.swift in Sources */, AD8D1809268F7290004E3A5C /* TypingSuggester.swift in Sources */, + ADF509732C5A80EE008F95CD /* PollAttachmentView.swift in Sources */, F880DEA32628528B0025AD64 /* GalleryVC.swift in Sources */, ADCB577728A42D7700B81AE8 /* ArraySection.swift in Sources */, C1FC2F8727416E150062530F /* OperationTask.swift in Sources */, @@ -10556,6 +10543,7 @@ 847D602D2679EF8A00FB701D /* VideoPlaybackControlView.swift in Sources */, 40FA4DD52A12A0C300DA21D2 /* RecordingTipView.swift in Sources */, 40824D212A1271B9003B61FD /* RecordButton.swift in Sources */, + ADF509712C5A80EE008F95CD /* PollAttachmentOptionListView.swift in Sources */, ADCB577528A42D7700B81AE8 /* ContentEquatable.swift in Sources */, 400F063129A63A0B00242A86 /* ChatMessageDecorationView.swift in Sources */, 88CABC8E25936E440061BB67 /* ChatMessageReactionsBubbleTail.swift in Sources */, @@ -10707,6 +10695,7 @@ AD0EC6D52A45AAAF005220B1 /* ChatMessageListVC_Mock.swift in Sources */, ADA8EBEB28CFD82C00DB9B03 /* ChatMessageContentViewDelegate_Mock.swift in Sources */, 8897305E265D046D00F83739 /* ChatMessageLayoutOptionsResolver_Tests.swift in Sources */, + AD142AD22C73BB7600ABCC1F /* PollAttachmentView_Tests.swift in Sources */, ADA8EBE928CFD52F00DB9B03 /* TextViewUserMentionsHandler_Mock.swift in Sources */, ADD2A99828FF227D00A83305 /* ImageSizeCalculator_Tests.swift in Sources */, 408599972A1FB93900FD6E26 /* StreamAudioSessionFeedbackGenerator_Tests.swift in Sources */, @@ -10907,6 +10896,7 @@ C17E0AF72B04D190007188F1 /* BackgroundListDatabaseObserver_Mock.swift in Sources */, A344077827D753530044F150 /* CurrentChatUser_Mock.swift in Sources */, 82E6554B2B067ED700D64906 /* WaitUntil.swift in Sources */, + AD142ACE2C73B0C700ABCC1F /* Poll_Mock.swift in Sources */, A3C3BC6027E8AA0A00224761 /* Date+Unique.swift in Sources */, 82F714A92B0785D900442A74 /* XCTest+Helpers.swift in Sources */, A3C3BC2827E87F2000224761 /* UserRequestBody.swift in Sources */, @@ -11528,6 +11518,7 @@ 84C85B3F2BF2394E008A7AA5 /* PollController_Tests.swift in Sources */, 84D5BC71277B61B900A65C75 /* PinnedMessagesSortingKey_Tests.swift in Sources */, 88381E8725825A240047A6A3 /* AttachmentEndpoints_Tests.swift in Sources */, + AD142ACA2C739D6600ABCC1F /* Poll_Tests.swift in Sources */, 84DCB855269F56A7006CDF32 /* EventsController+SwiftUI_Tests.swift in Sources */, 79B5517724E595DA00CE9FEC /* CurrentUserPayloads_Tests.swift in Sources */, 40B345F829C46AE500B96027 /* AudioPlaybackRate_Tests.swift in Sources */, @@ -12358,11 +12349,13 @@ C121EB7A2746A1E700023E4C /* String+Extensions.swift in Sources */, C121EB7B2746A1E700023E4C /* ChatMessage+Extensions.swift in Sources */, C121EB7C2746A1E700023E4C /* Animation.swift in Sources */, + ADF509702C5A80EE008F95CD /* PollAttachmentOptionListItemView.swift in Sources */, C121EB7D2746A1E700023E4C /* ChatChannelNamer.swift in Sources */, C121EB7E2746A1E700023E4C /* UIStackView+Extensions.swift in Sources */, C121EB7F2746A1E700023E4C /* SystemEnvironment.swift in Sources */, C121EB802746A1E700023E4C /* CACornerMask+Extensions.swift in Sources */, C121EB812746A1E700023E4C /* CGRect+Extensions.swift in Sources */, + AD4C8C232C5D479B00E1C414 /* StackedUserAvatarsView.swift in Sources */, C121EB822746A1E700023E4C /* CGPoint+Extensions.swift in Sources */, 40FA4DEB2A12A46D00DA21D2 /* VoiceRecordingAttachmentComposerPreview_Tests.swift in Sources */, C121EB832746A1E700023E4C /* NavigationVC.swift in Sources */, @@ -12395,9 +12388,11 @@ AD7BE1712C234798000A5756 /* ChatThreadListLoadingView.swift in Sources */, C121EB962746A1E800023E4C /* AttachmentPreviewContainer.swift in Sources */, C121EB972746A1E800023E4C /* AttachmentPreviewProvider.swift in Sources */, + ADD328772C07E9B300BAD0E9 /* ViewContainerBuilder.swift in Sources */, C121EB982746A1E800023E4C /* DefaultAttachmentPreviewProvider.swift in Sources */, C121EB992746A1E800023E4C /* FileAttachmentView.swift in Sources */, AD78F9FC28EC735700BC0FCE /* PerfomanceLog.swift in Sources */, + ADF509742C5A80EE008F95CD /* PollAttachmentView.swift in Sources */, C121EB9A2746A1E800023E4C /* ImageAttachmentComposerPreview.swift in Sources */, C121EB9B2746A1E800023E4C /* VideoAttachmentComposerPreview.swift in Sources */, C121EB9C2746A1E800023E4C /* SendButton.swift in Sources */, @@ -12411,6 +12406,7 @@ C121EBA32746A1E800023E4C /* QuotedChatMessageView.swift in Sources */, C121EBA42746A1E800023E4C /* QuotedChatMessageView+SwiftUI.swift in Sources */, C121EBA52746A1E800023E4C /* OnlineIndicatorView.swift in Sources */, + ADE8B4B62C611DEA00C26FBF /* CheckboxButton.swift in Sources */, AD8B72762908016400921C31 /* ImageDownloadRequest.swift in Sources */, AD78F9FE28EC735700BC0FCE /* Token.swift in Sources */, ADDB2F5A2954CBF700BF80DA /* ViewPaginationHandling.swift in Sources */, @@ -12431,6 +12427,7 @@ C121EBAE2746A1E800023E4C /* CommandLabelView.swift in Sources */, CF33B3AD28171BE500C84CDB /* CooldownView.swift in Sources */, C121EBAF2746A1E800023E4C /* ListCollectionViewLayout.swift in Sources */, + ADF509722C5A80EE008F95CD /* PollAttachmentOptionListView.swift in Sources */, C121EBB02746A1E900023E4C /* CellSeparatorView.swift in Sources */, C121EBB12746A1E900023E4C /* ContainerStackView.swift in Sources */, C121EBB22746A1E900023E4C /* OnlyLinkTappableTextView.swift in Sources */, @@ -12445,6 +12442,7 @@ CFE5F85C2874A9330099A6A1 /* ChatChannelListEmptyView.swift in Sources */, AD78F9F528EC735700BC0FCE /* SwiftyMarkdown+macOS.swift in Sources */, C121EBB92746A1E900023E4C /* PlayerView.swift in Sources */, + ADF509762C5A80EE008F95CD /* PollAttachmentViewInjector.swift in Sources */, C121EBBA2746A1E900023E4C /* GalleryCollectionViewCell.swift in Sources */, C1788F5929B8C1B400149883 /* ChatMessageHeaderDecoratorView.swift in Sources */, C121EBBB2746A1E900023E4C /* ImageAttachmentGalleryCell.swift in Sources */, diff --git a/TestTools/StreamChatTestTools/Extensions/Unique/Poll+Unique.swift b/TestTools/StreamChatTestTools/Extensions/Unique/Poll+Unique.swift index c803fd39f48..28e2a757ee4 100644 --- a/TestTools/StreamChatTestTools/Extensions/Unique/Poll+Unique.swift +++ b/TestTools/StreamChatTestTools/Extensions/Unique/Poll+Unique.swift @@ -26,7 +26,8 @@ extension Poll { createdBy: .mock(id: .unique), latestAnswers: [], options: [], - latestVotesByOption: [] + latestVotesByOption: [], + ownVotes: [] ) } } diff --git a/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Poll_Mock.swift b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Poll_Mock.swift new file mode 100644 index 00000000000..59e1b6f69e3 --- /dev/null +++ b/TestTools/StreamChatTestTools/Mocks/Models + Extensions/Poll_Mock.swift @@ -0,0 +1,77 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import Foundation +@testable import StreamChat + +extension Poll { + static func mock( + allowAnswers: Bool = true, + allowUserSuggestedOptions: Bool = true, + answersCount: Int = 0, + createdAt: Date = .unique, + pollDescription: String? = nil, + enforceUniqueVote: Bool = false, + id: String = .unique, + name: String = .unique, + updatedAt: Date? = nil, + voteCount: Int = 0, + extraData: [String : RawJSON] = [:], + voteCountsByOption: [String : Int]? = nil, + isClosed: Bool = false, + maxVotesAllowed: Int? = nil, + votingVisibility: VotingVisibility? = nil, + createdBy: ChatUser? = nil, + latestAnswers: [PollVote] = [], + options: [PollOption] = [], + latestVotesByOption: [PollOption] = [], + ownVotes: [PollVote] = [] + ) -> Poll { + .init( + allowAnswers: allowAnswers, + allowUserSuggestedOptions: allowUserSuggestedOptions, + answersCount: answersCount, + createdAt: createdAt, + pollDescription: pollDescription, + enforceUniqueVote: enforceUniqueVote, + id: id, + name: name, + updatedAt: updatedAt, + voteCount: voteCount, + extraData: extraData, + voteCountsByOption: voteCountsByOption, + isClosed: isClosed, + maxVotesAllowed: maxVotesAllowed, + votingVisibility: votingVisibility, + createdBy: createdBy, + latestAnswers: latestAnswers, + options: options, + latestVotesByOption: latestVotesByOption, + ownVotes: ownVotes + ) + } +} +extension PollVote { + static func mock( + id: String = .unique, + createdAt: Date = .unique, + updatedAt: Date = .unique, + pollId: String = .unique, + optionId: String? = nil, + isAnswer: Bool = false, + answerText: String? = nil, + user: ChatUser? = nil + ) -> PollVote { + .init( + id: id, + createdAt: createdAt, + updatedAt: updatedAt, + pollId: pollId, + optionId: optionId, + isAnswer: isAnswer, + answerText: answerText, + user: user + ) + } +} diff --git a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/PollPayload_Tests.swift b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/PollPayload_Tests.swift index 3079a3f7d30..dbf1ca29533 100644 --- a/Tests/StreamChatTests/APIClient/Endpoints/Payloads/PollPayload_Tests.swift +++ b/Tests/StreamChatTests/APIClient/Endpoints/Payloads/PollPayload_Tests.swift @@ -24,7 +24,7 @@ final class PollPayload_Tests: XCTestCase { XCTAssertEqual(payload.answersCount, 0) XCTAssertNil(payload.voteCountsByOption) XCTAssertTrue(payload.latestVotesByOption?.isEmpty == true) - XCTAssertEqual(payload.ownVotes.count, 1) + XCTAssertEqual(payload.ownVotes?.count, 1) XCTAssertEqual(payload.createdById, "luke_skywalker") XCTAssertNotNil(payload.createdBy) } diff --git a/Tests/StreamChatTests/Models/Poll_Tests.swift b/Tests/StreamChatTests/Models/Poll_Tests.swift new file mode 100644 index 00000000000..ebd91d9948b --- /dev/null +++ b/Tests/StreamChatTests/Models/Poll_Tests.swift @@ -0,0 +1,476 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +@testable import StreamChat +@testable import StreamChatTestTools +import XCTest + +final class Poll_Tests: XCTestCase { + func test_currentMaximumVoteCount() { + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 5, "option2": 10], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertEqual(poll.currentMaximumVoteCount, 10) + } + + func test_isOptionWinner_whenClosed_whenMostVotes_returnsTrue() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 10, "option2": 5], + isClosed: true, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertTrue(poll.isOptionWinner(option)) + } + + func test_isOptionWinner_whenClosed_whenOneOfMostVotes_returnsFalse() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 10, "option2": 10], + isClosed: true, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertFalse(poll.isOptionWinner(option)) + } + + func test_isOptionWinner_whenNotClosed_returnsFalse() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 10, "option2": 5], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertFalse(poll.isOptionWinner(option)) + } + + func test_isOptionOneOfTheWinners_whenClosed_whenOneOfMostVotes_returnsTrue() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 25, + extraData: [:], + voteCountsByOption: ["option1": 10, "option2": 10, "option3": 5], + isClosed: true, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertTrue(poll.isOptionOneOfTheWinners(option)) + } + + func test_isOptionOneOfTheWinners_whenNotClosed_whenOneOfMostVotes_returnsFalse() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 10, "option2": 10], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertFalse(poll.isOptionOneOfTheWinners(option)) + } + + func test_isOptionWithMostVotes_whenTheOnlyOneWithMostVotes_returnsTrue() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 10, "option2": 5], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertTrue(poll.isOptionWithMostVotes(option)) + } + + func test_isOptionWithMostVotes_whenOneOfTheMaximumVotes_returnsFalse() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 10, "option2": 10], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertFalse(poll.isOptionWithMostVotes(option)) + } + + func test_isOptionWithMaximumVotes_whenOneOfTheMostVotes_returnsTrue() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 10, "option2": 10], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertTrue(poll.isOptionWithMaximumVotes(option)) + } + + func test_isOptionWithMaximumVotes_whenOneOfTheLeastVotes_returnsFalse() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 5, "option2": 10], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertFalse(poll.isOptionWithMaximumVotes(option)) + } + + func test_voteCountForOption() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 5, "option2": 10], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertEqual(poll.voteCount(for: option), 5) + } + + func test_voteRatioForOption() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 5, "option2": 10], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertEqual(poll.voteRatio(for: option), 0.5) + } + + func test_voteRatioForOption_whenOneOfTheMostVotes() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 10, "option2": 10, "option3": 5], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertEqual(poll.voteRatio(for: option), 1) + } + + func test_voteRatioForOption_whenCurrentMaxVoteCountIsZero_returnZero() { + let option = PollOption(id: "option1", text: "Option 1") + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 0, + extraData: [:], + voteCountsByOption: [:], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [] + ) + + XCTAssertEqual(poll.voteRatio(for: option), 0) + } + + func test_currentUserVoteForOption() { + let option = PollOption(id: "option1", text: "Option 1") + let vote = PollVote( + id: .unique, + createdAt: .unique, + updatedAt: .unique, + pollId: "123", + optionId: "option1", + isAnswer: false, + answerText: nil, + user: .unique + ) + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 5, "option2": 10], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [vote] + ) + + XCTAssertEqual(poll.currentUserVote(for: option), vote) + } + + func test_hasCurrentUserVotedForOption() { + let option = PollOption(id: "option1", text: "Option 1") + let vote = PollVote( + id: .unique, + createdAt: .unique, + updatedAt: .unique, + pollId: "123", + optionId: "option1", + isAnswer: false, + answerText: nil, + user: .unique + ) + let poll = Poll( + allowAnswers: true, + allowUserSuggestedOptions: false, + answersCount: 10, + createdAt: Date(), + pollDescription: "Sample poll", + enforceUniqueVote: true, + id: "123", + name: "Test Poll", + updatedAt: nil, + voteCount: 20, + extraData: [:], + voteCountsByOption: ["option1": 5, "option2": 10], + isClosed: false, + maxVotesAllowed: 1, + votingVisibility: .public, + createdBy: nil, + latestAnswers: [], + options: [], + latestVotesByOption: [], + ownVotes: [vote] + ) + + XCTAssertTrue(poll.hasCurrentUserVoted(for: option)) + } +} diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView+SwiftUI_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView+SwiftUI_Tests.swift index cb3f5931a6e..1363f295ce8 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView+SwiftUI_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView+SwiftUI_Tests.swift @@ -27,7 +27,7 @@ final class ChatChannelListItemView_SwiftUI_Tests: XCTestCase { .mask(Circle()) .frame(width: 50, height: 50) - Spacer() + SwiftUI.Spacer() Text(dataSource.content!.channel.name!) }.padding() diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift index 0adc25e97f8..89b7cdfba73 100644 --- a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/ChatChannelListItemView_Tests.swift @@ -753,6 +753,96 @@ final class ChatChannelListItemView_Tests: XCTestCase { AssertSnapshot(view, variants: .onlyUserInterfaceStyles) } + func test_appearance_pollPreview_whenLatestVoterIsCurrentUser() throws { + let message: ChatMessage = try mockPollMessage( + poll: .mock( + name: "Poll", + latestVotesByOption: [ + .init(text: "1", latestVotes: [ + .mock(user: currentUser), + .mock(user: .mock(id: .unique)) + ]) + ] + ), + messageAuthor: currentUser, + isSentByCurrentUser: true + ) + + let view = channelItemView( + content: .init( + channel: channel(previewMessage: message), + currentUserId: currentUser.id + ) + ) + + AssertSnapshot(view) + } + + func test_appearance_pollPreview_whenLatestVoterIsAnotherUser() throws { + let message: ChatMessage = try mockPollMessage( + poll: .mock( + name: "Poll", + latestVotesByOption: [ + .init(text: "1", latestVotes: [ + .mock(user: .mock(id: .unique, name: "Someone")), + .mock(user: .mock(id: currentUser.id)) + ]) + ] + ), + messageAuthor: currentUser, + isSentByCurrentUser: true + ) + + let view = channelItemView( + content: .init( + channel: channel(previewMessage: message), + currentUserId: currentUser.id + ) + ) + + AssertSnapshot(view, variants: [.defaultLight]) + } + + func test_appearance_pollPreview_whenPollCreatedByCurrentUser() throws { + let message: ChatMessage = try mockPollMessage( + poll: .mock( + name: "Poll", + createdBy: currentUser + ), + messageAuthor: currentUser, + isSentByCurrentUser: true + ) + + let view = channelItemView( + content: .init( + channel: channel(previewMessage: message), + currentUserId: currentUser.id + ) + ) + + AssertSnapshot(view, variants: [.defaultLight]) + } + + func test_appearance_pollPreview_whenPollCreatedByAnotherUser() throws { + let message: ChatMessage = try mockPollMessage( + poll: .mock( + name: "Poll", + createdBy: .mock(id: .unique, name: "Darth Vader") + ), + messageAuthor: .mock(id: .unique, name: "Darth Vader"), + isSentByCurrentUser: false + ) + + let view = channelItemView( + content: .init( + channel: channel(previewMessage: message), + currentUserId: currentUser.id + ) + ) + + AssertSnapshot(view, variants: [.defaultLight]) + } + func test_appearanceCustomization_usingAppearance() { var appearance = Appearance() appearance.fonts.bodyBold = .italicSystemFont(ofSize: 20) @@ -1567,7 +1657,7 @@ final class ChatChannelListItemView_Tests: XCTestCase { private func channel( previewMessage: ChatMessage? = nil, - readEventsEnabled: Bool, + readEventsEnabled: Bool = true, memberCount: Int = 0, membership: ChatChannelMember? = nil ) -> ChatChannel { @@ -1698,6 +1788,21 @@ final class ChatChannelListItemView_Tests: XCTestCase { isSentByCurrentUser: isSentByCurrentUser ) } + + private func mockPollMessage(poll: Poll, messageAuthor: ChatUser, isSentByCurrentUser: Bool) throws -> ChatMessage { + .mock( + id: .unique, + cid: .unique, + text: "", + type: .regular, + author: messageAuthor, + createdAt: Date(timeIntervalSince1970: 100), + attachments: [], + localState: nil, + isSentByCurrentUser: isSentByCurrentUser, + poll: poll + ) + } } private extension ChatChannelListItemView { diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsAnotherUser.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsAnotherUser.default-light.png new file mode 100644 index 00000000000..39aaaa805b7 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsAnotherUser.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.default-light.png new file mode 100644 index 00000000000..594e3ba6036 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.extraExtraExtraLarge-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.extraExtraExtraLarge-light.png new file mode 100644 index 00000000000..d6afad80502 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.extraExtraExtraLarge-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.rightToLeftLayout-default.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.rightToLeftLayout-default.png new file mode 100644 index 00000000000..12e4f720714 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.rightToLeftLayout-default.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.small-dark.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.small-dark.png new file mode 100644 index 00000000000..2f9d2018035 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenLatestVoterIsCurrentUser.small-dark.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenPollCreatedByAnotherUser.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenPollCreatedByAnotherUser.default-light.png new file mode 100644 index 00000000000..57293f0f579 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenPollCreatedByAnotherUser.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenPollCreatedByCurrentUser.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenPollCreatedByCurrentUser.default-light.png new file mode 100644 index 00000000000..74ceedceed5 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatChannelList/__Snapshots__/ChatChannelListItemView_Tests/test_appearance_pollPreview_whenPollCreatedByCurrentUser.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollAttachmentView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollAttachmentView_Tests.swift new file mode 100644 index 00000000000..2fac8236c0a --- /dev/null +++ b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/PollAttachmentView_Tests.swift @@ -0,0 +1,133 @@ +// +// Copyright © 2024 Stream.io Inc. All rights reserved. +// + +import StreamChat +@testable import StreamChatTestTools +@testable import StreamChatUI +import StreamSwiftTestHelpers +import UIKit +import XCTest + +final class PollAttachmentView_Tests: XCTestCase { + /// Static setUp() is only run once. Which is what we want in this case to preload the images. + override class func setUp() { + /// Dummy snapshot to preload the TestImages.yoda.url image + /// This was the only workaround to make sure the image always appears in the snapshots. + let view = UIImageView(frame: .init(center: .zero, size: .init(width: 100, height: 100))) + Components.default.imageLoader.loadImage(into: view, from: TestImages.yoda.url) + AssertSnapshot(view, variants: [.defaultLight]) + } + + let currentUser = ChatUser.mock( + id: .unique, + imageURL: TestImages.yoda.url + ) + + func test_appearance() { + let poll = makePoll(isClosed: false) + let view = makeMessageView(for: poll) + AssertSnapshot(view) + } + + func test_appearance_whenClosed() { + let poll = makePoll(isClosed: true) + let view = makeMessageView(for: poll) + AssertSnapshot(view, variants: [.defaultLight, .defaultDark]) + } + + func test_subtitleText() { + let pollAttachmentView = PollAttachmentView() + let pollDefault = makePoll(isClosed: false) + let pollClosed = makePoll(isClosed: true) + let pollUniqueVotes = makePoll(isClosed: false, enforceUniqueVote: true) + let pollMaxVotesAllowed = makePoll(isClosed: false, maxVotesAllowed: 3) + + pollAttachmentView.content = .init(poll: pollDefault, currentUserId: .unique) + XCTAssertEqual(pollAttachmentView.subtitleText, "Select one or more") + + pollAttachmentView.content = .init(poll: pollClosed, currentUserId: .unique) + XCTAssertEqual(pollAttachmentView.subtitleText, "Vote ended") + + pollAttachmentView.content = .init(poll: pollUniqueVotes, currentUserId: .unique) + XCTAssertEqual(pollAttachmentView.subtitleText, "Select one") + + pollAttachmentView.content = .init(poll: pollMaxVotesAllowed, currentUserId: .unique) + XCTAssertEqual(pollAttachmentView.subtitleText, "Select up to 3") + } +} + +// MARK: - Factory Helpers + +extension PollAttachmentView_Tests { + private func makePoll( + isClosed: Bool, + enforceUniqueVote: Bool = false, + maxVotesAllowed: Int? = nil, + createdBy: ChatUser? = nil + ) -> Poll { + Poll.mock( + enforceUniqueVote: enforceUniqueVote, + name: "Best football player", + voteCount: 6, + voteCountsByOption: [ + "ronaldo": 3, + "eusebio": 2, + "pele": 1, + "maradona": 0, + "messi": 0 + ], + isClosed: isClosed, + maxVotesAllowed: maxVotesAllowed, + createdBy: createdBy ?? currentUser, + options: [ + .init(id: "messi", text: "Messi"), + .init(id: "ronaldo", text: "Ronaldo", latestVotes: [ + .mock(user: currentUser), + .mock(user: .mock(id: .unique)), + .mock(user: .mock(id: .unique)) + ]), + .init(id: "pele", text: "Pele", latestVotes: [ + .mock(user: .mock(id: .unique)) + ]), + .init(id: "maradona", text: "Maradona"), + .init(id: "eusebio", text: "Eusebio", latestVotes: [ + .mock(user: currentUser), + .mock(user: .mock(id: .unique)) + ]) + ], + ownVotes: [ + .mock(optionId: "ronaldo"), + .mock(optionId: "eusebio") + ] + ) + } + + private func makeMessageView( + for poll: Poll, + appearance: Appearance = .default, + components: Components = .default + ) -> ChatMessageContentView { + let channel = ChatChannel.mock(cid: .unique) + let message = ChatMessage.mock(text: "", poll: poll) + let layoutOptions = components.messageLayoutOptionsResolver.optionsForMessage( + at: .init(item: 0, section: 0), + in: .mock(cid: .unique), + with: .init([message]), + appearance: appearance + ) + + let view = ChatMessageContentView().withoutAutoresizingMaskConstraints + view.widthAnchor.constraint(equalToConstant: 360).isActive = true + view.appearance = appearance + view.components = components + view.setUpLayoutIfNeeded( + options: layoutOptions, + attachmentViewInjectorType: PollAttachmentViewInjector.self + ) + view.content = message + view.channel = channel + view.currentUserId = currentUser.id + return view + } +} diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/setUp.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/setUp.default-light.png new file mode 100644 index 00000000000..e99eaebf6dd Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/setUp.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.default-light.png new file mode 100644 index 00000000000..5c9aeb4e42f Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.extraExtraExtraLarge-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.extraExtraExtraLarge-light.png new file mode 100644 index 00000000000..806bea21415 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.extraExtraExtraLarge-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.rightToLeftLayout-default.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.rightToLeftLayout-default.png new file mode 100644 index 00000000000..3f3738405d1 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.rightToLeftLayout-default.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.small-dark.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.small-dark.png new file mode 100644 index 00000000000..d60b1dd86cb Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance.small-dark.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance_whenClosed.default-dark.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance_whenClosed.default-dark.png new file mode 100644 index 00000000000..66076458548 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance_whenClosed.default-dark.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance_whenClosed.default-light.png b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance_whenClosed.default-light.png new file mode 100644 index 00000000000..c3ab01a56b2 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/ChatMessageList/Poll/__Snapshots__/PollAttachmentView_Tests/test_appearance_whenClosed.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatUserAvatarView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatUserAvatarView_Tests.swift index 7af44581068..2fbad2206d6 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatUserAvatarView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/ChatUserAvatarView_Tests.swift @@ -37,6 +37,15 @@ final class ChatUserAvatarView_Tests: XCTestCase { AssertSnapshot(avatarViewOffline, variants: .onlyUserInterfaceStyles, suffix: "without online indicator") } + func test_appearance_whenOnlineIndicatorDisabled() { + let avatarViewOnline = ChatUserAvatarView().withoutAutoresizingMaskConstraints + avatarViewOnline.addSizeConstraints() + avatarViewOnline.components = .mock + avatarViewOnline.content = user + avatarViewOnline.shouldShowOnlineIndicator = false + AssertSnapshot(avatarViewOnline, variants: [.defaultLight]) + } + func test_appearanceCustomization_usingAppearanceAndComponents() { class RectIndicator: UIView, MaskProviding { override func didMoveToSuperview() { diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/__Snapshots__/ChatUserAvatarView_Tests/test_appearance_whenOnlineIndicatorDisabled.default-light.png b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/__Snapshots__/ChatUserAvatarView_Tests/test_appearance_whenOnlineIndicatorDisabled.default-light.png new file mode 100644 index 00000000000..232eb338353 Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/CommonViews/AvatarView/__Snapshots__/ChatUserAvatarView_Tests/test_appearance_whenOnlineIndicatorDisabled.default-light.png differ diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView_Tests.swift b/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView_Tests.swift index d89aaba8e4b..ab3b2d874cb 100644 --- a/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView_Tests.swift +++ b/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/QuotedChatMessageView_Tests.swift @@ -248,6 +248,12 @@ final class QuotedChatMessageView_Tests: XCTestCase { AssertSnapshot(view, variants: [.defaultLight]) } + func test_withPoll() { + view.content = makeContent(text: "", poll: .mock(name: "Best player")) + + AssertSnapshot(view, variants: [.defaultLight]) + } + func test_appearanceCustomization_usingComponents() { class TestView: ChatAvatarView { override func setUpAppearance() { @@ -352,7 +358,8 @@ extension QuotedChatMessageView_Tests { channel: ChatChannel? = nil, isSentByCurrentUser: Bool = false, avatarAlignment: QuotedAvatarAlignment = .leading, - attachments: [AnyChatMessageAttachment] = [] + attachments: [AnyChatMessageAttachment] = [], + poll: Poll? = nil ) -> QuotedChatMessageView.Content { let message = ChatMessage.mock( id: .unique, @@ -361,7 +368,8 @@ extension QuotedChatMessageView_Tests { author: .mock(id: .unique), translations: translations, attachments: attachments, - isSentByCurrentUser: isSentByCurrentUser + isSentByCurrentUser: isSentByCurrentUser, + poll: poll ) return .init(message: message, avatarAlignment: avatarAlignment, channel: channel) } diff --git a/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/__Snapshots__/QuotedChatMessageView_Tests/test_withPoll.default-light.png b/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/__Snapshots__/QuotedChatMessageView_Tests/test_withPoll.default-light.png new file mode 100644 index 00000000000..7ccbc30c50e Binary files /dev/null and b/Tests/StreamChatUITests/SnapshotTests/CommonViews/QuotedChatMessageView/__Snapshots__/QuotedChatMessageView_Tests/test_withPoll.default-light.png differ diff --git a/docusaurus/docs/iOS/uikit/custom-components.md b/docusaurus/docs/iOS/uikit/custom-components.md index 2cf62869114..0f03e5efac7 100644 --- a/docusaurus/docs/iOS/uikit/custom-components.md +++ b/docusaurus/docs/iOS/uikit/custom-components.md @@ -12,11 +12,10 @@ These are the steps that you need to follow to use a custom component: 1. Make changes to layout, styling, behavior as needed. 1. Configure the SDK to use your custom component. -To make customizations as easy as possible all view components share the same lifecycle and expose common properties such as `content`, `components` and `appearance`. When building your own custom -component in most cases you only need to override methods from the `Customizable` protocol such as `updateContent()`, `setUpAppearance()` or `setUpLayout()`. +To make customizations as easy as possible all view components share the same lifecycle and expose common properties such as `content`, `components` and `appearance`. When building your own custom component if you only want to change the styling and the layout of the view, you only need to override the `setUpAppearance()` and `setUpLayout()` functions. In case you want to change the logic of the component or how the content is updated, you should override the `updateContent()` function. :::note -Most UI components are stateless view classes. Components like `MessageList`, `ChannelList` and `MessageComposer` are stateful and are view controllers. Customizations for these components are described in detail in their own doc pages. +Most UI components are stateless view classes. Components like `ChatMessageListVC`, `ChatChannelListVC` and `ComposerVC` are stateful and are view controllers. Customizations for these components are described in detail in their own doc pages. ::: ## The `Components` object @@ -57,20 +56,22 @@ func updateContent() ``` ### `setUp()` + You can see this lifecycle method as a custom constructor of the view since it is only called once in the lifecycle of the component. This is a good place for setting delegates, adding gesture recognizers or adding any kind of target action. Usually you want to call `super.setUp()` when overriding this lifecycle method, but you can choose not to if you want to configure all the delegates and actions from scratch. ### `setUpAppearance()` + This lifecycle method is where you can customize the appearance of the component, like changing colors, corner radius, everything that changes the style of the UI but not the layout. You should call `super.setUpAppearance()` if you only want to override part of the view's appearance and not everything. ### `setUpLayout()` -This method is where you should customize the layout of the component, for example, changing the position of the views, padding, margins or even remove some child views. All the UI Components of the SDK use **AutoLayout** to layout the views, but our SDK provides a `ContainerStackView` component to make the customization easier. The `ContainerStackView` works very much like a `UIStackView`, in fact, it has almost the same API, but it is better suitable for our needs in terms of customizability. Just like the other lifecycle methods, you can call `super.setUpLayout()` depending on if you want to make the layout of the component from scratch or just want to change some parts of the component. + +This method is where you should customize the layout of the component, for example, changing the position of the views, padding, margins or even remove some child views. By overriding this lifecycle method you can customize the layout of the view while the view functionality will remain the same. Just like the other lifecycle methods, you can call `super.setUpLayout()` depending on if you want to make the layout of the component from scratch or just want to change some parts of the components. ### `updateContent()` -Finally, this method is called whenever the data of the component changes. Here you can change the logic of the component, change how the data is displayed or formatted. In the Stream SDK all of the components have a `content` property that represents the data of the component. This method should be used if the user interface depends on the data of the component. -In addition to this, view components expose their content with the `content` property. For instance the `MessageContent` component `content`'s property holds the `ChatMessage` object. +Finally, this method is called whenever the data of the component changes. Here you can change the logic of the component, change how the data is displayed or formatted. In the Stream SDK all of the components have a `content` property that represents the data of the component. This method should be used if the user interface depends on the data of the component. -## Example: Custom Avatar +## Customizing the appearance Let's say, we want to change the appearance of avatars by adding a border. In this case, since it is a pretty simple example, we only need to change the appearance of the component: @@ -104,7 +105,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { And that's it 🎉, as you can see all avatars across the UI are now with a border. -#### Change Avatar only in one view +### Applying changes to only one view In the previous example we saw that when we customized the avatar view, it changed every UI component that uses an avatar view. All the components in the `Components` configuration are shared components, but it is also possible to customize a shared component of a specific view only. Let's imagine that we want to apply the previous customization of a bordered avatar view, but only in the quoted reply view: @@ -131,9 +132,11 @@ As you can see, we override the `authorAvatarView` property of the `QuotedChatMe | ------------- | ------------- | | ![Default Avatars](../assets/default-avatars.png) | ![Bordered Quote Avatar](../assets/bordered-quote-avatar.png) | -## Example: Custom Unread Count Indicator +## Customizing the layout + +When customizing the layout of a component you can either add new views, remove existing views or just changing the existing views constraints. Most of our components use stack views so that it easy to move one view to another stack, but you can also override our existing constraints since they have by default a lower priority. -Now, to show an example on how to use the other lifecycle methods, let's try to change the channel unread count indicator to look like the one in iMessage: +Let's see an example on how to change the channel unread count indicator to look like the one in iMessage: | Default style | Custom "iMessage" Style | | ------------- | ------------- | @@ -141,15 +144,6 @@ Now, to show an example on how to use the other lifecycle methods, let's try to First, we need to create a custom subclass of `ChatChannelListItemView`, which is the component responsible for showing the channel summary in the channel list. Because the iMessage-style unread indicator is just a blue dot, rather then trying to modify the existing unread indicator, it's easier to create a brand new view for it: -```swift -class iMessageChannelListItemView: ChatChannelListItemView { - /// Blue "dot" indicator visible for channels with unread messages - private lazy var customUnreadView = UIView() -} -``` - -Then, we just follow the structure defined by the lifecycle methods and apply the proper customization for each step: - ```swift class iMessageChannelListItemView: ChatChannelListItemView { private lazy var customUnreadView = UIView() @@ -182,11 +176,13 @@ class iMessageChannelListItemView: ChatChannelListItemView { // We change the alpha value only because we want the view to still be part // of the layout system. - customUnreadView.alpha = unreadCountView.content == .noUnread ? 0 : 1 + customUnreadView.alpha = content?.channel.unreadCount.messages == 0 ? 0 : 1 } } ``` +Because we added a new view, we also overridden the `updateContent()` method to update the visibility of the `customUnreadView` based on the unread count. + Finally, don't forget to change the `Components` configuration: ```swift @@ -198,3 +194,211 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } } ``` + +## New View Container Builder + +Since version **4.63.0**, we introduced a new way to re-layout the components. The `ViewContainerBuilder` is a result builder that allows you to create stack views with a declarative syntax. If you are already familiar with SwiftUI, you will find this new way of building the layout very similar. The only difference is that the `ViewContainerBuilder` is only meant to be used for layout purposes, the appearance should still be set in the `setUpAppearance()` method and the component logic should still be set in the `updateContent()` method. + +The interface of the `ViewContainerBuilder` is very simple, below you can find all the available methods: + +- `HContainer()`, to create horizontal stack views. +- `VContainer()`, to create vertical stack views. +- `Spacer()`, to create a flexible space between views. +- `UIView.layout {}`, a closure to apply additional layout changes to the view. +- `UIView.constraints {}`, a result builder function to automatically activate multiple constraints. +- `UIView.width()` and `UIView.height()`, convenience methods to set the width and height of the view. +- `UIStackView.views {}`, a result builder function to replace the views of a stack view. +- `UIStackView.embed()` and `UIStackView.embedToMargins()`, convenience methods to embed the containers to a parent view. + +### HContainer + +The `HContainer` is used to create horizontal stack views. You can add as many views as you want to the container. It has the following parameters: + +- `spacing: CGFloat`, the spacing between the views, by default it is `0`. +- `distribution: UIStackView.Distribution`, the distribution of the views, by default it is `.fill`. +- `alignment: UIStackView.Alignment`, the alignment of the views, by default it is `.fill`. + +### VContainer + +The `VContainer` is used to create vertical stack views. You can add as many views as you want to the container. It has the following parameters: + +- `spacing: CGFloat`, the spacing between the views, by default it is `0`. +- `distribution: UIStackView.Distribution`, the distribution of the views, by default it is `.fill`. +- `alignment: UIStackView.Alignment`, the alignment of the views, by default it is `.fill`. + +### Spacer + +The `Spacer` is used to create a flexible space between views. There are no parameters for this method. + +### UIView.layout + +The `UIView.layout` is a closure that allows you to apply additional layout changes to the view. Here is an example of how to use it: + +```swift +VContainer { + unreadCountView + replyTimestampLabel.layout { + $0.setContentCompressionResistancePriority(.required, for: .horizontal) + NSLayoutConstraint.activate([ + $0.heightAnchor.constraint(equalToConstant: 15), + $0.widthAnchor.constraint(equalToConstant: 15) + ]) + } +} +``` + +The `$0` is the reference of the view. You could also write the code above like this: + +```swift +VContainer { + unreadCountView + replyTimestampLabel.layout { view in + view.setContentCompressionResistancePriority(.required, for: .horizontal) + NSLayoutConstraint.activate([ + view.heightAnchor.constraint(equalToConstant: 15) + view.widthAnchor.constraint(equalToConstant: 15) + ]) + } +} +``` + +### UView.contraints + +The `UIView.constraints` is a result builder function that allows you to activate multiple constraints at once. It is similar to `UIView.layout` but you can only provide an array of constraints, and it activates the constraints automatically for you. Here is an example of how to use it: + +```swift +VContainer { + unreadCountView + replyTimestampLabel.constraints { + $0.heightAnchor.constraint(equalToConstant: 15) + $0.widthAnchor.constraint(equalToConstant: 15) + } +} +``` + +### UIView.width and UIView.height + +Because setting the width and height of a view is a very common operation, we created two convenience methods to make it easier. They both support the following parameters: + +- `value: CGFloat`, the same as using `equalToConstant` constraint. +- `greaterThanOrEqualTo: CGFloat`, the same as using `greaterThanOrEqualToConstant` constraint. +- `lessThanOrEqualTo: CGFloat`, the same as using `lessThanOrEqualToConstant` constraint. + +Here is an example of how to use it: + +```swift +VContainer { + unreadCountView + replyTimestampLabel + .width(15) + .height(greaterThanOrEqualTo: 15) +} +``` + +### UIStackView.views + +The `UIStackView.views` is a result builder function that allows you to replace the views of an existing stack view. Here is an example of how to use it: + +```swift +bottomContainer.views { + unreadCountView + replyTimestampLabel +} +``` + +### UIStackView.embed + +The `UIStackView.embed` and `UIStackView.embedToMargins` are convenience methods to embed the containers to a parent view. The following methods are available: + +- `embed(in view: UIView) -> UIStackView`, to embed the stack view to a parent view. +- `embed(in view: UIView, insets: NSDirectionalEdgeInsets) -> UIStackView`, to embed the stack view to a parent view with insets. +- `embedToMargins(in view: UIView) -> UIStackView`, to embed the stack view to the given view layout margins guide. + +Here is an example of how to use them: + +```swift +// Default embed +VContainer { + unreadCountView + replyTimestampLabel +}.embed(in: self) // Assuming that self is a view + +// With insets +VContainer { + unreadCountView + replyTimestampLabel +}.embed(in: self, insets: .init(top: 10, leading: 10, bottom: 10, trailing: 10)) + +// Respecting the layout margins +self.directionalLayoutMargins = .init(top: 10, leading: 10, bottom: 10, trailing: 10) +VContainer { + unreadCountView + replyTimestampLabel +}.embedToMargins(in: self) +``` + +### Example + +In order to show how to use the `ViewContainerBuilder`, let's see an example of how to create a custom layout from scratch for the `ChatThreadListItemView`. + +The end result will look like this: + +| Before| After | +| ------------- | ------------- | +| ![Before](../assets/thread-list/ChatThreadListItemView.png) | ![After](../assets/thread-list/ChatThreadListItemView_Custom.png) | + +The goal is to have the thread title at the top with the unread threads on the right top corner, then the channel name in the middle and finally the reply author avatar, description and timestamp at the bottom, horizontally aligned. + +Here is the code to achieve this: + +```swift +public class CustomChatThreadListItemView: ChatThreadListItemView { + + lazy var channelNameLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .footnote) + return label + }() + + public override func setUpLayout() { + // We do not call super on purpose to complete re-layout the view from scratch + + VContainer(spacing: 4) { + HContainer(spacing: 2) { + threadIconView + threadTitleLabel + Spacer() + threadUnreadCountView + .width(20) + .height(20) + } + channelNameLabel + HContainer(spacing: 4) { + replyAuthorAvatarView + .width(20) + .height(20) + replyDescriptionLabel + Spacer() + replyTimestampLabel.layout { + $0.setContentCompressionResistancePriority(.required, for: .horizontal) + } + } + } + .embedToMargins(in: self) + } + + public override func updateContent() { + super.updateContent() + + // Displays the parent message in the thread title label instead of the channel name + threadTitleLabel.text = parentMessagePreviewText + // Displays the channel name in the new channel name label + channelNameLabel.text = "# \(channelNameText ?? "")" + } +} + +// Don't forget to set the custom component in the Components configuration +Components.default.threadListItemView = CustomChatThreadListItemView.self +``` + +From the version **4.63.0** onward our own components will be using the `ViewContainerBuilder` to create the layout. This will make it easier to understand how the components are laid out and make it easier to customize them. This does not mean that you need to use the `ViewContainerBuilder` to customize the components, you can still use regular UIKit or your own UI framework to customize the components.