Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 4 additions & 0 deletions V2er.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

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

/* Begin PBXFileReference section */
28B24CA82EA3460D00F82B2A /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = "<group>"; };
28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStatsInfo.swift; sourceTree = "<group>"; };
4EC32AF129D818FC003A3BD4 /* WebBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowserView.swift; sourceTree = "<group>"; };
5D02BD5E26909146007B6A1B /* LoadmoreIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadmoreIndicatorView.swift; sourceTree = "<group>"; };
5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedInfo.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -581,6 +583,7 @@
5DA2AD3A26C17EFE007FB1EF /* Model */ = {
isa = PBXGroup;
children = (
28B24CAA2EA3561400F82B2A /* OnlineStatsInfo.swift */,
5D3CD31E26D0F5F600B3C2D3 /* BaseModel.swift */,
5D04BF9626C9FB6E0005F7E3 /* FeedInfo.swift */,
5D3CD32026D0F9CC00B3C2D3 /* TabInfo.swift */,
Expand Down Expand Up @@ -887,6 +890,7 @@
5D3CD32126D0F9CC00B3C2D3 /* TabInfo.swift in Sources */,
5D0A513F26E36F15006F3D9B /* FeedDetailActions.swift in Sources */,
5DA2AD3726C17EB9007FB1EF /* AppState.swift in Sources */,
28B24CAB2EA3561400F82B2A /* OnlineStatsInfo.swift in Sources */,
5D71DF59247C155400B53ED4 /* MessagePage.swift in Sources */,
5DA2AD4626C18208007FB1EF /* MeState.swift in Sources */,
5D88D5DA26C200FB00302265 /* FeedReducer.swift in Sources */,
Expand Down
70 changes: 70 additions & 0 deletions V2er/State/DataFlow/Model/OnlineStatsInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// OnlineStatsInfo.swift
// V2er
//
// Created by ghui on 2025/10/18.
// Copyright © 2025 lessmore.io. All rights reserved.
//

import Foundation
import SwiftSoup

public struct OnlineStatsInfo: BaseModel, Codable {
var rawData: String?
var onlineCount: Int = 0
var maxRecord: Int = 0

init() {}

enum CodingKeys: String, CodingKey {
case onlineCount, maxRecord
}

init?(from html: Element?) {
guard let root = html else {
log("OnlineStatsInfo: root element is nil")
return nil
}

// Parse from footer HTML
// Structure: <strong>... 2576 人在线</strong> &nbsp; <span class="fade">最高记录 6679</span>

// Get all text content from the page
let pageText = root.value(.text)

// Extract online count using simple pattern matching
// Pattern: "数字 人在线"
let onlinePattern = "(\\d+)\\s*人在线"
if let regex = try? NSRegularExpression(pattern: onlinePattern) {
let nsText = pageText as NSString
let matches = regex.matches(in: pageText, range: NSRange(location: 0, length: nsText.length))
if let match = matches.first, match.numberOfRanges > 1 {
let numberStr = nsText.substring(with: match.range(at: 1))
onlineCount = Int(numberStr.replacingOccurrences(of: ",", with: "")) ?? 0
log("OnlineStatsInfo: Found online count = \(onlineCount)")
}
}

// Extract max record
let maxPattern = "最高记录\\s+(\\d+)"
if let regex = try? NSRegularExpression(pattern: maxPattern) {
let nsText = pageText as NSString
let matches = regex.matches(in: pageText, range: NSRange(location: 0, length: nsText.length))
if let match = matches.first, match.numberOfRanges > 1 {
let numberStr = nsText.substring(with: match.range(at: 1))
maxRecord = Int(numberStr.replacingOccurrences(of: ",", with: "")) ?? 0
log("OnlineStatsInfo: Found max record = \(maxRecord)")
}
}

// If we didn't find the data, return nil
if onlineCount == 0 {
log("OnlineStatsInfo: Parse failed, onlineCount = 0")
return nil
}
}

func isValid() -> Bool {
return onlineCount > 0
}
}
24 changes: 24 additions & 0 deletions V2er/State/DataFlow/Reducers/FeedReducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ func feedStateReducer(_ state: FeedState, _ action: Action) -> (FeedState, Actio
followingAction = FeedActions.FetchData.Start(isFromFilterChange: true)
case let action as FeedActions.ToggleFilterMenu:
state.showFilterMenu.toggle()
case let action as FeedActions.FetchOnlineStats.Done:
if case .success(let onlineStats) = action.result {
state.onlineStats = onlineStats
log("FeedReducer: Received online stats: \(String(describing: onlineStats))")
} else if case .failure(let error) = action.result {
log("FeedReducer: Failed to fetch online stats: \(error)")
}
default:
break
}
Expand Down Expand Up @@ -138,4 +145,21 @@ struct FeedActions {
var target: Reducer = reducer
}

struct FetchOnlineStats {
struct Start: AwaitAction {
var target: Reducer = reducer

func execute(in store: Store) async {
let result: APIResult<OnlineStatsInfo> = await APIService.shared
.htmlGet(endpoint: .onlineStats)
dispatch(FetchOnlineStats.Done(result: result))
}
}

struct Done: Action {
var target: Reducer = reducer
let result: APIResult<OnlineStatsInfo>
}
}

}
1 change: 1 addition & 0 deletions V2er/State/DataFlow/State/FeedState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ struct FeedState: FluxState {
var selectedTab: Tab = Tab.getSelectedTab()
var showFilterMenu: Bool = false
var scrollToTop: Int = 0 // Trigger scroll to top when changed
var onlineStats: OnlineStatsInfo? = nil
}
4 changes: 4 additions & 0 deletions V2er/State/Networking/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ enum Endpoint {
case starNode(id: String), dailyMission
case checkin, downMyTopic(id: String), pinTopic(id: String)
case balance
case onlineStats
case search
case general(url: String)

Expand Down Expand Up @@ -150,6 +151,9 @@ enum Endpoint {
info.path = "/sticky/topic/\(id)"
case .balance:
info.path = "/balance"
case .onlineStats:
info.path = "/"
info.ua = .web
case let .search:
info.path = "https://www.sov2ex.com/api/search"
case let .general(url):
Expand Down
4 changes: 3 additions & 1 deletion V2er/View/Feed/FeedPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ struct FeedPage: BaseHomePageView {
}
}
}
.updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, max(state.scrollToTop, scrollTop(tab: .feed))) {
.updatable(autoRefresh: state.showProgressView, hasMoreData: state.hasMoreData, max(state.scrollToTop, scrollTop(tab: .feed)), onlineStats: state.onlineStats) {
if AccountState.hasSignIn() {
// Fetch online stats in parallel with feed data
Task { await run(action: FeedActions.FetchOnlineStats.Start()) }
await run(action: FeedActions.FetchData.Start())
}
} loadMore: {
Expand Down
18 changes: 13 additions & 5 deletions V2er/View/Widget/Updatable/HeadIndicatorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,34 @@ struct HeadIndicatorView: View {
var scrollY: CGFloat
@Binding var progress: CGFloat
@Binding var isRefreshing: Bool

var onlineStats: OnlineStatsInfo?

var offset: CGFloat {
return isRefreshing ? (0 - scrollY) : -height
}
init(threshold: CGFloat, progress: Binding<CGFloat>, scrollY: CGFloat,isRefreshing: Binding<Bool>) {

init(threshold: CGFloat, progress: Binding<CGFloat>, scrollY: CGFloat, isRefreshing: Binding<Bool>, onlineStats: OnlineStatsInfo? = nil) {
self.height = threshold
self.scrollY = scrollY
self._progress = progress
self._isRefreshing = isRefreshing
self.onlineStats = onlineStats
}

var body: some View {
Group {
VStack(spacing: 4) {
if progress == 1 || isRefreshing {
ActivityIndicator()
} else {
Image(systemName: "arrow.down")
.font(.title2.weight(.regular))
}

if let stats = onlineStats, stats.isValid() {
Text("\(stats.onlineCount) 人在线")
.font(.caption)
.foregroundColor(.secondaryText)
}
}
.frame(height: height)
.offset(y: offset)
Expand Down
22 changes: 14 additions & 8 deletions V2er/View/Widget/Updatable/UpdatableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct UpdatableView<Content: View>: View {
let damper: Float = 1.2
@State var hapticed = false
let state: UpdatableState
let onlineStats: OnlineStatsInfo?

private var refreshable: Bool {
return onRefresh != nil
Expand All @@ -43,12 +44,14 @@ struct UpdatableView<Content: View>: View {
onLoadMore: LoadMoreAction,
onScroll: ScrollAction?,
state: UpdatableState,
onlineStats: OnlineStatsInfo? = nil,
@ViewBuilder content: () -> Content) {
self.onRefresh = onRefresh
self.onLoadMore = onLoadMore
self.onScroll = onScroll
self.content = content()
self.state = state
self.onlineStats = onlineStats
}

var body: some View {
Expand All @@ -68,7 +71,7 @@ struct UpdatableView<Content: View>: View {
}
.alignmentGuide(.top, computeValue: { d in (self.isRefreshing ? (-self.threshold + scrollY) : 0.0) })
if refreshable {
HeadIndicatorView(threshold: threshold, progress: $progress, scrollY: scrollY, isRefreshing: $isRefreshing)
HeadIndicatorView(threshold: threshold, progress: $progress, scrollY: scrollY, isRefreshing: $isRefreshing, onlineStats: onlineStats)
}
}
}
Expand Down Expand Up @@ -197,37 +200,39 @@ extension View {
public func updatable(autoRefresh: Bool = false,
hasMoreData: Bool = true,
_ scrollToTop: Int = 0,
onlineStats: OnlineStatsInfo? = nil,
refresh: RefreshAction = nil,
loadMore: LoadMoreAction = nil,
onScroll: ScrollAction? = nil) -> some View {
let state = UpdatableState(hasMoreData: hasMoreData, showLoadingView: autoRefresh, scrollToTop: scrollToTop)
return self.modifier(UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state))
return self.modifier(UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: onlineStats))
}

public func updatable(_ state: UpdatableState,
onlineStats: OnlineStatsInfo? = nil,
refresh: RefreshAction = nil,
loadMore: LoadMoreAction = nil,
onScroll: ScrollAction? = nil) -> some View {
let modifier = UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state)
let modifier = UpdatableModifier(onRefresh: refresh, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: onlineStats)
return self.modifier(modifier)
}

public func loadMore(_ state: UpdatableState,
_ loadMore: LoadMoreAction = nil,
onScroll: ScrollAction? = nil) -> some View {
let modifier = UpdatableModifier(onRefresh: nil, onLoadMore: loadMore, onScroll: onScroll, state: state)
let modifier = UpdatableModifier(onRefresh: nil, onLoadMore: loadMore, onScroll: onScroll, state: state, onlineStats: nil)
return self.modifier(modifier)
}

public func loadMore(autoRefresh: Bool = false,
hasMoreData: Bool = true,
_ loadMore: LoadMoreAction = nil,
onScroll: ScrollAction? = nil) -> some View {
self.updatable(autoRefresh: autoRefresh, hasMoreData: hasMoreData, refresh: nil, loadMore: loadMore, onScroll: onScroll)
self.updatable(autoRefresh: autoRefresh, hasMoreData: hasMoreData, onlineStats: nil, refresh: nil, loadMore: loadMore, onScroll: onScroll)
}

public func onScroll(onScroll: ScrollAction?) -> some View {
self.updatable(onScroll: onScroll)
self.updatable(onlineStats: nil, onScroll: onScroll)
}
}

Expand All @@ -236,10 +241,11 @@ struct UpdatableModifier: ViewModifier {
let onLoadMore: LoadMoreAction
let onScroll: ScrollAction?
let state: UpdatableState

let onlineStats: OnlineStatsInfo?

func body(content: Content) -> some View {
UpdatableView(onRefresh: onRefresh, onLoadMore: onLoadMore,
onScroll: onScroll, state: state) {
onScroll: onScroll, state: state, onlineStats: onlineStats) {
content
}
}
Expand Down
Loading