Skip to content

Commit 6d0cda2

Browse files
authored
Fix PollOptionAllVotesViewModel not loading more votes (#1067)
* Fix `PollOptionAllVotesViewModel` not loading more votes * Update CHANGELOG.md
1 parent 38ffbc8 commit 6d0cda2

File tree

4 files changed

+277
-13
lines changed

4 files changed

+277
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
77
- Open `ChatChannelInfoViewModel.leaveButtonTitle` and `ChatChannelInfoViewModel.leaveConversationDescription` [#1018](https://github.com/GetStream/stream-chat-swiftui/pull/1018)
88
### 🐞 Fixed
99
- Use `muteChannel` capability for showing mute channel button in the `ChatChannelInfoView` [#1018](https://github.com/GetStream/stream-chat-swiftui/pull/1018)
10+
- Fix `PollOptionAllVotesViewModel` not loading more votes [#1067](https://github.com/GetStream/stream-chat-swiftui/pull/1067)
1011

1112
# [4.94.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.94.0)
1213
_December 02, 2025_

Sources/StreamChatSwiftUI/ChatChannel/MessageList/Polls/PollOptionAllVotesViewModel.swift

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@ class PollOptionAllVotesViewModel: ObservableObject, PollVoteListControllerDeleg
1616

1717
private var cancellables = Set<AnyCancellable>()
1818
private(set) var animateChanges = false
19-
private var loadingVotes = false
20-
21-
init(poll: Poll, option: PollOption) {
19+
var loadingVotes = false
20+
21+
init(poll: Poll, option: PollOption, controller: PollVoteListController? = nil) {
2222
self.poll = poll
2323
self.option = option
2424
let query = PollVoteListQuery(
2525
pollId: poll.id,
26-
optionId: option.id
26+
optionId: option.id,
27+
pagination: .init(pageSize: 25)
2728
)
28-
controller = InjectedValues[\.chatClient].pollVoteListController(query: query)
29-
controller.delegate = self
29+
self.controller = controller ?? InjectedValues[\.chatClient].pollVoteListController(query: query)
30+
self.controller.delegate = self
3031
refresh()
3132

3233
// No animation for initial load
@@ -41,20 +42,21 @@ class PollOptionAllVotesViewModel: ObservableObject, PollVoteListControllerDeleg
4142
controller.synchronize { [weak self] error in
4243
guard let self else { return }
4344
self.pollVotes = Array(self.controller.votes)
44-
if self.pollVotes.isEmpty {
45-
self.loadVotes()
46-
}
4745
if error != nil {
4846
self.errorShown = true
4947
}
5048
}
5149
}
5250

5351
func onAppear(vote: PollVote) {
54-
guard !loadingVotes,
55-
let index = pollVotes.firstIndex(where: { $0 == vote }),
56-
index > pollVotes.count - 10 else { return }
57-
52+
guard let index = pollVotes.firstIndex(where: { $0 == vote }) else {
53+
return
54+
}
55+
56+
guard index > pollVotes.count - 10 && pollVotes.count > 25 else {
57+
return
58+
}
59+
5860
loadVotes()
5961
}
6062

@@ -76,6 +78,10 @@ class PollOptionAllVotesViewModel: ObservableObject, PollVoteListControllerDeleg
7678
}
7779

7880
private func loadVotes() {
81+
if loadingVotes || controller.hasLoadedAllVotes {
82+
return
83+
}
84+
7985
loadingVotes = true
8086

8187
controller.loadMoreVotes { [weak self] error in

StreamChatSwiftUI.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@
533533
AD51D9182DB9543A0068D0B0 /* MessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD51D9172DB9543A0068D0B0 /* MessageViewModel.swift */; };
534534
AD5C0A5F2D6FDD9700E1E500 /* BouncedMessageActionsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD5C0A5E2D6FDD8600E1E500 /* BouncedMessageActionsModifier.swift */; };
535535
AD6B7E052D356E8800ADEF39 /* ReactionsUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD6B7E042D356E8800ADEF39 /* ReactionsUsersViewModel.swift */; };
536+
AD723F002EEB6653007C3718 /* PollOptionAllVotesViewModel_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD723EFF2EEB6653007C3718 /* PollOptionAllVotesViewModel_Tests.swift */; };
536537
AD9138AC2E707F9100581EB0 /* AttachmentDownloadingStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9138AB2E707F9100581EB0 /* AttachmentDownloadingStateView.swift */; };
537538
AD9138AE2E71C81D00581EB0 /* PercentageProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9138AD2E71C81D00581EB0 /* PercentageProgressView.swift */; };
538539
AD9138B02E7241D900581EB0 /* FileAttachmentView_Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9138AF2E7241D900581EB0 /* FileAttachmentView_Tests.swift */; };
@@ -1149,6 +1150,7 @@
11491150
AD51D9172DB9543A0068D0B0 /* MessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = "<group>"; };
11501151
AD5C0A5E2D6FDD8600E1E500 /* BouncedMessageActionsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BouncedMessageActionsModifier.swift; sourceTree = "<group>"; };
11511152
AD6B7E042D356E8800ADEF39 /* ReactionsUsersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsUsersViewModel.swift; sourceTree = "<group>"; };
1153+
AD723EFF2EEB6653007C3718 /* PollOptionAllVotesViewModel_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionAllVotesViewModel_Tests.swift; sourceTree = "<group>"; };
11521154
AD9138AB2E707F9100581EB0 /* AttachmentDownloadingStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentDownloadingStateView.swift; sourceTree = "<group>"; };
11531155
AD9138AD2E71C81D00581EB0 /* PercentageProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PercentageProgressView.swift; sourceTree = "<group>"; };
11541156
AD9138AF2E7241D900581EB0 /* FileAttachmentView_Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAttachmentView_Tests.swift; sourceTree = "<group>"; };
@@ -2173,6 +2175,7 @@
21732175
846B15F22817E7440017F7A1 /* ChannelInfo */,
21742176
8423C340277CB5C70092DCF1 /* Suggestions */,
21752177
AD3DB8332E7C7BD50023D377 /* MessageAttachmentsConverter_Tests.swift */,
2178+
AD723EFF2EEB6653007C3718 /* PollOptionAllVotesViewModel_Tests.swift */,
21762179
AD9138AF2E7241D900581EB0 /* FileAttachmentView_Tests.swift */,
21772180
84779C742AEBBACD000A6A68 /* BottomReactionsView_Tests.swift */,
21782181
ADA77F042E1EC2B700A3641F /* MessageAvatarView_Tests.swift */,
@@ -3129,6 +3132,7 @@
31293132
84D6B55A27DF6EC7009C6D07 /* LoadingView_Tests.swift in Sources */,
31303133
84C94D0427578BF2007FE2B9 /* TestError.swift in Sources */,
31313134
84C94D422757C16D007FE2B9 /* ChatChannelListTestHelpers.swift in Sources */,
3135+
AD723F002EEB6653007C3718 /* PollOptionAllVotesViewModel_Tests.swift in Sources */,
31323136
84BB4C4E284115C200CBE004 /* MessageListDateUtils_Tests.swift in Sources */,
31333137
84C94D5A275A2E43007FE2B9 /* StreamChat_Utils_Tests.swift in Sources */,
31343138
84E0478A284A444E00BAFA17 /* VideoPreviewLoader_Mock.swift in Sources */,
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
@testable import StreamChat
6+
@testable import StreamChatSwiftUI
7+
@testable import StreamChatTestTools
8+
import XCTest
9+
10+
final class PollOptionAllVotesViewModel_Tests: StreamChatTestCase {
11+
func test_init_thenSynchronizeCalled() {
12+
// Given
13+
let poll = Poll.mock()
14+
let option = poll.options.first!
15+
let controller = makeVoteListController(poll: poll, option: option)
16+
17+
// When
18+
_ = PollOptionAllVotesViewModel(
19+
poll: poll,
20+
option: option,
21+
controller: controller
22+
)
23+
24+
// Then
25+
XCTAssertTrue(controller.synchronize_called)
26+
}
27+
28+
func test_refresh_whenSuccess_thenUpdatesPollVotes() {
29+
// Given
30+
let poll = Poll.mock()
31+
let option = poll.options.first!
32+
let controller = makeVoteListController(poll: poll, option: option)
33+
controller.votes_simulated = LazyCachedMapCollection(source: [], map: { $0 })
34+
controller.synchronize_completion_result = .success(())
35+
let viewModel = PollOptionAllVotesViewModel(
36+
poll: poll,
37+
option: option,
38+
controller: controller
39+
)
40+
41+
// When
42+
let votes = [
43+
PollVote.mock(pollId: poll.id, optionId: option.id),
44+
PollVote.mock(pollId: poll.id, optionId: option.id)
45+
]
46+
controller.votes_simulated = LazyCachedMapCollection(source: votes, map: { $0 })
47+
viewModel.refresh()
48+
49+
// Then
50+
XCTAssertEqual(viewModel.pollVotes.count, 2)
51+
XCTAssertEqual(viewModel.errorShown, false)
52+
}
53+
54+
func test_refresh_whenError_thenShowsError() {
55+
// Given
56+
let poll = Poll.mock()
57+
let option = poll.options.first!
58+
let controller = makeVoteListController(poll: poll, option: option)
59+
controller.votes_simulated = LazyCachedMapCollection(source: [], map: { $0 })
60+
controller.synchronize_completion_result = .failure(ClientError("ERROR"))
61+
let viewModel = PollOptionAllVotesViewModel(
62+
poll: poll,
63+
option: option,
64+
controller: controller
65+
)
66+
67+
// When
68+
viewModel.refresh()
69+
70+
// Then
71+
XCTAssertEqual(viewModel.errorShown, true)
72+
}
73+
74+
func test_onAppear_whenInsideThresholdAndMoreThan25Votes_thenLoadsMoreVotes() {
75+
// Given
76+
let poll = Poll.mock()
77+
let option = poll.options.first!
78+
let controller = makeVoteListController(poll: poll, option: option)
79+
let votes = makeVotes(count: 30, pollId: poll.id, optionId: option.id)
80+
controller.votes_simulated = LazyCachedMapCollection(source: votes, map: { $0 })
81+
controller.synchronize_completion_result = .success(())
82+
let viewModel = PollOptionAllVotesViewModel(
83+
poll: poll,
84+
option: option,
85+
controller: controller
86+
)
87+
88+
// When - appear at index 21 (which is > 30 - 10 = 20)
89+
let voteAtIndex21 = viewModel.pollVotes[21]
90+
viewModel.onAppear(vote: voteAtIndex21)
91+
92+
// Then
93+
XCTAssertTrue(viewModel.loadingVotes)
94+
}
95+
96+
func test_onAppear_whenNotInThreshold_thenDoesNotLoadMoreVotes() {
97+
// Given
98+
let poll = Poll.mock()
99+
let option = poll.options.first!
100+
let controller = makeVoteListController(poll: poll, option: option)
101+
let votes = makeVotes(count: 30, pollId: poll.id, optionId: option.id)
102+
controller.votes_simulated = LazyCachedMapCollection(source: votes, map: { $0 })
103+
controller.synchronize_completion_result = .success(())
104+
let viewModel = PollOptionAllVotesViewModel(
105+
poll: poll,
106+
option: option,
107+
controller: controller
108+
)
109+
110+
// When - appear at index 0 (which is not > 30 - 10 = 20)
111+
let voteAtIndex0 = viewModel.pollVotes[0]
112+
viewModel.onAppear(vote: voteAtIndex0)
113+
114+
// Then
115+
XCTAssertFalse(viewModel.loadingVotes)
116+
}
117+
118+
func test_onAppear_whenLessThan25Votes_thenDoesNotLoadMoreVotes() {
119+
// Given
120+
let poll = Poll.mock()
121+
let option = poll.options.first!
122+
let controller = makeVoteListController(poll: poll, option: option)
123+
let votes = makeVotes(count: 20, pollId: poll.id, optionId: option.id)
124+
controller.votes_simulated = LazyCachedMapCollection(source: votes, map: { $0 })
125+
controller.synchronize_completion_result = .success(())
126+
let viewModel = PollOptionAllVotesViewModel(
127+
poll: poll,
128+
option: option,
129+
controller: controller
130+
)
131+
132+
// When - appear at last vote (index 19, which is > 20 - 10 = 10)
133+
let lastVote = viewModel.pollVotes[19]
134+
viewModel.onAppear(vote: lastVote)
135+
136+
// Then
137+
XCTAssertFalse(viewModel.loadingVotes)
138+
}
139+
140+
func test_onAppear_whenVoteNotFound_thenDoesNotLoadMoreVotes() {
141+
// Given
142+
let poll = Poll.mock()
143+
let option = poll.options.first!
144+
let controller = makeVoteListController(poll: poll, option: option)
145+
let votes = makeVotes(count: 30, pollId: poll.id, optionId: option.id)
146+
controller.votes_simulated = LazyCachedMapCollection(source: votes, map: { $0 })
147+
controller.synchronize_completion_result = .success(())
148+
let viewModel = PollOptionAllVotesViewModel(
149+
poll: poll,
150+
option: option,
151+
controller: controller
152+
)
153+
154+
// When - appear with vote not in list
155+
let otherVote = PollVote.mock(pollId: poll.id, optionId: "other_option")
156+
viewModel.onAppear(vote: otherVote)
157+
158+
// Then
159+
XCTAssertFalse(viewModel.loadingVotes)
160+
}
161+
162+
func test_loadMoreVotes_whenSuccess_thenUpdatesPollVotes() {
163+
// Given
164+
let poll = Poll.mock()
165+
let option = poll.options.first!
166+
let controller = makeVoteListController(poll: poll, option: option)
167+
let initialVotes = makeVotes(count: 30, pollId: poll.id, optionId: option.id)
168+
controller.votes_simulated = LazyCachedMapCollection(source: initialVotes, map: { $0 })
169+
controller.synchronize_completion_result = .success(())
170+
controller.loadMoreVotes_completion_result = .success(())
171+
let viewModel = PollOptionAllVotesViewModel(
172+
poll: poll,
173+
option: option,
174+
controller: controller
175+
)
176+
177+
// When
178+
let voteAtIndex21 = viewModel.pollVotes[21]
179+
viewModel.onAppear(vote: voteAtIndex21)
180+
let additionalVotes = makeVotes(count: 10, pollId: poll.id, optionId: option.id)
181+
let allVotes = initialVotes + additionalVotes
182+
controller.votes_simulated = LazyCachedMapCollection(source: allVotes, map: { $0 })
183+
// Trigger the delegate method to update the view model
184+
viewModel.controller(controller, didChangeVotes: [])
185+
186+
// Then
187+
XCTAssertEqual(viewModel.pollVotes.count, 40)
188+
XCTAssertEqual(viewModel.errorShown, false)
189+
}
190+
191+
func test_loadMoreVotes_whenError_thenShowsError() {
192+
// Given
193+
let poll = Poll.mock()
194+
let option = poll.options.first!
195+
let controller = makeVoteListController(poll: poll, option: option)
196+
let votes = makeVotes(count: 30, pollId: poll.id, optionId: option.id)
197+
controller.votes_simulated = LazyCachedMapCollection(source: votes, map: { $0 })
198+
controller.synchronize_completion_result = .success(())
199+
controller.loadMoreVotes_completion_result = .failure(ClientError("ERROR"))
200+
let viewModel = PollOptionAllVotesViewModel(
201+
poll: poll,
202+
option: option,
203+
controller: controller
204+
)
205+
206+
// When
207+
let voteAtIndex21 = viewModel.pollVotes[21]
208+
viewModel.onAppear(vote: voteAtIndex21)
209+
210+
// Then
211+
XCTAssertEqual(viewModel.errorShown, true)
212+
}
213+
214+
func test_controllerDidUpdatePoll_thenUpdatesPoll() {
215+
// Given
216+
let poll = Poll.mock(name: "Original")
217+
let option = poll.options.first!
218+
let controller = makeVoteListController(poll: poll, option: option)
219+
let viewModel = PollOptionAllVotesViewModel(
220+
poll: poll,
221+
option: option,
222+
controller: controller
223+
)
224+
225+
// When
226+
let updatedPoll = Poll.mock(name: "Updated")
227+
viewModel.controller(controller, didUpdatePoll: updatedPoll)
228+
229+
// Then
230+
XCTAssertEqual(viewModel.poll.name, "Updated")
231+
}
232+
233+
// MARK: - Test Data
234+
235+
private func makeVoteListController(poll: Poll, option: PollOption) -> PollVoteListController_Mock {
236+
let query = PollVoteListQuery(
237+
pollId: poll.id,
238+
optionId: option.id,
239+
pagination: .init(pageSize: 25)
240+
)
241+
return PollVoteListController_Mock(query: query, client: chatClient)
242+
}
243+
244+
private func makeVotes(count: Int, pollId: String, optionId: String) -> [PollVote] {
245+
(0..<count).map { index in
246+
PollVote.mock(
247+
id: "vote_\(index)",
248+
pollId: pollId,
249+
optionId: optionId
250+
)
251+
}
252+
}
253+
}

0 commit comments

Comments
 (0)