Skip to content

Commit 06d1675

Browse files
graycreateclaudeweb-flow
authored
feat: Display online user count in pull-to-refresh view (#56)
* feat: Display online user count in pull-to-refresh view Add real-time online user count display in the Feed page pull-to-refresh indicator, fetched from V2EX homepage. Changes: - Add OnlineStatsInfo model to parse online user count from V2EX HTML - Add /onlineStats API endpoint with desktop UA to fetch homepage data - Create FetchOnlineStats action and reducer to handle data flow - Update HeadIndicatorView to display online count (e.g., "2613 人在线") - Modify UpdatableView to pass onlineStats through to HeadIndicatorView - Fetch online stats in parallel with feed data on pull-to-refresh The online user count appears in the pull-to-refresh indicator when users refresh the feed, providing real-time community activity information. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: Address Copilot review comments - Remove unused extractNumber helper methods in OnlineStatsInfo - Use explicit Task syntax for parallel execution instead of async let Co-Authored-By: GitHub Copilot <noreply@github.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: GitHub Copilot <noreply@github.com>
1 parent d33394c commit 06d1675

File tree

8 files changed

+133
-14
lines changed

8 files changed

+133
-14
lines changed

V2er.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
/* Begin PBXBuildFile section */
1010
28B24CA92EA3460D00F82B2A /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B24CA82EA3460D00F82B2A /* BalanceView.swift */; };
11+
28B24CAB2EA3561400F82B2A /* OnlineStatsInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */; };
1112
28CC76CC2E963D6700C939B5 /* FilterMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A490A3E111D941C4B30F0BACA6B5E984 /* FilterMenuView.swift */; };
1213
4E55BE8A29D45FC00044389C /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4E55BE8929D45FC00044389C /* Kingfisher */; };
1314
4EC32AF029D81863003A3BD4 /* WebView in Frameworks */ = {isa = PBXBuildFile; productRef = 4EC32AEF29D81863003A3BD4 /* WebView */; };
@@ -170,6 +171,7 @@
170171

171172
/* Begin PBXFileReference section */
172173
28B24CA82EA3460D00F82B2A /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = "<group>"; };
174+
28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStatsInfo.swift; sourceTree = "<group>"; };
173175
4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowserView.swift; sourceTree = "<group>"; };
174176
5D02BD5E26909146007B6A1B /* LoadmoreIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadmoreIndicatorView.swift; sourceTree = "<group>"; };
175177
5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInfo.swift; sourceTree = "<group>"; };
@@ -581,6 +583,7 @@
581583
5DA2AD3A26C17EFE007FB1EF /* Model */ = {
582584
isa = PBXGroup;
583585
children = (
586+
28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */,
584587
5D3CD31E26D0F5F600B3C2D3 /* BaseModel.swift */,
585588
5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */,
586589
5D3CD32026D0F9CC00B3C2D3 /* TabInfo.swift */,
@@ -887,6 +890,7 @@
887890
5D3CD32126D0F9CC00B3C2D3 /* TabInfo.swift in Sources */,
888891
5D0A513F26E36F15006F3D9B /* FeedDetailActions.swift in Sources */,
889892
5DA2AD3726C17EB9007FB1EF /* AppState.swift in Sources */,
893+
28B24CAB2EA3561400F82B2A /* OnlineStatsInfo.swift in Sources */,
890894
5D71DF59247C155400B53ED4 /* MessagePage.swift in Sources */,
891895
5DA2AD4626C18208007FB1EF /* MeState.swift in Sources */,
892896
5D88D5DA26C200FB00302265 /* FeedReducer.swift in Sources */,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// OnlineStatsInfo.swift
3+
// V2er
4+
//
5+
// Created by ghui on 2025/10/18.
6+
// Copyright © 2025 lessmore.io. All rights reserved.
7+
//
8+
9+
import Foundation
10+
import SwiftSoup
11+
12+
public struct OnlineStatsInfo: BaseModel, Codable {
13+
var rawData: String?
14+
var onlineCount: Int = 0
15+
var maxRecord: Int = 0
16+
17+
init() {}
18+
19+
enum CodingKeys: String, CodingKey {
20+
case onlineCount, maxRecord
21+
}
22+
23+
init?(from html: Element?) {
24+
guard let root = html else {
25+
log("OnlineStatsInfo: root element is nil")
26+
return nil
27+
}
28+
29+
// Parse from footer HTML
30+
// Structure: <strong>... 2576 人在线</strong> &nbsp; <span class="fade">最高记录 6679</span>
31+
32+
// Get all text content from the page
33+
let pageText = root.value(.text)
34+
35+
// Extract online count using simple pattern matching
36+
// Pattern: "数字 人在线"
37+
let onlinePattern = "(\\d+)\\s*人在线"
38+
if let regex = try? NSRegularExpression(pattern: onlinePattern) {
39+
let nsText = pageText as NSString
40+
let matches = regex.matches(in: pageText, range: NSRange(location: 0, length: nsText.length))
41+
if let match = matches.first, match.numberOfRanges > 1 {
42+
let numberStr = nsText.substring(with: match.range(at: 1))
43+
onlineCount = Int(numberStr.replacingOccurrences(of: ",", with: "")) ?? 0
44+
log("OnlineStatsInfo: Found online count = \(onlineCount)")
45+
}
46+
}
47+
48+
// Extract max record
49+
let maxPattern = "最高记录\\s+(\\d+)"
50+
if let regex = try? NSRegularExpression(pattern: maxPattern) {
51+
let nsText = pageText as NSString
52+
let matches = regex.matches(in: pageText, range: NSRange(location: 0, length: nsText.length))
53+
if let match = matches.first, match.numberOfRanges > 1 {
54+
let numberStr = nsText.substring(with: match.range(at: 1))
55+
maxRecord = Int(numberStr.replacingOccurrences(of: ",", with: "")) ?? 0
56+
log("OnlineStatsInfo: Found max record = \(maxRecord)")
57+
}
58+
}
59+
60+
// If we didn't find the data, return nil
61+
if onlineCount == 0 {
62+
log("OnlineStatsInfo: Parse failed, onlineCount = 0")
63+
return nil
64+
}
65+
}
66+
67+
func isValid() -> Bool {
68+
return onlineCount > 0
69+
}
70+
}

V2er/State/DataFlow/Reducers/FeedReducer.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio
5858
followingAction = FeedActions.FetchData.Start(isFromFilterChange: true)
5959
case let action as FeedActions.ToggleFilterMenu:
6060
state.showFilterMenu.toggle()
61+
case let action as FeedActions.FetchOnlineStats.Done:
62+
if case .success(let onlineStats) = action.result {
63+
state.onlineStats = onlineStats
64+
log("FeedReducer: Received online stats: \(String(describing: onlineStats))")
65+
} else if case .failure(let error) = action.result {
66+
log("FeedReducer: Failed to fetch online stats: \(error)")
67+
}
6168
default:
6269
break
6370
}
@@ -138,4 +145,21 @@ struct FeedActions {
138145
var target: Reducer = reducer
139146
}
140147

148+
struct FetchOnlineStats {
149+
struct Start: AwaitAction {
150+
var target: Reducer = reducer
151+
152+
func execute(in store: Store) async {
153+
let result: APIResult<OnlineStatsInfo> = await APIService.shared
154+
.htmlGet(endpoint: .onlineStats)
155+
dispatch(FetchOnlineStats.Done(result: result))
156+
}
157+
}
158+
159+
struct Done: Action {
160+
var target: Reducer = reducer
161+
let result: APIResult<OnlineStatsInfo>
162+
}
163+
}
164+
141165
}

V2er/State/DataFlow/State/FeedState.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ struct FeedState: FluxState {
1919
var selectedTab: Tab = Tab.getSelectedTab()
2020
var showFilterMenu: Bool = false
2121
var scrollToTop: Int = 0 // Trigger scroll to top when changed
22+
var onlineStats: OnlineStatsInfo? = nil
2223
}

V2er/State/Networking/Endpoint.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ enum Endpoint {
3232
case starNode(id: String), dailyMission
3333
case checkin, downMyTopic(id: String), pinTopic(id: String)
3434
case balance
35+
case onlineStats
3536
case search
3637
case general(url: String)
3738

@@ -150,6 +151,9 @@ enum Endpoint {
150151
info.path = "/sticky/topic/\(id)"
151152
case .balance:
152153
info.path = "/balance"
154+
case .onlineStats:
155+
info.path = "/"
156+
info.ua = .web
153157
case let .search:
154158
info.path = "https://www.sov2ex.com/api/search"
155159
case let .general(url):

V2er/View/Feed/FeedPage.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,10 @@ struct FeedPage: BaseHomePageView {
4444
}
4545
}
4646
}
47-
.updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, max(state.scrollToTop, scrollTop(tab: .feed))) {
47+
.updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, max(state.scrollToTop, scrollTop(tab: .feed)), onlineStats: state.onlineStats) {
4848
if AccountState.hasSignIn() {
49+
// Fetch online stats in parallel with feed data
50+
Task { await run(action: FeedActions.FetchOnlineStats.Start()) }
4951
await run(action: FeedActions.FetchData.Start())
5052
}
5153
} loadMore: {

V2er/View/Widget/Updatable/HeadIndicatorView.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,26 +13,34 @@ struct HeadIndicatorView: View {
1313
var scrollY: CGFloat
1414
@Binding var progress: CGFloat
1515
@Binding var isRefreshing: Bool
16-
16+
var onlineStats: OnlineStatsInfo?
17+
1718
var offset: CGFloat {
1819
return isRefreshing ? (0 - scrollY) : -height
1920
}
20-
21-
init(threshold: CGFloat, progress: Binding<CGFloat>, scrollY: CGFloat,isRefreshing: Binding<Bool>) {
21+
22+
init(threshold: CGFloat, progress: Binding<CGFloat>, scrollY: CGFloat, isRefreshing: Binding<Bool>, onlineStats: OnlineStatsInfo? = nil) {
2223
self.height = threshold
2324
self.scrollY = scrollY
2425
self._progress = progress
2526
self._isRefreshing = isRefreshing
27+
self.onlineStats = onlineStats
2628
}
27-
29+
2830
var body: some View {
29-
Group {
31+
VStack(spacing: 4) {
3032
if progress == 1 || isRefreshing {
3133
ActivityIndicator()
3234
} else {
3335
Image(systemName: "arrow.down")
3436
.font(.title2.weight(.regular))
3537
}
38+
39+
if let stats = onlineStats, stats.isValid() {
40+
Text("\(stats.onlineCount) 人在线")
41+
.font(.caption)
42+
.foregroundColor(.secondaryText)
43+
}
3644
}
3745
.frame(height: height)
3846
.offset(y: offset)

V2er/View/Widget/Updatable/UpdatableView.swift

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ struct UpdatableView<Content: View>: View {
3030
let damper: Float = 1.2
3131
@State var hapticed = false
3232
let state: UpdatableState
33+
let onlineStats: OnlineStatsInfo?
3334

3435
private var refreshable: Bool {
3536
return onRefresh != nil
@@ -43,12 +44,14 @@ struct UpdatableView<Content: View>: View {
4344
onLoadMore: LoadMoreAction,
4445
onScroll: ScrollAction?,
4546
state: UpdatableState,
47+
onlineStats: OnlineStatsInfo? = nil,
4648
@ViewBuilder content: () -> Content) {
4749
self.onRefresh = onRefresh
4850
self.onLoadMore = onLoadMore
4951
self.onScroll = onScroll
5052
self.content = content()
5153
self.state = state
54+
self.onlineStats = onlineStats
5255
}
5356

5457
var body: some View {
@@ -68,7 +71,7 @@ struct UpdatableView<Content: View>: View {
6871
}
6972
.alignmentGuide(.top, computeValue: { d in (self.isRefreshing ? (-self.threshold + scrollY) : 0.0) })
7073
if refreshable {
71-
HeadIndicatorView(threshold: threshold, progress: $progress, scrollY: scrollY, isRefreshing: $isRefreshing)
74+
HeadIndicatorView(threshold: threshold, progress: $progress, scrollY: scrollY, isRefreshing: $isRefreshing, onlineStats: onlineStats)
7275
}
7376
}
7477
}
@@ -197,37 +200,39 @@ extension View {
197200
public func updatable(autoRefresh: Bool = false,
198201
hasMoreData: Bool = true,
199202
_ scrollToTop: Int = 0,
203+
onlineStats: OnlineStatsInfo? = nil,
200204
refresh: RefreshAction = nil,
201205
loadMore: LoadMoreAction = nil,
202206
onScroll: ScrollAction? = nil) -> some View {
203207
let state = UpdatableState(hasMoreData: hasMoreData, showLoadingView: autoRefresh, scrollToTop: scrollToTop)
204-
return self.modifier(UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state))
208+
return self.modifier(UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: onlineStats))
205209
}
206210

207211
public func updatable(_ state: UpdatableState,
212+
onlineStats: OnlineStatsInfo? = nil,
208213
refresh: RefreshAction = nil,
209214
loadMore: LoadMoreAction = nil,
210215
onScroll: ScrollAction? = nil) -> some View {
211-
let modifier = UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state)
216+
let modifier = UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: onlineStats)
212217
return self.modifier(modifier)
213218
}
214219

215220
public func loadMore(_ state: UpdatableState,
216221
_ loadMore: LoadMoreAction = nil,
217222
onScroll: ScrollAction? = nil) -> some View {
218-
let modifier = UpdatableModifier(onRefresh: nil, onLoadMore: loadMore, onScroll: onScroll, state: state)
223+
let modifier = UpdatableModifier(onRefresh: nil, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: nil)
219224
return self.modifier(modifier)
220225
}
221226

222227
public func loadMore(autoRefresh: Bool = false,
223228
hasMoreData: Bool = true,
224229
_ loadMore: LoadMoreAction = nil,
225230
onScroll: ScrollAction? = nil) -> some View {
226-
self.updatable(autoRefresh: autoRefresh, hasMoreData: hasMoreData, refresh: nil, loadMore: loadMore, onScroll: onScroll)
231+
self.updatable(autoRefresh: autoRefresh, hasMoreData: hasMoreData, onlineStats: nil, refresh: nil, loadMore: loadMore, onScroll: onScroll)
227232
}
228233

229234
public func onScroll(onScroll: ScrollAction?) -> some View {
230-
self.updatable(onScroll: onScroll)
235+
self.updatable(onlineStats: nil, onScroll: onScroll)
231236
}
232237
}
233238

@@ -236,10 +241,11 @@ struct UpdatableModifier: ViewModifier {
236241
let onLoadMore: LoadMoreAction
237242
let onScroll: ScrollAction?
238243
let state: UpdatableState
239-
244+
let onlineStats: OnlineStatsInfo?
245+
240246
func body(content: Content) -> some View {
241247
UpdatableView(onRefresh: onRefresh, onLoadMore: onLoadMore,
242-
onScroll: onScroll, state: state) {
248+
onScroll: onScroll, state: state, onlineStats: onlineStats) {
243249
content
244250
}
245251
}

0 commit comments

Comments
 (0)