Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[Polls] Add PollAttachmentView + ViewContainerBuilder #3374

Merged
merged 47 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
ee9a734
Introduce new ViewContainerBuilder
nuno-vieira Jul 22, 2024
436fcc5
Add PollAttachmentView component
nuno-vieira Jul 23, 2024
6d9129a
Change to SwiftUI ENV
nuno-vieira Jul 30, 2024
e08bf0b
Add `shouldShowOnlineIndicator` to turn off online presence in `ChatU…
nuno-vieira Jul 31, 2024
3bee17d
Fix small iOS 13 deprecation
nuno-vieira Aug 1, 2024
e6e1fab
Add domain logic helpers to Poll
nuno-vieira Aug 1, 2024
a1d34af
Message Polls live updates
nuno-vieira Aug 1, 2024
eddb971
Event.ownVotes workaround (Drop when fixed)
nuno-vieira Aug 1, 2024
578ab02
Manage data races when voting on a poll in the message list
nuno-vieira Aug 1, 2024
632b88b
Add haptic feedback when voting
nuno-vieira Aug 1, 2024
5a8c700
Make sure to update the latestVotes author avatar views
nuno-vieira Aug 2, 2024
dd02bf5
Improve performance of Option List View by no recreating the item vie…
nuno-vieira Aug 2, 2024
968945b
Improve flexibility of Option List View without compromising performance
nuno-vieira Aug 2, 2024
7d3688a
Make content of StackedUserAvatarsView public
nuno-vieira Aug 5, 2024
bfeb8f7
Re-structure Common Buttons Folder
nuno-vieira Aug 5, 2024
b27679f
Add `CheckboxButton` as a common view
nuno-vieira Aug 5, 2024
b155062
Add vote ratio logic to `ChatChannelListItemView`
nuno-vieira Aug 5, 2024
d546dc8
Fix UI Glitches for option list item view when text is too big and av…
nuno-vieira Aug 5, 2024
cfda8d1
Extract more common logic to `Poll` domain model
nuno-vieira Aug 5, 2024
860948d
Add closed poll and winner option state logic
nuno-vieira Aug 5, 2024
21451fd
Add subtitle text logic to `PollAttachmentView`
nuno-vieira Aug 5, 2024
c2d6dc4
Change winner logic to be the one and only one option with most votes
nuno-vieira Aug 5, 2024
78f5974
Add channel list message preview text for polls
nuno-vieira Aug 6, 2024
9c6c77c
Add poll quoted message text rendering
nuno-vieira Aug 6, 2024
95cb90d
Add View Results and End Button to the Poll Attachment View
nuno-vieira Aug 6, 2024
bcdb906
Fix poll option label width ui glitch
nuno-vieira Aug 7, 2024
ff556d6
Added End Poll functionality
nuno-vieira Aug 7, 2024
a00f087
Add isOptionWinner and isOptionOneOfTheWinners to Poll
nuno-vieira Aug 14, 2024
4d18826
Fix message with Polls not showing when long pressing
nuno-vieira Aug 8, 2024
8ea00ee
Fix poll controller with incorrect data in message list
nuno-vieira Aug 8, 2024
97b5b2f
Sort the `latestVotes` and `latestAnswers` in the LLC
nuno-vieira Aug 19, 2024
0227520
Add test coverage to Poll domain helpers
nuno-vieira Aug 19, 2024
21d1cc6
Add Test Coverage to Poll Message Preview in Channel List
nuno-vieira Aug 19, 2024
713860f
Add test coverage to PollAttachmentView
nuno-vieira Aug 19, 2024
f50d4c2
Add test coverage to disabling online indicator in ChatUserAvatarView
nuno-vieira Aug 19, 2024
5bf6b60
Add test coverage to quoted poll
nuno-vieira Aug 19, 2024
57073a4
Fix some existing snapshot tests
nuno-vieira Aug 19, 2024
bf1f713
Use UIStackView for Spacer() to avoid drawing
nuno-vieira Aug 19, 2024
c3e81a2
Fix Xcode 14 Build
nuno-vieira Aug 19, 2024
fb7a7d4
Fix Poll AttachmentView Tests on CI
nuno-vieira Aug 19, 2024
04247bd
Update CHANGELOG.md
nuno-vieira Aug 19, 2024
3746d0f
Update CHANGELOG.md
nuno-vieira Aug 19, 2024
87f802e
Fix Xcode 14 Build, conflict with SwiftUI Spacer
nuno-vieira Aug 19, 2024
f4bb257
Revert "Change to SwiftUI ENV"
nuno-vieira Aug 20, 2024
6afa558
Add documentation to ViewContainerBuilder
nuno-vieira Aug 20, 2024
63b8439
Fix vale issues
nuno-vieira Aug 20, 2024
7326c3f
Fix using pin() instead of constraint() in docs
nuno-vieira Aug 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .styles/config/vocabularies/Base/accept.txt
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,6 @@ swappable
telehealth
subclassing
[Ss]ubview
scrollable
scrollable
HContainer
VContainer
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]?
Expand All @@ -183,6 +183,8 @@ struct PollPayload: Decodable {
var votingVisibility: String?
var createdBy: UserPayload?

var fromEvent: Bool = false

init(
allowAnswers: Bool,
allowUserSuggestedOptions: Bool,
Expand Down
27 changes: 24 additions & 3 deletions Sources/StreamChat/Database/DTOs/PollDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class PollDTO: NSManagedObject {
@NSManaged var votingVisibility: String?
@NSManaged var createdBy: UserDTO?
@NSManaged var latestAnswers: Set<PollVoteDTO>
@NSManaged var ownVotes: Set<PollVoteDTO>
@NSManaged var message: MessageDTO?
@NSManaged var options: NSOrderedSet
@NSManaged var latestVotesByOption: Set<PollOptionDTO>
Expand Down Expand Up @@ -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() }
)
}

Expand Down Expand Up @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion Sources/StreamChat/Database/DTOs/PollOptionDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down
24 changes: 22 additions & 2 deletions Sources/StreamChat/Database/DTOs/PollVoteDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion Sources/StreamChat/Database/DatabaseSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@
<relationship name="latestVotesByOption" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PollOptionDTO" inverseName="pollLatestVotes" inverseEntity="PollOptionDTO"/>
<relationship name="message" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MessageDTO" inverseName="poll" inverseEntity="MessageDTO"/>
<relationship name="options" optional="YES" toMany="YES" deletionRule="Nullify" ordered="YES" destinationEntity="PollOptionDTO" inverseName="poll" inverseEntity="PollOptionDTO"/>
<relationship name="ownVotes" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PollVoteDTO" inverseName="currentUserPoll" inverseEntity="PollVoteDTO"/>
</entity>
<entity name="PollOptionDTO" representedClassName="PollOptionDTO" syncable="YES">
<attribute name="custom" optional="YES" attributeType="Binary"/>
Expand All @@ -349,6 +350,7 @@
<attribute name="optionId" optional="YES" attributeType="String"/>
<attribute name="pollId" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="currentUserPoll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PollDTO" inverseName="ownVotes" inverseEntity="PollDTO"/>
<relationship name="option" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PollOptionDTO" inverseName="latestVotes" inverseEntity="PollOptionDTO"/>
<relationship name="poll" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PollDTO" inverseName="latestAnswers" inverseEntity="PollDTO"/>
<relationship name="queries" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="PollVoteListQueryDTO" inverseName="votes" inverseEntity="PollVoteListQueryDTO"/>
Expand Down
1 change: 1 addition & 0 deletions Sources/StreamChat/Models/ChatMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
58 changes: 58 additions & 0 deletions Sources/StreamChat/Models/Poll.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
laevandus marked this conversation as resolved.
Show resolved Hide resolved
/// 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)
}
}
8 changes: 8 additions & 0 deletions Sources/StreamChatUI/Appearance+Images.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
30 changes: 30 additions & 0 deletions Sources/StreamChatUI/ChatChannelList/ChatChannelListItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading