From f0191c1396b9721ddfe6cffbf40a5525caafeb2d Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Thu, 1 Oct 2026 20:23:17 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20=E8=BF=9B=E5=90=A7=E5=85=B3?= =?UTF-8?q?=E6=B3=A8=E8=B4=B4=E5=90=A7=E6=98=BE=E7=A4=BA=E7=AD=89=E7=BA=A7?= =?UTF-8?q?=E4=B8=8E=E4=BB=8A=E6=97=A5=E7=AD=BE=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改用 /c/f/forum/forumGuide 一次取回关注贴吧列表里的 level_id 与 is_sign,在磁贴上显示 Lv.N 与已签角标。 状态请求与原关注列表并行发出,失败时只缺少装饰信息, 不影响吧列表本身。 --- TiebaPure.xcodeproj/project.pbxproj | 4 + .../Core/Auth/SessionExpirationMonitor.swift | 6 + TiebaPure/Core/Network/TiebaAPIService.swift | 7 + TiebaPure/Core/Network/TiebaEndpoint.swift | 5 + TiebaPure/Core/Network/TiebaSocialAPI.swift | 149 ++++++++++++++++++ TiebaPure/Domain/Models/Forum.swift | 16 ++ .../Features/ForumHub/ForumHubView.swift | 90 ++++++++++- TiebaPureTests/FollowedForumStatusTests.swift | 130 +++++++++++++++ 8 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 TiebaPureTests/FollowedForumStatusTests.swift diff --git a/TiebaPure.xcodeproj/project.pbxproj b/TiebaPure.xcodeproj/project.pbxproj index 7d3a83a..5bbccf2 100644 --- a/TiebaPure.xcodeproj/project.pbxproj +++ b/TiebaPure.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 1173DB2C53FF026697D5DEB8 /* OrderedCollectionPersistenceFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2856CD40A8BF8EC9A8007C76 /* OrderedCollectionPersistenceFactory.swift */; }; 143BCEEE525C98950A8466D8 /* ReaderSplitLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9349EF851351C624AE35FF31 /* ReaderSplitLayout.swift */; }; 15DF8B955B68CF4317A10AE7 /* ForumSignTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF457C95754D5EAA87ECEFD /* ForumSignTests.swift */; }; + E1E35B36146715FAD1408C0D /* FollowedForumStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3530125D224F55B435E632F2 /* FollowedForumStatusTests.swift */; }; 15E4532BC1DF75316CA8D95A /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07566334FE43A9F9BF360D89 /* Account.swift */; }; 1765CC21F9E90D5C7D85EFF2 /* TiebaAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC5695CD74141BB65787AB6 /* TiebaAPIService.swift */; }; 1B38ECEBD8AB77434B402F8A /* ForumThreadsRequestKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A1242A6C7E6C6F3740D3E6 /* ForumThreadsRequestKey.swift */; }; @@ -434,6 +435,7 @@ C80CE02414B9F8BD668C519D /* FrsPage_FrsPage.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrsPage_FrsPage.pb.swift; sourceTree = ""; }; CBBF9E6133FA9F395134196F /* SavedThreadMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedThreadMedia.swift; sourceTree = ""; }; CBF457C95754D5EAA87ECEFD /* ForumSignTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForumSignTests.swift; sourceTree = ""; }; + 3530125D224F55B435E632F2 /* FollowedForumStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedForumStatusTests.swift; sourceTree = ""; }; CD2C30123EFF90A5F9BEE7C2 /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; D1180626C13544DCC568FA5B /* AccountThreadFavoritesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountThreadFavoritesTests.swift; sourceTree = ""; }; D31CA90EA6BEF04EEA9AD1C3 /* TiebaContentFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaContentFilter.swift; sourceTree = ""; }; @@ -755,6 +757,7 @@ 5E2FAEA095A18F41BED60B8D /* EmoticonResourceTests.swift */, 3A20C49C14881A792297DFF2 /* FileOrderedCollectionPersistenceTests.swift */, 7F4A700D0BB079E5CEC104C1 /* FixtureDecodingTests.swift */, + 3530125D224F55B435E632F2 /* FollowedForumStatusTests.swift */, CBF457C95754D5EAA87ECEFD /* ForumSignTests.swift */, 9F66E0D4FBBBBF5F6DA8C979 /* HomeNavigationTests.swift */, 99D55E6E9D9C9BE0630B5C89 /* InlineTextReuseTests.swift */, @@ -1306,6 +1309,7 @@ 7670D2DD682E7DA328CABB62 /* EmoticonResourceTests.swift in Sources */, 4E2A3DFCCFD09D57BC62A04F /* FileOrderedCollectionPersistenceTests.swift in Sources */, 34511F43DA6B267EB2816388 /* FixtureDecodingTests.swift in Sources */, + E1E35B36146715FAD1408C0D /* FollowedForumStatusTests.swift in Sources */, 15DF8B955B68CF4317A10AE7 /* ForumSignTests.swift in Sources */, 6B31188D9AA7AE70A00D53EF /* HomeNavigationTests.swift in Sources */, E209C76E1A8310E8F94F6A32 /* InlineTextReuseTests.swift in Sources */, diff --git a/TiebaPure/Core/Auth/SessionExpirationMonitor.swift b/TiebaPure/Core/Auth/SessionExpirationMonitor.swift index 4f423e5..9804b10 100644 --- a/TiebaPure/Core/Auth/SessionExpirationMonitor.swift +++ b/TiebaPure/Core/Auth/SessionExpirationMonitor.swift @@ -215,6 +215,12 @@ struct SessionMonitoringTiebaAPI: TiebaAPIService { } } + func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] { + try await monitored(account: account) { + try await base.followedForumStatuses(account: account) + } + } + func accountThreadFavorites( account: Account, page: Int diff --git a/TiebaPure/Core/Network/TiebaAPIService.swift b/TiebaPure/Core/Network/TiebaAPIService.swift index be54f14..cd55aff 100644 --- a/TiebaPure/Core/Network/TiebaAPIService.swift +++ b/TiebaPure/Core/Network/TiebaAPIService.swift @@ -50,6 +50,7 @@ protocol TiebaAPIService { func forumMembership(account: Account, forum: Forum) async throws -> ForumMembership func setForumFollowed(account: Account, forum: Forum, followed: Bool) async throws -> ForumMembership func signForum(account: Account, forum: Forum) async throws -> ForumSignResult + func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] func accountThreadFavorites(account: Account, page: Int) async throws -> AccountThreadFavoritesPage func setAccountThreadFavorite( account: Account, @@ -79,6 +80,12 @@ extension TiebaAPIService { throw UserProfileMutationError.unsupportedByService } + /// Level and check-in state per followed forum is optional: services without + /// the guide listing reject it rather than reporting a wrong level. + func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] { + throw UserProfileMutationError.unsupportedByService + } + func accountThreadFavorites(account: Account, page: Int) async throws -> AccountThreadFavoritesPage { throw UserProfileMutationError.unsupportedByService } diff --git a/TiebaPure/Core/Network/TiebaEndpoint.swift b/TiebaPure/Core/Network/TiebaEndpoint.swift index e92c13e..f044a8f 100644 --- a/TiebaPure/Core/Network/TiebaEndpoint.swift +++ b/TiebaPure/Core/Network/TiebaEndpoint.swift @@ -11,6 +11,7 @@ enum TiebaEndpoint { case initNickname case webMyInfo case followedForums + case followedForumGuide case forumPageForm case personalized case frsPage @@ -53,6 +54,10 @@ enum TiebaEndpoint { return Self.base.appending(path: "/mo/q/newmoindex") case .followedForums: return Self.appBase.appending(path: "/c/f/forum/getforumlist") + case .followedForumGuide: + // The web "hybrid" guide endpoint: the only followed-forum call + // that also reports the account's per-forum level and check-in. + return Self.base.appending(path: "/c/f/forum/forumGuide") case .forumPageForm: return Self.appBase.appending(path: "/c/f/frs/page") case .personalized: diff --git a/TiebaPure/Core/Network/TiebaSocialAPI.swift b/TiebaPure/Core/Network/TiebaSocialAPI.swift index 2606f3a..72070fa 100644 --- a/TiebaPure/Core/Network/TiebaSocialAPI.swift +++ b/TiebaPure/Core/Network/TiebaSocialAPI.swift @@ -64,6 +64,24 @@ enum TiebaSocialRequestFactory { ] } + static func followedForumGuideFields( + tbs: String, + page: Int, + pageSize: Int = FollowedForumGuidePolicy.pageSize + ) throws -> [String: String] { + let resolvedTBS = tbs.trimmingCharacters(in: .whitespacesAndNewlines) + guard resolvedTBS.isEmpty == false else { throw TiebaMutationError.missingTBS } + // Credentials travel as cookies on this web endpoint, so the factory + // deliberately carries neither BDUSS nor stoken as form fields. + return [ + "tbs": resolvedTBS, + "sort_type": "3", + "call_from": "3", + "page_no": "\(max(page, 1))", + "res_num": "\(max(pageSize, 1))" + ] + } + static func likeFields( account: Account, tbs: String, @@ -329,7 +347,129 @@ struct ForumMembershipResponseDTO: Decodable { } } +struct FollowedForumGuideResponseDTO: Decodable { + struct ForumDTO: Decodable { + var forumID: Int64 + var level: Int + var isSignedToday: Bool + + enum CodingKeys: String, CodingKey { + case forumID = "forum_id" + case level = "level_id" + case isSignedToday = "is_sign" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + forumID = container.flexibleInt64(forKey: .forumID) + level = container.flexibleInt(forKey: .level) + isSignedToday = container.flexibleBool(forKey: .isSignedToday) + } + } + + struct DataDTO: Decodable { + var forums: [ForumDTO] + var hasMore: Bool + + enum CodingKeys: String, CodingKey { + case forums = "like_forum" + case hasMore = "like_forum_has_more" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + forums = try container.decodeIfPresent([ForumDTO].self, forKey: .forums) ?? [] + hasMore = container.flexibleBool(forKey: .hasMore) + } + } + + var errorCode: Int + var errorMessage: String + var data: DataDTO? + + enum CodingKeys: String, CodingKey { + case errorCode = "error_code" + case errorMessage = "error_msg" + case data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + errorCode = container.flexibleInt(forKey: .errorCode) + errorMessage = container.decodeStringIfPresent(forKey: .errorMessage) ?? "" + data = try container.decodeIfPresent(DataDTO.self, forKey: .data) + } +} + +/// Bounds the followed-forum guide pagination. A page of 200 is the largest the +/// endpoint serves, so five pages cover 1000 followed forums — far past any real +/// account — while a malformed `like_forum_has_more` cannot loop forever. +enum FollowedForumGuidePolicy { + static let pageSize = 200 + static let maximumPages = 5 +} + extension TiebaAPI { + /// One followed-forum listing with the account's level and today's check-in + /// state per forum. + /// + /// The level-info endpoint needs one request per forum and never reports + /// whether the check-in is already done, so the hub reads both values from + /// this guide listing instead. + func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] { + var statuses: [FollowedForumStatus] = [] + var page = 1 + + while page <= FollowedForumGuidePolicy.maximumPages { + let response = try await followedForumGuidePage(account: account, page: page) + try TiebaResponseValidator.validate(code: response.errorCode, message: response.errorMessage) + guard let data = response.data else { throw TiebaAPIError.emptyResponse } + + statuses.append(contentsOf: data.forums.compactMap { forum in + guard forum.forumID > 0 else { return nil } + return FollowedForumStatus( + forumID: forum.forumID, + level: max(forum.level, 0), + isSignedToday: forum.isSignedToday + ) + }) + + // An empty page with a sticky "has more" flag would otherwise spin. + guard data.hasMore, data.forums.isEmpty == false else { break } + page += 1 + } + + return statuses + } + + private func followedForumGuidePage( + account: Account, + page: Int + ) async throws -> FollowedForumGuideResponseDTO { + let tbs = try await resolvedClientTBS(for: account) + let fields = try TiebaSocialRequestFactory.followedForumGuideFields( + tbs: tbs, + page: page + ) + return try await client.postForm( + .followedForumGuide, + fields: fields, + headers: [ + "Cookie": account.minimalCookieHeader, + "Subapp-Type": "hybrid" + ], + as: FollowedForumGuideResponseDTO.self + ) + } + + /// Reading the guide needs a valid TBS, but a login normally stores one: + /// refresh only when the stored value is missing. + private func resolvedClientTBS(for account: Account) async throws -> String { + let stored = account.tbs.trimmingCharacters(in: .whitespacesAndNewlines) + guard stored.isEmpty else { return stored } + return try await refreshedClientTBS(for: account) + } + func refreshedClientTBS(for account: Account) async throws -> String { try await refreshedClientTBS(for: account, allowsStoredFallback: true) } @@ -636,4 +776,13 @@ private extension KeyedDecodingContainer { if let value = try? decode(String.self, forKey: key) { return Int64(value) ?? 0 } return 0 } + + func flexibleBool(forKey key: Key) -> Bool { + if let value = try? decode(Bool.self, forKey: key) { return value } + if let value = try? decode(Int.self, forKey: key) { return value != 0 } + if let value = try? decode(String.self, forKey: key) { + return value == "1" || value.lowercased() == "true" + } + return false + } } diff --git a/TiebaPure/Domain/Models/Forum.swift b/TiebaPure/Domain/Models/Forum.swift index 68220ff..37194b8 100644 --- a/TiebaPure/Domain/Models/Forum.swift +++ b/TiebaPure/Domain/Models/Forum.swift @@ -8,3 +8,19 @@ struct Forum: Identifiable, Equatable, Codable, Sendable { var memberCount: Int var threadCount: Int } + +/// The signed-in account's standing inside one followed forum. +/// +/// The followed-forum guide endpoint reports both values per forum, so the +/// hub can show them without one level request per forum. Kept out of `Forum` +/// itself: the forum list itself still comes from the avatar-carrying endpoint, +/// and a status that failed to load must not invent a forum. +struct FollowedForumStatus: Equatable, Sendable, Identifiable { + var forumID: Int64 + /// The account's membership level in this forum (0 when unreported). + var level: Int + /// Whether the account has already checked in today. + var isSignedToday: Bool + + var id: Int64 { forumID } +} diff --git a/TiebaPure/Features/ForumHub/ForumHubView.swift b/TiebaPure/Features/ForumHub/ForumHubView.swift index 5caf965..a7a5a4e 100644 --- a/TiebaPure/Features/ForumHub/ForumHubView.swift +++ b/TiebaPure/Features/ForumHub/ForumHubView.swift @@ -9,6 +9,7 @@ struct ForumHubView: View { @ObservedObject private var recentStore = RecentForumStore.shared @ObservedObject private var blocklistStore = BlocklistStore.shared @State private var followedForums: [Forum] = [] + @State private var followedStatuses: [Int64: FollowedForumStatus] = [:] @State private var isLoadingFollowed = false @State private var didLoadFollowed = false @State private var followedError: String? @@ -17,6 +18,7 @@ struct ForumHubView: View { @State private var splitDetailPath: [ReaderSplitThreadRoute] = [] @State private var requestGeneration = 0 @State private var loadTask: Task<[Forum], Error>? + @State private var statusTask: Task<[FollowedForumStatus], Error>? @State private var isManagingRecentForums = false @State private var showsClearRecentConfirmation = false @State private var showsRecentStorageError = false @@ -146,7 +148,9 @@ struct ForumHubView: View { id: "\(forum.id)-\(forum.name)", title: forum.displayName, avatarURL: forum.avatarURL, - forum: forum + forum: forum, + level: followedStatuses[forum.id]?.level ?? 0, + isSignedToday: followedStatuses[forum.id]?.isSignedToday ?? false ) }, isManaging: false, @@ -223,7 +227,9 @@ struct ForumHubView: View { .onChange(of: account?.sessionIdentity) { _ in requestGeneration += 1 loadTask?.cancel() + statusTask?.cancel() followedForums = [] + followedStatuses = [:] followedError = nil didLoadFollowed = false isLoadingFollowed = false @@ -238,6 +244,7 @@ struct ForumHubView: View { } .onDisappear { loadTask?.cancel() + statusTask?.cancel() requestGeneration += 1 isLoadingFollowed = false } @@ -406,6 +413,7 @@ struct ForumHubView: View { private func loadFollowed(account: Account) async { loadTask?.cancel() + statusTask?.cancel() requestGeneration += 1 let generation = requestGeneration let accountID = account.id @@ -416,6 +424,10 @@ struct ForumHubView: View { do { let task = Task { try await environment.api.followedForums(account: account) } loadTask = task + // Level and check-in come from a second listing. Start it right + // away so both requests overlap instead of serializing. + let statuses = Task { try await environment.api.followedForumStatuses(account: account) } + statusTask = statuses let loaded = try await task.value guard generation == requestGeneration, requestedSession == self.account?.sessionIdentity else { return } @@ -427,10 +439,21 @@ struct ForumHubView: View { accountID: accountID, forums: followedForums ) + // A forum list without a level is still a usable forum list, so a + // failed status read leaves the tiles unadorned instead of empty. + if let reported = try? await statuses.value, + generation == requestGeneration, + requestedSession == self.account?.sessionIdentity { + followedStatuses = Dictionary( + reported.map { ($0.forumID, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + } } catch is CancellationError { guard generation == requestGeneration, requestedSession == self.account?.sessionIdentity else { return } loadTask = nil + statusTask = nil isLoadingFollowed = false return } catch { @@ -441,6 +464,7 @@ struct ForumHubView: View { guard generation == requestGeneration, requestedSession == self.account?.sessionIdentity else { return } loadTask = nil + statusTask = nil isLoadingFollowed = false didLoadFollowed = true } @@ -632,11 +656,36 @@ enum ForumHubTapPolicy { } } +/// The tile's level and check-in state are visual ornaments, so they reach +/// assistive tech as one readable label rather than three loose fragments. +enum ForumTileAccessibilityPolicy { + static func label( + title: String, + level: Int, + isSignedToday: Bool, + isManaging: Bool + ) -> String { + guard isManaging == false else { return title } + var parts = ["进入\(title)"] + if level > 0 { + parts.append("等级\(level)") + } + if isSignedToday { + parts.append("今日已签到") + } + return parts.joined(separator: ",") + } +} + struct ForumTile: Identifiable, Equatable { let id: String let title: String let avatarURL: URL? let forum: Forum + /// The account's level in this forum; 0 when the status read did not report + /// one, in which case no level is shown. + var level: Int = 0 + var isSignedToday: Bool = false } /// Forums read as a wall of small squares rather than a list of rows: the name @@ -679,6 +728,17 @@ private struct ForumTileButton: View { let onOpen: () -> Void let onDelete: (() -> Void)? + /// One spoken label instead of three loose fragments: the level and the + /// check-in mark are only useful to assistive tech as part of this row. + private var accessibilityLabel: String { + ForumTileAccessibilityPolicy.label( + title: tile.title, + level: tile.level, + isSignedToday: tile.isSignedToday, + isManaging: isManaging + ) + } + var body: some View { Button { // Managing is a separate mode: a tap there is almost always aimed @@ -688,18 +748,44 @@ private struct ForumTileButton: View { } label: { VStack(spacing: TiebaPureTheme.Spacing.xs) { AvatarView(url: tile.avatarURL, title: tile.title, size: 52) + // The row already announces the forum name; letting the + // avatar add its own would read the name twice. + .accessibilityHidden(true) + .overlay(alignment: .bottomTrailing) { + if tile.isSignedToday { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(Color.white, Color(uiColor: .systemGreen)) + .accessibilityHidden(true) + } + } Text(tile.title) .font(.caption) .foregroundStyle(.primary) .lineLimit(1) .truncationMode(.tail) + + if tile.level > 0 { + Text("Lv.\(tile.level)") + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, TiebaPureTheme.Spacing.xxs) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: TiebaPureTheme.Radius.chip, style: .continuous) + .fill(TiebaPureTheme.ColorToken.readerSecondarySurface) + ) + .accessibilityHidden(true) + } } .frame(maxWidth: .infinity) .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(isManaging ? tile.title : "进入\(tile.title)") + .accessibilityLabel(accessibilityLabel) .accessibilityIdentifier("forum-hub-forum-row") .overlay(alignment: .topTrailing) { if isManaging, let onDelete { diff --git a/TiebaPureTests/FollowedForumStatusTests.swift b/TiebaPureTests/FollowedForumStatusTests.swift new file mode 100644 index 0000000..fec6bb9 --- /dev/null +++ b/TiebaPureTests/FollowedForumStatusTests.swift @@ -0,0 +1,130 @@ +import XCTest +@testable import TiebaPure + +final class FollowedForumStatusTests: XCTestCase { + func testGuideEndpointTargetsTheWebGuideHost() { + let url = TiebaEndpoint.followedForumGuide.url + + XCTAssertEqual(url.host, "tieba.baidu.com") + XCTAssertEqual(url.path, "/c/f/forum/forumGuide") + XCTAssertNil(url.query) + } + + func testGuideFieldsCarryGuideParametersWithoutCredentials() throws { + let fields = try TiebaSocialRequestFactory.followedForumGuideFields( + tbs: " tbs-value ", + page: 2 + ) + + XCTAssertEqual(fields["tbs"], "tbs-value") + XCTAssertEqual(fields["sort_type"], "3") + XCTAssertEqual(fields["call_from"], "3") + XCTAssertEqual(fields["page_no"], "2") + XCTAssertEqual(fields["res_num"], "\(FollowedForumGuidePolicy.pageSize)") + XCTAssertNil(fields["BDUSS"], "这个网页接口靠 Cookie 传凭据,表单里不应再出现 BDUSS") + XCTAssertNil(fields["sign"]) + } + + func testGuideFieldsClampPageAndPageSize() throws { + let fields = try TiebaSocialRequestFactory.followedForumGuideFields( + tbs: "tbs", + page: 0, + pageSize: 0 + ) + + XCTAssertEqual(fields["page_no"], "1") + XCTAssertEqual(fields["res_num"], "1") + } + + func testGuideFieldsRejectEmptyTBS() { + XCTAssertThrowsError( + try TiebaSocialRequestFactory.followedForumGuideFields(tbs: " ", page: 1) + ) { error in + XCTAssertEqual(error as? TiebaMutationError, .missingTBS) + } + } + + func testGuideResponseDecodesLevelAndCheckInForEveryForum() throws { + let json = """ + { + "error_code": 0, + "error_msg": "", + "data": { + "like_forum": [ + { "forum_id": "4242", "forum_name": "合成吧", "level_id": "12", "is_sign": true }, + { "forum_id": 4343, "forum_name": "另一个吧", "level_id": 3, "is_sign": 0 } + ], + "like_forum_has_more": "0" + } + } + """ + + let response = try JSONDecoder().decode(FollowedForumGuideResponseDTO.self, from: Data(json.utf8)) + + XCTAssertEqual(response.errorCode, 0) + let forums = try XCTUnwrap(response.data?.forums) + XCTAssertEqual(forums.count, 2) + XCTAssertEqual(forums[0].forumID, 4242) + XCTAssertEqual(forums[0].level, 12, "字符串等级也要解出来") + XCTAssertTrue(forums[0].isSignedToday, "布尔签到态也要解出来") + XCTAssertEqual(forums[1].forumID, 4343) + XCTAssertEqual(forums[1].level, 3) + XCTAssertFalse(forums[1].isSignedToday, "数字签到态也要解出来") + XCTAssertEqual(response.data?.hasMore, false) + } + + func testGuideResponseTreatsMissingForumListAsEmpty() throws { + let json = """ + { "error_code": 0, "error_msg": "", "data": { "like_forum_has_more": 1 } } + """ + + let response = try JSONDecoder().decode(FollowedForumGuideResponseDTO.self, from: Data(json.utf8)) + + XCTAssertEqual(response.data?.forums, []) + XCTAssertEqual(response.data?.hasMore, true) + } + + func testGuideResponseSurfacesServiceErrorCode() throws { + let json = """ + { "error_code": 1989, "error_msg": "参数错误", "data": null } + """ + + let response = try JSONDecoder().decode(FollowedForumGuideResponseDTO.self, from: Data(json.utf8)) + + XCTAssertEqual(response.errorCode, 1989) + XCTAssertEqual(response.errorMessage, "参数错误") + XCTAssertNil(response.data) + } + + func testTileLabelReadsLevelAndCheckInAsOneSentence() { + XCTAssertEqual( + ForumTileAccessibilityPolicy.label( + title: "壁纸吧", + level: 12, + isSignedToday: true, + isManaging: false + ), + "进入壁纸吧,等级12,今日已签到" + ) + XCTAssertEqual( + ForumTileAccessibilityPolicy.label( + title: "壁纸吧", + level: 0, + isSignedToday: false, + isManaging: false + ), + "进入壁纸吧", + "没有等级和签到信息时不要念出占位文案" + ) + XCTAssertEqual( + ForumTileAccessibilityPolicy.label( + title: "壁纸吧", + level: 12, + isSignedToday: true, + isManaging: true + ), + "壁纸吧", + "管理模式下的磁贴只念名字,避免和删除按钮冲突" + ) + } +} From 7e3817c103b7e58547f3b570d1f6281d226ce7e2 Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 22:32:12 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(home):=20add=20a=20=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=20/=20=E7=83=AD=E7=82=B9=20paged=20feed=20backed=20by?= =?UTF-8?q?=20hotThreadList?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首页顶部分页:推荐复用原有个性化流,热点读取 /c/f/forum/hotThreadList (cmd=309661) 的 protobuf 端点,显示吧内热帖与子分页。 新增 TiebaHotAPI、HotFeed 与 HotThreadsView;热点响应按实测的线格式解码, 并用逐字段用例固定下来(论坛 id 的变长整数、手写标题字节都由 fixture 覆盖)。 新源文件与生成的 proto 产物注册进 Xcode 工程,条目顺序与 UUID 与 XcodeGen 的输出一致。顺带修掉 FollowedForumStatusTests 里一处非 Equatable 比较。 --- Protos/HotThreadList/HotThreadList.proto | 37 ++ README.md | 4 +- TiebaPure.xcodeproj/project.pbxproj | 28 +- .../Core/Auth/SessionExpirationMonitor.swift | 6 + TiebaPure/Core/Network/TiebaAPI.swift | 2 +- TiebaPure/Core/Network/TiebaAPIService.swift | 7 + TiebaPure/Core/Network/TiebaEndpoint.swift | 7 + TiebaPure/Core/Network/TiebaHotAPI.swift | 83 +++++ .../HotThreadList_HotThreadList.pb.swift | 317 ++++++++++++++++++ TiebaPure/Domain/Models/HotFeed.swift | 20 ++ TiebaPure/Features/Home/HomeView.swift | 67 +++- TiebaPure/Features/Home/HotThreadsView.swift | 251 ++++++++++++++ TiebaPureTests/FollowedForumStatusTests.swift | 2 +- TiebaPureTests/HotThreadListTests.swift | 284 ++++++++++++++++ TiebaPureUITests/TiebaPureUITests.swift | 11 +- scripts/generate-ios-protos.sh | 1 + 16 files changed, 1105 insertions(+), 22 deletions(-) create mode 100644 Protos/HotThreadList/HotThreadList.proto create mode 100644 TiebaPure/Core/Network/TiebaHotAPI.swift create mode 100644 TiebaPure/Core/Protobuf/Generated/HotThreadList_HotThreadList.pb.swift create mode 100644 TiebaPure/Domain/Models/HotFeed.swift create mode 100644 TiebaPure/Features/Home/HotThreadsView.swift create mode 100644 TiebaPureTests/HotThreadListTests.swift diff --git a/Protos/HotThreadList/HotThreadList.proto b/Protos/HotThreadList/HotThreadList.proto new file mode 100644 index 0000000..62655c0 --- /dev/null +++ b/Protos/HotThreadList/HotThreadList.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; + +package tieba.hotThreadList; + +import "CommonRequest.proto"; +import "Error.proto"; +import "ThreadInfo.proto"; + +// Project-maintained projection of the hot-thread (热榜) endpoint. Field numbers +// are wire identifiers and must only change with a matching fixture update. +// Only the fields TiebaPure reads are declared; the topic list and inner tabs +// the endpoint also returns are skipped by Protobuf as unknown fields. + +message FrsTabInfo { + string tab_name = 3; + string tab_code = 8; +} + +message HotThreadListRequestData { + tieba.CommonRequest common = 1; + string tab_id = 2; + string tab_code = 3; +} + +message HotThreadListRequest { + HotThreadListRequestData data = 1; +} + +message HotThreadListResponseData { + repeated tieba.ThreadInfo thread_info = 2; + repeated FrsTabInfo hot_thread_tab_info = 3; +} + +message HotThreadListResponse { + tieba.Error error = 1; + HotThreadListResponseData data = 2; +} \ No newline at end of file diff --git a/README.md b/README.md index 334f315..be52fee 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ **浏览**(无需登录) -- 首页推荐、进吧、吧内列表,「最新 / 精华」分类,可按回复时间或发帖时间排序 +- 首页分页:推荐(个性化推荐流)与热点(贴吧热榜,可切换子分页) +- 进吧、吧内列表,「最新 / 精华」分类,可按回复时间或发帖时间排序 - 搜索主题与回复,吧内搜索 - 帖子详情、楼中楼、图片缩放与保存、视频播放 @@ -28,6 +29,7 @@ - 消息:回复我的、@我的 - 关注或取消关注用户、贴吧,查看关注与粉丝列表 +- 关注的贴吧在「进吧」里直接显示吧内等级和今日是否已签到 - 收藏帖子,收藏保存在贴吧账号里,与官方客户端同步;收藏列表支持搜索、按阅读进度筛选和批量取消 - 贴吧签到:一键为关注的贴吧签到,也可以设置每天第一次打开时自动签到 - 主贴、楼层、楼中楼点赞 diff --git a/TiebaPure.xcodeproj/project.pbxproj b/TiebaPure.xcodeproj/project.pbxproj index 5bbccf2..7d2b229 100644 --- a/TiebaPure.xcodeproj/project.pbxproj +++ b/TiebaPure.xcodeproj/project.pbxproj @@ -22,9 +22,9 @@ 1173DB2C53FF026697D5DEB8 /* OrderedCollectionPersistenceFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2856CD40A8BF8EC9A8007C76 /* OrderedCollectionPersistenceFactory.swift */; }; 143BCEEE525C98950A8466D8 /* ReaderSplitLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9349EF851351C624AE35FF31 /* ReaderSplitLayout.swift */; }; 15DF8B955B68CF4317A10AE7 /* ForumSignTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF457C95754D5EAA87ECEFD /* ForumSignTests.swift */; }; - E1E35B36146715FAD1408C0D /* FollowedForumStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3530125D224F55B435E632F2 /* FollowedForumStatusTests.swift */; }; 15E4532BC1DF75316CA8D95A /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07566334FE43A9F9BF360D89 /* Account.swift */; }; 1765CC21F9E90D5C7D85EFF2 /* TiebaAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEC5695CD74141BB65787AB6 /* TiebaAPIService.swift */; }; + 1A9A76A8D207ED2F5571F1EF /* HotFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = B19C95736E6F6922D110E2A8 /* HotFeed.swift */; }; 1B38ECEBD8AB77434B402F8A /* ForumThreadsRequestKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67A1242A6C7E6C6F3740D3E6 /* ForumThreadsRequestKey.swift */; }; 1C53CA119B8A4874452D2F6B /* ContentDraftFilePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFE6592A34B15BF182198BFA /* ContentDraftFilePersistenceTests.swift */; }; 1D73C1F9246A5740B5D63601 /* LogoutCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6B235101269A6DE9BEB53A /* LogoutCoordinator.swift */; }; @@ -44,6 +44,7 @@ 32FB7209CF53027C77B378A7 /* TiebaThreadStoreAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F256CCF10CED308966BFC23 /* TiebaThreadStoreAPI.swift */; }; 3350633EF8736792C66DA2B4 /* MeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D671D758CEF85777E7C7D8E4 /* MeView.swift */; }; 34511F43DA6B267EB2816388 /* FixtureDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4A700D0BB079E5CEC104C1 /* FixtureDecodingTests.swift */; }; + 34F4E2B21AEED679D02A333E /* HotThreadListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD788A408EE7E4022C73211 /* HotThreadListTests.swift */; }; 35D97F8C9E055B516F478254 /* TiebaForumSignAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722158AB6808A4470B0BDE41 /* TiebaForumSignAPI.swift */; }; 35DB68280B2FAF0F4C1493B9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 37E6671F9A9A069EADCFAFB9 /* PrivacyInfo.xcprivacy */; }; 3788E594CA3F66F7C738E184 /* ThreadPageMainPostPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D748C5A77D226EAD9A7EEB85 /* ThreadPageMainPostPolicyTests.swift */; }; @@ -98,10 +99,12 @@ 6A3F2F36CE6C23941BD07F7E /* MediaPreviewPresentationArbiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A89193F661C5F61F3470AC /* MediaPreviewPresentationArbiter.swift */; }; 6B31188D9AA7AE70A00D53EF /* HomeNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F66E0D4FBBBBF5F6DA8C979 /* HomeNavigationTests.swift */; }; 6BF8CB32442EA2DD70E1BEE9 /* SocialRelationshipStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72A829A6D2ABD0F1D55D90E6 /* SocialRelationshipStateTests.swift */; }; + 6FA2D3AF5E89699B2CE4B308 /* FollowedForumStatusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55A7D6595D3A08623BD6998A /* FollowedForumStatusTests.swift */; }; 7189EC19091324F1EF6886C5 /* OrderedCollectionPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFC583D811BB2682A40FF679 /* OrderedCollectionPersistence.swift */; }; 71CE2CA03E418210ED2CCF8B /* CapsuleLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 994E9D3128F82A0683DA39E0 /* CapsuleLabel.swift */; }; 7364CD4DC533CA94A061941E /* CompatibleUIFoundationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16EEFF870AC95D5BA9A0AABD /* CompatibleUIFoundationTests.swift */; }; 736CA9D7B75258F513F36463 /* TiebaPureTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C25F6DDA828E44F9B5A0E5D9 /* TiebaPureTheme.swift */; }; + 76026EEB99DF530AF2195462 /* TiebaHotAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAF031BCC6CB1F2182077E3E /* TiebaHotAPI.swift */; }; 76335BCC2EF12774CA12ACC1 /* ContentSubmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6FB0DE22872019BC145C2AF /* ContentSubmission.swift */; }; 7670D2DD682E7DA328CABB62 /* EmoticonResourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E2FAEA095A18F41BED60B8D /* EmoticonResourceTests.swift */; }; 78CB3ACAE3BBCC622A4F0BDE /* ExternalRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63DFA01B546626F59DF261F1 /* ExternalRoute.swift */; }; @@ -189,6 +192,7 @@ DA522E7CE4A6AF749533B6C3 /* VoiceAudioClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = E442EEBED99D57F019D339D0 /* VoiceAudioClient.swift */; }; DB2E0B012007D19C5D4F3B9D /* MediaGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 277C554E042BEC92DDCC065C /* MediaGridView.swift */; }; DB3881EB1B055DE5583FBE65 /* Post.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD2C30123EFF90A5F9BEE7C2 /* Post.swift */; }; + DBBBFF168ACE83D13C1F6054 /* HotThreadList_HotThreadList.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 211C5275B02ECB525D237083 /* HotThreadList_HotThreadList.pb.swift */; }; DDD7176346D4B9BFD3A834BD /* SocialRelationshipState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9718D80CC5DA7EC934BCFEB /* SocialRelationshipState.swift */; }; DE64CDA4FA60F57188F7BC53 /* UserProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A38AD5D710823BA66CAE23C /* UserProfileTests.swift */; }; DEA70B990FBB4F0B3A27998B /* SearchRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93DFFE894245E0C72DB94FFD /* SearchRoute.swift */; }; @@ -199,6 +203,7 @@ E3CF1C9A62133316FF6D93E2 /* UserProfileMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65F7C671CDA66E5F18CEC24 /* UserProfileMapper.swift */; }; E4BC7D1B10FF88D762725986 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E2287E1195B4261B0B88E8 /* SettingsView.swift */; }; E6FCA5689969E1EE7020CF44 /* ReadingSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432F97822473B7D42DB148EC /* ReadingSettingsView.swift */; }; + E8EBAA96255CA7A82C08DBD7 /* HotThreadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6798D74B94DFC3C6E8320452 /* HotThreadsView.swift */; }; EAADABB00751D25DB084C26D /* TiebaHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65BFE5160CFC6F1F12CEF /* TiebaHTTPClient.swift */; }; EBD1AC0F607CBF4865A92A2A /* SessionExpirationMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A578DF23FC437057492F4BE /* SessionExpirationMonitor.swift */; }; ECDE960EDA7E9E0E186B2D3C /* ExternalRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63DFA01B546626F59DF261F1 /* ExternalRoute.swift */; }; @@ -290,6 +295,7 @@ 1E3321457BFA9611243ABF93 /* TiebaURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaURL.swift; sourceTree = ""; }; 1E53AE6B81F63DC0CDEC38B8 /* ShortPullRefresh.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortPullRefresh.swift; sourceTree = ""; }; 1E72CA28607562B62AC8C5CD /* ContentComposerPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentComposerPresentation.swift; sourceTree = ""; }; + 211C5275B02ECB525D237083 /* HotThreadList_HotThreadList.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotThreadList_HotThreadList.pb.swift; sourceTree = ""; }; 221856558BEBEBF7F21F19A9 /* Error.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Error.pb.swift; sourceTree = ""; }; 22D652931311ACE40A9A096D /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; 2345ABFFDDC5D3B04B88A017 /* ThreadInfo.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadInfo.pb.swift; sourceTree = ""; }; @@ -336,11 +342,13 @@ 475452DA26606C9EEF2982D2 /* ReaderStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderStateView.swift; sourceTree = ""; }; 47A8570B3E26BDD92BF4524C /* TiebaMessageAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaMessageAPI.swift; sourceTree = ""; }; 481EB2A4D9C019B333364901 /* PersistenceBoundaryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceBoundaryTests.swift; sourceTree = ""; }; + 4AD788A408EE7E4022C73211 /* HotThreadListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotThreadListTests.swift; sourceTree = ""; }; 4EF25E73F880C6AD77740631 /* TiebaEmoticon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaEmoticon.swift; sourceTree = ""; }; 511250E8EAC9A3A1E00A990B /* PersistencePrimitives.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistencePrimitives.swift; sourceTree = ""; }; 522AA51FA729818AC890CD19 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = ""; }; 532EEE9965373A3008560F53 /* PbFloor_PbFloorRequest.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PbFloor_PbFloorRequest.pb.swift; sourceTree = ""; }; 53A22DDA746D43724E44B7D2 /* TiebaPureSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaPureSmokeTests.swift; sourceTree = ""; }; + 55A7D6595D3A08623BD6998A /* FollowedForumStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedForumStatusTests.swift; sourceTree = ""; }; 57251BD2DFECD14E39C9442A /* ZhiBoInfoTW.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZhiBoInfoTW.pb.swift; sourceTree = ""; }; 57D95CB06B18A5C26C9E618F /* ContentSubmission.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSubmission.pb.swift; sourceTree = ""; }; 582A83F679EC3456CD6D08E4 /* BlocklistSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlocklistSettingsView.swift; sourceTree = ""; }; @@ -360,6 +368,7 @@ 6443B9D2D0368D0B870565A7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 67285F50B29BA765B74B5271 /* SwiftDataOrderedCollectionPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataOrderedCollectionPersistence.swift; sourceTree = ""; }; 674313EBDA3BABF79049EF20 /* AvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarView.swift; sourceTree = ""; }; + 6798D74B94DFC3C6E8320452 /* HotThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotThreadsView.swift; sourceTree = ""; }; 67A1242A6C7E6C6F3740D3E6 /* ForumThreadsRequestKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForumThreadsRequestKey.swift; sourceTree = ""; }; 68FBDB720D1FCC2A3138B0CC /* FollowedUsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedUsersView.swift; sourceTree = ""; }; 6A68DE1DD356B7AC4AB669F8 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; @@ -409,12 +418,14 @@ A87CD00DD298F86614F66AAD /* Lbs.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lbs.pb.swift; sourceTree = ""; }; A8FC3906D642656961F9DAE4 /* ContentSubmissionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSubmissionCoordinator.swift; sourceTree = ""; }; AABE9716DCC2C5720A003041 /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; + AAF031BCC6CB1F2182077E3E /* TiebaHotAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaHotAPI.swift; sourceTree = ""; }; AAFD6B34343B4446671A80AA /* AccountThreadFavoritesLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountThreadFavoritesLoader.swift; sourceTree = ""; }; AC28DAC5B04603ABECBEBF97 /* TiebaPostingBootstrap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaPostingBootstrap.swift; sourceTree = ""; }; AEC5695CD74141BB65787AB6 /* TiebaAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaAPIService.swift; sourceTree = ""; }; AF02A66EF1B9089518F9ED99 /* ImageViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewer.swift; sourceTree = ""; }; AFF65BFE5160CFC6F1F12CEF /* TiebaHTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaHTTPClient.swift; sourceTree = ""; }; B0B960AF4A983D18E1496538 /* SavedThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedThreadsView.swift; sourceTree = ""; }; + B19C95736E6F6922D110E2A8 /* HotFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotFeed.swift; sourceTree = ""; }; B3312A539F9532E1B21FD622 /* AppPosInfo.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPosInfo.pb.swift; sourceTree = ""; }; B55649E6AEA1AD2EF4813452 /* TiebaUserProfileAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaUserProfileAPI.swift; sourceTree = ""; }; B571B662CFFFF46F507E2FC5 /* TiebaPureOpenIn.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = TiebaPureOpenIn.appex; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -435,7 +446,6 @@ C80CE02414B9F8BD668C519D /* FrsPage_FrsPage.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrsPage_FrsPage.pb.swift; sourceTree = ""; }; CBBF9E6133FA9F395134196F /* SavedThreadMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedThreadMedia.swift; sourceTree = ""; }; CBF457C95754D5EAA87ECEFD /* ForumSignTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForumSignTests.swift; sourceTree = ""; }; - 3530125D224F55B435E632F2 /* FollowedForumStatusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedForumStatusTests.swift; sourceTree = ""; }; CD2C30123EFF90A5F9BEE7C2 /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; D1180626C13544DCC568FA5B /* AccountThreadFavoritesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountThreadFavoritesTests.swift; sourceTree = ""; }; D31CA90EA6BEF04EEA9AD1C3 /* TiebaContentFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaContentFilter.swift; sourceTree = ""; }; @@ -518,6 +528,7 @@ 07C158046155BBD6748519CF /* TiebaContentSubmissionAPI.swift */, 33822A8A48EC0393DD592DDA /* TiebaEndpoint.swift */, 722158AB6808A4470B0BDE41 /* TiebaForumSignAPI.swift */, + AAF031BCC6CB1F2182077E3E /* TiebaHotAPI.swift */, AFF65BFE5160CFC6F1F12CEF /* TiebaHTTPClient.swift */, 47A8570B3E26BDD92BF4524C /* TiebaMessageAPI.swift */, AC28DAC5B04603ABECBEBF97 /* TiebaPostingBootstrap.swift */, @@ -565,6 +576,7 @@ 57D95CB06B18A5C26C9E618F /* ContentSubmission.pb.swift */, 221856558BEBEBF7F21F19A9 /* Error.pb.swift */, C80CE02414B9F8BD668C519D /* FrsPage_FrsPage.pb.swift */, + 211C5275B02ECB525D237083 /* HotThreadList_HotThreadList.pb.swift */, A87CD00DD298F86614F66AAD /* Lbs.pb.swift */, FD087D4F0E7FA8246D26D8CB /* Media.pb.swift */, EF7D805CD76F1E84BC119F98 /* Page.pb.swift */, @@ -633,6 +645,7 @@ 5E92E90BB801373B85685DDD /* Forum.swift */, 7E4CFA3390403419B8C3E890 /* ForumSignSettings.swift */, 93E0B943190782C7374C3690 /* ForumThreadCategory.swift */, + B19C95736E6F6922D110E2A8 /* HotFeed.swift */, 8B097E875FE8B825D77AD05F /* LocalThreadLibrary.swift */, EFC583D811BB2682A40FF679 /* OrderedCollectionPersistence.swift */, 2856CD40A8BF8EC9A8007C76 /* OrderedCollectionPersistenceFactory.swift */, @@ -757,9 +770,10 @@ 5E2FAEA095A18F41BED60B8D /* EmoticonResourceTests.swift */, 3A20C49C14881A792297DFF2 /* FileOrderedCollectionPersistenceTests.swift */, 7F4A700D0BB079E5CEC104C1 /* FixtureDecodingTests.swift */, - 3530125D224F55B435E632F2 /* FollowedForumStatusTests.swift */, + 55A7D6595D3A08623BD6998A /* FollowedForumStatusTests.swift */, CBF457C95754D5EAA87ECEFD /* ForumSignTests.swift */, 9F66E0D4FBBBBF5F6DA8C979 /* HomeNavigationTests.swift */, + 4AD788A408EE7E4022C73211 /* HotThreadListTests.swift */, 99D55E6E9D9C9BE0630B5C89 /* InlineTextReuseTests.swift */, 1781B1F0906AC36975BFDE9F /* LegacyScrollTelemetryTests.swift */, DE6967B15A66331DA9F947F0 /* MessageAPITests.swift */, @@ -934,6 +948,7 @@ isa = PBXGroup; children = ( 6B9769DFBF55471E036C585C /* HomeView.swift */, + 6798D74B94DFC3C6E8320452 /* HotThreadsView.swift */, ); path = Home; sourceTree = ""; @@ -1183,6 +1198,9 @@ 8CF348082F0201BCE9ED6E27 /* ForumThreadsView.swift in Sources */, 0E9AD304E9D62F985EBE1408 /* FrsPage_FrsPage.pb.swift in Sources */, 437B6FDDA2747BECF4DB14FD /* HomeView.swift in Sources */, + 1A9A76A8D207ED2F5571F1EF /* HotFeed.swift in Sources */, + DBBBFF168ACE83D13C1F6054 /* HotThreadList_HotThreadList.pb.swift in Sources */, + E8EBAA96255CA7A82C08DBD7 /* HotThreadsView.swift in Sources */, B152F8A68F8CB2ACBCD895AD /* ImageDownloadService.swift in Sources */, 4336E79CF69F763DE348DB22 /* ImageViewer.swift in Sources */, CCA17993A164DE84409AF929 /* InteractiveNavigationPop.swift in Sources */, @@ -1257,6 +1275,7 @@ D7C5F5C2AC1C00249AD9BC1E /* TiebaEndpoint.swift in Sources */, 35D97F8C9E055B516F478254 /* TiebaForumSignAPI.swift in Sources */, EAADABB00751D25DB084C26D /* TiebaHTTPClient.swift in Sources */, + 76026EEB99DF530AF2195462 /* TiebaHotAPI.swift in Sources */, 2450C4AB99C06D9CABEC5EBE /* TiebaMessageAPI.swift in Sources */, 7A6AB3811639046A81C2CB53 /* TiebaPostingBootstrap.swift in Sources */, B5EC9216076BE5E4C2EF8721 /* TiebaPureApp.swift in Sources */, @@ -1309,9 +1328,10 @@ 7670D2DD682E7DA328CABB62 /* EmoticonResourceTests.swift in Sources */, 4E2A3DFCCFD09D57BC62A04F /* FileOrderedCollectionPersistenceTests.swift in Sources */, 34511F43DA6B267EB2816388 /* FixtureDecodingTests.swift in Sources */, - E1E35B36146715FAD1408C0D /* FollowedForumStatusTests.swift in Sources */, + 6FA2D3AF5E89699B2CE4B308 /* FollowedForumStatusTests.swift in Sources */, 15DF8B955B68CF4317A10AE7 /* ForumSignTests.swift in Sources */, 6B31188D9AA7AE70A00D53EF /* HomeNavigationTests.swift in Sources */, + 34F4E2B21AEED679D02A333E /* HotThreadListTests.swift in Sources */, E209C76E1A8310E8F94F6A32 /* InlineTextReuseTests.swift in Sources */, D774D89D5189D8CB9A1D877B /* LegacyScrollTelemetryTests.swift in Sources */, 80D70D09DAD45ED618094984 /* MessageAPITests.swift in Sources */, diff --git a/TiebaPure/Core/Auth/SessionExpirationMonitor.swift b/TiebaPure/Core/Auth/SessionExpirationMonitor.swift index 9804b10..94b1964 100644 --- a/TiebaPure/Core/Auth/SessionExpirationMonitor.swift +++ b/TiebaPure/Core/Auth/SessionExpirationMonitor.swift @@ -221,6 +221,12 @@ struct SessionMonitoringTiebaAPI: TiebaAPIService { } } + func hotThreads(account: Account?, tabCode: String) async throws -> HotFeed { + try await monitored(account: account) { + try await base.hotThreads(account: account, tabCode: tabCode) + } + } + func accountThreadFavorites( account: Account, page: Int diff --git a/TiebaPure/Core/Network/TiebaAPI.swift b/TiebaPure/Core/Network/TiebaAPI.swift index a99f138..f1347da 100644 --- a/TiebaPure/Core/Network/TiebaAPI.swift +++ b/TiebaPure/Core/Network/TiebaAPI.swift @@ -620,7 +620,7 @@ extension TiebaAPI { || TiebaProtobufErrorClassifier.isDecodeFailure(error) } - private func validateTiebaError(_ error: Tieba_Error) throws { + func validateTiebaError(_ error: Tieba_Error) throws { try validateResponseCode( Int(error.errorCode), message: error.userMsg.isEmpty ? error.errorMsg : error.userMsg diff --git a/TiebaPure/Core/Network/TiebaAPIService.swift b/TiebaPure/Core/Network/TiebaAPIService.swift index cd55aff..6506d33 100644 --- a/TiebaPure/Core/Network/TiebaAPIService.swift +++ b/TiebaPure/Core/Network/TiebaAPIService.swift @@ -51,6 +51,7 @@ protocol TiebaAPIService { func setForumFollowed(account: Account, forum: Forum, followed: Bool) async throws -> ForumMembership func signForum(account: Account, forum: Forum) async throws -> ForumSignResult func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] + func hotThreads(account: Account?, tabCode: String) async throws -> HotFeed func accountThreadFavorites(account: Account, page: Int) async throws -> AccountThreadFavoritesPage func setAccountThreadFavorite( account: Account, @@ -86,6 +87,12 @@ extension TiebaAPIService { throw UserProfileMutationError.unsupportedByService } + /// The hot-thread tab is optional: services without that endpoint reject it + /// so the tab can show an unavailable state instead of an empty list. + func hotThreads(account: Account?, tabCode: String) async throws -> HotFeed { + throw UserProfileMutationError.unsupportedByService + } + func accountThreadFavorites(account: Account, page: Int) async throws -> AccountThreadFavoritesPage { throw UserProfileMutationError.unsupportedByService } diff --git a/TiebaPure/Core/Network/TiebaEndpoint.swift b/TiebaPure/Core/Network/TiebaEndpoint.swift index f044a8f..8129b1b 100644 --- a/TiebaPure/Core/Network/TiebaEndpoint.swift +++ b/TiebaPure/Core/Network/TiebaEndpoint.swift @@ -14,6 +14,7 @@ enum TiebaEndpoint { case followedForumGuide case forumPageForm case personalized + case hotThreadList case frsPage case pbPage case pbFloor @@ -64,6 +65,12 @@ enum TiebaEndpoint { return Self.base .appending(path: "/c/f/excellent/personalized") .appending(queryItems: [.init(name: "cmd", value: "309264")]) + case .hotThreadList: + // The hot-thread (热榜) listing behind the app's 热点 tab. An empty + // tab code asks for the default tab plus the tab list itself. + return Self.base + .appending(path: "/c/f/forum/hotThreadList") + .appending(queryItems: [.init(name: "cmd", value: "309661")]) case .frsPage: return Self.base .appending(path: "/c/f/frs/page") diff --git a/TiebaPure/Core/Network/TiebaHotAPI.swift b/TiebaPure/Core/Network/TiebaHotAPI.swift new file mode 100644 index 0000000..4a0a7de --- /dev/null +++ b/TiebaPure/Core/Network/TiebaHotAPI.swift @@ -0,0 +1,83 @@ +import Foundation + +enum TiebaHotRequestFactory { + /// The hot-thread endpoint answers with the default tab when the tab code is + /// empty — together with the tab list — and with one specific tab otherwise, + /// so the tab id stays fixed and the code is the only variable part. + static func request( + account: Account?, + tabCode: String, + requestBuilder: TiebaRequestBuilder + ) -> Tieba_HotThreadList_HotThreadListRequest { + var requestData = Tieba_HotThreadList_HotThreadListRequestData() + requestData.common = requestBuilder.common(account: account) + requestData.tabID = "1" + requestData.tabCode = tabCode + + var request = Tieba_HotThreadList_HotThreadListRequest() + request.data = requestData + return request + } +} + +enum HotFeedMapper { + static func makeFeed( + from data: Tieba_HotThreadList_HotThreadListResponseData + ) -> HotFeed { + HotFeed( + tabs: data.hotThreadTabInfo.compactMap { tab in + // A tab without a code cannot be requested, and a tab without a + // name has nothing to show on the tab bar. + guard tab.tabCode.isEmpty == false, tab.tabName.isEmpty == false else { return nil } + return HotTab(code: tab.tabCode, name: tab.tabName) + }, + threads: dedupedByID( + data.threadInfo + .filter(TiebaContentFilter.shouldMap(thread:)) + .map { ThreadMapper.fromThreadInfo($0, usersByID: [:]) } + ) + ) + } + + /// The same thread can appear twice in one listing (a pinned row repeated at + /// the top of a sub-tab), and duplicate row identities break list diffing. + private static func dedupedByID(_ threads: [ThreadSummary]) -> [ThreadSummary] { + var seen = Set() + return threads.filter { seen.insert($0.id).inserted } + } +} + +extension TiebaAPI { + /// 热点 (hot threads) for one sub-tab of the home hot-thread tab. + /// + /// An empty `tabCode` asks for the service's default tab and the tab list, + /// which is how the tab bootstraps its own sub-tabs before the user picks + /// one. Threads are filtered and mapped exactly like the personalized feed + /// so both tabs share one row presentation. + func hotThreads(account: Account?, tabCode: String) async throws -> HotFeed { + let request = TiebaHotRequestFactory.request( + account: account, + tabCode: tabCode, + requestBuilder: requestBuilder + ) + let multipart = try requestBuilder.multipart( + protobuf: request, + account: account, + includeSToken: false + ) + let response = try await client.postProtobuf( + .hotThreadList, + body: multipart.body, + contentType: multipart.contentType, + headers: [ + "X-BD-DATA-TYPE": "protobuf", + "Cookie": "ka=open" + ], + as: Tieba_HotThreadList_HotThreadListResponse.self + ) + + try validateTiebaError(response.error) + guard response.hasData else { throw TiebaAPIError.emptyResponse } + return HotFeedMapper.makeFeed(from: response.data) + } +} \ No newline at end of file diff --git a/TiebaPure/Core/Protobuf/Generated/HotThreadList_HotThreadList.pb.swift b/TiebaPure/Core/Protobuf/Generated/HotThreadList_HotThreadList.pb.swift new file mode 100644 index 0000000..cb8c317 --- /dev/null +++ b/TiebaPure/Core/Protobuf/Generated/HotThreadList_HotThreadList.pb.swift @@ -0,0 +1,317 @@ +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: HotThreadList/HotThreadList.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate nonisolated struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +nonisolated struct Tieba_HotThreadList_FrsTabInfo: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var tabName: String = String() + + var tabCode: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Tieba_HotThreadList_HotThreadListRequestData: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var common: Tieba_CommonRequest { + get {_common ?? Tieba_CommonRequest()} + set {_common = newValue} + } + /// Returns true if `common` has been explicitly set. + var hasCommon: Bool {self._common != nil} + /// Clears the value of `common`. Subsequent reads from it will return its default value. + mutating func clearCommon() {self._common = nil} + + var tabID: String = String() + + var tabCode: String = String() + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _common: Tieba_CommonRequest? = nil +} + +nonisolated struct Tieba_HotThreadList_HotThreadListRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var data: Tieba_HotThreadList_HotThreadListRequestData { + get {_data ?? Tieba_HotThreadList_HotThreadListRequestData()} + set {_data = newValue} + } + /// Returns true if `data` has been explicitly set. + var hasData: Bool {self._data != nil} + /// Clears the value of `data`. Subsequent reads from it will return its default value. + mutating func clearData() {self._data = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _data: Tieba_HotThreadList_HotThreadListRequestData? = nil +} + +nonisolated struct Tieba_HotThreadList_HotThreadListResponseData: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var threadInfo: [Tieba_ThreadInfo] = [] + + var hotThreadTabInfo: [Tieba_HotThreadList_FrsTabInfo] = [] + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +nonisolated struct Tieba_HotThreadList_HotThreadListResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var error: Tieba_Error { + get {_error ?? Tieba_Error()} + set {_error = newValue} + } + /// Returns true if `error` has been explicitly set. + var hasError: Bool {self._error != nil} + /// Clears the value of `error`. Subsequent reads from it will return its default value. + mutating func clearError() {self._error = nil} + + var data: Tieba_HotThreadList_HotThreadListResponseData { + get {_data ?? Tieba_HotThreadList_HotThreadListResponseData()} + set {_data = newValue} + } + /// Returns true if `data` has been explicitly set. + var hasData: Bool {self._data != nil} + /// Clears the value of `data`. Subsequent reads from it will return its default value. + mutating func clearData() {self._data = nil} + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} + + fileprivate var _error: Tieba_Error? = nil + fileprivate var _data: Tieba_HotThreadList_HotThreadListResponseData? = nil +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +fileprivate nonisolated let _protobuf_package = "tieba.hotThreadList" + +nonisolated extension Tieba_HotThreadList_FrsTabInfo: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".FrsTabInfo" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{4}\u{3}tab_name\0\u{4}\u{5}tab_code\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 3: try { try decoder.decodeSingularStringField(value: &self.tabName) }() + case 8: try { try decoder.decodeSingularStringField(value: &self.tabCode) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.tabName.isEmpty { + try visitor.visitSingularStringField(value: self.tabName, fieldNumber: 3) + } + if !self.tabCode.isEmpty { + try visitor.visitSingularStringField(value: self.tabCode, fieldNumber: 8) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Tieba_HotThreadList_FrsTabInfo, rhs: Tieba_HotThreadList_FrsTabInfo) -> Bool { + if lhs.tabName != rhs.tabName {return false} + if lhs.tabCode != rhs.tabCode {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Tieba_HotThreadList_HotThreadListRequestData: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".HotThreadListRequestData" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}common\0\u{3}tab_id\0\u{3}tab_code\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._common) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.tabID) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.tabCode) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case/if branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._common { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + if !self.tabID.isEmpty { + try visitor.visitSingularStringField(value: self.tabID, fieldNumber: 2) + } + if !self.tabCode.isEmpty { + try visitor.visitSingularStringField(value: self.tabCode, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Tieba_HotThreadList_HotThreadListRequestData, rhs: Tieba_HotThreadList_HotThreadListRequestData) -> Bool { + if lhs._common != rhs._common {return false} + if lhs.tabID != rhs.tabID {return false} + if lhs.tabCode != rhs.tabCode {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Tieba_HotThreadList_HotThreadListRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".HotThreadListRequest" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}data\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._data) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case/if branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._data { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Tieba_HotThreadList_HotThreadListRequest, rhs: Tieba_HotThreadList_HotThreadListRequest) -> Bool { + if lhs._data != rhs._data {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Tieba_HotThreadList_HotThreadListResponseData: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".HotThreadListResponseData" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{4}\u{2}thread_info\0\u{3}hot_thread_tab_info\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 2: try { try decoder.decodeRepeatedMessageField(value: &self.threadInfo) }() + case 3: try { try decoder.decodeRepeatedMessageField(value: &self.hotThreadTabInfo) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if !self.threadInfo.isEmpty { + try visitor.visitRepeatedMessageField(value: self.threadInfo, fieldNumber: 2) + } + if !self.hotThreadTabInfo.isEmpty { + try visitor.visitRepeatedMessageField(value: self.hotThreadTabInfo, fieldNumber: 3) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Tieba_HotThreadList_HotThreadListResponseData, rhs: Tieba_HotThreadList_HotThreadListResponseData) -> Bool { + if lhs.threadInfo != rhs.threadInfo {return false} + if lhs.hotThreadTabInfo != rhs.hotThreadTabInfo {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Tieba_HotThreadList_HotThreadListResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".HotThreadListResponse" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}error\0\u{1}data\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularMessageField(value: &self._error) }() + case 2: try { try decoder.decodeSingularMessageField(value: &self._data) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case/if branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if let v = self._error { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try { if let v = self._data { + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Tieba_HotThreadList_HotThreadListResponse, rhs: Tieba_HotThreadList_HotThreadListResponse) -> Bool { + if lhs._error != rhs._error {return false} + if lhs._data != rhs._data {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/TiebaPure/Domain/Models/HotFeed.swift b/TiebaPure/Domain/Models/HotFeed.swift new file mode 100644 index 0000000..273b38d --- /dev/null +++ b/TiebaPure/Domain/Models/HotFeed.swift @@ -0,0 +1,20 @@ +import Foundation + +/// The 热点 (hot-thread) tab as one response: the service's own sub-tabs and +/// the hot threads themselves. +/// +/// The tab list comes back on every request, so switching sub-tabs keeps the +/// same set of tabs without a second discovery call. +struct HotFeed: Equatable, Sendable { + var tabs: [HotTab] = [] + var threads: [ThreadSummary] = [] +} + +/// One sub-tab of the hot-thread listing. An empty code means the service's +/// default tab, which is how the tab asks for the first page. +struct HotTab: Identifiable, Equatable, Sendable { + var code: String + var name: String + + var id: String { code } +} \ No newline at end of file diff --git a/TiebaPure/Features/Home/HomeView.swift b/TiebaPure/Features/Home/HomeView.swift index 24a66f1..1bef120 100644 --- a/TiebaPure/Features/Home/HomeView.swift +++ b/TiebaPure/Features/Home/HomeView.swift @@ -12,6 +12,7 @@ struct HomeView: View { @ObservedObject private var blocklistStore = BlocklistStore.shared @State private var activeSearch: SearchRoute? + @State private var feedSegment: HomeFeedSegment = .recommended @State private var threads: [ThreadSummary] = [] @State private var page = 1 @State private var hasMore = true @@ -80,20 +81,9 @@ struct HomeView: View { } private var feedColumn: some View { - ScrollViewReader { scrollProxy in - refreshableScrollView { - feedContent - } - .onChange(of: scrollToTopRequest) { _ in - // Feed changes during an animated scroll-to-top can make the - // lazy layout update endlessly on iOS 26. Finish the jump in - // a transaction that cannot inherit the refresh animation. - var transaction = Transaction(animation: nil) - transaction.disablesAnimations = true - withTransaction(transaction) { - scrollProxy.scrollTo(HomeScrollTarget.top, anchor: .top) - } - } + VStack(spacing: 0) { + feedSegmentPicker + feedSegmentContent } .navigationTitle("首页") .navigationBarTitleDisplayMode(.inline) @@ -219,6 +209,55 @@ struct HomeView: View { } } + private var feedSegmentPicker: some View { + Picker("首页分页", selection: $feedSegment) { + ForEach(HomeFeedSegment.allCases) { segment in + Text(segment.title) + .tag(segment) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, TiebaPureTheme.Spacing.md) + .padding(.vertical, TiebaPureTheme.Spacing.xs) + .background(TiebaPureTheme.ColorToken.readerGroupedBackground) + .accessibilityIdentifier("home-feed-segment-picker") + } + + @ViewBuilder + private var feedSegmentContent: some View { + switch feedSegment { + case .recommended: + recommendedFeedColumn + case .hot: + HotThreadsView( + account: account, + onOpenThread: { openThread($0) }, + onOpenComments: { openThread($0, initialDestination: .replies) }, + onOpenForum: openForum, + onOpenUser: { openUser($0, sourceThreadID: nil) } + ) + .accessibilityIdentifier("home-hot-feed") + } + } + + private var recommendedFeedColumn: some View { + ScrollViewReader { scrollProxy in + refreshableScrollView { + feedContent + } + .onChange(of: scrollToTopRequest) { _ in + // Feed changes during an animated scroll-to-top can make the + // lazy layout update endlessly on iOS 26. Finish the jump in + // a transaction that cannot inherit the refresh animation. + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { + scrollProxy.scrollTo(HomeScrollTarget.top, anchor: .top) + } + } + } + } + private var usesSplitDetailLayout: Bool { horizontalSizeClass == .regular } diff --git a/TiebaPure/Features/Home/HotThreadsView.swift b/TiebaPure/Features/Home/HotThreadsView.swift new file mode 100644 index 0000000..ec12de2 --- /dev/null +++ b/TiebaPure/Features/Home/HotThreadsView.swift @@ -0,0 +1,251 @@ +import SwiftUI +import UIKit + +/// The two top-level tabs of the home feed. 推荐 is the personalized feed the +/// app has always shown; 热点 is the service's hot-thread listing. +enum HomeFeedSegment: String, CaseIterable, Identifiable, Sendable { + case recommended + case hot + + var id: String { rawValue } + + var title: String { + switch self { + case .recommended: + return "推荐" + case .hot: + return "热点" + } + } + + var accessibilityHint: String { + switch self { + case .recommended: + return "显示个性化推荐的帖子" + case .hot: + return "显示贴吧热点帖子" + } + } +} + +/// The 热点 half of the home feed. +/// +/// The endpoint answers one listing per sub-tab and takes no page parameter, so +/// this view loads a whole tab at a time: a sub-tab switch replaces the list +/// instead of appending a page, and pulling down reloads the current sub-tab. +struct HotThreadsView: View { + @EnvironmentObject private var environment: AppEnvironment + @Environment(\.readingPreferences) private var readingPreferences + @ObservedObject private var blocklistStore = BlocklistStore.shared + + let account: Account? + let onOpenThread: (ThreadSummary) -> Void + let onOpenComments: (ThreadSummary) -> Void + let onOpenForum: (Forum) -> Void + let onOpenUser: (UserSummary) -> Void + + @State private var tabs: [HotTab] = [] + @State private var selectedTabCode = "" + @State private var threads: [ThreadSummary] = [] + @State private var isLoading = false + @State private var didLoad = false + @State private var errorMessage: String? + @State private var requestGeneration = 0 + @State private var loadTask: Task? + + var body: some View { + VStack(spacing: 0) { + if tabs.isEmpty == false { + hotTabBar + } + + feedContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(TiebaPureTheme.ColorToken.readerGroupedBackground) + .task { + guard didLoad == false else { return } + await load(tabCode: selectedTabCode) + } + .onChange(of: account?.sessionIdentity) { _ in + requestGeneration += 1 + loadTask?.cancel() + tabs = [] + selectedTabCode = "" + threads = [] + errorMessage = nil + didLoad = false + isLoading = false + Task { await load(tabCode: selectedTabCode) } + } + .onDisappear { + loadTask?.cancel() + requestGeneration += 1 + isLoading = false + } + } + + private var hotTabBar: some View { + ScrollView(.horizontal) { + HStack(spacing: TiebaPureTheme.Spacing.xs) { + ForEach(tabs) { tab in + Button { + guard tab.code != selectedTabCode else { return } + selectedTabCode = tab.code + Task { await load(tabCode: tab.code) } + } label: { + CapsuleLabel( + tab.name, + isSelected: tab.code == selectedTabCode + ) + } + .buttonStyle(.plain) + .minTouchTarget() + .accessibilityLabel(tab.name) + .accessibilityIdentifier("hot-thread-tab-\(tab.code)") + } + } + .padding(.horizontal, TiebaPureTheme.Spacing.md) + .padding(.vertical, TiebaPureTheme.Spacing.xs) + } + .scrollIndicators(.hidden) + .accessibilityIdentifier("hot-thread-tab-bar") + .background(TiebaPureTheme.ColorToken.readerGroupedBackground) + } + + @ViewBuilder + private var feedContent: some View { + if isLoading && didLoad == false { + ReaderStateView.loading("正在加载热点") + } else if let errorMessage, threads.isEmpty { + ReaderStateScrollView(refresh: { await reload() }) { + ReaderStateView.error(message: errorMessage) { + Task { await reload() } + } + } + } else if threads.isEmpty { + ReaderStateScrollView(refresh: { await reload() }) { + ReaderStateView.empty( + title: "暂无热点", + message: "下拉即可刷新热点帖子。" + ) + } + } else { + ScrollView { + LazyVStack(spacing: TiebaPureTheme.Spacing.sm, pinnedViews: []) { + ForEach(threads) { thread in + ForumThreadRow( + thread: thread, + presentation: .homeFeed, + onOpenThread: { onOpenThread(thread) }, + onOpenForum: onOpenForum, + onOpenUser: { onOpenUser($0) }, + onBlockForum: { blockedThread in + blocklistStore.addForum( + id: blockedThread.forumID, + named: blockedThread.forumName + ) + }, + onOpenMedia: { item, mediaItems, sourceFrame, sourceImage, sourceAnchor in + switch HomeMediaActionPolicy.action(for: item, in: mediaItems) { + case let .previewImages(images, index): + ImagePreviewCoordinator.shared.present( + ImagePreviewSession( + images: images, + initialIndex: index, + sourceFrame: sourceFrame, + sourceImage: sourceImage, + sourceAnchor: sourceAnchor, + prefetchesAdjacentPages: readingPreferences.mediaLoading != .manual + ) + ) + case let .playVideo(video): + VideoPreviewCoordinator.shared.present( + VideoPreviewSession( + video: video, + sourceFrame: sourceFrame, + sourceImage: sourceImage, + sourceAnchor: sourceAnchor + ) + ) + case .openThread: + onOpenThread(thread) + } + }, + onOpenComments: { onOpenComments(thread) }, + commentsAccessibilityIdentifier: "hot-comments-button-\(thread.id)" + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("thread-row") + } + + if let errorMessage { + InlineLoadErrorView(message: errorMessage) { + Task { await reload() } + } + } + + Color.clear + .frame(height: 64) + .accessibilityHidden(true) + } + .padding(.horizontal, TiebaPureTheme.Spacing.sm) + .padding(.vertical, TiebaPureTheme.Spacing.sm) + .readableWidth() + } + .accessibilityIdentifier("hot-thread-scroll-view") + .shortPullRefresh( + isEnabled: didLoad && isLoading == false, + surface: .grouped, + accessibilityIdentifier: "hot-thread-refresh-animation" + ) { + await reload() + } + } + } + + private func reload() async { + await load(tabCode: selectedTabCode) + } + + private func load(tabCode: String) async { + loadTask?.cancel() + requestGeneration += 1 + let generation = requestGeneration + let requestedSession = account?.sessionIdentity + isLoading = true + errorMessage = nil + + do { + let task = Task { + try await environment.api.hotThreads(account: account, tabCode: tabCode) + } + loadTask = task + let feed = try await task.value + guard generation == requestGeneration, + requestedSession == account?.sessionIdentity else { return } + tabs = feed.tabs + threads = feed.threads.filter(TiebaContentFilter.shouldKeep(thread:)) + // A sub-tab the service stopped reporting must not leave the tab bar + // pointing at a listing that can no longer be requested. + if tabs.contains(where: { $0.code == tabCode }) == false { + selectedTabCode = "" + } + } catch is CancellationError { + guard generation == requestGeneration, + requestedSession == account?.sessionIdentity else { return } + loadTask = nil + isLoading = false + return + } catch { + guard generation == requestGeneration, + requestedSession == account?.sessionIdentity else { return } + errorMessage = ReaderErrorMessage.message(for: error) + } + guard generation == requestGeneration, + requestedSession == account?.sessionIdentity else { return } + loadTask = nil + isLoading = false + didLoad = true + } +} \ No newline at end of file diff --git a/TiebaPureTests/FollowedForumStatusTests.swift b/TiebaPureTests/FollowedForumStatusTests.swift index fec6bb9..438c0f4 100644 --- a/TiebaPureTests/FollowedForumStatusTests.swift +++ b/TiebaPureTests/FollowedForumStatusTests.swift @@ -80,7 +80,7 @@ final class FollowedForumStatusTests: XCTestCase { let response = try JSONDecoder().decode(FollowedForumGuideResponseDTO.self, from: Data(json.utf8)) - XCTAssertEqual(response.data?.forums, []) + XCTAssertEqual(response.data?.forums.isEmpty, true) XCTAssertEqual(response.data?.hasMore, true) } diff --git a/TiebaPureTests/HotThreadListTests.swift b/TiebaPureTests/HotThreadListTests.swift new file mode 100644 index 0000000..75cba03 --- /dev/null +++ b/TiebaPureTests/HotThreadListTests.swift @@ -0,0 +1,284 @@ +import SwiftProtobuf +import XCTest +@testable import TiebaPure + +/// One assertion per test on purpose. The CI runner keeps the failing test's +/// NAME but drops the assertion body (the `.xcresult` is never uploaded), so a +/// per-field failure is only identifiable from the test that carries it. +private func assertField( + _ actual: T, + _ expected: T, + file: StaticString = #filePath, + line: UInt = #line +) { + if actual != expected { + XCTFail("field mismatch", file: file, line: line) + } +} + +final class HotThreadListTests: XCTestCase { + /// Hand-encoded wire bytes, deliberately not produced by SwiftProtobuf, so + /// field-number or wire-type drift in the generated schema fails the decode + /// instead of round-tripping silently. A minimal HotThreadListResponse: + /// data(2) holding one thread_info(2) {id 9001, title, replyNum 7, + /// forumId 555, forumName, authorId 77} and one hot_thread_tab_info(3) + /// {tab_name 3 = 综合, tab_code 8 = hot_all}. + private static let hotThreadWireHex = + "1241" + + "122c" + + "08a946" + + "1a12e7babfe6a0bce5bc8fe783ade782b9e5b896" + + "2007" + + "d801ab04" + + "e20109e783ade782b9e590a7" + + "c0034d" + + "1a11" + + "1a06e7bbbce59088" + + "4207686f745f616c6c" + + private static func data(hex: String) -> Data? { + var bytes: [UInt8] = [] + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + guard let byte = UInt8(hex[index.. [WireField] { + let bytes = [UInt8](data) + var index = 0 + var fields: [WireField] = [] + + func readVarint() throws -> UInt64 { + var result: UInt64 = 0 + var shift: UInt64 = 0 + while true { + guard index < bytes.count else { + throw WireError.truncated + } + let byte = bytes[index] + index += 1 + result |= UInt64(byte & 0x7F) << shift + if byte & 0x80 == 0 { break } + shift += 7 + } + return result + } + + while index < bytes.count { + let tag = try readVarint() + let number = Int(tag >> 3) + let wireType = UInt8(tag & 0x07) + switch wireType { + case 0: + _ = try readVarint() + fields.append(WireField(number: number, wireType: wireType, payload: Data())) + case 2: + let length = Int(try readVarint()) + guard index + length <= bytes.count else { throw WireError.truncated } + fields.append(WireField( + number: number, + wireType: wireType, + payload: Data(bytes[index..<(index + length)]) + )) + index += length + default: + throw WireError.unsupportedWireType(wireType) + } + } + return fields + } + + private enum WireError: Error { + case truncated + case unsupportedWireType(UInt8) + } + + private var requestBuilder: TiebaRequestBuilder { + TiebaRequestBuilder( + screenScale: 3, + screenWidth: 1179, + screenHeight: 2556, + clientID: "hotwireclient" + ) + } + + private func decodedWireThreadInfo() throws -> Tieba_ThreadInfo { + let wireData = try XCTUnwrap(Self.data(hex: Self.hotThreadWireHex)) + let decoded = try Tieba_HotThreadList_HotThreadListResponse(serializedBytes: wireData) + return try XCTUnwrap(decoded.data.threadInfo.first) + } + + private func mappedWireThread() throws -> ThreadSummary { + ThreadMapper.fromThreadInfo(try decodedWireThreadInfo(), usersByID: [:]) + } + + func testHotThreadListEndpointTargetsTheCommandProtobufPath() { + let url = TiebaEndpoint.hotThreadList.url + + XCTAssertEqual(url.host, "tieba.baidu.com") + XCTAssertEqual(url.path, "/c/f/forum/hotThreadList") + XCTAssertEqual(url.query, "cmd=309661") + } + + func testRequestCarriesTabIdentifierAndCodeOnTheirWireFields() throws { + let request = TiebaHotRequestFactory.request( + account: nil, + tabCode: "hot_all", + requestBuilder: requestBuilder + ) + + let outer = try Self.wireFields(try request.serializedData()) + XCTAssertEqual(outer.map(\.number), [1], "请求只带一个 data 字段") + + let dataFields = try Self.wireFields(outer[0].payload) + XCTAssertEqual( + dataFields.map(\.number).sorted(), + [1, 2, 3], + "data 里必须是 common(1)、tab_id(2)、tab_code(3)" + ) + let tabID = try XCTUnwrap(dataFields.first { $0.number == 2 }) + XCTAssertEqual(String(decoding: tabID.payload, as: UTF8.self), "1") + let tabCode = try XCTUnwrap(dataFields.first { $0.number == 3 }) + XCTAssertEqual(String(decoding: tabCode.payload, as: UTF8.self), "hot_all") + } + + func testRequestWithoutTabCodeOmitsTheCodeField() throws { + let request = TiebaHotRequestFactory.request( + account: nil, + tabCode: "", + requestBuilder: requestBuilder + ) + + let outer = try Self.wireFields(try request.serializedData()) + let dataFields = try Self.wireFields(outer[0].payload) + + XCTAssertEqual( + dataFields.map(\.number), + [1, 2], + "默认分页请求只带 common 与固定的 tab_id,不写空 tab_code" + ) + } + + func testHandCraftedHotThreadWireBytesDecodeAndMapToDomain() throws { + let wireData = try XCTUnwrap(Self.data(hex: Self.hotThreadWireHex)) + let decoded = try Tieba_HotThreadList_HotThreadListResponse(serializedBytes: wireData) + + XCTAssertEqual(decoded.error.errorCode, 0) + XCTAssertEqual(decoded.data.threadInfo.count, 1) + XCTAssertEqual(decoded.data.hotThreadTabInfo.count, 1) + XCTAssertEqual(decoded.data.hotThreadTabInfo[0].tabName, "综合") + XCTAssertEqual(decoded.data.hotThreadTabInfo[0].tabCode, "hot_all") + + let tabs = HotFeedMapper.makeFeed(from: decoded.data).tabs + XCTAssertEqual(tabs, [HotTab(code: "hot_all", name: "综合")]) + } + + // MARK: - Hand-crafted wire bytes, one assertion per test + + func testHotWireDecodesThreadIDFromField1() throws { + assertField(try decodedWireThreadInfo().id, 9001) + } + + func testHotWireDecodesThreadTitleFromField3() throws { + assertField(try decodedWireThreadInfo().title, "线格式热点帖") + } + + func testHotWireDecodesThreadReplyNumFromField4() throws { + assertField(try decodedWireThreadInfo().replyNum, 7) + } + + func testHotWireDecodesThreadForumIDFromField27() throws { + assertField(try decodedWireThreadInfo().forumID, 555) + } + + func testHotWireDecodesThreadForumNameFromField28() throws { + assertField(try decodedWireThreadInfo().forumName, "热点吧") + } + + func testHotWireDecodesThreadAuthorIDFromField56() throws { + assertField(try decodedWireThreadInfo().authorID, 77) + } + + func testHotThreadMapsIDFromWireBytes() throws { + assertField(try mappedWireThread().id, 9001) + } + + func testHotThreadMapsTitleFromWireBytes() throws { + assertField(try mappedWireThread().title, "线格式热点帖") + } + + func testHotThreadMapsReplyCountFromWireBytes() throws { + assertField(try mappedWireThread().replyCount, 7) + } + + func testHotThreadMapsForumIDFromWireBytes() throws { + assertField(try mappedWireThread().forumID, 555) + } + + func testHotThreadMapsForumNameFromWireBytes() throws { + assertField(try mappedWireThread().forumName, "热点吧") + } + + func testHotThreadMapsAuthorIDFromWireBytes() throws { + assertField(try mappedWireThread().author.id, 77) + } + + func testTabsWithoutCodeOrNameAreDropped() { + var responseData = Tieba_HotThreadList_HotThreadListResponseData() + + var codeOnly = Tieba_HotThreadList_FrsTabInfo() + codeOnly.tabCode = "hot_all" + var nameOnly = Tieba_HotThreadList_FrsTabInfo() + nameOnly.tabName = "综合" + var usable = Tieba_HotThreadList_FrsTabInfo() + usable.tabCode = "hot_pic" + usable.tabName = "图片" + responseData.hotThreadTabInfo = [codeOnly, nameOnly, usable] + + let feed = HotFeedMapper.makeFeed(from: responseData) + + XCTAssertEqual(feed.tabs, [HotTab(code: "hot_pic", name: "图片")]) + } + + func testRepeatedThreadsAreDedupedByID() { + var responseData = Tieba_HotThreadList_HotThreadListResponseData() + + var first = Tieba_ThreadInfo() + first.id = 9001 + first.title = "线格式热点帖" + var duplicate = Tieba_ThreadInfo() + duplicate.id = 9001 + duplicate.title = "线格式热点帖" + var other = Tieba_ThreadInfo() + other.id = 9002 + other.title = "另一条热点" + responseData.threadInfo = [first, duplicate, other] + + let feed = HotFeedMapper.makeFeed(from: responseData) + + XCTAssertEqual(feed.threads.map(\.id), [9001, 9002]) + } + + func testSegmentsExposeStableTitlesAndHints() { + XCTAssertEqual(HomeFeedSegment.allCases.map(\.title), ["推荐", "热点"]) + XCTAssertEqual(HomeFeedSegment.recommended.accessibilityHint, "显示个性化推荐的帖子") + XCTAssertEqual(HomeFeedSegment.hot.accessibilityHint, "显示贴吧热点帖子") + XCTAssertEqual( + Set(HomeFeedSegment.allCases.map(\.id)).count, + HomeFeedSegment.allCases.count + ) + } +} diff --git a/TiebaPureUITests/TiebaPureUITests.swift b/TiebaPureUITests/TiebaPureUITests.swift index fdb2a5a..eed8795 100644 --- a/TiebaPureUITests/TiebaPureUITests.swift +++ b/TiebaPureUITests/TiebaPureUITests.swift @@ -1832,7 +1832,16 @@ final class TiebaPureUITests: XCTestCase { guard let frame = waitForStableFrame(of: firstRow) else { return XCTFail("刷新后的首行位置未稳定") } - XCTAssertEqual(frame.minY, app.navigationBars["首页"].frame.maxY, accuracy: 24) + // The feed now starts under the 推荐/热点 分页控件, not directly + // under the navigation bar. + let feedHeader = app.descendants(matching: .any)["home-feed-segment-picker"] + if feedHeader.exists { + XCTAssertEqual(frame.minY, feedHeader.frame.maxY, accuracy: 24) + } else { + let segmented = app.segmentedControls.firstMatch + XCTAssertTrue(segmented.waitForExistence(timeout: 5), "首页应有推荐/热点分页控件") + XCTAssertEqual(frame.minY, segmented.frame.maxY, accuracy: 24) + } } } diff --git a/scripts/generate-ios-protos.sh b/scripts/generate-ios-protos.sh index d4f015d..903de33 100755 --- a/scripts/generate-ios-protos.sh +++ b/scripts/generate-ios-protos.sh @@ -31,6 +31,7 @@ import sys root = pathlib.Path(sys.argv[1]) roots = [ "Personalized.proto", + "HotThreadList/HotThreadList.proto", "FrsPage/FrsPage.proto", "PbPage/PbPageRequest.proto", "PbPage/PbPageResponse.proto", From c2cb72aa370d4b4b345ac3e81b792a3e399ead8c Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 22:32:13 +0800 Subject: [PATCH 03/11] feat(diagnostics): add an exportable in-app log and instrument the feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机上无法从外部区分两个症状:首页分页显示与官方客户端不一致的帖子,以及 关注贴吧磁贴从不显示等级或签到状态。两处都被吞掉了——ForumHubView 用 try? 丢弃状态错误,followedForumGuide 是唯一从未观察过字段名的调用。 - 新增 AppLog(有界内存日志)与设置页的导出入口,记录未公开端点的真实响应: forumGuide 的原始 body 骨架、逐页 error_code、行数与等级直方图;推荐/热点 流是否已登录、子分页名与前几行的来源吧;ForumHubView 不再丢弃状态错误。 - 每条消息在写入时即抹掉 BDUSS、STOKEN、tbs 等凭据,导出文件可直接发送。 - 两个 protobuf 信息流的 Cookie 头带上已登录凭据:只发 ka=open 会把请求标记 为匿名,这很可能就是推荐流是通用排序而非官方客户端的关注吧混排的原因。 - 注册新源文件到 Xcode 工程,并修掉 AppLog 的两处编译错误与一处 range 长度读取。 --- TiebaPure.xcodeproj/project.pbxproj | 20 ++ TiebaPure/Core/Diagnostics/AppLog.swift | 238 ++++++++++++++++++ TiebaPure/Core/Network/TiebaAPI.swift | 57 +++-- TiebaPure/Core/Network/TiebaHotAPI.swift | 54 +++- .../Core/Network/TiebaRequestBuilder.swift | 21 ++ TiebaPure/Core/Network/TiebaSocialAPI.swift | 55 +++- .../Features/ForumHub/ForumHubView.swift | 13 +- .../Settings/DiagnosticsLogView.swift | 117 +++++++++ .../Features/Settings/SettingsView.swift | 14 ++ TiebaPureTests/AppLogTests.swift | 105 ++++++++ 10 files changed, 658 insertions(+), 36 deletions(-) create mode 100644 TiebaPure/Core/Diagnostics/AppLog.swift create mode 100644 TiebaPure/Features/Settings/DiagnosticsLogView.swift create mode 100644 TiebaPureTests/AppLogTests.swift diff --git a/TiebaPure.xcodeproj/project.pbxproj b/TiebaPure.xcodeproj/project.pbxproj index 7d2b229..4bc46b9 100644 --- a/TiebaPure.xcodeproj/project.pbxproj +++ b/TiebaPure.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 3188EE996CB318CB2D3BE009 /* CrossVersionStateRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD21B955EE2D4C98D90CD394 /* CrossVersionStateRegressionTests.swift */; }; 32FB7209CF53027C77B378A7 /* TiebaThreadStoreAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F256CCF10CED308966BFC23 /* TiebaThreadStoreAPI.swift */; }; 3350633EF8736792C66DA2B4 /* MeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D671D758CEF85777E7C7D8E4 /* MeView.swift */; }; + 343FC50F5CF563F7F475780A /* AppLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B67B72CCFDBBE45015D1F13 /* AppLog.swift */; }; 34511F43DA6B267EB2816388 /* FixtureDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4A700D0BB079E5CEC104C1 /* FixtureDecodingTests.swift */; }; 34F4E2B21AEED679D02A333E /* HotThreadListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD788A408EE7E4022C73211 /* HotThreadListTests.swift */; }; 35D97F8C9E055B516F478254 /* TiebaForumSignAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722158AB6808A4470B0BDE41 /* TiebaForumSignAPI.swift */; }; @@ -143,6 +144,7 @@ AC68D0057C4A48786599AB69 /* ContentFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B7B87BD4A9EB32A9F340FC /* ContentFilterTests.swift */; }; AC73C05F2AE82D15E3B429EB /* AlaLiveInfo.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9E3067B91DE94BBC410176 /* AlaLiveInfo.pb.swift */; }; AC8C9EFD5E2B710919306D8B /* FileContentDraftPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77980DB0EC284A4F53AA4028 /* FileContentDraftPersistence.swift */; }; + AD96834436F60A2AA9FEEE2B /* AppLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2BD8B60A3A79B25E28A14E /* AppLogTests.swift */; }; B0A89B13400DEB2576E1A025 /* AvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 674313EBDA3BABF79049EF20 /* AvatarView.swift */; }; B152F8A68F8CB2ACBCD895AD /* ImageDownloadService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35CD0BAA4D26A1DBC8367F53 /* ImageDownloadService.swift */; }; B1EC4ECCDB577E77B8026BE6 /* TiebaEmoticon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF25E73F880C6AD77740631 /* TiebaEmoticon.swift */; }; @@ -214,6 +216,7 @@ F0FF2290BC0E84D98B000FF3 /* Anti.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BEB3F78F38EA8D24DAA64A4 /* Anti.pb.swift */; }; F24BAE224091125E43EAC298 /* ForumSignSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E4CFA3390403419B8C3E890 /* ForumSignSettings.swift */; }; F2FE7D83BA305985F7A5600D /* User.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D98C38DD4DF0A6BBAEE14B /* User.pb.swift */; }; + F3180D681797698B90677812 /* DiagnosticsLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A5E3C5C102B3E93BFF0EBB /* DiagnosticsLogView.swift */; }; F5B1D066296990A96D69CC0C /* SavedThreadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 460C7E1E4622409127D0F888 /* SavedThreadTests.swift */; }; F60F13E9F3CF7AA790EE1C4A /* MessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C6EE1B24CFBA1308B81FD41 /* MessagesView.swift */; }; F81A17ADFC8827935D28CB43 /* Forum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E92E90BB801373B85685DDD /* Forum.swift */; }; @@ -290,6 +293,7 @@ 16DDB351392A69BC42810B9E /* SearchResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResult.swift; sourceTree = ""; }; 16EEFF870AC95D5BA9A0AABD /* CompatibleUIFoundationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibleUIFoundationTests.swift; sourceTree = ""; }; 1781B1F0906AC36975BFDE9F /* LegacyScrollTelemetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyScrollTelemetryTests.swift; sourceTree = ""; }; + 1B67B72CCFDBBE45015D1F13 /* AppLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLog.swift; sourceTree = ""; }; 1CB122673DB1880647B37AE8 /* ContentDraftMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentDraftMigrationTests.swift; sourceTree = ""; }; 1D6B235101269A6DE9BEB53A /* LogoutCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutCoordinator.swift; sourceTree = ""; }; 1E3321457BFA9611243ABF93 /* TiebaURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaURL.swift; sourceTree = ""; }; @@ -415,6 +419,7 @@ 9F1C4599FA7188B0D810AE19 /* UserProfileMutationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileMutationTests.swift; sourceTree = ""; }; 9F66E0D4FBBBBF5F6DA8C979 /* HomeNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeNavigationTests.swift; sourceTree = ""; }; A37648A3CE4B69FBC511A204 /* ContentDraftCodecPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentDraftCodecPolicyTests.swift; sourceTree = ""; }; + A5A5E3C5C102B3E93BFF0EBB /* DiagnosticsLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsLogView.swift; sourceTree = ""; }; A87CD00DD298F86614F66AAD /* Lbs.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lbs.pb.swift; sourceTree = ""; }; A8FC3906D642656961F9DAE4 /* ContentSubmissionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSubmissionCoordinator.swift; sourceTree = ""; }; AABE9716DCC2C5720A003041 /* VideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = ""; }; @@ -444,6 +449,7 @@ C4B3F485FB45C4828FD7C5C6 /* AppPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPersistence.swift; sourceTree = ""; }; C60B7C87AF367770BAF70762 /* SearchAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAPITests.swift; sourceTree = ""; }; C80CE02414B9F8BD668C519D /* FrsPage_FrsPage.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrsPage_FrsPage.pb.swift; sourceTree = ""; }; + CA2BD8B60A3A79B25E28A14E /* AppLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLogTests.swift; sourceTree = ""; }; CBBF9E6133FA9F395134196F /* SavedThreadMedia.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedThreadMedia.swift; sourceTree = ""; }; CBF457C95754D5EAA87ECEFD /* ForumSignTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForumSignTests.swift; sourceTree = ""; }; CD2C30123EFF90A5F9BEE7C2 /* Post.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Post.swift; sourceTree = ""; }; @@ -614,6 +620,14 @@ path = Profile; sourceTree = ""; }; + 35B17E1AD8135439509E0218 /* Diagnostics */ = { + isa = PBXGroup; + children = ( + 1B67B72CCFDBBE45015D1F13 /* AppLog.swift */, + ); + path = Diagnostics; + sourceTree = ""; + }; 3BEE4D7FD5CBF59A76794D9C /* App */ = { isa = PBXGroup; children = ( @@ -684,6 +698,7 @@ isa = PBXGroup; children = ( C90A4859BC32AE7A63046550 /* Auth */, + 35B17E1AD8135439509E0218 /* Diagnostics */, 1302D50619E47863D519A324 /* Network */, C565AEACE7A674D5A1835546 /* Protobuf */, 83CD19E2D26B92FC58D823F0 /* State */, @@ -753,6 +768,7 @@ children = ( D1180626C13544DCC568FA5B /* AccountThreadFavoritesTests.swift */, DBA0A4653556BD11ACA8C0A3 /* AnonymousLiveSmokeTests.swift */, + CA2BD8B60A3A79B25E28A14E /* AppLogTests.swift */, 7D258A9BF97B365AE98B663F /* AuthSessionTests.swift */, 16EEFF870AC95D5BA9A0AABD /* CompatibleUIFoundationTests.swift */, 08C0F8A37BD6F0E4EA09BFCD /* ContentComposerPolicyTests.swift */, @@ -917,6 +933,7 @@ isa = PBXGroup; children = ( 582A83F679EC3456CD6D08E4 /* BlocklistSettingsView.swift */, + A5A5E3C5C102B3E93BFF0EBB /* DiagnosticsLogView.swift */, 432F97822473B7D42DB148EC /* ReadingSettingsView.swift */, B0B960AF4A983D18E1496538 /* SavedThreadsView.swift */, 10E2287E1195B4261B0B88E8 /* SettingsView.swift */, @@ -1158,6 +1175,7 @@ F0FF2290BC0E84D98B000FF3 /* Anti.pb.swift in Sources */, DEF7E4F5C3BB5C3AC25E89EF /* AppAppearance.swift in Sources */, 89331B1C9CEFD4999D01A906 /* AppEnvironment.swift in Sources */, + 343FC50F5CF563F7F475780A /* AppLog.swift in Sources */, 42DE9B83B9572D1EA986C997 /* AppPersistence.swift in Sources */, F91AF0FEAF078DC66EA3C184 /* AppPosInfo.pb.swift in Sources */, C093E45405F12EC29B1F62B5 /* AuthSession.swift in Sources */, @@ -1181,6 +1199,7 @@ 76335BCC2EF12774CA12ACC1 /* ContentSubmission.swift in Sources */, FA74FB79B01F1BE9C5A98873 /* ContentSubmissionCoordinator.swift in Sources */, 9FCC42B360A1683D6696808F /* ContentSubmissionSettings.swift in Sources */, + F3180D681797698B90677812 /* DiagnosticsLogView.swift in Sources */, 5A56CE0B756E52E025521190 /* Error.pb.swift in Sources */, ECDE960EDA7E9E0E186B2D3C /* ExternalRoute.swift in Sources */, AC8C9EFD5E2B710919306D8B /* FileContentDraftPersistence.swift in Sources */, @@ -1311,6 +1330,7 @@ files = ( 2BE5F35354B2C09E7C1BC462 /* AccountThreadFavoritesTests.swift in Sources */, D0B17827BAA31E0CF4AD29ED /* AnonymousLiveSmokeTests.swift in Sources */, + AD96834436F60A2AA9FEEE2B /* AppLogTests.swift in Sources */, 4C637302510F02D057F7F18D /* AuthSessionTests.swift in Sources */, 7364CD4DC533CA94A061941E /* CompatibleUIFoundationTests.swift in Sources */, 96E558670B2758520F748005 /* ContentComposerPolicyTests.swift in Sources */, diff --git a/TiebaPure/Core/Diagnostics/AppLog.swift b/TiebaPure/Core/Diagnostics/AppLog.swift new file mode 100644 index 0000000..185146a --- /dev/null +++ b/TiebaPure/Core/Diagnostics/AppLog.swift @@ -0,0 +1,238 @@ +import Foundation + +/// A bounded, in-memory diagnostic log the user can export from Settings. +/// +/// The app talks to undocumented Tieba endpoints whose response shapes are only +/// known from observation. When a screen silently shows nothing there is no way +/// to tell an authentication failure from a wrong field name, so the network +/// layer records what it actually received and the user can hand the file back. +/// +/// Everything here is best-effort: recording must never throw and never block a +/// network call, and the buffer is capped so a long session cannot grow without +/// bound. +actor AppLog { + static let shared = AppLog() + + enum Level: String, Sendable { + case info = "INFO" + case warning = "WARN" + case error = "ERROR" + } + + struct Entry: Identifiable, Sendable, Equatable { + let id: Int64 + let date: Date + let level: Level + let category: String + let message: String + } + + /// Roughly a long browsing session; older entries fall off the end. + private static let capacity = 800 + + private var entries: [Entry] = [] + private var nextID: Int64 = 1 + + func record( + _ level: Level = .info, + _ category: String, + _ message: String + ) { + entries.append( + Entry( + id: nextID, + date: Date(), + level: level, + category: category, + message: DiagnosticRedaction.redact(message) + ) + ) + nextID += 1 + if entries.count > Self.capacity { + entries.removeFirst(entries.count - Self.capacity) + } + } + + func recordError( + _ category: String, + _ message: String, + error: Error + ) { + record(.error, category, "\(message): \(error)") + } + + func recent(_ limit: Int = 100) -> [Entry] { + Array(entries.suffix(max(limit, 0))) + } + + func count() -> Int { + entries.count + } + + func clear() { + entries.removeAll() + } + + /// The exported file. Includes a small header so a log that arrives without + /// its conversation still identifies the app build and device. + func exportText(deviceSummary: String) -> String { + var lines: [String] = [] + lines.append("TiebaPure 诊断日志") + lines.append("导出时间:\(Self.timestamp(Date()))") + lines.append("设备:\(DiagnosticRedaction.redact(deviceSummary))") + lines.append("条目数:\(entries.count)") + lines.append("") + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HH:mm:ss.SSS" + + for entry in entries { + lines.append( + "\(formatter.string(from: entry.date)) [\(entry.level.rawValue)] \(entry.category): \(entry.message)" + ) + } + return lines.joined(separator: "\n") + "\n" + } + + private static func timestamp(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZZ" + return formatter.string(from: date) + } +} + +/// Keeps credentials out of an export that leaves the device. +/// +/// The network layer never writes a secret on purpose, but raw response bodies +/// and cookie echoes can carry `BDUSS`, `STOKEN` or `tbs` anyway, so every +/// message is scrubbed on the way in rather than trusting each call site. +enum DiagnosticRedaction { + static let placeholder = "<已脱敏>" + + /// Field names whose values are credentials or session tokens. + private static let secretKeys = [ + "BDUSS", "STOKEN", "tbs", "sign", "cuid", "CUID", "baiduid", "BAIDUID", + "sekey", "token", "cookie", "Cookie", "session", "Session" + ] + + private static let secretKeyPattern = secretKeys + .map { NSRegularExpression.escapedPattern(for: $0) } + .joined(separator: "|") + + private static let jsonSecretPattern = try? NSRegularExpression( + pattern: "\"(\(secretKeyPattern))\"\\s*:\\s*\"[^\"]*\"", + options: [.caseInsensitive] + ) + + private static let cookieSecretPattern = try? NSRegularExpression( + pattern: "\\b(\(secretKeyPattern))\\s*=\\s*[^;\\s\"']+", + options: [.caseInsensitive] + ) + + /// Long opaque blobs — cookie jars, signed protobuf envelopes, base64 media + /// markers — carry no diagnostic value once their length is known. + private static let longBlobPattern = try? NSRegularExpression( + pattern: "[A-Za-z0-9_\\-]{40,}" + ) + + static func redact(_ text: String) -> String { + let withoutJSONSecrets = substitute(jsonSecretPattern, in: text) { match, source in + "\"\(captured(in: match, in: source))\":\"\(placeholder)\"" + } + let withoutCookieSecrets = substitute(cookieSecretPattern, in: withoutJSONSecrets) { match, source in + "\(captured(in: match, in: source))=\(placeholder)" + } + return substitute(longBlobPattern, in: withoutCookieSecrets) { match, _ in + // `match.range(in:)` does not survive the Swift import of + // NSTextCheckingResult, whose `range` property shadows that method, + // so the length has to come off the property. + "\(placeholder)(\(match.range.length) chars)" + } + } + + /// Replaces every match with the name captured by group 1, keeping the rest + /// of the token so a redacted log still reads like a cookie header. + private static func substitute( + _ pattern: NSRegularExpression?, + in text: String, + with replacement: (NSTextCheckingResult, String) -> String + ) -> String { + var result = text + guard let pattern else { return result } + + // Regex ranges are UTF-16 offsets, so matches are collected first and + // applied back to front: replacing a later match cannot shift an earlier + // match's offsets. + let matches = pattern.matches( + in: result, + range: NSRange(result.startIndex.. String { + guard match.numberOfRanges > 1, + let range = Range(match.range(at: 1), in: text) else { return "" } + return String(text[range]) + } +} + +/// Summarises a JSON payload as a type/field skeleton. +/// +/// The interesting question when a decode fails is usually "what is this +/// endpoint actually called?", not "what are the values", so scalar values are +/// reported only for the numeric and boolean leaves that make an error code +/// readable, and object/array keys are reported in full. +enum DiagnosticJSON { + /// A forum guide row has a handful of fields, so the sample is generous + /// enough to show a real shape while a pathological payload still prints + /// a readable prefix. + private static let maxEntriesPerObject = 6 + + static func skeleton(_ data: Data) -> String { + let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + return describe(object, depth: 0) + } + + static func describe(_ value: Any?, depth: Int) -> String { + // A guide row nests four levels deep (root → data → array → row), so the + // limit has to clear that or every scalar leaf prints as an ellipsis. + guard depth < 6 else { return "…"} + switch value { + case let dictionary as [String: Any]: + let keys = dictionary.keys.sorted() + guard keys.isEmpty == false else { return "{}"} + let body = keys + .prefix(maxEntriesPerObject) + .map { "\($0): \(describe(dictionary[$0], depth: depth + 1))" } + .joined(separator: ", ") + let rest = keys.count > maxEntriesPerObject + ? ", …\(keys.count - maxEntriesPerObject) more" + : "" + return "{\(body)\(rest)}" + case let array as [Any]: + guard let first = array.first else { return "[]"} + return "[\(array.count) × \(describe(first, depth: depth + 1))]" + case let number as NSNumber: + // JSONSerialization bridges true/false to NSNumber too, and a + // check-in flag is only readable as a bool if it is not printed as + // 0/1 next to a level count. + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return "Bool(\(number.boolValue))" + } + return "Num(\(number.stringValue))" + case is NSNull: + return "null" + case let string as String: + return string.isEmpty ? "String(empty)" : "String(\(string.count))" + default: + return "\(type(of: value))" + } + } +} \ No newline at end of file diff --git a/TiebaPure/Core/Network/TiebaAPI.swift b/TiebaPure/Core/Network/TiebaAPI.swift index f1347da..5cf4bc4 100644 --- a/TiebaPure/Core/Network/TiebaAPI.swift +++ b/TiebaPure/Core/Network/TiebaAPI.swift @@ -341,23 +341,50 @@ extension TiebaAPI { request.data = requestData let multipart = try requestBuilder.multipart(protobuf: request, account: account, includeSToken: false) - let response = try await client.postProtobuf( - .personalized, - body: multipart.body, - contentType: multipart.contentType, - headers: [ - "X-BD-DATA-TYPE": "protobuf", - "Cookie": "ka=open" - ], - as: Tieba_PersonalizedResponse.self - ) + do { + let response = try await client.postProtobuf( + .personalized, + body: multipart.body, + contentType: multipart.contentType, + headers: [ + "X-BD-DATA-TYPE": "protobuf", + "Cookie": TiebaFeedCookie.value(for: account) + ], + as: Tieba_PersonalizedResponse.self + ) - try validateTiebaError(response.error) - guard response.hasData else { throw TiebaAPIError.emptyResponse } + try validateTiebaError(response.error) + guard response.hasData else { throw TiebaAPIError.emptyResponse } + + let threads = response.data.threadList + .filter(TiebaContentFilter.shouldMap(thread:)) + .map { ThreadMapper.fromThreadInfo($0, usersByID: [:]) } + await AppLog.shared.record( + .info, + "首页推荐", + "第\(requestPage)页 loadType=\(loadType) 已登录=\(account != nil) " + + "原始\(response.data.threadList.count)条 保留\(threads.count)条 " + + "来源吧=\(Self.feedForumNames(threads))" + ) + return threads + } catch { + await AppLog.shared.recordError( + "首页推荐", + "第\(requestPage)页 loadType=\(loadType) 失败", + error: error + ) + throw error + } + } - return response.data.threadList - .filter(TiebaContentFilter.shouldMap(thread:)) - .map { ThreadMapper.fromThreadInfo($0, usersByID: [:]) } + /// The official app's recommendation is built from followed forums, so the + /// forum mix is the evidence for whether personalization actually applied. + private static func feedForumNames(_ threads: [ThreadSummary]) -> String { + let names = threads + .prefix(8) + .compactMap { $0.forumName?.isEmpty == false ? $0.forumName : nil } + guard names.isEmpty == false else { return "(帖子未带吧名)" } + return names.joined(separator: "、") } func followedForums(account: Account) async throws -> [Forum] { diff --git a/TiebaPure/Core/Network/TiebaHotAPI.swift b/TiebaPure/Core/Network/TiebaHotAPI.swift index 4a0a7de..e99b2b1 100644 --- a/TiebaPure/Core/Network/TiebaHotAPI.swift +++ b/TiebaPure/Core/Network/TiebaHotAPI.swift @@ -65,19 +65,47 @@ extension TiebaAPI { account: account, includeSToken: false ) - let response = try await client.postProtobuf( - .hotThreadList, - body: multipart.body, - contentType: multipart.contentType, - headers: [ - "X-BD-DATA-TYPE": "protobuf", - "Cookie": "ka=open" - ], - as: Tieba_HotThreadList_HotThreadListResponse.self - ) + do { + let response = try await client.postProtobuf( + .hotThreadList, + body: multipart.body, + contentType: multipart.contentType, + headers: [ + "X-BD-DATA-TYPE": "protobuf", + "Cookie": TiebaFeedCookie.value(for: account) + ], + as: Tieba_HotThreadList_HotThreadListResponse.self + ) + + try validateTiebaError(response.error) + guard response.hasData else { throw TiebaAPIError.emptyResponse } + let feed = HotFeedMapper.makeFeed(from: response.data) + await AppLog.shared.record( + .info, + "首页热点", + "tabCode=\(tabCode.isEmpty ? "(默认)" : tabCode) 已登录=\(account != nil) " + + "子标签=\(feed.tabs.map(\.name).joined(separator: "/")) 帖子\(feed.threads.count)条 " + + "来源吧=\(Self.forumNames(feed.threads))" + ) + return feed + } catch { + await AppLog.shared.recordError( + "首页热点", + "tabCode=\(tabCode.isEmpty ? "(默认)" : tabCode) 失败", + error: error + ) + throw error + } + } - try validateTiebaError(response.error) - guard response.hasData else { throw TiebaAPIError.emptyResponse } - return HotFeedMapper.makeFeed(from: response.data) + /// A hot listing has no per-forum grouping, so the forum names are the only + /// way to tell "the service sent the global hot list" from "the service sent + /// the tab we expected" once a user compares it with the official app. + private static func forumNames(_ threads: [ThreadSummary]) -> String { + let names = threads + .prefix(8) + .compactMap { $0.forumName?.isEmpty == false ? $0.forumName : nil } + guard names.isEmpty == false else { return "(帖子未带吧名)" } + return names.joined(separator: "、") } } \ No newline at end of file diff --git a/TiebaPure/Core/Network/TiebaRequestBuilder.swift b/TiebaPure/Core/Network/TiebaRequestBuilder.swift index e60e2a7..81ccb95 100644 --- a/TiebaPure/Core/Network/TiebaRequestBuilder.swift +++ b/TiebaPure/Core/Network/TiebaRequestBuilder.swift @@ -8,6 +8,27 @@ enum TiebaClientVersion: String { case mini = "7.2.0.0" } +/// Cookie header for the protobuf feed endpoints. +/// +/// `ka=open` alone marks the request as anonymous, and an anonymous feed is a +/// generic ranking rather than the followed-forum mix the official app shows. +/// The endpoints are same-origin with the tieba domain, so the signed-in +/// credentials belong in the cookie next to `ka=open` — the same pairing the +/// posting layer already uses. +enum TiebaFeedCookie { + static func value(for account: Account?) -> String { + guard let account else { return "ka=open" } + var parts = ["ka=open"] + if account.bduss.isEmpty == false { + parts.append("BDUSS=\(account.bduss)") + } + if account.stoken.isEmpty == false { + parts.append("STOKEN=\(account.stoken)") + } + return parts.joined(separator: "; ") + } +} + struct TiebaRequestBuilder { static let boundary = "--------7da3d81520810*" diff --git a/TiebaPure/Core/Network/TiebaSocialAPI.swift b/TiebaPure/Core/Network/TiebaSocialAPI.swift index 72070fa..75a1171 100644 --- a/TiebaPure/Core/Network/TiebaSocialAPI.swift +++ b/TiebaPure/Core/Network/TiebaSocialAPI.swift @@ -425,23 +425,52 @@ extension TiebaAPI { try TiebaResponseValidator.validate(code: response.errorCode, message: response.errorMessage) guard let data = response.data else { throw TiebaAPIError.emptyResponse } - statuses.append(contentsOf: data.forums.compactMap { forum in + let decoded = data.forums.compactMap { forum -> FollowedForumStatus? in guard forum.forumID > 0 else { return nil } return FollowedForumStatus( forumID: forum.forumID, level: max(forum.level, 0), isSignedToday: forum.isSignedToday ) - }) + } + + await AppLog.shared.record( + .info, + "进吧等级", + "第\(page)页 error_code=\(response.errorCode) has_more=\(data.hasMore) " + + "原始\(data.forums.count)条 有效\(decoded.count)条 " + + "等级分布=\(Self.levelHistogram(decoded))" + ) + + statuses.append(contentsOf: decoded) // An empty page with a sticky "has more" flag would otherwise spin. guard data.hasMore, data.forums.isEmpty == false else { break } page += 1 } + await AppLog.shared.record( + .info, + "进吧等级", + "汇总 \(statuses.count) 个贴吧,等级>0 的 \(statuses.filter { $0.level > 0 }.count) 个," + + "已签到 \(statuses.filter(\.isSignedToday).count) 个" + ) return statuses } + /// Level values that never arrive are the difference between "not signed in" + /// and "wrong field name", and a histogram shows both at a glance. + private static func levelHistogram(_ statuses: [FollowedForumStatus]) -> String { + var buckets: [Int: Int] = [:] + for status in statuses { + buckets[status.level, default: 0] += 1 + } + return buckets + .sorted { $0.key < $1.key } + .map { "Lv\($0.key)×\($0.value)" } + .joined(separator: " ") + } + private func followedForumGuidePage( account: Account, page: Int @@ -451,15 +480,31 @@ extension TiebaAPI { tbs: tbs, page: page ) - return try await client.postForm( + let data = try await client.postFormData( .followedForumGuide, fields: fields, headers: [ "Cookie": account.minimalCookieHeader, "Subapp-Type": "hybrid" - ], - as: FollowedForumGuideResponseDTO.self + ] + ) + + // The guide payload is the least documented response in the app: the + // forum list key, the level key and the check-in key all come from + // observation, so the raw skeleton is recorded before decoding rather + // than after, where a mismatch would already be a silent empty list. + await AppLog.shared.record( + .info, + "进吧等级", + "第\(page)页 原始\(data.count)字节 结构=\(DiagnosticJSON.skeleton(data))" ) + + do { + return try JSONDecoder().decode(FollowedForumGuideResponseDTO.self, from: data) + } catch { + await AppLog.shared.recordError("进吧等级", "第\(page)页解码失败", error: error) + throw error + } } /// Reading the guide needs a valid TBS, but a login normally stores one: diff --git a/TiebaPure/Features/ForumHub/ForumHubView.swift b/TiebaPure/Features/ForumHub/ForumHubView.swift index a7a5a4e..543bdfd 100644 --- a/TiebaPure/Features/ForumHub/ForumHubView.swift +++ b/TiebaPure/Features/ForumHub/ForumHubView.swift @@ -441,13 +441,20 @@ struct ForumHubView: View { ) // A forum list without a level is still a usable forum list, so a // failed status read leaves the tiles unadorned instead of empty. - if let reported = try? await statuses.value, - generation == requestGeneration, - requestedSession == self.account?.sessionIdentity { + // The reason still reaches the diagnostic log, otherwise "no badge" + // and "wrong field name" look identical from the outside. + do { + let reported = try await statuses.value + guard generation == requestGeneration, + requestedSession == self.account?.sessionIdentity else { return } followedStatuses = Dictionary( reported.map { ($0.forumID, $0) }, uniquingKeysWith: { _, latest in latest } ) + } catch is CancellationError { + return + } catch { + await AppLog.shared.recordError("进吧等级", "读取失败,等级与签到不显示", error: error) } } catch is CancellationError { guard generation == requestGeneration, diff --git a/TiebaPure/Features/Settings/DiagnosticsLogView.swift b/TiebaPure/Features/Settings/DiagnosticsLogView.swift new file mode 100644 index 0000000..64db40d --- /dev/null +++ b/TiebaPure/Features/Settings/DiagnosticsLogView.swift @@ -0,0 +1,117 @@ +import SwiftUI +import UIKit + +/// Export surface for `AppLog`. +/// +/// The log exists to answer "what did the service actually send?", so the file +/// is a plain UTF-8 text file the user can attach anywhere, and every entry is +/// redacted before it reaches this view. +struct DiagnosticsLogView: View { + @State private var entries: [AppLog.Entry] = [] + @State private var exportURL: URL? + + var body: some View { + List { + Section { + LabeledContent("已记录", value: "\(entries.count) 条") + + if let exportURL { + ShareLink(item: exportURL) { + Label("导出日志", systemImage: "square.and.arrow.up") + } + .accessibilityIdentifier("diagnostics-export-share") + } else { + Button { + Task { await prepareExport() } + } label: { + Label("导出日志", systemImage: "square.and.arrow.up") + } + .disabled(entries.isEmpty) + .accessibilityIdentifier("diagnostics-export-button") + } + + Button(role: .destructive) { + Task { + await AppLog.shared.clear() + await reload() + } + } label: { + Label("清空日志", systemImage: "trash") + } + .disabled(entries.isEmpty) + .accessibilityIdentifier("diagnostics-clear-button") + } header: { + Text("诊断") + } footer: { + Text("只保存在本机内存,退出应用即清空。导出的内容已自动去掉 BDUSS、STOKEN、tbs 等凭据。") + } + + Section("最近记录") { + if entries.isEmpty { + Text("暂无记录。回到首页或进吧刷新一次,再回来查看。") + .foregroundStyle(.secondary) + .accessibilityIdentifier("diagnostics-empty") + } else { + ForEach(entries.reversed()) { entry in + VStack(alignment: .leading, spacing: TiebaPureTheme.Spacing.xxs) { + HStack(spacing: TiebaPureTheme.Spacing.xs) { + Text(entry.level.rawValue) + .font(.caption2.weight(.semibold)) + .foregroundStyle(levelColor(entry.level)) + Text(entry.category) + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + Text(entry.message) + .font(.system(.footnote, design: .monospaced)) + .textSelection(.enabled) + } + .padding(.vertical, TiebaPureTheme.Spacing.xxs) + } + } + } + } + .navigationTitle("诊断日志") + .task { await reload() } + } + + private func levelColor(_ level: AppLog.Level) -> Color { + switch level { + case .info: return .secondary + case .warning: return .orange + case .error: return .red + } + } + + private func reload() async { + entries = await AppLog.shared.recent(200) + } + + private func prepareExport() async { + let text = await AppLog.shared.exportText(deviceSummary: deviceSummary()) + let url = Self.temporaryFileURL() + do { + try text.write(to: url, atomically: true, encoding: .utf8) + exportURL = url + await reload() + } catch { + // A failed export is itself worth recording; there is nowhere else to + // show it and the in-app list stays usable. + await AppLog.shared.recordError("诊断日志", "导出失败", error: error) + } + } + + private func deviceSummary() -> String { + let device = UIDevice.current + return "\(device.systemName) \(device.systemVersion) / \(device.model) / \(device.name)" + } + + static func temporaryFileURL() -> URL { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd-HHmmss" + let name = "TiebaPure-diagnostics-\(formatter.string(from: Date())).log" + return URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(name) + } +} \ No newline at end of file diff --git a/TiebaPure/Features/Settings/SettingsView.swift b/TiebaPure/Features/Settings/SettingsView.swift index d14559b..7befdfa 100644 --- a/TiebaPure/Features/Settings/SettingsView.swift +++ b/TiebaPure/Features/Settings/SettingsView.swift @@ -94,6 +94,20 @@ struct SettingsView: View { Text("发帖和回帖使用非官方实验接口。开启并使用后,可能触发贴吧风控,造成内容被隐藏或删除、账号功能受限;极端情况下账号可能被冻结。请确认能够承担风险后再使用。关闭点赞后仍会显示点赞数量;设置和屏蔽规则仅保存在本机。") } + Section { + NavigationLink { + DiagnosticsLogView() + } label: { + Label("诊断日志", systemImage: "doc.text.magnifyingglass") + } + .accessibilityHint("查看并导出首页推荐、热点和进吧等级的接口返回记录") + .accessibilityIdentifier("settings-diagnostics-entry") + } header: { + Text("诊断") + } footer: { + Text("记录首页和进吧相关接口的返回情况,用于排查「内容不对」或「等级不显示」的问题。日志只保存在本机内存,退出应用即清空。") + } + if let account { Section { Toggle(isOn: automaticSignSelection) { diff --git a/TiebaPureTests/AppLogTests.swift b/TiebaPureTests/AppLogTests.swift new file mode 100644 index 0000000..7b981c7 --- /dev/null +++ b/TiebaPureTests/AppLogTests.swift @@ -0,0 +1,105 @@ +import XCTest +@testable import TiebaPure + +/// The log is the only evidence channel for undocumented endpoints, so the two +/// properties that make it trustworthy are covered here: a secret never leaves +/// the device, and a captured payload stays readable as a shape description. +final class AppLogTests: XCTestCase { + private func makeLog() -> AppLog { + AppLog() + } + + func testRecordingKeepsEntriesInOrder() async { + let log = makeLog() + await log.record(.info, "分类甲", "第一条") + await log.record(.warning, "分类乙", "第二条") + + let entries = await log.recent() + XCTAssertEqual(entries.count, 2) + XCTAssertEqual(entries.map(\.message), ["第一条", "第二条"]) + XCTAssertEqual(entries.map(\.category), ["分类甲", "分类乙"]) + XCTAssertEqual(entries[1].level, .warning) + } + + func testRecentRespectsLimitAndKeepsNewest() async { + let log = makeLog() + for index in 0 ..< 10 { + await log.record(.info, "分类", "消息\(index)") + } + + let entries = await log.recent(3) + XCTAssertEqual(entries.map(\.message), ["消息7", "消息8", "消息9"]) + } + + func testClearRemovesEverything() async { + let log = makeLog() + await log.record(.info, "分类", "消息") + await log.clear() + + let count = await log.count() + XCTAssertEqual(count, 0) + } + + func testExportContainsEveryRecordedMessage() async { + let log = makeLog() + await log.record(.info, "进吧等级", "第1页 error_code=0") + await log.record(.error, "首页推荐", "第2页 失败") + + let text = await log.exportText(deviceSummary: "iOS 18.0 / iPhone") + XCTAssertTrue(text.contains("第1页 error_code=0")) + XCTAssertTrue(text.contains("第2页 失败")) + XCTAssertTrue(text.contains("iOS 18.0 / iPhone")) + XCTAssertTrue(text.contains("条目数:2")) + } + + func testRedactionRemovesJSONSecretValues() { + let text = #"{"error_code":0,"BDUSS":"abcdef123456","STOKEN":"zzzz","tbs":"qqq"}"# + let redacted = DiagnosticRedaction.redact(text) + + XCTAssertFalse(redacted.contains("abcdef123456")) + XCTAssertFalse(redacted.contains("zzzz")) + XCTAssertFalse(redacted.contains("qqq")) + XCTAssertTrue(redacted.contains("\"error_code\":0")) + } + + func testRedactionRemovesCookieSecretValues() { + let text = "BDUSS=secretvalue; STOKEN=other; ka=open" + let redacted = DiagnosticRedaction.redact(text) + + XCTAssertFalse(redacted.contains("secretvalue")) + XCTAssertFalse(redacted.contains("other")) + XCTAssertTrue(redacted.contains("ka=open")) + } + + func testRedactionKeepsOrdinaryDiagnosticText() { + let text = "第1页 error_code=0 帖子12条 来源吧=百度贴吧、Android" + XCTAssertEqual(DiagnosticRedaction.redact(text), text) + } + + func testRedactionShortensLongOpaqueBlobs() { + let blob = String(repeating: "a1b2c3d4", count: 12) + let redacted = DiagnosticRedaction.redact("原始\(blob)字节") + + XCTAssertFalse(redacted.contains(blob)) + XCTAssertTrue(redacted.contains("字节")) + } + + func testSkeletonReportsKeysWithoutLeakingValues() throws { + let json = """ + {"error_code":0,"data":{"like_forum":[{"forum_id":1,"level_id":7,"is_sign":1}]}} + """ + let skeleton = DiagnosticJSON.skeleton(Data(json.utf8)) + + XCTAssertTrue(skeleton.contains("error_code")) + XCTAssertTrue(skeleton.contains("like_forum")) + XCTAssertTrue(skeleton.contains("level_id")) + XCTAssertTrue(skeleton.contains("is_sign")) + XCTAssertTrue(skeleton.contains("Num(7)")) + } + + func testSkeletonMarksEmptyContainers() { + XCTAssertEqual(DiagnosticJSON.skeleton(Data("{}".utf8)), "{}") + XCTAssertEqual(DiagnosticJSON.skeleton(Data("[]".utf8)), "[]") + XCTAssertEqual(DiagnosticJSON.skeleton(Data("\"\"".utf8)), "String(empty)") + } +} \ No newline at end of file From 331d27272c6cf04cf3d342f122e62f12c21cd86e Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 22:32:14 +0800 Subject: [PATCH 04/11] fix(api): decode forumGuide flat and add an all hot tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 诊断日志证实两处线格式假设错误。 进吧等级:/c/f/forum/forumGuide 的响应是扁平结构,like_forum 与 like_forum_has_more 和 error_code 同级,没有 data 包装层,原 DTO 按嵌套 data 解码后每次都读到 nil,再被 guard 抛成「服务器返回了空数据」。改为顶层 解码(与 aiotieba、tieba-lite-harmony 同一端点同一参数集一致),并把 DataDTO 折叠进 DTO;第 1 页无条目时补一条 ERROR,以区分「0 关注」与 「字段名不符」。 热点:默认请求此前发空 tab_code,服务端只回 4 条残缺列表;官方实现用 tab_code=all,现在空码一律改写为 HotTab.allCode。服务端 hot_thread_tab_info 只含分类子标签,mapper 固定补一个「全部」入口排在最前(服务端已给 all 则 不重复),标签栏初始选中即全部。 诊断:DiagnosticJSON 上限 6 → 40,顶层键列表不再被 …N more 截断;热点日志 改打服务端子标签 name(code) 与原始/保留条数。 测试:扁平指南 DTO(含多余展示键)、缺 like_forum 视为空、空 tab_code → all、 mapper 补全部且不重复、skeleton 不再截断字段列表。 --- Protos/HotThreadList/HotThreadList.proto | 6 ++ TiebaPure/Core/Diagnostics/AppLog.swift | 9 +-- TiebaPure/Core/Network/TiebaHotAPI.swift | 56 +++++++++++++------ TiebaPure/Core/Network/TiebaSocialAPI.swift | 51 +++++++++-------- TiebaPure/Domain/Models/HotFeed.swift | 11 +++- TiebaPure/Features/Home/HotThreadsView.swift | 10 ++-- TiebaPureTests/AppLogTests.swift | 18 +++++- TiebaPureTests/FollowedForumStatusTests.swift | 46 ++++++++------- TiebaPureTests/HotThreadListTests.swift | 45 +++++++++++++-- 9 files changed, 172 insertions(+), 80 deletions(-) diff --git a/Protos/HotThreadList/HotThreadList.proto b/Protos/HotThreadList/HotThreadList.proto index 62655c0..8ed6fe8 100644 --- a/Protos/HotThreadList/HotThreadList.proto +++ b/Protos/HotThreadList/HotThreadList.proto @@ -16,6 +16,12 @@ message FrsTabInfo { string tab_code = 8; } +// Request semantics, verified against the official client and the two public +// reimplementations: tab_id is always "1", and tab_code = "all" is the +// whole-list tab. An empty tab_code is NOT the default — the service answers it +// with a stub of a handful of threads — so callers must send "all" (see +// HotTab.allCode). The tab list the service reports carries only its category +// sub-tabs, never "all", so the 全部 entry is supplied by the client. message HotThreadListRequestData { tieba.CommonRequest common = 1; string tab_id = 2; diff --git a/TiebaPure/Core/Diagnostics/AppLog.swift b/TiebaPure/Core/Diagnostics/AppLog.swift index 185146a..acdd83f 100644 --- a/TiebaPure/Core/Diagnostics/AppLog.swift +++ b/TiebaPure/Core/Diagnostics/AppLog.swift @@ -190,10 +190,11 @@ enum DiagnosticRedaction { /// reported only for the numeric and boolean leaves that make an error code /// readable, and object/array keys are reported in full. enum DiagnosticJSON { - /// A forum guide row has a handful of fields, so the sample is generous - /// enough to show a real shape while a pathological payload still prints - /// a readable prefix. - private static let maxEntriesPerObject = 6 + /// The key list is the payload: a nested-vs-flat mismatch hides exactly in + /// the keys a six-entry cap truncated away, and a guide row carries well + /// under forty fields, so the cap now only guards against a pathological + /// response instead of steering what is visible. + private static let maxEntriesPerObject = 40 static func skeleton(_ data: Data) -> String { let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) diff --git a/TiebaPure/Core/Network/TiebaHotAPI.swift b/TiebaPure/Core/Network/TiebaHotAPI.swift index e99b2b1..85e9d3e 100644 --- a/TiebaPure/Core/Network/TiebaHotAPI.swift +++ b/TiebaPure/Core/Network/TiebaHotAPI.swift @@ -1,9 +1,14 @@ import Foundation enum TiebaHotRequestFactory { - /// The hot-thread endpoint answers with the default tab when the tab code is - /// empty — together with the tab list — and with one specific tab otherwise, - /// so the tab id stays fixed and the code is the only variable part. + /// One listing per sub-tab: the tab id stays fixed and the code is the only + /// variable part. The tab list itself comes back on every response, so the + /// first request needs no discovery round. + /// + /// An empty code is not a usable "default": the service answers it with a + /// stub of a handful of threads, while `tab_code="all"` is the whole hot + /// list. The view therefore asks for `HotTab.allCode` and this mapping only + /// keeps an empty caller from reproducing the stub. static func request( account: Account?, tabCode: String, @@ -12,7 +17,7 @@ enum TiebaHotRequestFactory { var requestData = Tieba_HotThreadList_HotThreadListRequestData() requestData.common = requestBuilder.common(account: account) requestData.tabID = "1" - requestData.tabCode = tabCode + requestData.tabCode = tabCode.isEmpty ? HotTab.allCode : tabCode var request = Tieba_HotThreadList_HotThreadListRequest() request.data = requestData @@ -24,13 +29,21 @@ enum HotFeedMapper { static func makeFeed( from data: Tieba_HotThreadList_HotThreadListResponseData ) -> HotFeed { - HotFeed( - tabs: data.hotThreadTabInfo.compactMap { tab in - // A tab without a code cannot be requested, and a tab without a - // name has nothing to show on the tab bar. - guard tab.tabCode.isEmpty == false, tab.tabName.isEmpty == false else { return nil } - return HotTab(code: tab.tabCode, name: tab.tabName) - }, + let reported = data.hotThreadTabInfo.compactMap { tab -> HotTab? in + // A tab without a code cannot be requested, and a tab without a + // name has nothing to show on the tab bar. + guard tab.tabCode.isEmpty == false, tab.tabName.isEmpty == false else { return nil } + return HotTab(code: tab.tabCode, name: tab.tabName) + } + var tabs = reported + // The service lists only its category sub-tabs, so without this chip + // the bar would have no way back to the whole list after a category is + // picked. A category already called "all" would then duplicate it. + if tabs.contains(where: { $0.code == HotTab.allCode }) == false { + tabs.insert(HotTab.all, at: 0) + } + return HotFeed( + tabs: tabs, threads: dedupedByID( data.threadInfo .filter(TiebaContentFilter.shouldMap(thread:)) @@ -50,10 +63,9 @@ enum HotFeedMapper { extension TiebaAPI { /// 热点 (hot threads) for one sub-tab of the home hot-thread tab. /// - /// An empty `tabCode` asks for the service's default tab and the tab list, - /// which is how the tab bootstraps its own sub-tabs before the user picks - /// one. Threads are filtered and mapped exactly like the personalized feed - /// so both tabs share one row presentation. + /// An empty `tabCode` is resolved to `HotTab.allCode`, so every request + /// names a real listing. Threads are filtered and mapped exactly like the + /// personalized feed so both tabs share one row presentation. func hotThreads(account: Account?, tabCode: String) async throws -> HotFeed { let request = TiebaHotRequestFactory.request( account: account, @@ -80,18 +92,26 @@ extension TiebaAPI { try validateTiebaError(response.error) guard response.hasData else { throw TiebaAPIError.emptyResponse } let feed = HotFeedMapper.makeFeed(from: response.data) + // The service's own tab list (with codes) is what decides whether a + // missing 全部 is a server change or a local one, and the raw count + // separates "the service sent four threads" from "we filtered them". + let serverTabs = response.data.hotThreadTabInfo + .map { "\($0.tabName)(\($0.tabCode))" } + .joined(separator: "/") await AppLog.shared.record( .info, "首页热点", - "tabCode=\(tabCode.isEmpty ? "(默认)" : tabCode) 已登录=\(account != nil) " - + "子标签=\(feed.tabs.map(\.name).joined(separator: "/")) 帖子\(feed.threads.count)条 " + "tabCode=\(tabCode.isEmpty ? HotTab.allCode : tabCode) 已登录=\(account != nil) " + + "服务端子标签=\(serverTabs.isEmpty ? "(无)" : serverTabs) " + + "展示=\(feed.tabs.map(\.name).joined(separator: "/")) " + + "原始\(response.data.threadInfo.count)条 保留\(feed.threads.count)条 " + "来源吧=\(Self.forumNames(feed.threads))" ) return feed } catch { await AppLog.shared.recordError( "首页热点", - "tabCode=\(tabCode.isEmpty ? "(默认)" : tabCode) 失败", + "tabCode=\(tabCode.isEmpty ? HotTab.allCode : tabCode) 失败", error: error ) throw error diff --git a/TiebaPure/Core/Network/TiebaSocialAPI.swift b/TiebaPure/Core/Network/TiebaSocialAPI.swift index 75a1171..c61e5cd 100644 --- a/TiebaPure/Core/Network/TiebaSocialAPI.swift +++ b/TiebaPure/Core/Network/TiebaSocialAPI.swift @@ -367,37 +367,29 @@ struct FollowedForumGuideResponseDTO: Decodable { } } - struct DataDTO: Decodable { - var forums: [ForumDTO] - var hasMore: Bool - - enum CodingKeys: String, CodingKey { - case forums = "like_forum" - case hasMore = "like_forum_has_more" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - forums = try container.decodeIfPresent([ForumDTO].self, forKey: .forums) ?? [] - hasMore = container.flexibleBool(forKey: .hasMore) - } - } - + /// The guide payload is flat: the list and its paging flag sit next to the + /// error code, not inside a `data` wrapper. Decoding a nested `data` read + /// `nil` on every successful response, which the caller then reported as + /// "the service answered with empty data" — the exact 39KB-then-failure + /// pair the diagnostic log recorded. + var forums: [ForumDTO] + var hasMore: Bool var errorCode: Int var errorMessage: String - var data: DataDTO? enum CodingKeys: String, CodingKey { + case forums = "like_forum" + case hasMore = "like_forum_has_more" case errorCode = "error_code" case errorMessage = "error_msg" - case data } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + forums = try container.decodeIfPresent([ForumDTO].self, forKey: .forums) ?? [] + hasMore = container.flexibleBool(forKey: .hasMore) errorCode = container.flexibleInt(forKey: .errorCode) errorMessage = container.decodeStringIfPresent(forKey: .errorMessage) ?? "" - data = try container.decodeIfPresent(DataDTO.self, forKey: .data) } } @@ -423,9 +415,8 @@ extension TiebaAPI { while page <= FollowedForumGuidePolicy.maximumPages { let response = try await followedForumGuidePage(account: account, page: page) try TiebaResponseValidator.validate(code: response.errorCode, message: response.errorMessage) - guard let data = response.data else { throw TiebaAPIError.emptyResponse } - let decoded = data.forums.compactMap { forum -> FollowedForumStatus? in + let decoded = response.forums.compactMap { forum -> FollowedForumStatus? in guard forum.forumID > 0 else { return nil } return FollowedForumStatus( forumID: forum.forumID, @@ -437,15 +428,27 @@ extension TiebaAPI { await AppLog.shared.record( .info, "进吧等级", - "第\(page)页 error_code=\(response.errorCode) has_more=\(data.hasMore) " - + "原始\(data.forums.count)条 有效\(decoded.count)条 " + "第\(page)页 error_code=\(response.errorCode) has_more=\(response.hasMore) " + + "原始\(response.forums.count)条 有效\(decoded.count)条 " + "等级分布=\(Self.levelHistogram(decoded))" ) + // A first page without the list is either an account with no + // followed forums or a field-name miss — the line above carries the + // raw key list, which is the only thing that tells them apart. + if page == 1, response.forums.isEmpty { + await AppLog.shared.record( + .error, + "进吧等级", + "第1页没有 like_forum 条目:要么该吧关注数为0,要么字段名对不上;键列表见上一行" + ) + break + } + statuses.append(contentsOf: decoded) // An empty page with a sticky "has more" flag would otherwise spin. - guard data.hasMore, data.forums.isEmpty == false else { break } + guard response.hasMore, response.forums.isEmpty == false else { break } page += 1 } diff --git a/TiebaPure/Domain/Models/HotFeed.swift b/TiebaPure/Domain/Models/HotFeed.swift index 273b38d..eb15c3f 100644 --- a/TiebaPure/Domain/Models/HotFeed.swift +++ b/TiebaPure/Domain/Models/HotFeed.swift @@ -10,9 +10,16 @@ struct HotFeed: Equatable, Sendable { var threads: [ThreadSummary] = [] } -/// One sub-tab of the hot-thread listing. An empty code means the service's -/// default tab, which is how the tab asks for the first page. +/// One sub-tab of the hot-thread listing. struct HotTab: Identifiable, Equatable, Sendable { + /// The whole-list tab. The service reports only its category sub-tabs + /// (视频/长更/游戏/数码…) in `hot_thread_tab_info`, and `tab_code="all"` is + /// the only request that answers with the full hot list — so the chip is + /// built here, and it is also what a freshly opened tab starts on. + static let allCode = "all" + static let allName = "全部" + static let all = HotTab(code: allCode, name: allName) + var code: String var name: String diff --git a/TiebaPure/Features/Home/HotThreadsView.swift b/TiebaPure/Features/Home/HotThreadsView.swift index ec12de2..aff1a72 100644 --- a/TiebaPure/Features/Home/HotThreadsView.swift +++ b/TiebaPure/Features/Home/HotThreadsView.swift @@ -45,7 +45,7 @@ struct HotThreadsView: View { let onOpenUser: (UserSummary) -> Void @State private var tabs: [HotTab] = [] - @State private var selectedTabCode = "" + @State private var selectedTabCode = HotTab.allCode @State private var threads: [ThreadSummary] = [] @State private var isLoading = false @State private var didLoad = false @@ -71,7 +71,7 @@ struct HotThreadsView: View { requestGeneration += 1 loadTask?.cancel() tabs = [] - selectedTabCode = "" + selectedTabCode = HotTab.allCode threads = [] errorMessage = nil didLoad = false @@ -227,9 +227,11 @@ struct HotThreadsView: View { tabs = feed.tabs threads = feed.threads.filter(TiebaContentFilter.shouldKeep(thread:)) // A sub-tab the service stopped reporting must not leave the tab bar - // pointing at a listing that can no longer be requested. + // pointing at a listing that can no longer be requested. 全部 is + // always on the bar, so the selection falls back to it and the next + // refresh puts the highlighted chip and the list back in agreement. if tabs.contains(where: { $0.code == tabCode }) == false { - selectedTabCode = "" + selectedTabCode = HotTab.allCode } } catch is CancellationError { guard generation == requestGeneration, diff --git a/TiebaPureTests/AppLogTests.swift b/TiebaPureTests/AppLogTests.swift index 7b981c7..c988b51 100644 --- a/TiebaPureTests/AppLogTests.swift +++ b/TiebaPureTests/AppLogTests.swift @@ -102,4 +102,20 @@ final class AppLogTests: XCTestCase { XCTAssertEqual(DiagnosticJSON.skeleton(Data("[]".utf8)), "[]") XCTAssertEqual(DiagnosticJSON.skeleton(Data("\"\"".utf8)), "String(empty)") } -} \ No newline at end of file + + func testSkeletonNeverTruncatesTheFieldListTheDiagnosisDependsOn() { + // 顶层键列表就是诊断依据:缺了 like_forum 之外的某个键看不出扁平/嵌套, + // 所以超过旧的 6 键上限也必须一个不少地打出来。 + let json = #"{"block_pop_info":null,"ctime":"1","data":null,"error_code":0,"error_msg":"","fold_display_num":null,"forum_create_info":null,"like_forum":[],"like_forum_has_more":0,"other":1,"user":{}}"# + let skeleton = DiagnosticJSON.skeleton(Data(json.utf8)) + + for key in [ + "block_pop_info", "ctime", "data", "error_code", "error_msg", + "fold_display_num", "forum_create_info", "like_forum", + "like_forum_has_more", "other", "user" + ] { + XCTAssertTrue(skeleton.contains(key), "字段 \(key) 不该被截断") + } + XCTAssertFalse(skeleton.contains("…"), "字段列表不该再出现 …N more 截断尾") + } +} diff --git a/TiebaPureTests/FollowedForumStatusTests.swift b/TiebaPureTests/FollowedForumStatusTests.swift index 438c0f4..3480dcf 100644 --- a/TiebaPureTests/FollowedForumStatusTests.swift +++ b/TiebaPureTests/FollowedForumStatusTests.swift @@ -45,55 +45,59 @@ final class FollowedForumStatusTests: XCTestCase { } func testGuideResponseDecodesLevelAndCheckInForEveryForum() throws { + // The service answers FLAT: like_forum sits beside error_code, with no + // data wrapper, and unrelated display keys ride along. Decoding a + // nested data read nil on every success and reported the whole payload + // as "empty data". let json = """ { "error_code": 0, "error_msg": "", - "data": { - "like_forum": [ - { "forum_id": "4242", "forum_name": "合成吧", "level_id": "12", "is_sign": true }, - { "forum_id": 4343, "forum_name": "另一个吧", "level_id": 3, "is_sign": 0 } - ], - "like_forum_has_more": "0" - } + "ctime": "1", + "block_pop_info": null, + "forum_create_info": null, + "like_forum": [ + { "forum_id": "4242", "forum_name": "合成吧", "level_id": "12", "is_sign": true }, + { "forum_id": 4343, "forum_name": "另一个吧", "level_id": 3, "is_sign": 0 } + ], + "like_forum_has_more": "0" } """ let response = try JSONDecoder().decode(FollowedForumGuideResponseDTO.self, from: Data(json.utf8)) XCTAssertEqual(response.errorCode, 0) - let forums = try XCTUnwrap(response.data?.forums) - XCTAssertEqual(forums.count, 2) - XCTAssertEqual(forums[0].forumID, 4242) - XCTAssertEqual(forums[0].level, 12, "字符串等级也要解出来") - XCTAssertTrue(forums[0].isSignedToday, "布尔签到态也要解出来") - XCTAssertEqual(forums[1].forumID, 4343) - XCTAssertEqual(forums[1].level, 3) - XCTAssertFalse(forums[1].isSignedToday, "数字签到态也要解出来") - XCTAssertEqual(response.data?.hasMore, false) + XCTAssertEqual(response.forums.count, 2) + XCTAssertEqual(response.forums[0].forumID, 4242) + XCTAssertEqual(response.forums[0].level, 12, "字符串等级也要解出来") + XCTAssertTrue(response.forums[0].isSignedToday, "布尔签到态也要解出来") + XCTAssertEqual(response.forums[1].forumID, 4343) + XCTAssertEqual(response.forums[1].level, 3) + XCTAssertFalse(response.forums[1].isSignedToday, "数字签到态也要解出来") + XCTAssertEqual(response.hasMore, false) } func testGuideResponseTreatsMissingForumListAsEmpty() throws { let json = """ - { "error_code": 0, "error_msg": "", "data": { "like_forum_has_more": 1 } } + { "error_code": 0, "error_msg": "", "like_forum_has_more": 1 } """ let response = try JSONDecoder().decode(FollowedForumGuideResponseDTO.self, from: Data(json.utf8)) - XCTAssertEqual(response.data?.forums.isEmpty, true) - XCTAssertEqual(response.data?.hasMore, true) + XCTAssertEqual(response.forums.isEmpty, true) + XCTAssertEqual(response.hasMore, true) } func testGuideResponseSurfacesServiceErrorCode() throws { let json = """ - { "error_code": 1989, "error_msg": "参数错误", "data": null } + { "error_code": 1989, "error_msg": "参数错误" } """ let response = try JSONDecoder().decode(FollowedForumGuideResponseDTO.self, from: Data(json.utf8)) XCTAssertEqual(response.errorCode, 1989) XCTAssertEqual(response.errorMessage, "参数错误") - XCTAssertNil(response.data) + XCTAssertEqual(response.forums.isEmpty, true) } func testTileLabelReadsLevelAndCheckInAsOneSentence() { diff --git a/TiebaPureTests/HotThreadListTests.swift b/TiebaPureTests/HotThreadListTests.swift index 75cba03..82e4c76 100644 --- a/TiebaPureTests/HotThreadListTests.swift +++ b/TiebaPureTests/HotThreadListTests.swift @@ -155,7 +155,7 @@ final class HotThreadListTests: XCTestCase { XCTAssertEqual(String(decoding: tabCode.payload, as: UTF8.self), "hot_all") } - func testRequestWithoutTabCodeOmitsTheCodeField() throws { + func testRequestWithoutTabCodeAsksForTheAllTab() throws { let request = TiebaHotRequestFactory.request( account: nil, tabCode: "", @@ -166,9 +166,14 @@ final class HotThreadListTests: XCTestCase { let dataFields = try Self.wireFields(outer[0].payload) XCTAssertEqual( - dataFields.map(\.number), - [1, 2], - "默认分页请求只带 common 与固定的 tab_id,不写空 tab_code" + dataFields.map(\.number).sorted(), + [1, 2, 3], + "空 tab_code 会被改写成 all:服务端对空代码只回一条残缺列表" + ) + let tabCode = try XCTUnwrap(dataFields.first { $0.number == 3 }) + XCTAssertEqual( + String(decoding: tabCode.payload, as: UTF8.self), + HotTab.allCode ) } @@ -183,7 +188,11 @@ final class HotThreadListTests: XCTestCase { XCTAssertEqual(decoded.data.hotThreadTabInfo[0].tabCode, "hot_all") let tabs = HotFeedMapper.makeFeed(from: decoded.data).tabs - XCTAssertEqual(tabs, [HotTab(code: "hot_all", name: "综合")]) + XCTAssertEqual( + tabs, + [HotTab.all, HotTab(code: "hot_all", name: "综合")], + "全部恒排第一,服务端的分类标签紧随其后" + ) } // MARK: - Hand-crafted wire bytes, one assertion per test @@ -250,7 +259,31 @@ final class HotThreadListTests: XCTestCase { let feed = HotFeedMapper.makeFeed(from: responseData) - XCTAssertEqual(feed.tabs, [HotTab(code: "hot_pic", name: "图片")]) + XCTAssertEqual( + feed.tabs, + [HotTab.all, HotTab(code: "hot_pic", name: "图片")], + "被丢弃的两个坏标签不出现,本地补的全部仍在最前" + ) + } + + func testServerReportedAllTabIsNotDuplicated() { + var responseData = Tieba_HotThreadList_HotThreadListResponseData() + + var serviceAll = Tieba_HotThreadList_FrsTabInfo() + serviceAll.tabCode = HotTab.allCode + serviceAll.tabName = "综合" + var other = Tieba_HotThreadList_FrsTabInfo() + other.tabCode = "hot_pic" + other.tabName = "图片" + responseData.hotThreadTabInfo = [serviceAll, other] + + let feed = HotFeedMapper.makeFeed(from: responseData) + + XCTAssertEqual( + feed.tabs, + [HotTab(code: HotTab.allCode, name: "综合"), HotTab(code: "hot_pic", name: "图片")], + "服务端已给 all 时不能再补一个,否则 tab 识别 id 重复" + ) } func testRepeatedThreadsAreDedupedByID() { From 15f17781fce2b5bbe0c6fdf8f604ef7928f59e69 Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 13:51:55 +0800 Subject: [PATCH 05/11] feat(forumhub): skip already-signed forums and show check-in progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机反馈:一键签到只有转圈,看不出进行到哪、还剩多少;而且对今天已签到的吧也照样发 一次请求,关注列表长了就很慢。三个改动: - 签到前先读一次关注吧 guide(forumGuide 的 is_sign),今天已签到的吧直接计入 今天已签过并跳过请求。关注 100 个吧、其中 90 个已签时,写请求从 100 次降到 10 次。 - guide 读不到时不清空任何吧(重复签到只是无操作的 no-op),并往诊断日志记一条 error, 避免读状态失败被当成全都签过了。 - 新增 ForumSignProgress(已完成/总数/当前吧名)并发布:进吧签到区显示 3/120 计数、 正在签到:xx吧 一行、以及取消按钮;结束后清空,结果仍由既有 alert 汇报。设置页说明 文案同步改为今天已签到的贴吧会直接跳过。 Fixture 新增 .signAlreadyDone 场景驱动 guide 返回签到态;补 3 个测试(跳过已签到、 进度发布并在结束后清空、进度文案)。build number 59 到 60。 --- TiebaPure/Core/Network/FixtureTiebaAPI.swift | 16 ++++ .../Core/State/ForumSignCoordinator.swift | 83 ++++++++++++++++++- .../Domain/Models/ForumSignSettings.swift | 35 ++++++++ .../Features/ForumHub/ForumHubView.swift | 29 ++++++- .../Features/Settings/SettingsView.swift | 2 +- TiebaPureTests/ForumSignTests.swift | 76 +++++++++++++++++ 6 files changed, 235 insertions(+), 6 deletions(-) diff --git a/TiebaPure/Core/Network/FixtureTiebaAPI.swift b/TiebaPure/Core/Network/FixtureTiebaAPI.swift index 5d7d262..07cd707 100644 --- a/TiebaPure/Core/Network/FixtureTiebaAPI.swift +++ b/TiebaPure/Core/Network/FixtureTiebaAPI.swift @@ -25,6 +25,7 @@ enum FixtureScenario: String { case forumCategoryRace case voicePlayback case signFailure + case signAlreadyDone case readingPosition case scrollPerformance case layoutPreview @@ -596,6 +597,21 @@ struct FixtureTiebaAPI: TiebaAPIService { ) } + /// The guide listing the check-in coordinator reads to skip forums that are + /// already signed today. Services without that listing reject the call, so + /// only the dedicated scenario answers it: the first forum is done for the + /// day, the second still needs a request. + func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] { + try await prepare() + guard scenario == .signAlreadyDone else { + throw UserProfileMutationError.unsupportedByService + } + return [ + FollowedForumStatus(forumID: Self.forum.id, level: 7, isSignedToday: true), + FollowedForumStatus(forumID: Self.forumTwo.id, level: 3, isSignedToday: false) + ] + } + func accountThreadFavorites(account: Account, page: Int) async throws -> AccountThreadFavoritesPage { try await prepare(page: page) guard scenario != .empty, page == 1 else { diff --git a/TiebaPure/Core/State/ForumSignCoordinator.swift b/TiebaPure/Core/State/ForumSignCoordinator.swift index 5d574dd..338005f 100644 --- a/TiebaPure/Core/State/ForumSignCoordinator.swift +++ b/TiebaPure/Core/State/ForumSignCoordinator.swift @@ -5,12 +5,17 @@ import Foundation /// The requests are issued one at a time on purpose: a burst of writes from a /// third-party client is exactly what the service rate-limits, and a check-in /// that silently drops half the forums is worse than one that takes a few -/// seconds longer. +/// seconds longer. Forums the guide already reports as signed today are skipped +/// without a request, so a long followed list does not become a long serial run +/// — and the run publishes its counters, because a spinner alone cannot tell a +/// two-second run from a two-minute one. @MainActor final class ForumSignCoordinator: ObservableObject { @Published private(set) var isRunning = false @Published private(set) var lastSummary: ForumSignRunSummary? @Published private(set) var lastError: String? + /// Live counters for the run the UI is presenting, nil when nothing runs. + @Published private(set) var progress: ForumSignProgress? private let api: any TiebaAPIService private let settings: ForumSignSettingsStore @@ -63,12 +68,36 @@ final class ForumSignCoordinator: ObservableObject { do { let forums = try await api.followedForums(account: account) try Task.checkCancellation() - for (index, forum) in forums.enumerated() { + + // Today's check-in only has work for the forums the guide does + // not already mark as signed. Sending those writes anyway costs + // one throttled round trip per forum to learn what the service + // has just said. + let alreadySignedToday = await signedForumIDs(account: account) + try Task.checkCancellation() + let pending = forums.filter { alreadySignedToday.contains($0.id) == false } + summary.alreadySignedCount += forums.count - pending.count + var completed = forums.count - pending.count + publishProgress( + completed: completed, + total: forums.count, + session: session, + summary: summary + ) + + for (index, forum) in pending.enumerated() { if index > 0 { try await Task.sleep(for: requestSpacing) } do { try Task.checkCancellation() + publishProgress( + completed: completed, + total: forums.count, + currentForumName: forum.displayName, + session: session, + summary: summary + ) let result = try await api.signForum(account: account, forum: forum) try Task.checkCancellation() if result.wasAlreadySigned { @@ -81,6 +110,13 @@ final class ForumSignCoordinator: ObservableObject { } catch { summary.failedForumNames.append(forum.displayName) } + completed += 1 + publishProgress( + completed: completed, + total: forums.count, + session: session, + summary: summary + ) } // Only a run that reached every forum counts as today's run; // otherwise tomorrow's automatic attempt would be skipped after @@ -193,6 +229,48 @@ final class ForumSignCoordinator: ObservableObject { globalInvalidationCount == 0 && sessionInvalidationCounts[session] == nil } + /// Only the session the UI is showing drives the visible counters; a run for + /// a replaced login keeps its own state until it is drained. + private func publishProgress( + completed: Int, + total: Int, + currentForumName: String? = nil, + session: AccountSessionIdentity, + summary: ForumSignRunSummary + ) { + guard presentationSession == session else { return } + progress = ForumSignProgress( + completed: completed, + total: total, + currentForumName: currentForumName, + signedCount: summary.signedCount, + alreadySignedCount: summary.alreadySignedCount, + failedCount: summary.failedForumNames.count + ) + } + + /// Forum IDs the service already reports as signed today. + /// + /// A service without the guide listing cannot answer, and an unknown sign + /// state must not read as "everything is done": signing an already-signed + /// forum is a harmless no-op, so the run attempts every forum instead and + /// leaves a trace in the diagnostic log. + private func signedForumIDs(account: Account) async -> Set { + do { + let statuses = try await api.followedForumStatuses(account: account) + return Set(statuses.filter(\.isSignedToday).map(\.forumID)) + } catch is CancellationError { + return [] + } catch { + await AppLog.shared.recordError( + "一键签到", + "读取今日签到状态失败,本次不跳过任何贴吧", + error: error + ) + return [] + } + } + private func finishRun( id: UUID, session: AccountSessionIdentity, @@ -202,6 +280,7 @@ final class ForumSignCoordinator: ObservableObject { runs[session] = nil isRunning = runs.isEmpty == false guard presentationSession == session else { return } + progress = nil if outcome.wasCancelled == false { lastSummary = outcome.summary lastError = outcome.errorMessage diff --git a/TiebaPure/Domain/Models/ForumSignSettings.swift b/TiebaPure/Domain/Models/ForumSignSettings.swift index 546f002..2106366 100644 --- a/TiebaPure/Domain/Models/ForumSignSettings.swift +++ b/TiebaPure/Domain/Models/ForumSignSettings.swift @@ -97,6 +97,41 @@ struct ForumSignRunSummary: Equatable, Sendable { ) } +/// Live state of a check-in run. +/// +/// The run issues one throttled write per forum, so a followed list of any size +/// spends a long time with nothing on screen but a spinner. `completed` counts +/// forums the run is done with — including the ones skipped because today's +/// check-in already happened — so the number matches the followed list the user +/// is looking at. +struct ForumSignProgress: Equatable, Sendable { + var completed: Int + var total: Int + var currentForumName: String? + var signedCount: Int + var alreadySignedCount: Int + var failedCount: Int + + var isFinished: Bool { completed >= total } +} + +enum ForumSignProgressText { + /// One line for the section header: the counter is what changes while the + /// run works through the list. + static func counter(for progress: ForumSignProgress) -> String { + "\(progress.completed)/\(progress.total)" + } + + /// The forum currently being signed, or nil when nothing is in flight. + static func currentForum(for progress: ForumSignProgress) -> String? { + guard let name = progress.currentForumName, + name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { + return nil + } + return "正在签到:\(name)" + } +} + enum ForumSignSummaryText { static func message(for summary: ForumSignRunSummary) -> String { guard summary.isEmpty == false else { diff --git a/TiebaPure/Features/ForumHub/ForumHubView.swift b/TiebaPure/Features/ForumHub/ForumHubView.swift index 543bdfd..555ace1 100644 --- a/TiebaPure/Features/ForumHub/ForumHubView.swift +++ b/TiebaPure/Features/ForumHub/ForumHubView.swift @@ -168,9 +168,25 @@ struct ForumHubView: View { Spacer(minLength: TiebaPureTheme.Spacing.sm) if account != nil, visibleFollowedForums.isEmpty == false { if signCoordinator.isRunning { - ProgressView() - .controlSize(.small) - .accessibilityLabel("正在签到") + HStack(spacing: TiebaPureTheme.Spacing.xs) { + ProgressView() + .controlSize(.small) + .accessibilityLabel("正在签到") + if let progress = signCoordinator.progress { + Text(ForumSignProgressText.counter(for: progress)) + .font(.footnote) + .monospacedDigit() + .foregroundStyle(.secondary) + .accessibilityIdentifier("forum-hub-sign-progress") + } + Button("取消") { + signCoordinator.cancel() + } + .font(.footnote) + .textCase(nil) + .accessibilityLabel("取消签到") + .accessibilityIdentifier("forum-hub-sign-cancel") + } } else { Button("一键签到") { startSignAllFollowedForums() @@ -182,6 +198,13 @@ struct ForumHubView: View { } } } + } footer: { + if let progress = signCoordinator.progress, + let currentForum = ForumSignProgressText.currentForum(for: progress) { + Text(currentForum) + .font(.footnote) + .accessibilityIdentifier("forum-hub-sign-current-forum") + } } } .scrollContentBackground(.hidden) diff --git a/TiebaPure/Features/Settings/SettingsView.swift b/TiebaPure/Features/Settings/SettingsView.swift index 7befdfa..93e5235 100644 --- a/TiebaPure/Features/Settings/SettingsView.swift +++ b/TiebaPure/Features/Settings/SettingsView.swift @@ -229,7 +229,7 @@ struct SettingsView: View { if let message = signStatusMessage { return message } - return "签到会按关注列表逐个请求,需要几秒到几十秒;同一天只会自动执行一次。" + return "今天已签到的贴吧会直接跳过,只对还没签的吧逐个请求;同一天只会自动执行一次。" } private var signStatusMessage: String? { diff --git a/TiebaPureTests/ForumSignTests.swift b/TiebaPureTests/ForumSignTests.swift index 20a6122..df67c06 100644 --- a/TiebaPureTests/ForumSignTests.swift +++ b/TiebaPureTests/ForumSignTests.swift @@ -194,6 +194,33 @@ final class ForumSignTests: XCTestCase { ) } + func testProgressTextReportsTheCounterAndTheForumInFlight() { + let running = ForumSignProgress( + completed: 3, + total: 12, + currentForumName: "壁纸吧", + signedCount: 2, + alreadySignedCount: 1, + failedCount: 0 + ) + + XCTAssertEqual(ForumSignProgressText.counter(for: running), "3/12") + XCTAssertEqual(ForumSignProgressText.currentForum(for: running), "正在签到:壁纸吧") + XCTAssertFalse(running.isFinished) + + var idle = running + idle.currentForumName = nil + XCTAssertNil(ForumSignProgressText.currentForum(for: idle), "没有在签的吧时不显示这一行") + + var blank = running + blank.currentForumName = " " + XCTAssertNil(ForumSignProgressText.currentForum(for: blank), "空白吧名同样不显示") + + var done = running + done.completed = 12 + XCTAssertTrue(done.isFinished) + } + @MainActor func testCoordinatorSignsEveryFollowedForumAndRecordsTheDay() async throws { let defaults = try makeScratchDefaults() @@ -219,6 +246,55 @@ final class ForumSignTests: XCTestCase { XCTAssertEqual(repeated.alreadySignedCount, 2) } + @MainActor + func testRunSkipsForumsTheGuideAlreadyReportsAsSignedToday() async throws { + let defaults = try makeScratchDefaults() + let settings = ForumSignSettingsStore(defaults: defaults) + let coordinator = ForumSignCoordinator( + api: FixtureTiebaAPI(scenario: .signAlreadyDone), + settings: settings, + requestSpacing: .zero + ) + + let summary = await coordinator.signAllFollowedForums(account: account) + + XCTAssertEqual(summary.signedCount, 1, "只有今天未签到的那个吧才发请求") + XCTAssertEqual(summary.alreadySignedCount, 1, "已签到的吧直接计入已签过") + XCTAssertTrue(summary.failedForumNames.isEmpty) + XCTAssertTrue( + settings.hasRunToday(accountID: account.id), + "全部已签到时也算今天跑过,否则明天之前会反复自动重试" + ) + } + + @MainActor + func testRunPublishesProgressWhileSigningAndClearsItAfterwards() async throws { + let defaults = try makeScratchDefaults() + let coordinator = ForumSignCoordinator( + api: FixtureTiebaAPI(scenario: .success, delayMilliseconds: 60), + settings: ForumSignSettingsStore(defaults: defaults), + requestSpacing: .zero + ) + + let run = Task { @MainActor in + await coordinator.signAllFollowedForums(account: account) + } + + var observed: ForumSignProgress? + for _ in 0..<400 where observed == nil { + try await Task.sleep(nanoseconds: 1_000_000) + observed = coordinator.progress + } + + let progress = try XCTUnwrap(observed, "签到过程中应能读到进度") + XCTAssertEqual(progress.total, 2) + XCTAssertFalse(progress.isFinished, "总数 2 时中途不可能已经结束") + + _ = await run.value + XCTAssertNil(coordinator.progress, "结束后进度清空,不再显示上一次的数字") + XCTAssertFalse(coordinator.isRunning) + } + @MainActor func testAutomaticRunIsSkippedWhenDisabledOrAlreadyDoneToday() async throws { let defaults = try makeScratchDefaults() From c4a991e84c7b8e6144cb4113301a74cc71e58616 Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 22:32:15 +0800 Subject: [PATCH 06/11] build(protos): declare the topic list and hot_num MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 热点「全部」要按真实热度合并四个分类,并把话题榜渲染到页面顶部,两者都用到现在 被当作未知字段丢掉的字段: - HotThreadListResponseData 增加 topic_list = 1(RecomTopicList:topic_id=1 / topic_name=2 / discuss_num=4),即官方热点页顶部那块热议话题。 - ThreadInfo 增加 optional hot_num = 182:热点响应每条都带它,且每个分类列表 本身已按它降序,合并后的「全部」用它排序。 - scripts/generate-ios-protos.sh 的产物清单与 .proto 同步更新;Swift 生成产物 一并提交(nameMap 字节码无法从现有样例可靠反推,不能手写)。 --- Protos/HotThreadList/HotThreadList.proto | 25 ++++++++++++++++-------- Protos/ThreadInfo.proto | 3 +++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Protos/HotThreadList/HotThreadList.proto b/Protos/HotThreadList/HotThreadList.proto index 8ed6fe8..5f25ec9 100644 --- a/Protos/HotThreadList/HotThreadList.proto +++ b/Protos/HotThreadList/HotThreadList.proto @@ -8,20 +8,28 @@ import "ThreadInfo.proto"; // Project-maintained projection of the hot-thread (热榜) endpoint. Field numbers // are wire identifiers and must only change with a matching fixture update. -// Only the fields TiebaPure reads are declared; the topic list and inner tabs -// the endpoint also returns are skipped by Protobuf as unknown fields. +// Only the fields TiebaPure reads are declared; the inner-tab and per-topic +// fields the endpoint also returns are skipped by Protobuf as unknown fields. message FrsTabInfo { string tab_name = 3; string tab_code = 8; } -// Request semantics, verified against the official client and the two public -// reimplementations: tab_id is always "1", and tab_code = "all" is the -// whole-list tab. An empty tab_code is NOT the default — the service answers it -// with a stub of a handful of threads — so callers must send "all" (see -// HotTab.allCode). The tab list the service reports carries only its category -// sub-tabs, never "all", so the 全部 entry is supplied by the client. +// One 话题榜 (hot-topic) entry. Every response carries the same list — the +// default request included — so the section needs no extra call. +message RecomTopicList { + uint64 topic_id = 1; + string topic_name = 2; + uint64 discuss_num = 4; +} + +// Request semantics, measured against the service itself (2026-10-02): +// tab_id is always "1", and the tab list carries only the four category +// sub-tabs (视频/长更/游戏/数码) — never a whole-list entry. There is no 全部 +// code at all: an empty tab_code, "all" and "0" return the byte-identical +// default response (7 topics + 4 threads), so the client builds the 全部 +// listing by merging the category listings (see HotTab.allCode). message HotThreadListRequestData { tieba.CommonRequest common = 1; string tab_id = 2; @@ -33,6 +41,7 @@ message HotThreadListRequest { } message HotThreadListResponseData { + repeated RecomTopicList topic_list = 1; repeated tieba.ThreadInfo thread_info = 2; repeated FrsTabInfo hot_thread_tab_info = 3; } diff --git a/Protos/ThreadInfo.proto b/Protos/ThreadInfo.proto index 726747a..a15e3d9 100644 --- a/Protos/ThreadInfo.proto +++ b/Protos/ThreadInfo.proto @@ -42,4 +42,7 @@ message ThreadInfo { repeated PbContent firstPostContent = 142; SimpleForum forumInfo = 155; int32 isDeleted = 181; + // 热度值, only the hot-thread (热榜) endpoint sends it, and each listing + // arrives sorted by it. Used to order the merged 全部 listing. + optional int64 hot_num = 182; } From 96ea3b3e3aafe2dc1d6aa6728cfddaeeb17877d6 Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 22:32:17 +0800 Subject: [PATCH 07/11] =?UTF-8?q?feat(topic):=20merge=20the=20hot=20catego?= =?UTF-8?q?ries=20and=20open=20a=20topic=20page=20from=20the=20=E8=AF=9D?= =?UTF-8?q?=E9=A2=98=E6=A6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 热点「全部」:服务端没有「全部」分类码(空码、all、0 返回同一份 4 条默认 列表),只能客户端拼:默认列表 + 四个分类列表的并集,按服务端下发的 hot_num(182) 降序,同热度按回复数、再按 id 兜底,重复 tid 保留热度更高的一份; 四个分类并发拉取,任一失败只记 AppLog 并继续用其余分类。话题榜(topic_list) 渲染在热点页顶部:排名 + 话题名 + 讨论数,前三名用官方红/橙/黄。 话题页:新端点 GET /mo/q/newtopic/topicDetail(JSON),匿名探针实测后按游标 翻页(只给 pn/offset 会返回 no=300000 param invalid,必须带上一页最后一条的 feed_id)。新增 TopicDetailView(话题头部 + 帖子流 + 下拉刷新 + 触底加载), 从热点页热议话题进入;HomeNavigationRoute 增加 .topic 路由。v1 限制:media 点击直接进帖子,该接口不返回视频信息。 顺带修复:切标签取消旧请求时 URLError.cancelled 曾被当成分类拉取失败记 ERROR 并合并出残缺列表,现在取消按取消处理。 测试:话题 JSON 解码(字符串/数字混用)、行映射与缺字段、游标、空列表; 话题 wire 解码、话题过滤与顺序、讨论数文案、合并去重/热度排序/同热度兜底。 --- TiebaPure.xcodeproj/project.pbxproj | 24 ++ .../Core/Auth/SessionExpirationMonitor.swift | 37 ++ TiebaPure/Core/Network/TiebaAPIService.swift | 38 ++ TiebaPure/Core/Network/TiebaEndpoint.swift | 5 + .../Core/Network/TiebaForumSignAPI.swift | 30 +- TiebaPure/Core/Network/TiebaHotAPI.swift | 99 ++++- TiebaPure/Core/Network/TiebaTopicAPI.swift | 376 ++++++++++++++++++ .../HotThreadList_HotThreadList.pb.swift | 79 +++- .../Protobuf/Generated/ThreadInfo.pb.swift | 20 +- .../Core/State/ForumSignCoordinator.swift | 49 ++- TiebaPure/Domain/Mapping/ThreadMapper.swift | 3 +- TiebaPure/Domain/Models/HotFeed.swift | 59 ++- TiebaPure/Domain/Models/ThreadSummary.swift | 12 +- TiebaPure/Domain/Models/TopicThreads.swift | 35 ++ TiebaPure/Features/Home/HomeView.swift | 24 +- TiebaPure/Features/Home/HotThreadsView.swift | 79 ++++ .../Features/Topic/TopicDetailView.swift | 231 +++++++++++ TiebaPureTests/HotThreadListTests.swift | 94 +++++ TiebaPureTests/TopicThreadsTests.swift | 145 +++++++ 19 files changed, 1415 insertions(+), 24 deletions(-) create mode 100644 TiebaPure/Core/Network/TiebaTopicAPI.swift create mode 100644 TiebaPure/Domain/Models/TopicThreads.swift create mode 100644 TiebaPure/Features/Topic/TopicDetailView.swift create mode 100644 TiebaPureTests/TopicThreadsTests.swift diff --git a/TiebaPure.xcodeproj/project.pbxproj b/TiebaPure.xcodeproj/project.pbxproj index 4bc46b9..a922d99 100644 --- a/TiebaPure.xcodeproj/project.pbxproj +++ b/TiebaPure.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 1C53CA119B8A4874452D2F6B /* ContentDraftFilePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFE6592A34B15BF182198BFA /* ContentDraftFilePersistenceTests.swift */; }; 1D73C1F9246A5740B5D63601 /* LogoutCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6B235101269A6DE9BEB53A /* LogoutCoordinator.swift */; }; 1E84B72ACDF13678A6BE6CB8 /* SavedThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DB03A9A81B77E7ECBD00B30 /* SavedThread.swift */; }; + 20CF0955A1868EDA71C3FB81 /* TopicThreads.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18C7C93DD4135FB481FE96D /* TopicThreads.swift */; }; 215E174D28331CD22F134EE6 /* TiebaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BFF7EDD31716056CE12B69 /* TiebaAPI.swift */; }; 221CEAF5C48D697203C0FC6B /* SwiftData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 158DD0B24F8C6500639DB687 /* SwiftData.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; 22C326EADD75278D51629E56 /* ReaderCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 431A697F330BBEC18E2252D0 /* ReaderCard.swift */; }; @@ -131,6 +132,7 @@ 9C4E81261CE11986A7FC94CA /* PersistenceBoundaryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481EB2A4D9C019B333364901 /* PersistenceBoundaryTests.swift */; }; 9E782527D741226479B50A92 /* TiebaContentSubmissionAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C158046155BBD6748519CF /* TiebaContentSubmissionAPI.swift */; }; 9FCC42B360A1683D6696808F /* ContentSubmissionSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A4C581F09C568D7DFC25D8 /* ContentSubmissionSettings.swift */; }; + A27947A715E6615086B1DC6B /* TopicThreadsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80909CE4938DDC2C15B34363 /* TopicThreadsTests.swift */; }; A4FCD6601BAA005BF0F264D0 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11227FF243EF6E0046E5C4A1 /* RootView.swift */; }; A5757E03976CA021ADE696FB /* Personalized.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6711EB46FB5BE95EF86ACB6 /* Personalized.pb.swift */; }; A6537596454795C265B252AD /* TiebaRequestBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F9A3625B3F19C2F07165809 /* TiebaRequestBuilder.swift */; }; @@ -141,6 +143,7 @@ A918F9AD31F8563BBA483DDE /* ThreadInfo.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2345ABFFDDC5D3B04B88A017 /* ThreadInfo.pb.swift */; }; A9A2CC0EE649F6C31E920713 /* TiebaFormSignerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12A3703C41D45165012736BB /* TiebaFormSignerTests.swift */; }; AA3AF8E9A9956DD4FAEA8638 /* VideoInfo.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = B99D4BFCB0FC486AF2592D85 /* VideoInfo.pb.swift */; }; + AB5FE601D32E24CB2CCE96DA /* TiebaTopicAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 441B122C474BADCC01B55B09 /* TiebaTopicAPI.swift */; }; AC68D0057C4A48786599AB69 /* ContentFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36B7B87BD4A9EB32A9F340FC /* ContentFilterTests.swift */; }; AC73C05F2AE82D15E3B429EB /* AlaLiveInfo.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9E3067B91DE94BBC410176 /* AlaLiveInfo.pb.swift */; }; AC8C9EFD5E2B710919306D8B /* FileContentDraftPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77980DB0EC284A4F53AA4028 /* FileContentDraftPersistence.swift */; }; @@ -204,6 +207,7 @@ E209C76E1A8310E8F94F6A32 /* InlineTextReuseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99D55E6E9D9C9BE0630B5C89 /* InlineTextReuseTests.swift */; }; E3CF1C9A62133316FF6D93E2 /* UserProfileMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65F7C671CDA66E5F18CEC24 /* UserProfileMapper.swift */; }; E4BC7D1B10FF88D762725986 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10E2287E1195B4261B0B88E8 /* SettingsView.swift */; }; + E52701BA120B4F45F3016CDA /* TopicDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C64C8B87B07DA4103B1B7A /* TopicDetailView.swift */; }; E6FCA5689969E1EE7020CF44 /* ReadingSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432F97822473B7D42DB148EC /* ReadingSettingsView.swift */; }; E8EBAA96255CA7A82C08DBD7 /* HotThreadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6798D74B94DFC3C6E8320452 /* HotThreadsView.swift */; }; EAADABB00751D25DB084C26D /* TiebaHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF65BFE5160CFC6F1F12CEF /* TiebaHTTPClient.swift */; }; @@ -341,6 +345,7 @@ 42BC57D4AB4F3F67B48877D9 /* ThreadFavoritesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadFavoritesView.swift; sourceTree = ""; }; 431A697F330BBEC18E2252D0 /* ReaderCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderCard.swift; sourceTree = ""; }; 432F97822473B7D42DB148EC /* ReadingSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingSettingsView.swift; sourceTree = ""; }; + 441B122C474BADCC01B55B09 /* TiebaTopicAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaTopicAPI.swift; sourceTree = ""; }; 44C549F603F6A8B5988B0B31 /* BrowsingHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsingHistory.swift; sourceTree = ""; }; 460C7E1E4622409127D0F888 /* SavedThreadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedThreadTests.swift; sourceTree = ""; }; 475452DA26606C9EEF2982D2 /* ReaderStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderStateView.swift; sourceTree = ""; }; @@ -391,6 +396,7 @@ 7E4CFA3390403419B8C3E890 /* ForumSignSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForumSignSettings.swift; sourceTree = ""; }; 7F4A700D0BB079E5CEC104C1 /* FixtureDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FixtureDecodingTests.swift; sourceTree = ""; }; 7F9A3625B3F19C2F07165809 /* TiebaRequestBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaRequestBuilder.swift; sourceTree = ""; }; + 80909CE4938DDC2C15B34363 /* TopicThreadsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicThreadsTests.swift; sourceTree = ""; }; 80C482CF17BA5520B13F1B03 /* AppAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAppearance.swift; sourceTree = ""; }; 81C9E000B6A70DAE855C1F80 /* ThreadSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSummary.swift; sourceTree = ""; }; 83A586DD3DD949672854FF9E /* ContentSubmissionNetworkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSubmissionNetworkTests.swift; sourceTree = ""; }; @@ -430,6 +436,7 @@ AF02A66EF1B9089518F9ED99 /* ImageViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewer.swift; sourceTree = ""; }; AFF65BFE5160CFC6F1F12CEF /* TiebaHTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaHTTPClient.swift; sourceTree = ""; }; B0B960AF4A983D18E1496538 /* SavedThreadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedThreadsView.swift; sourceTree = ""; }; + B0C64C8B87B07DA4103B1B7A /* TopicDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicDetailView.swift; sourceTree = ""; }; B19C95736E6F6922D110E2A8 /* HotFeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotFeed.swift; sourceTree = ""; }; B3312A539F9532E1B21FD622 /* AppPosInfo.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPosInfo.pb.swift; sourceTree = ""; }; B55649E6AEA1AD2EF4813452 /* TiebaUserProfileAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaUserProfileAPI.swift; sourceTree = ""; }; @@ -444,6 +451,7 @@ BA707FB3466F64C9B7BB74F3 /* NavigationSourceLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationSourceLifecycleTests.swift; sourceTree = ""; }; BC9BED7F7A1BE8C617FE2F15 /* TiebaEmoticonRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaEmoticonRepository.swift; sourceTree = ""; }; C017341C9C78B3832CB8A72F /* PbContent.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PbContent.pb.swift; sourceTree = ""; }; + C18C7C93DD4135FB481FE96D /* TopicThreads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopicThreads.swift; sourceTree = ""; }; C25F6DDA828E44F9B5A0E5D9 /* TiebaPureTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TiebaPureTheme.swift; sourceTree = ""; }; C3B9F1DB7C284253E1A90DCA /* ContentSubmissionCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSubmissionCoordinatorTests.swift; sourceTree = ""; }; C4B3F485FB45C4828FD7C5C6 /* AppPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPersistence.swift; sourceTree = ""; }; @@ -543,6 +551,7 @@ 5B099CE340C14B26B97B9E57 /* TiebaSocialAPI.swift */, 8FFB1A84AD3090A9581D1DA7 /* TiebaSubmissionChallenge.swift */, 8F256CCF10CED308966BFC23 /* TiebaThreadStoreAPI.swift */, + 441B122C474BADCC01B55B09 /* TiebaTopicAPI.swift */, B55649E6AEA1AD2EF4813452 /* TiebaUserProfileAPI.swift */, ); path = Network; @@ -640,6 +649,14 @@ path = App; sourceTree = ""; }; + 3D4B2E27E74D1D4BB018469F /* Topic */ = { + isa = PBXGroup; + children = ( + B0C64C8B87B07DA4103B1B7A /* TopicDetailView.swift */, + ); + path = Topic; + sourceTree = ""; + }; 3E5E855F98820C90A36621B3 /* Models */ = { isa = PBXGroup; children = ( @@ -677,6 +694,7 @@ 4EF25E73F880C6AD77740631 /* TiebaEmoticon.swift */, BC9BED7F7A1BE8C617FE2F15 /* TiebaEmoticonRepository.swift */, 1E3321457BFA9611243ABF93 /* TiebaURL.swift */, + C18C7C93DD4135FB481FE96D /* TopicThreads.swift */, 6A68DE1DD356B7AC4AB669F8 /* UserProfile.swift */, ); path = Models; @@ -809,6 +827,7 @@ 2792F6DFB423B6000542ADFB /* TiebaPostingBootstrapTests.swift */, 53A22DDA746D43724E44B7D2 /* TiebaPureSmokeTests.swift */, 9CE43843ABF0E6FE36F6A7BD /* TiebaURLTests.swift */, + 80909CE4938DDC2C15B34363 /* TopicThreadsTests.swift */, 9F1C4599FA7188B0D810AE19 /* UserProfileMutationTests.swift */, 3A38AD5D710823BA66CAE23C /* UserProfileTests.swift */, 28FE6EA296C633FE6B2A0AD3 /* VideoAudioSessionTests.swift */, @@ -1013,6 +1032,7 @@ D33EEBDA51E8F8D1D006E0FD /* Search */, C1FC9133DCCA4B099E0A9997 /* Settings */, 458BAF838EC5BD818708339E /* Thread */, + 3D4B2E27E74D1D4BB018469F /* Topic */, AF9CB51DE2DE586FC3F27182 /* Voice */, ); path = Features; @@ -1305,8 +1325,11 @@ C7A32644A038BA4FDE2C2864 /* TiebaSocialAPI.swift in Sources */, 4AA30396C89848D6E874828A /* TiebaSubmissionChallenge.swift in Sources */, 32FB7209CF53027C77B378A7 /* TiebaThreadStoreAPI.swift in Sources */, + AB5FE601D32E24CB2CCE96DA /* TiebaTopicAPI.swift in Sources */, 61AC96A8DF8D9A6671239F30 /* TiebaURL.swift in Sources */, 66CB3F6B00C11572EF7276D1 /* TiebaUserProfileAPI.swift in Sources */, + E52701BA120B4F45F3016CDA /* TopicDetailView.swift in Sources */, + 20CF0955A1868EDA71C3FB81 /* TopicThreads.swift in Sources */, F2FE7D83BA305985F7A5600D /* User.pb.swift in Sources */, C9381BB37A6F1CDB5053291B /* UserProfile.swift in Sources */, E3CF1C9A62133316FF6D93E2 /* UserProfileMapper.swift in Sources */, @@ -1371,6 +1394,7 @@ 85EFBF3494B6DBAE4707E464 /* TiebaPostingBootstrapTests.swift in Sources */, C0E5134C9E23DDD474D84DDA /* TiebaPureSmokeTests.swift in Sources */, 450F423517B8D1DEFD51B15B /* TiebaURLTests.swift in Sources */, + A27947A715E6615086B1DC6B /* TopicThreadsTests.swift in Sources */, B673FC1D9439E3D10DF147C0 /* UserProfileMutationTests.swift in Sources */, DE64CDA4FA60F57188F7BC53 /* UserProfileTests.swift in Sources */, 7ABBC168BCF747C297537AB6 /* VideoAudioSessionTests.swift in Sources */, diff --git a/TiebaPure/Core/Auth/SessionExpirationMonitor.swift b/TiebaPure/Core/Auth/SessionExpirationMonitor.swift index 94b1964..fd5cc1d 100644 --- a/TiebaPure/Core/Auth/SessionExpirationMonitor.swift +++ b/TiebaPure/Core/Auth/SessionExpirationMonitor.swift @@ -215,6 +215,21 @@ struct SessionMonitoringTiebaAPI: TiebaAPIService { } } + /// Forwarded on purpose: the protocol default would reject the call, and a + /// check-in run would silently fall back to resolving a token per forum — + /// the very cost this token reuse exists to remove. + func signingTBS(account: Account) async throws -> String { + try await monitored(account: account) { + try await base.signingTBS(account: account) + } + } + + func signForum(account: Account, forum: Forum, tbs: String) async throws -> ForumSignResult { + try await monitored(account: account) { + try await base.signForum(account: account, forum: forum, tbs: tbs) + } + } + func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] { try await monitored(account: account) { try await base.followedForumStatuses(account: account) @@ -227,6 +242,28 @@ struct SessionMonitoringTiebaAPI: TiebaAPIService { } } + /// Forwarded on purpose: without it the topic page would only ever see the + /// protocol's "unsupported" default. + func topicThreads( + account: Account?, + topicID: Int64, + topicName: String, + cursor: String, + page: Int, + pageSize: Int + ) async throws -> TopicThreadPage { + try await monitored(account: account) { + try await base.topicThreads( + account: account, + topicID: topicID, + topicName: topicName, + cursor: cursor, + page: page, + pageSize: pageSize + ) + } + } + func accountThreadFavorites( account: Account, page: Int diff --git a/TiebaPure/Core/Network/TiebaAPIService.swift b/TiebaPure/Core/Network/TiebaAPIService.swift index 6506d33..cfea500 100644 --- a/TiebaPure/Core/Network/TiebaAPIService.swift +++ b/TiebaPure/Core/Network/TiebaAPIService.swift @@ -50,8 +50,22 @@ protocol TiebaAPIService { func forumMembership(account: Account, forum: Forum) async throws -> ForumMembership func setForumFollowed(account: Account, forum: Forum, followed: Bool) async throws -> ForumMembership func signForum(account: Account, forum: Forum) async throws -> ForumSignResult + /// One check-in run signs many forums, and the write token the service wants + /// comes out of a login handshake. Resolving it once per run keeps that + /// handshake out of the per-forum path. + func signingTBS(account: Account) async throws -> String + func signForum(account: Account, forum: Forum, tbs: String) async throws -> ForumSignResult func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] func hotThreads(account: Account?, tabCode: String) async throws -> HotFeed + /// 话题 (topic) threads behind the 话题榜 rows on the hot page. + func topicThreads( + account: Account?, + topicID: Int64, + topicName: String, + cursor: String, + page: Int, + pageSize: Int + ) async throws -> TopicThreadPage func accountThreadFavorites(account: Account, page: Int) async throws -> AccountThreadFavoritesPage func setAccountThreadFavorite( account: Account, @@ -81,6 +95,30 @@ extension TiebaAPIService { throw UserProfileMutationError.unsupportedByService } + /// Services without check-in have no write token to hand out either. + func signingTBS(account: Account) async throws -> String { + throw UserProfileMutationError.unsupportedByService + } + + /// A service that cannot pre-resolve the token still signs: this falls back + /// to the per-forum path, which resolves the token itself. + func signForum(account: Account, forum: Forum, tbs: String) async throws -> ForumSignResult { + try await signForum(account: account, forum: forum) + } + + /// The topic page is optional too: services without it reject the call so + /// the row can stay a plain label instead of opening an empty screen. + func topicThreads( + account: Account?, + topicID: Int64, + topicName: String, + cursor: String, + page: Int, + pageSize: Int + ) async throws -> TopicThreadPage { + throw UserProfileMutationError.unsupportedByService + } + /// Level and check-in state per followed forum is optional: services without /// the guide listing reject it rather than reporting a wrong level. func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] { diff --git a/TiebaPure/Core/Network/TiebaEndpoint.swift b/TiebaPure/Core/Network/TiebaEndpoint.swift index 8129b1b..ece6cc9 100644 --- a/TiebaPure/Core/Network/TiebaEndpoint.swift +++ b/TiebaPure/Core/Network/TiebaEndpoint.swift @@ -15,6 +15,7 @@ enum TiebaEndpoint { case forumPageForm case personalized case hotThreadList + case topicDetail case frsPage case pbPage case pbFloor @@ -71,6 +72,10 @@ enum TiebaEndpoint { return Self.base .appending(path: "/c/f/forum/hotThreadList") .appending(queryItems: [.init(name: "cmd", value: "309661")]) + case .topicDetail: + // 话题详情: the web JSON endpoint behind one 话题榜 row. It pages by + // cursor (`last_id`), which is why the API layer carries one. + return Self.base.appending(path: "/mo/q/newtopic/topicDetail") case .frsPage: return Self.base .appending(path: "/c/f/frs/page") diff --git a/TiebaPure/Core/Network/TiebaForumSignAPI.swift b/TiebaPure/Core/Network/TiebaForumSignAPI.swift index c1e0643..5ac841b 100644 --- a/TiebaPure/Core/Network/TiebaForumSignAPI.swift +++ b/TiebaPure/Core/Network/TiebaForumSignAPI.swift @@ -132,8 +132,36 @@ private extension KeyedDecodingContainer { } extension TiebaAPI { + /// The write token a check-in run needs, resolved once so the login + /// handshake behind it does not repeat for every forum in the list. + func signingTBS(account: Account) async throws -> String { + try await refreshedClientTBS(for: account) + } + func signForum(account: Account, forum: Forum) async throws -> ForumSignResult { - let tbs = try await refreshedClientTBS(for: account) + try await performSign( + account: account, + forum: forum, + tbs: try await refreshedClientTBS(for: account) + ) + } + + /// Signing with a token the caller already resolved. An empty token means + /// the caller could not resolve one, so this resolves it here instead of + /// sending a request the service would reject. + func signForum(account: Account, forum: Forum, tbs: String) async throws -> ForumSignResult { + let resolved = tbs.trimmingCharacters(in: .whitespacesAndNewlines) + guard resolved.isEmpty == false else { + return try await signForum(account: account, forum: forum) + } + return try await performSign(account: account, forum: forum, tbs: resolved) + } + + private func performSign( + account: Account, + forum: Forum, + tbs: String + ) async throws -> ForumSignResult { try Task.checkCancellation() let fields = try TiebaForumSignRequestFactory.signFields( account: account, diff --git a/TiebaPure/Core/Network/TiebaHotAPI.swift b/TiebaPure/Core/Network/TiebaHotAPI.swift index 85e9d3e..ad6f76e 100644 --- a/TiebaPure/Core/Network/TiebaHotAPI.swift +++ b/TiebaPure/Core/Network/TiebaHotAPI.swift @@ -5,10 +5,9 @@ enum TiebaHotRequestFactory { /// variable part. The tab list itself comes back on every response, so the /// first request needs no discovery round. /// - /// An empty code is not a usable "default": the service answers it with a - /// stub of a handful of threads, while `tab_code="all"` is the whole hot - /// list. The view therefore asks for `HotTab.allCode` and this mapping only - /// keeps an empty caller from reproducing the stub. + /// An empty code folds into `HotTab.allCode`, so no caller can send a + /// code-less request: it and "all" answer with the same 4-thread default, + /// which the API layer turns into the merged 全部 listing. static func request( account: Account?, tabCode: String, @@ -44,6 +43,7 @@ enum HotFeedMapper { } return HotFeed( tabs: tabs, + topics: topics(in: data), threads: dedupedByID( data.threadInfo .filter(TiebaContentFilter.shouldMap(thread:)) @@ -52,6 +52,21 @@ enum HotFeedMapper { ) } + /// 话题榜 rows. A topic without an id or a name has nothing to render and + /// nothing to identify it by. + private static func topics( + in data: Tieba_HotThreadList_HotThreadListResponseData + ) -> [HotTopic] { + data.topicList.compactMap { topic -> HotTopic? in + guard topic.topicID > 0, topic.topicName.isEmpty == false else { return nil } + return HotTopic( + id: Int64(clamping: topic.topicID), + name: topic.topicName, + discussCount: Int(clamping: topic.discussNum) + ) + } + } + /// The same thread can appear twice in one listing (a pinned row repeated at /// the top of a sub-tab), and duplicate row identities break list diffing. private static func dedupedByID(_ threads: [ThreadSummary]) -> [ThreadSummary] { @@ -63,10 +78,74 @@ enum HotFeedMapper { extension TiebaAPI { /// 热点 (hot threads) for one sub-tab of the home hot-thread tab. /// - /// An empty `tabCode` is resolved to `HotTab.allCode`, so every request - /// names a real listing. Threads are filtered and mapped exactly like the + /// The service has no whole-list code, so `HotTab.allCode` is assembled from + /// the category listings instead of being one request; every other code is + /// exactly one. Threads are filtered and mapped exactly like the /// personalized feed so both tabs share one row presentation. func hotThreads(account: Account?, tabCode: String) async throws -> HotFeed { + guard tabCode.isEmpty || tabCode == HotTab.allCode else { + return try await hotThreadsListing(account: account, tabCode: tabCode) + } + return try await allHotThreads(account: account) + } + + /// 全部: the service's default listing (its rows, the tab list and the + /// 话题榜) plus every category listing merged into one 热度-ordered list. + private func allHotThreads(account: Account?) async throws -> HotFeed { + let base = try await hotThreadsListing(account: account, tabCode: HotTab.allCode) + let categoryCodes = base.tabs.map(\.code).filter { $0 != HotTab.allCode } + guard categoryCodes.isEmpty == false else { return base } + + // The categories are independent reads, so they run at once: the 全部 + // listing is the first thing the tab shows. + let feeds = await withTaskGroup(of: HotFeed?.self) { group in + for code in categoryCodes { + group.addTask { + do { + return try await hotThreadsListing(account: account, tabCode: code) + } catch is CancellationError { + // The user switched tabs; a cancelled read is not a + // category failure and must not be logged as one. + return nil + } catch { + if Task.isCancelled || (error as? URLError)?.code == .cancelled { + return nil + } + // One category failing still leaves a usable 全部 listing, + // but it must not disappear without a trace. + await AppLog.shared.recordError( + "首页热点", + "全部合并:\(code) 拉取失败,本次只用其余分类", + error: error + ) + return nil + } + } + } + var collected: [HotFeed] = [] + for await feed in group { + if let feed { collected.append(feed) } + } + return collected + } + // A superseded load (another tab was tapped) must not report itself as a + // successful, smaller 全部 merge. + try Task.checkCancellation() + + let merged = HotFeedMerge.threads(from: [base] + feeds) + await AppLog.shared.record( + .info, + "首页热点", + "全部合并 \(categoryCodes.count) 个分类:默认 \(base.threads.count) 条 + 分类 " + + "\(feeds.reduce(0) { $0 + $1.threads.count }) 条 → 去重后 \(merged.count) 条 " + + "话题\(base.topics.count)条" + ) + return HotFeed(tabs: base.tabs, topics: base.topics, threads: merged) + } + + /// One listing from one request, with the diagnostic line that separates + /// "the service sent four threads" from "we filtered them". + private func hotThreadsListing(account: Account?, tabCode: String) async throws -> HotFeed { let request = TiebaHotRequestFactory.request( account: account, tabCode: tabCode, @@ -93,8 +172,7 @@ extension TiebaAPI { guard response.hasData else { throw TiebaAPIError.emptyResponse } let feed = HotFeedMapper.makeFeed(from: response.data) // The service's own tab list (with codes) is what decides whether a - // missing 全部 is a server change or a local one, and the raw count - // separates "the service sent four threads" from "we filtered them". + // missing 全部 is a server change or a local one. let serverTabs = response.data.hotThreadTabInfo .map { "\($0.tabName)(\($0.tabCode))" } .joined(separator: "/") @@ -109,6 +187,11 @@ extension TiebaAPI { ) return feed } catch { + // Switching tabs cancels the in-flight read; that is not a failure + // and should not fill the log with errors. + if error is CancellationError || (error as? URLError)?.code == .cancelled || Task.isCancelled { + throw error + } await AppLog.shared.recordError( "首页热点", "tabCode=\(tabCode.isEmpty ? HotTab.allCode : tabCode) 失败", diff --git a/TiebaPure/Core/Network/TiebaTopicAPI.swift b/TiebaPure/Core/Network/TiebaTopicAPI.swift new file mode 100644 index 0000000..2eeaef9 --- /dev/null +++ b/TiebaPure/Core/Network/TiebaTopicAPI.swift @@ -0,0 +1,376 @@ +import Foundation + +/// The 话题 (topic) page behind one 话题榜 row. +/// +/// The web endpoint answers JSON, so this is a plain `Decodable` DTO rather than +/// a protobuf message. Field names and paging behaviour were read off the live +/// service: it needs `is_new`/`is_share`, and `last_id` is the only parameter +/// that moves the feed (see `TopicThreadPage`). +struct TopicDetailResponseDTO: Decodable { + struct AuthorDTO: Decodable { + var id: Int64 + var name: String + var displayName: String + var portrait: String + + enum CodingKeys: String, CodingKey { + case id + case name + case displayName = "name_show" + case portrait + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = container.topicInt64(forKey: .id) + name = container.topicString(forKey: .name) + displayName = container.topicString(forKey: .displayName) + portrait = container.topicString(forKey: .portrait) + } + } + + struct MediaDTO: Decodable { + var type: String + var thumbnail: String + var original: String + var width: Int + var height: Int + + enum CodingKeys: String, CodingKey { + case type + case thumbnail = "small_pic" + case original = "big_pic" + case width + case height + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + type = container.topicString(forKey: .type) + thumbnail = container.topicString(forKey: .thumbnail) + original = container.topicString(forKey: .original) + // The service sends the dimensions as strings. + width = container.topicInt(forKey: .width) + height = container.topicInt(forKey: .height) + } + } + + struct ThreadInfoDTO: Decodable { + var id: Int64 + var title: String + var forumID: Int64 + var forumName: String + var replyCount: Int + var likeCount: Int + var firstPostID: UInt64 + var createdAt: Int64 + var lastReplyAt: Int64 + var summary: String + var author: AuthorDTO? + var media: [MediaDTO] + + enum CodingKeys: String, CodingKey { + case id = "tid" + case title + case forumID = "forum_id" + case forumName = "forum_name" + case replyCount = "reply_num" + case likeCount = "agree_num" + case firstPostID = "first_post_id" + case createdAt = "create_time" + case lastReplyAt = "last_time_int" + case summary = "abstract" + case author + case media + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = container.topicInt64(forKey: .id) + title = container.topicString(forKey: .title) + forumID = container.topicInt64(forKey: .forumID) + forumName = container.topicString(forKey: .forumName) + replyCount = container.topicInt(forKey: .replyCount) + likeCount = container.topicInt(forKey: .likeCount) + firstPostID = UInt64(clamping: container.topicInt64(forKey: .firstPostID)) + createdAt = container.topicInt64(forKey: .createdAt) + lastReplyAt = container.topicInt64(forKey: .lastReplyAt) + summary = container.topicString(forKey: .summary) + author = try? container.decodeIfPresent(AuthorDTO.self, forKey: .author) + media = (try? container.decodeIfPresent([MediaDTO].self, forKey: .media)) ?? [] + } + } + + struct ThreadItemDTO: Decodable { + var feedID: String + var threadInfo: ThreadInfoDTO? + + enum CodingKeys: String, CodingKey { + case feedID = "feed_id" + case threadInfo = "thread_info" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Next page's `last_id`. It matches the thread id, but the service + // defines it as the feed id, so keep it verbatim. + if let number = try? container.decode(Int64.self, forKey: .feedID) { + feedID = "\(number)" + } else { + feedID = container.topicString(forKey: .feedID) + } + threadInfo = try? container.decodeIfPresent(ThreadInfoDTO.self, forKey: .threadInfo) + } + } + + struct RelateThreadDTO: Decodable { + var threadList: [ThreadItemDTO] + + enum CodingKeys: String, CodingKey { + case threadList = "thread_list" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + threadList = (try? container.decodeIfPresent([ThreadItemDTO].self, forKey: .threadList)) ?? [] + } + } + + struct TopicInfoDTO: Decodable { + var id: Int64 + var name: String + var summary: String + var discussCount: Int + var browseCount: Int + var image: String + + enum CodingKeys: String, CodingKey { + case id = "topic_id" + case name = "topic_name" + case summary = "topic_desc" + case discussCount = "discuss_num" + case browseCount = "browse_num" + case image = "topic_image" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = container.topicInt64(forKey: .id) + name = container.topicString(forKey: .name) + summary = container.topicString(forKey: .summary) + discussCount = container.topicInt(forKey: .discussCount) + browseCount = container.topicInt(forKey: .browseCount) + image = container.topicString(forKey: .image) + } + } + + struct DataDTO: Decodable { + var topicInfo: TopicInfoDTO? + var relateThread: RelateThreadDTO? + var hasMore: Bool + + enum CodingKeys: String, CodingKey { + case topicInfo = "topic_info" + case relateThread = "relate_thread" + case hasMore = "has_more" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + topicInfo = try? container.decodeIfPresent(TopicInfoDTO.self, forKey: .topicInfo) + relateThread = try? container.decodeIfPresent(RelateThreadDTO.self, forKey: .relateThread) + hasMore = container.topicBool(forKey: .hasMore) + } + } + + var code: Int + var message: String + var data: DataDTO? + + enum CodingKeys: String, CodingKey { + case code = "no" + case message = "error" + case data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + code = container.topicInt(forKey: .code) + message = container.topicString(forKey: .message) + data = try? container.decodeIfPresent(DataDTO.self, forKey: .data) + } +} + +enum TopicMapper { + static func info(from dto: TopicDetailResponseDTO.TopicInfoDTO?) -> TopicInfo? { + guard let dto, dto.id > 0, dto.name.isEmpty == false else { return nil } + let image = TiebaURL.make(dto.image) + return TopicInfo( + id: dto.id, + name: dto.name, + summary: dto.summary, + discussCount: max(dto.discussCount, 0), + browseCount: max(dto.browseCount, 0), + imageURL: image + ) + } + + static func threads(from data: TopicDetailResponseDTO.DataDTO) -> [ThreadSummary] { + (data.relateThread?.threadList ?? []).compactMap { item in + guard let info = item.threadInfo else { return nil } + return thread(from: info) + } + } + + /// The cursor for the next page: the last row's feed id, as the service + /// expects it back in `last_id`. + static func cursor(from data: TopicDetailResponseDTO.DataDTO) -> String? { + let ids = (data.relateThread?.threadList ?? []) + .map(\.feedID) + .filter { $0.isEmpty == false } + return ids.last + } + + private static func thread(from info: TopicDetailResponseDTO.ThreadInfoDTO) -> ThreadSummary? { + guard info.id > 0 else { return nil } + var blocks: [ContentBlock] = [] + let summary = info.summary.trimmingCharacters(in: .whitespacesAndNewlines) + if summary.isEmpty == false { + blocks.append(.text(summary)) + } + blocks.append(contentsOf: imageBlocks(from: info.media)) + + let author = info.author + return ThreadSummary( + id: info.id, + forumID: info.forumID > 0 ? info.forumID : nil, + title: info.title, + author: UserSummary( + id: author?.id ?? 0, + name: author?.name ?? "", + displayName: author?.displayName ?? "", + portrait: author?.portrait ?? "" + ), + forumName: info.forumName.isEmpty ? nil : info.forumName, + forumAvatarURL: nil, + replyCount: max(info.replyCount, 0), + viewCount: 0, + likeCount: max(info.likeCount, 0), + firstPostID: info.firstPostID > 0 ? info.firstPostID : nil, + isLiked: false, + createdAt: info.createdAt > 0 ? Date(timeIntervalSince1970: TimeInterval(info.createdAt)) : nil, + lastReplyAt: info.lastReplyAt > 0 ? Date(timeIntervalSince1970: TimeInterval(info.lastReplyAt)) : nil, + blocks: blocks, + isTop: false, + isGood: false, + hasVideo: false + ) + } + + /// Only image rows render: a topic feed mixes videos in, and turning one of + /// those into a row needs the video payload this endpoint does not carry. + private static func imageBlocks(from media: [TopicDetailResponseDTO.MediaDTO]) -> [ContentBlock] { + media.compactMap { item in + guard item.type == "pic" else { return nil } + let thumbnail = TiebaURL.make(item.thumbnail) + let original = TiebaURL.make(item.original) + guard thumbnail != nil || original != nil else { return nil } + return .image(ImageContent( + thumbnailURL: thumbnail, + originalURL: original, + width: max(item.width, 0), + height: max(item.height, 0), + showOriginalButton: false + )) + } + } +} + +extension TiebaAPI { + /// One page of a 话题's thread feed. `cursor` is empty for the first page and + /// otherwise the previous page's `nextCursor`. + func topicThreads( + account: Account?, + topicID: Int64, + topicName: String, + cursor: String, + page: Int, + pageSize: Int = TopicPagePolicy.pageSize + ) async throws -> TopicThreadPage { + let resolvedPage = max(page, 1) + let resolvedSize = max(pageSize, 1) + let queryItems: [URLQueryItem] = [ + URLQueryItem(name: "topic_id", value: "\(topicID)"), + URLQueryItem(name: "topic_name", value: topicName), + URLQueryItem(name: "is_new", value: "1"), + URLQueryItem(name: "is_share", value: "1"), + URLQueryItem(name: "pn", value: "\(resolvedPage)"), + URLQueryItem(name: "rn", value: "\(resolvedSize)"), + URLQueryItem(name: "offset", value: "\((resolvedPage - 1) * resolvedSize)"), + URLQueryItem(name: "last_id", value: cursor) + ] + let response = try await client.getJSON( + .topicDetail, + queryItems: queryItems, + headers: [ + "Cookie": TiebaFeedCookie.value(for: account), + "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 16_4 like Mac OS X)" + ], + as: TopicDetailResponseDTO.self + ) + try TiebaResponseValidator.validate(code: response.code, message: response.message) + guard let data = response.data else { throw TiebaAPIError.emptyResponse } + + let threads = TopicMapper.threads(from: data) + let nextCursor = TopicMapper.cursor(from: data) ?? cursor + await AppLog.shared.record( + .info, + "话题", + "话题\(topicID) 第\(resolvedPage)页 原始\(data.relateThread?.threadList.count ?? 0)条 " + + "保留\(threads.count)条 has_more=\(data.hasMore) " + + "下一游标=\(nextCursor.isEmpty ? "(无)" : "有")" + ) + return TopicThreadPage( + info: TopicMapper.info(from: data.topicInfo), + threads: threads, + hasMore: data.hasMore, + nextCursor: nextCursor + ) + } +} + +/// The topic payload mixes strings and numbers for the same field (`discuss_num` +/// arrives as both), so every scalar goes through a tolerant reader. Named apart +/// from the other files' private helpers: those are file-scoped, and this one has +/// to stay local to the JSON decoding in this file. +private extension KeyedDecodingContainer { + func topicString(forKey key: Key) -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Int64.self, forKey: key) { return String(value) } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } + return "" + } + + func topicInt(forKey key: Key) -> Int { + if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Int64.self, forKey: key) { return Int(clamping: value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { return Int(value) ?? 0 } + return 0 + } + + func topicInt64(forKey key: Key) -> Int64 { + if let value = try? decodeIfPresent(Int64.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Int.self, forKey: key) { return Int64(value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { return Int64(value) ?? 0 } + return 0 + } + + func topicBool(forKey key: Key) -> Bool { + if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Int.self, forKey: key) { return value != 0 } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return value == "1" || value.lowercased() == "true" + } + return false + } +} diff --git a/TiebaPure/Core/Protobuf/Generated/HotThreadList_HotThreadList.pb.swift b/TiebaPure/Core/Protobuf/Generated/HotThreadList_HotThreadList.pb.swift index cb8c317..1cfd4c7 100644 --- a/TiebaPure/Core/Protobuf/Generated/HotThreadList_HotThreadList.pb.swift +++ b/TiebaPure/Core/Protobuf/Generated/HotThreadList_HotThreadList.pb.swift @@ -34,6 +34,30 @@ nonisolated struct Tieba_HotThreadList_FrsTabInfo: Sendable { init() {} } +/// One 话题榜 (hot-topic) entry. Every response carries the same list — the +/// default request included — so the section needs no extra call. +nonisolated struct Tieba_HotThreadList_RecomTopicList: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + var topicID: UInt64 = 0 + + var topicName: String = String() + + var discussNum: UInt64 = 0 + + var unknownFields = SwiftProtobuf.UnknownStorage() + + init() {} +} + +/// Request semantics, measured against the service itself (2026-10-02): +/// tab_id is always "1", and the tab list carries only the four category +/// sub-tabs (视频/长更/游戏/数码) — never a whole-list entry. There is no 全部 +/// code at all: an empty tab_code, "all" and "0" return the byte-identical +/// default response (7 topics + 4 threads), so the client builds the 全部 +/// listing by merging the category listings (see HotTab.allCode). nonisolated struct Tieba_HotThreadList_HotThreadListRequestData: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for @@ -85,6 +109,8 @@ nonisolated struct Tieba_HotThreadList_HotThreadListResponseData: Sendable { // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. + var topicList: [Tieba_HotThreadList_RecomTopicList] = [] + var threadInfo: [Tieba_ThreadInfo] = [] var hotThreadTabInfo: [Tieba_HotThreadList_FrsTabInfo] = [] @@ -164,6 +190,46 @@ nonisolated extension Tieba_HotThreadList_FrsTabInfo: SwiftProtobuf.Message, Swi } } +nonisolated extension Tieba_HotThreadList_RecomTopicList: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + static let protoMessageName: String = _protobuf_package + ".RecomTopicList" + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}topic_id\0\u{3}topic_name\0\u{4}\u{2}discuss_num\0") + + mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt64Field(value: &self.topicID) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.topicName) }() + case 4: try { try decoder.decodeSingularUInt64Field(value: &self.discussNum) }() + default: break + } + } + } + + func traverse(visitor: inout V) throws { + if self.topicID != 0 { + try visitor.visitSingularUInt64Field(value: self.topicID, fieldNumber: 1) + } + if !self.topicName.isEmpty { + try visitor.visitSingularStringField(value: self.topicName, fieldNumber: 2) + } + if self.discussNum != 0 { + try visitor.visitSingularUInt64Field(value: self.discussNum, fieldNumber: 4) + } + try unknownFields.traverse(visitor: &visitor) + } + + static func ==(lhs: Tieba_HotThreadList_RecomTopicList, rhs: Tieba_HotThreadList_RecomTopicList) -> Bool { + if lhs.topicID != rhs.topicID {return false} + if lhs.topicName != rhs.topicName {return false} + if lhs.discussNum != rhs.discussNum {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + nonisolated extension Tieba_HotThreadList_HotThreadListRequestData: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".HotThreadListRequestData" static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}common\0\u{3}tab_id\0\u{3}tab_code\0") @@ -184,7 +250,7 @@ nonisolated extension Tieba_HotThreadList_HotThreadListRequestData: SwiftProtobu func traverse(visitor: inout V) throws { // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case/if branch local when no optimizations + // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 try { if let v = self._common { @@ -226,7 +292,7 @@ nonisolated extension Tieba_HotThreadList_HotThreadListRequest: SwiftProtobuf.Me func traverse(visitor: inout V) throws { // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case/if branch local when no optimizations + // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 try { if let v = self._data { @@ -244,7 +310,7 @@ nonisolated extension Tieba_HotThreadList_HotThreadListRequest: SwiftProtobuf.Me nonisolated extension Tieba_HotThreadList_HotThreadListResponseData: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".HotThreadListResponseData" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{4}\u{2}thread_info\0\u{3}hot_thread_tab_info\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}topic_list\0\u{3}thread_info\0\u{3}hot_thread_tab_info\0") mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -252,6 +318,7 @@ nonisolated extension Tieba_HotThreadList_HotThreadListResponseData: SwiftProtob // allocates stack space for every case branch when no optimizations are // enabled. https://github.com/apple/swift-protobuf/issues/1034 switch fieldNumber { + case 1: try { try decoder.decodeRepeatedMessageField(value: &self.topicList) }() case 2: try { try decoder.decodeRepeatedMessageField(value: &self.threadInfo) }() case 3: try { try decoder.decodeRepeatedMessageField(value: &self.hotThreadTabInfo) }() default: break @@ -260,6 +327,9 @@ nonisolated extension Tieba_HotThreadList_HotThreadListResponseData: SwiftProtob } func traverse(visitor: inout V) throws { + if !self.topicList.isEmpty { + try visitor.visitRepeatedMessageField(value: self.topicList, fieldNumber: 1) + } if !self.threadInfo.isEmpty { try visitor.visitRepeatedMessageField(value: self.threadInfo, fieldNumber: 2) } @@ -270,6 +340,7 @@ nonisolated extension Tieba_HotThreadList_HotThreadListResponseData: SwiftProtob } static func ==(lhs: Tieba_HotThreadList_HotThreadListResponseData, rhs: Tieba_HotThreadList_HotThreadListResponseData) -> Bool { + if lhs.topicList != rhs.topicList {return false} if lhs.threadInfo != rhs.threadInfo {return false} if lhs.hotThreadTabInfo != rhs.hotThreadTabInfo {return false} if lhs.unknownFields != rhs.unknownFields {return false} @@ -296,7 +367,7 @@ nonisolated extension Tieba_HotThreadList_HotThreadListResponse: SwiftProtobuf.M func traverse(visitor: inout V) throws { // The use of inline closures is to circumvent an issue where the compiler - // allocates stack space for every case/if branch local when no optimizations + // allocates stack space for every if/case branch local when no optimizations // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // https://github.com/apple/swift-protobuf/issues/1182 try { if let v = self._error { diff --git a/TiebaPure/Core/Protobuf/Generated/ThreadInfo.pb.swift b/TiebaPure/Core/Protobuf/Generated/ThreadInfo.pb.swift index 1d76310..8f414f3 100644 --- a/TiebaPure/Core/Protobuf/Generated/ThreadInfo.pb.swift +++ b/TiebaPure/Core/Protobuf/Generated/ThreadInfo.pb.swift @@ -189,6 +189,17 @@ nonisolated struct Tieba_ThreadInfo: @unchecked Sendable { set {_uniqueStorage()._isDeleted = newValue} } + /// 热度值, only the hot-thread (热榜) endpoint sends it, and each listing + /// arrives sorted by it. Used to order the merged 全部 listing. + var hotNum: Int64 { + get {_storage._hotNum ?? 0} + set {_uniqueStorage()._hotNum = newValue} + } + /// Returns true if `hotNum` has been explicitly set. + var hasHotNum: Bool {_storage._hotNum != nil} + /// Clears the value of `hotNum`. Subsequent reads from it will return its default value. + mutating func clearHotNum() {_uniqueStorage()._hotNum = nil} + var unknownFields = SwiftProtobuf.UnknownStorage() init() {} @@ -202,7 +213,7 @@ fileprivate nonisolated let _protobuf_package = "tieba" nonisolated extension Tieba_ThreadInfo: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { static let protoMessageName: String = _protobuf_package + ".ThreadInfo" - static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}threadId\0\u{1}title\0\u{1}replyNum\0\u{1}viewNum\0\u{2}\u{2}lastTimeInt\0\u{2}\u{2}isTop\0\u{1}isGood\0\u{4}\u{5}is_voice_thread\0\u{2}\u{3}author\0\u{4}\u{3}_abstract\0\u{1}media\0\u{3}voice_info\0\u{2}\u{4}forumId\0\u{1}forumName\0\u{2}\u{c}firstPostId\0\u{2}\u{5}createTime\0\u{4}\u{5}collect_status\0\u{3}collect_mark_pid\0\u{2}\u{5}authorId\0\u{4}\u{10}twzhibo_info\0\u{2}\u{7}videoInfo\0\u{4}\"ala_info\0\u{2}\u{b}agreeNum\0\u{2}\u{2}agree\0\u{2}\u{10}firstPostContent\0\u{2}\u{d}forumInfo\0\u{2}\u{1a}isDeleted\0") + static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}threadId\0\u{1}title\0\u{1}replyNum\0\u{1}viewNum\0\u{2}\u{2}lastTimeInt\0\u{2}\u{2}isTop\0\u{1}isGood\0\u{4}\u{5}is_voice_thread\0\u{2}\u{3}author\0\u{4}\u{3}_abstract\0\u{1}media\0\u{3}voice_info\0\u{2}\u{4}forumId\0\u{1}forumName\0\u{2}\u{c}firstPostId\0\u{2}\u{5}createTime\0\u{4}\u{5}collect_status\0\u{3}collect_mark_pid\0\u{2}\u{5}authorId\0\u{4}\u{10}twzhibo_info\0\u{2}\u{7}videoInfo\0\u{4}\"ala_info\0\u{2}\u{b}agreeNum\0\u{2}\u{2}agree\0\u{2}\u{10}firstPostContent\0\u{2}\u{d}forumInfo\0\u{2}\u{1a}isDeleted\0\u{3}hot_num\0") fileprivate class _StorageClass { var _id: Int64 = 0 @@ -233,6 +244,7 @@ nonisolated extension Tieba_ThreadInfo: SwiftProtobuf.Message, SwiftProtobuf._Me var _firstPostContent: [Tieba_PbContent] = [] var _forumInfo: Tieba_SimpleForum? = nil var _isDeleted: Int32 = 0 + var _hotNum: Int64? = nil // This property is used as the initial default value for new instances of the type. // The type itself is protecting the reference to its storage via CoW semantics. @@ -271,6 +283,7 @@ nonisolated extension Tieba_ThreadInfo: SwiftProtobuf.Message, SwiftProtobuf._Me _firstPostContent = source._firstPostContent _forumInfo = source._forumInfo _isDeleted = source._isDeleted + _hotNum = source._hotNum } } @@ -317,6 +330,7 @@ nonisolated extension Tieba_ThreadInfo: SwiftProtobuf.Message, SwiftProtobuf._Me case 142: try { try decoder.decodeRepeatedMessageField(value: &_storage._firstPostContent) }() case 155: try { try decoder.decodeSingularMessageField(value: &_storage._forumInfo) }() case 181: try { try decoder.decodeSingularInt32Field(value: &_storage._isDeleted) }() + case 182: try { try decoder.decodeSingularInt64Field(value: &_storage._hotNum) }() default: break } } @@ -413,6 +427,9 @@ nonisolated extension Tieba_ThreadInfo: SwiftProtobuf.Message, SwiftProtobuf._Me if _storage._isDeleted != 0 { try visitor.visitSingularInt32Field(value: _storage._isDeleted, fieldNumber: 181) } + try { if let v = _storage._hotNum { + try visitor.visitSingularInt64Field(value: v, fieldNumber: 182) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -450,6 +467,7 @@ nonisolated extension Tieba_ThreadInfo: SwiftProtobuf.Message, SwiftProtobuf._Me if _storage._firstPostContent != rhs_storage._firstPostContent {return false} if _storage._forumInfo != rhs_storage._forumInfo {return false} if _storage._isDeleted != rhs_storage._isDeleted {return false} + if _storage._hotNum != rhs_storage._hotNum {return false} return true } if !storagesAreEqual {return false} diff --git a/TiebaPure/Core/State/ForumSignCoordinator.swift b/TiebaPure/Core/State/ForumSignCoordinator.swift index 338005f..04eef0f 100644 --- a/TiebaPure/Core/State/ForumSignCoordinator.swift +++ b/TiebaPure/Core/State/ForumSignCoordinator.swift @@ -65,16 +65,22 @@ final class ForumSignCoordinator: ObservableObject { let runID = UUID() let task = Task { @MainActor [api, settings, requestSpacing] in var summary = ForumSignRunSummary.empty + let runStarted = Date() do { + var stepStarted = Date() let forums = try await api.followedForums(account: account) try Task.checkCancellation() + let listSeconds = Date().timeIntervalSince(stepStarted) // Today's check-in only has work for the forums the guide does // not already mark as signed. Sending those writes anyway costs // one throttled round trip per forum to learn what the service // has just said. + stepStarted = Date() let alreadySignedToday = await signedForumIDs(account: account) try Task.checkCancellation() + let statusSeconds = Date().timeIntervalSince(stepStarted) + let pending = forums.filter { alreadySignedToday.contains($0.id) == false } summary.alreadySignedCount += forums.count - pending.count var completed = forums.count - pending.count @@ -84,7 +90,29 @@ final class ForumSignCoordinator: ObservableObject { session: session, summary: summary ) + // The per-step timings are the only way to tell a slow list, a + // slow guide and a slow write apart from the exported log. + await AppLog.shared.record( + .info, + "一键签到", + "关注 \(forums.count) 个,已签 \(summary.alreadySignedCount) 个,待签 \(pending.count) 个;" + + "关注列表 \(Self.seconds(listSeconds)),签到状态 \(Self.seconds(statusSeconds))" + ) + + // One write token for the whole run: the login handshake that + // produces it costs more than the check-in write itself, and + // doing it per forum is what made a long list feel stuck. + stepStarted = Date() + let tbs = (try? await api.signingTBS(account: account)) ?? "" + try Task.checkCancellation() + await AppLog.shared.record( + .info, + "一键签到", + "写令牌\(tbs.isEmpty ? "未取到,逐吧解析" : "已就绪,本轮回用")," + + "耗时 \(Self.seconds(Date().timeIntervalSince(stepStarted)))" + ) + let signingStarted = Date() for (index, forum) in pending.enumerated() { if index > 0 { try await Task.sleep(for: requestSpacing) @@ -98,7 +126,7 @@ final class ForumSignCoordinator: ObservableObject { session: session, summary: summary ) - let result = try await api.signForum(account: account, forum: forum) + let result = try await api.signForum(account: account, forum: forum, tbs: tbs) try Task.checkCancellation() if result.wasAlreadySigned { summary.alreadySignedCount += 1 @@ -109,6 +137,11 @@ final class ForumSignCoordinator: ObservableObject { throw CancellationError() } catch { summary.failedForumNames.append(forum.displayName) + await AppLog.shared.recordError( + "一键签到", + "\(forum.displayName) 签到失败", + error: error + ) } completed += 1 publishProgress( @@ -118,6 +151,14 @@ final class ForumSignCoordinator: ObservableObject { summary: summary ) } + let signingSeconds = Date().timeIntervalSince(signingStarted) + await AppLog.shared.record( + .info, + "一键签到", + "完成:成功 \(summary.signedCount),已签过 \(summary.alreadySignedCount)," + + "失败 \(summary.failedForumNames.count);写请求耗时 " + + "\(Self.seconds(signingSeconds)),整轮 \(Self.seconds(Date().timeIntervalSince(runStarted)))" + ) // Only a run that reached every forum counts as today's run; // otherwise tomorrow's automatic attempt would be skipped after // a partial failure. @@ -229,6 +270,12 @@ final class ForumSignCoordinator: ObservableObject { globalInvalidationCount == 0 && sessionInvalidationCounts[session] == nil } + /// One line for the diagnostic log: a run's cost only makes sense as a + /// number, and "slow" without seconds is not something to act on. + private static func seconds(_ interval: TimeInterval) -> String { + String(format: "%.1fs", max(interval, 0)) + } + /// Only the session the UI is showing drives the visible counters; a run for /// a replaced login keeps its own state until it is drained. private func publishProgress( diff --git a/TiebaPure/Domain/Mapping/ThreadMapper.swift b/TiebaPure/Domain/Mapping/ThreadMapper.swift index a1e5321..23da48a 100644 --- a/TiebaPure/Domain/Mapping/ThreadMapper.swift +++ b/TiebaPure/Domain/Mapping/ThreadMapper.swift @@ -43,7 +43,8 @@ enum ThreadMapper { blocks: blocks, isTop: proto.isTop != 0, isGood: proto.isGood != 0, - hasVideo: proto.hasVideoInfo || containsVideo(in: blocks) + hasVideo: proto.hasVideoInfo || containsVideo(in: blocks), + hotValue: proto.hasHotNum ? proto.hotNum : nil ) } diff --git a/TiebaPure/Domain/Models/HotFeed.swift b/TiebaPure/Domain/Models/HotFeed.swift index eb15c3f..41e2316 100644 --- a/TiebaPure/Domain/Models/HotFeed.swift +++ b/TiebaPure/Domain/Models/HotFeed.swift @@ -1,21 +1,68 @@ import Foundation -/// The 热点 (hot-thread) tab as one response: the service's own sub-tabs and -/// the hot threads themselves. +/// The 热点 (hot-thread) tab as one response: the service's own sub-tabs, the +/// 话题榜 it carries on every request, and the hot threads themselves. /// /// The tab list comes back on every request, so switching sub-tabs keeps the /// same set of tabs without a second discovery call. struct HotFeed: Equatable, Sendable { var tabs: [HotTab] = [] + /// 话题榜 (hot topics). The service sends the same list with every request, + /// category listings included. + var topics: [HotTopic] = [] var threads: [ThreadSummary] = [] } +/// One 话题榜 entry, shown above the hot-thread list. +struct HotTopic: Identifiable, Equatable, Sendable { + var id: Int64 + var name: String + /// 讨论数 as the service counts it. + var discussCount: Int + + var discussCountText: String { TiebaCountText.text(discussCount) } +} + +/// The service counts 讨论数 and 浏览数 into the millions, and the full number +/// crowds out whatever sits next to it, so anything past ten thousand is +/// rounded to 万. One owner, because the hot list and the topic page show the +/// same numbers. +enum TiebaCountText { + static func text(_ count: Int) -> String { + guard count >= 10_000 else { return "\(count)" } + return String(format: "%.1f万", Double(count) / 10_000) + } +} + +/// Builds the 全部 listing the service itself does not offer. +/// +/// There is no whole-list code — an empty code, "all" and "0" all answer with +/// the same 4-thread default — so 全部 is the union of the category listings, +/// deduped by thread id, ordered by the service's own 热度 value, which every +/// category listing already arrives sorted by. +enum HotFeedMerge { + static func threads(from feeds: [HotFeed]) -> [ThreadSummary] { + var best: [Int64: ThreadSummary] = [:] + for feed in feeds { + for thread in feed.threads { + if let existing = best[thread.id], existing.hotScore >= thread.hotScore { continue } + best[thread.id] = thread + } + } + return best.values.sorted { lhs, rhs in + if lhs.hotScore != rhs.hotScore { return lhs.hotScore > rhs.hotScore } + if lhs.replyCount != rhs.replyCount { return lhs.replyCount > rhs.replyCount } + return lhs.id > rhs.id + } + } +} + /// One sub-tab of the hot-thread listing. struct HotTab: Identifiable, Equatable, Sendable { - /// The whole-list tab. The service reports only its category sub-tabs - /// (视频/长更/游戏/数码…) in `hot_thread_tab_info`, and `tab_code="all"` is - /// the only request that answers with the full hot list — so the chip is - /// built here, and it is also what a freshly opened tab starts on. + /// The 全部 entry. The service reports only its category sub-tabs + /// (视频/长更/游戏/数码) in `hot_thread_tab_info` — it has no whole-list code + /// at all — so the chip is built here and its listing is the merge of the + /// category listings (see `HotFeedMerge`). static let allCode = "all" static let allName = "全部" static let all = HotTab(code: allCode, name: allName) diff --git a/TiebaPure/Domain/Models/ThreadSummary.swift b/TiebaPure/Domain/Models/ThreadSummary.swift index f856b13..875641a 100644 --- a/TiebaPure/Domain/Models/ThreadSummary.swift +++ b/TiebaPure/Domain/Models/ThreadSummary.swift @@ -18,6 +18,10 @@ struct ThreadSummary: Identifiable, Equatable, Codable, Sendable { var isTop: Bool var isGood: Bool var hasVideo: Bool + /// 热度 as the hot-thread endpoint reports it; nil for every other listing. + /// Optional on purpose: saved threads written before this field existed must + /// keep decoding. + var hotValue: Int64? init( id: Int64, @@ -36,7 +40,8 @@ struct ThreadSummary: Identifiable, Equatable, Codable, Sendable { blocks: [ContentBlock], isTop: Bool = false, isGood: Bool = false, - hasVideo: Bool = false + hasVideo: Bool = false, + hotValue: Int64? = nil ) { self.id = id self.forumID = forumID @@ -55,8 +60,13 @@ struct ThreadSummary: Identifiable, Equatable, Codable, Sendable { self.isTop = isTop self.isGood = isGood self.hasVideo = hasVideo + self.hotValue = hotValue } + /// Ordering key for the merged 全部 listing; 0 means the endpoint did not + /// report a 热度 value. + var hotScore: Int64 { hotValue ?? 0 } + var textPreview: String { blocks.compactMap(\.plainText).joined() } diff --git a/TiebaPure/Domain/Models/TopicThreads.swift b/TiebaPure/Domain/Models/TopicThreads.swift new file mode 100644 index 0000000..3563e38 --- /dev/null +++ b/TiebaPure/Domain/Models/TopicThreads.swift @@ -0,0 +1,35 @@ +import Foundation + +/// The header of one 话题 (topic): what a 话题榜 row shows, plus the counts the +/// topic page adds above its thread feed. +struct TopicInfo: Equatable, Sendable { + var id: Int64 + var name: String + var summary: String + var discussCount: Int + var browseCount: Int + var imageURL: URL? + + var discussCountText: String { TiebaCountText.text(discussCount) } + + var browseCountText: String { TiebaCountText.text(browseCount) } +} + +/// One page of a 话题's thread feed. +/// +/// The service pages by cursor, not by number: `pn` and `offset` alone answer +/// error 300000 ("param invalid"), and only `last_id` — the previous page's last +/// feed id — moves the feed forward, so `nextCursor` carries it. +struct TopicThreadPage: Equatable, Sendable { + /// Only the first page carries the header. + var info: TopicInfo? + var threads: [ThreadSummary] + var hasMore: Bool + var nextCursor: String + + static let empty = TopicThreadPage(info: nil, threads: [], hasMore: false, nextCursor: "") +} + +enum TopicPagePolicy { + static let pageSize = 20 +} diff --git a/TiebaPure/Features/Home/HomeView.swift b/TiebaPure/Features/Home/HomeView.swift index 1bef120..a62e88d 100644 --- a/TiebaPure/Features/Home/HomeView.swift +++ b/TiebaPure/Features/Home/HomeView.swift @@ -162,6 +162,19 @@ struct HomeView: View { .interactiveNavigationPopStateSync { removeNavigationRouteIfCurrent(route) } + case let .topic(id, name): + TopicDetailView( + account: account, + topicID: id, + topicName: name, + openThread: { openThread($0) }, + openComments: { openThread($0, initialDestination: .replies) }, + openForum: openForum, + openUser: { openUser($0, sourceThreadID: nil) } + ) + .interactiveNavigationPopStateSync { + removeNavigationRouteIfCurrent(route) + } } } .interactiveNavigationPopRevealSource() @@ -234,7 +247,8 @@ struct HomeView: View { onOpenThread: { openThread($0) }, onOpenComments: { openThread($0, initialDestination: .replies) }, onOpenForum: openForum, - onOpenUser: { openUser($0, sourceThreadID: nil) } + onOpenUser: { openUser($0, sourceThreadID: nil) }, + onOpenTopic: { openTopic($0) } ) .accessibilityIdentifier("home-hot-feed") } @@ -343,6 +357,13 @@ struct HomeView: View { ) } + private func openTopic(_ topic: HotTopic) { + navigationPath = HomeNavigationPathPolicy.pushing( + .topic(id: topic.id, name: topic.name), + onto: navigationPath + ) + } + private func openUser(_ user: UserSummary, sourceThreadID: Int64?) { navigationPath = HomeNavigationPathPolicy.pushing( .user(user: user, sourceThreadID: sourceThreadID), @@ -838,6 +859,7 @@ enum HomeNavigationRoute: Hashable { case thread(ReaderSplitThreadRoute) case forum(id: Int64, name: String, displayName: String, avatarURL: URL?) case user(user: UserSummary, sourceThreadID: Int64?) + case topic(id: Int64, name: String) static func fromForum(_ forum: Forum) -> HomeNavigationRoute { .forum( diff --git a/TiebaPure/Features/Home/HotThreadsView.swift b/TiebaPure/Features/Home/HotThreadsView.swift index aff1a72..34f780c 100644 --- a/TiebaPure/Features/Home/HotThreadsView.swift +++ b/TiebaPure/Features/Home/HotThreadsView.swift @@ -43,8 +43,10 @@ struct HotThreadsView: View { let onOpenComments: (ThreadSummary) -> Void let onOpenForum: (Forum) -> Void let onOpenUser: (UserSummary) -> Void + let onOpenTopic: (HotTopic) -> Void @State private var tabs: [HotTab] = [] + @State private var topics: [HotTopic] = [] @State private var selectedTabCode = HotTab.allCode @State private var threads: [ThreadSummary] = [] @State private var isLoading = false @@ -71,6 +73,7 @@ struct HotThreadsView: View { requestGeneration += 1 loadTask?.cancel() tabs = [] + topics = [] selectedTabCode = HotTab.allCode threads = [] errorMessage = nil @@ -113,6 +116,77 @@ struct HotThreadsView: View { .background(TiebaPureTheme.ColorToken.readerGroupedBackground) } + /// 话题榜: the service sends the same hot topics with every listing, and the + /// official page shows them above the threads. Tapping one opens its feed. + private var hotTopicSection: some View { + VStack(alignment: .leading, spacing: TiebaPureTheme.Spacing.xs) { + Text("热议话题") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + + ForEach(Array(topics.enumerated()), id: \.element.id) { index, topic in + Button { + onOpenTopic(topic) + } label: { + hotTopicRow(topic, rank: index) + } + .buttonStyle(.plain) + .accessibilityLabel("第\(index + 1)名 \(topic.name),\(topic.discussCountText)讨论") + .accessibilityHint("查看该话题的帖子") + .accessibilityIdentifier("hot-topic-row") + } + } + .padding(TiebaPureTheme.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: TiebaPureTheme.Radius.card, style: .continuous) + .fill(TiebaPureTheme.ColorToken.readerSecondarySurface) + ) + .accessibilityIdentifier("hot-topic-section") + } + + private func hotTopicRow(_ topic: HotTopic, rank: Int) -> some View { + HStack(alignment: .firstTextBaseline, spacing: TiebaPureTheme.Spacing.sm) { + Text("\(rank + 1)") + .font(.footnote.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(topicRankColor(rank)) + .frame(minWidth: 18, alignment: .trailing) + + Text(topic.name) + .font(.subheadline) + .foregroundStyle(.primary) + .multilineTextAlignment(.leading) + + Spacer(minLength: TiebaPureTheme.Spacing.xs) + + Text(topic.discussCountText) + .font(.caption) + .monospacedDigit() + .foregroundStyle(.secondary) + + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(.tertiary) + } + .contentShape(Rectangle()) + .frame(minHeight: 32) + } + + /// The first three ranks carry the service's own red/orange/yellow accents. + private func topicRankColor(_ index: Int) -> Color { + switch index { + case 0: + return Color(red: 0.84, green: 0.0, blue: 0.0) + case 1: + return Color(red: 1.0, green: 0.43, blue: 0.0) + case 2: + return Color(red: 0.98, green: 0.75, blue: 0.18) + default: + return .secondary + } + } + @ViewBuilder private var feedContent: some View { if isLoading && didLoad == false { @@ -133,6 +207,10 @@ struct HotThreadsView: View { } else { ScrollView { LazyVStack(spacing: TiebaPureTheme.Spacing.sm, pinnedViews: []) { + if topics.isEmpty == false { + hotTopicSection + } + ForEach(threads) { thread in ForumThreadRow( thread: thread, @@ -225,6 +303,7 @@ struct HotThreadsView: View { guard generation == requestGeneration, requestedSession == account?.sessionIdentity else { return } tabs = feed.tabs + topics = feed.topics threads = feed.threads.filter(TiebaContentFilter.shouldKeep(thread:)) // A sub-tab the service stopped reporting must not leave the tab bar // pointing at a listing that can no longer be requested. 全部 is diff --git a/TiebaPure/Features/Topic/TopicDetailView.swift b/TiebaPure/Features/Topic/TopicDetailView.swift new file mode 100644 index 0000000..5cc12b7 --- /dev/null +++ b/TiebaPure/Features/Topic/TopicDetailView.swift @@ -0,0 +1,231 @@ +import SwiftUI + +/// The thread feed behind one 话题榜 row. +/// +/// The service pages this feed by cursor, so "load more" carries the previous +/// page's last feed id instead of a page number — asking with a bare page number +/// answers error 300000. +struct TopicDetailView: View { + @EnvironmentObject private var environment: AppEnvironment + @ObservedObject private var blocklistStore = BlocklistStore.shared + + let account: Account? + let topicID: Int64 + let topicName: String + let openThread: (ThreadSummary) -> Void + let openComments: (ThreadSummary) -> Void + let openForum: (Forum) -> Void + let openUser: (UserSummary) -> Void + + @State private var info: TopicInfo? + @State private var threads: [ThreadSummary] = [] + @State private var nextCursor = "" + @State private var page = 1 + @State private var hasMore = false + @State private var isLoading = false + @State private var didLoad = false + @State private var errorMessage: String? + @State private var requestGeneration = 0 + @State private var loadTask: Task? + + var body: some View { + ScrollView { + LazyVStack(spacing: TiebaPureTheme.Spacing.sm, pinnedViews: []) { + if let info { + header(info) + } + + ForEach(threads) { thread in + ForumThreadRow( + thread: thread, + presentation: .homeFeed, + onOpenThread: { openThread(thread) }, + onOpenForum: openForum, + onOpenUser: { openUser($0) }, + onBlockForum: { blockedThread in + blocklistStore.addForum( + id: blockedThread.forumID, + named: blockedThread.forumName + ) + }, + // The topic payload carries images but no video payload, + // so a media tap opens the thread rather than a viewer. + onOpenMedia: { _, _, _, _, _ in openThread(thread) }, + onOpenComments: { openComments(thread) }, + commentsAccessibilityIdentifier: "topic-comments-button-\(thread.id)" + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("topic-thread-row") + } + + if hasMore, errorMessage == nil { + Color.clear + .frame(height: 1) + .onAppear { + Task { await loadMore() } + } + .accessibilityHidden(true) + } + + if let errorMessage { + InlineLoadErrorView(message: errorMessage) { + Task { await loadMore() } + } + } + + Color.clear + .frame(height: 64) + .accessibilityHidden(true) + } + .padding(.horizontal, TiebaPureTheme.Spacing.sm) + .padding(.vertical, TiebaPureTheme.Spacing.sm) + .readableWidth() + } + .accessibilityIdentifier("topic-thread-scroll-view") + .overlay { + if threads.isEmpty, isLoading { + ReaderStateView.loading("正在加载话题") + } else if threads.isEmpty, let errorMessage { + ReaderStateView.error(message: errorMessage) { + Task { await refresh() } + } + } else if threads.isEmpty, didLoad { + ReaderStateView.empty( + title: "这个话题还没有帖子", + message: "下拉即可刷新。" + ) + } + } + .shortPullRefresh( + isEnabled: isLoading == false, + surface: .grouped, + accessibilityIdentifier: "topic-refresh-animation" + ) { + await refresh() + } + .background(TiebaPureTheme.ColorToken.readerGroupedBackground) + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .task { + guard didLoad == false else { return } + await load(cursor: "", page: 1, replacing: true) + } + .onChange(of: account?.sessionIdentity) { _ in + requestGeneration += 1 + loadTask?.cancel() + info = nil + threads = [] + nextCursor = "" + page = 1 + hasMore = false + errorMessage = nil + didLoad = false + isLoading = false + } + .onDisappear { + loadTask?.cancel() + requestGeneration += 1 + isLoading = false + } + } + + private var navigationTitle: String { + let name = info?.name.isEmpty == false ? info?.name : topicName + guard let name, name.isEmpty == false else { return "话题" } + return "#\(name)" + } + + private func header(_ info: TopicInfo) -> some View { + VStack(alignment: .leading, spacing: TiebaPureTheme.Spacing.xs) { + Text("#\(info.name)") + .font(.headline) + .foregroundStyle(.primary) + + if info.summary.isEmpty == false { + Text(info.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + + HStack(spacing: TiebaPureTheme.Spacing.md) { + Text("讨论 \(info.discussCountText)") + Text("浏览 \(info.browseCountText)") + } + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(TiebaPureTheme.Spacing.md) + .background( + RoundedRectangle(cornerRadius: TiebaPureTheme.Radius.card, style: .continuous) + .fill(TiebaPureTheme.ColorToken.readerSecondarySurface) + ) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("topic-header") + } + + private func refresh() async { + await load(cursor: "", page: 1, replacing: true) + } + + private func loadMore() async { + guard hasMore, isLoading == false, didLoad, nextCursor.isEmpty == false else { return } + await load(cursor: nextCursor, page: page + 1, replacing: false) + } + + private func load(cursor: String, page requestedPage: Int, replacing: Bool) async { + loadTask?.cancel() + requestGeneration += 1 + let generation = requestGeneration + let requestedSession = account?.sessionIdentity + isLoading = true + errorMessage = nil + + do { + let task = Task { + try await environment.api.topicThreads( + account: account, + topicID: topicID, + topicName: topicName, + cursor: cursor, + page: requestedPage, + pageSize: TopicPagePolicy.pageSize + ) + } + loadTask = task + let pageResult = try await task.value + guard generation == requestGeneration, + requestedSession == account?.sessionIdentity else { return } + if let header = pageResult.info { + info = header + } + if replacing { + threads = pageResult.threads + } else { + // A cursor page can repeat a row the previous page already + // showed, and duplicate identities break list diffing. + let existing = Set(threads.map(\.id)) + threads.append(contentsOf: pageResult.threads.filter { existing.contains($0.id) == false }) + } + hasMore = pageResult.hasMore + nextCursor = pageResult.nextCursor + page = requestedPage + } catch is CancellationError { + guard generation == requestGeneration else { return } + loadTask = nil + isLoading = false + return + } catch { + guard generation == requestGeneration, + requestedSession == account?.sessionIdentity else { return } + errorMessage = ReaderErrorMessage.message(for: error) + } + + guard generation == requestGeneration, + requestedSession == account?.sessionIdentity else { return } + loadTask = nil + isLoading = false + didLoad = true + } +} diff --git a/TiebaPureTests/HotThreadListTests.swift b/TiebaPureTests/HotThreadListTests.swift index 82e4c76..df2a741 100644 --- a/TiebaPureTests/HotThreadListTests.swift +++ b/TiebaPureTests/HotThreadListTests.swift @@ -305,6 +305,100 @@ final class HotThreadListTests: XCTestCase { XCTAssertEqual(feed.threads.map(\.id), [9001, 9002]) } + func testTopicWireBytesDecodeIDNameAndDiscussCount() throws { + // topic_id(1) 28366254, topic_name(2) 假期打分大会, discuss_num(4) 1641000 + let wire = try XCTUnwrap(Self.data(hex: "08aeabc30d1212e58187e69c9fe68993e58886e5a4a7e4bc9a20a89464")) + + let topic = try Tieba_HotThreadList_RecomTopicList(serializedBytes: wire) + + XCTAssertEqual(topic.topicID, 28_366_254) + XCTAssertEqual(topic.topicName, "假期打分大会") + XCTAssertEqual(topic.discussNum, 1_641_000) + } + + func testTopicListMapsInServiceOrderAndSkipsIncompleteRows() { + var responseData = Tieba_HotThreadList_HotThreadListResponseData() + + var usable = Tieba_HotThreadList_RecomTopicList() + usable.topicID = 28_366_254 + usable.topicName = "假期打分大会" + usable.discussNum = 1_641_000 + var withoutName = Tieba_HotThreadList_RecomTopicList() + withoutName.topicID = 28_366_253 + var withoutID = Tieba_HotThreadList_RecomTopicList() + withoutID.topicName = "没有 id 的话题" + responseData.topicList = [usable, withoutName, withoutID] + + let feed = HotFeedMapper.makeFeed(from: responseData) + + XCTAssertEqual( + feed.topics, + [HotTopic(id: 28_366_254, name: "假期打分大会", discussCount: 1_641_000)], + "无 id 或无名字的话题行直接丢掉,其余保持服务端顺序" + ) + } + + func testTopicDiscussCountUsesWanPastTenThousand() { + func text(_ count: Int) -> String { + HotTopic(id: 1, name: "话题", discussCount: count).discussCountText + } + + XCTAssertEqual(text(0), "0") + XCTAssertEqual(text(9_999), "9999") + XCTAssertEqual(text(10_000), "1.0万") + XCTAssertEqual(text(1_641_000), "164.1万") + } + + func testMergedAllListingUnionsCategoriesAndOrdersByHeat() { + let merged = HotFeedMerge.threads(from: [ + hotFeed(threads: [hotThread(id: 1, hot: 100), hotThread(id: 2, hot: 300)]), + hotFeed(threads: [hotThread(id: 3, hot: 200)]) + ]) + + XCTAssertEqual(merged.map(\.id), [2, 3, 1]) + } + + func testMergedAllListingKeepsTheHottestCopyOfADuplicate() { + let merged = HotFeedMerge.threads(from: [ + hotFeed(threads: [hotThread(id: 7, hot: 10)]), + hotFeed(threads: [hotThread(id: 7, hot: 90)]) + ]) + + XCTAssertEqual(merged.count, 1) + XCTAssertEqual(merged[0].hotScore, 90) + } + + func testMergedAllListingBreaksEqualHeatByReplyCount() { + let merged = HotFeedMerge.threads(from: [ + hotFeed(threads: [ + hotThread(id: 4, hot: 0, replies: 3), + hotThread(id: 5, hot: 0, replies: 9) + ]) + ]) + + XCTAssertEqual(merged.map(\.id), [5, 4]) + } + + func testMergedAllListingOfNoFeedsIsEmpty() { + XCTAssertTrue(HotFeedMerge.threads(from: []).isEmpty) + } + + private func hotFeed(threads: [ThreadSummary]) -> HotFeed { + HotFeed(threads: threads) + } + + private func hotThread(id: Int64, hot: Int64, replies: Int = 0) -> ThreadSummary { + ThreadSummary( + id: id, + title: "帖子\(id)", + author: UserSummary(id: 1, name: "author", displayName: "作者", portrait: ""), + replyCount: replies, + viewCount: 0, + blocks: [], + hotValue: hot == 0 ? nil : hot + ) + } + func testSegmentsExposeStableTitlesAndHints() { XCTAssertEqual(HomeFeedSegment.allCases.map(\.title), ["推荐", "热点"]) XCTAssertEqual(HomeFeedSegment.recommended.accessibilityHint, "显示个性化推荐的帖子") diff --git a/TiebaPureTests/TopicThreadsTests.swift b/TiebaPureTests/TopicThreadsTests.swift new file mode 100644 index 0000000..4aaf23a --- /dev/null +++ b/TiebaPureTests/TopicThreadsTests.swift @@ -0,0 +1,145 @@ +import XCTest +@testable import TiebaPure + +/// The topic page's JSON is hand-shaped here from the live service's real +/// response: strings and numbers mix for the same field (`discuss_num` arrives +/// as both), `abstract` is a plain string, and the paging cursor is the row's +/// feed id. +final class TopicThreadsTests: XCTestCase { + private static let fixture = """ + { + "no": 0, + "error": "success", + "data": { + "has_more": true, + "topic_info": { + "topic_id": "28366254", + "topic_name": "假期打分大会,奥特曼客串锐评", + "topic_desc": "国庆奥特曼客串回打分大会,来给剧情打个分。", + "discuss_num": "2294400", + "browse_num": 284537, + "topic_image": "https://example.com/topic.jpg" + }, + "relate_thread": { + "thread_list": [ + { + "feed_id": 11068502310, + "thread_info": { + "tid": 11068502310, + "title": "我发所有拍摄经手过赛罗戏份的导演,你来给他的赛罗打分", + "forum_id": 7046, + "forum_name": "奥特曼", + "reply_num": 210, + "agree_num": 1067, + "first_post_id": 153987296729, + "create_time": 1790825388, + "last_time_int": 1790925058, + "abstract": "摘要文本", + "author": { + "id": 6682378323, + "name": "sky", + "name_show": "sky遥远彼之地", + "portrait": "tb.1.95276bfd" + }, + "media": [ + { + "type": "pic", + "width": "560", + "height": "746", + "small_pic": "https://example.com/small.jpg", + "big_pic": "https://example.com/big.jpg" + } + ] + } + }, + { + "feed_id": 11068684304, + "thread_info": { + "tid": 11068684304, + "title": "第二个帖子" + } + } + ] + } + } + } + """ + + private func decoded() throws -> TopicDetailResponseDTO { + try JSONDecoder().decode(TopicDetailResponseDTO.self, from: Data(Self.fixture.utf8)) + } + + private func data() throws -> TopicDetailResponseDTO.DataDTO { + try XCTUnwrap(try decoded().data) + } + + func testResponseDecodesSuccessCodeAndPagingFlag() throws { + let response = try decoded() + + XCTAssertEqual(response.code, 0) + XCTAssertEqual(response.message, "success") + XCTAssertTrue(try data().hasMore) + } + + func testTopicInfoDecodesCountsSentAsStringsAndNumbers() throws { + let info = try XCTUnwrap(TopicMapper.info(from: try data().topicInfo)) + + XCTAssertEqual(info.id, 28_366_254) + XCTAssertEqual(info.name, "假期打分大会,奥特曼客串锐评") + XCTAssertEqual(info.summary, "国庆奥特曼客串回打分大会,来给剧情打个分。") + XCTAssertEqual(info.discussCount, 2_294_400, "讨论数是字符串也要解出来") + XCTAssertEqual(info.browseCount, 284_537) + XCTAssertEqual(info.imageURL?.absoluteString, "https://example.com/topic.jpg") + } + + func testTopicCountsAreRoundedToWanPastTenThousand() throws { + let info = try XCTUnwrap(TopicMapper.info(from: try data().topicInfo)) + + XCTAssertEqual(info.discussCountText, "229.4万") + XCTAssertEqual(info.browseCountText, "28.5万") + } + + func testThreadsMapToRowsWithTextSummaryAndImage() throws { + let threads = TopicMapper.threads(from: try data()) + + XCTAssertEqual(threads.count, 2) + let first = try XCTUnwrap(threads.first) + XCTAssertEqual(first.id, 11_068_502_310) + XCTAssertEqual(first.title, "我发所有拍摄经手过赛罗戏份的导演,你来给他的赛罗打分") + XCTAssertEqual(first.forumID, 7046) + XCTAssertEqual(first.forumName, "奥特曼") + XCTAssertEqual(first.replyCount, 210) + XCTAssertEqual(first.likeCount, 1067) + XCTAssertEqual(first.author.displayName, "sky遥远彼之地") + XCTAssertEqual(first.firstPostID, 153_987_296_729) + XCTAssertNotNil(first.createdAt) + XCTAssertEqual(first.textPreview, "摘要文本") + XCTAssertEqual(first.mediaBlocks.count, 1) + } + + func testThreadWithoutForumOrAuthorStillMaps() throws { + let threads = TopicMapper.threads(from: try data()) + + XCTAssertEqual(threads.count, 2) + let second = try XCTUnwrap(threads.last) + XCTAssertEqual(second.id, 11_068_684_304) + XCTAssertNil(second.forumID, "服务端没给吧 id 时不要凭空造一个") + XCTAssertEqual(second.author.displayNameResolved, "未知用户") + XCTAssertTrue(second.mediaBlocks.isEmpty) + } + + func testCursorIsTheLastFeedIDForTheNextPage() throws { + XCTAssertEqual(TopicMapper.cursor(from: try data()), "11068684304") + } + + func testEmptyThreadListStillProducesACursorlessPage() throws { + let json = #"{"no":0,"error":"success","data":{"has_more":false}}"# + let response = try JSONDecoder().decode(TopicDetailResponseDTO.self, from: Data(json.utf8)) + let data = try XCTUnwrap(response.data) + + XCTAssertTrue(TopicMapper.threads(from: data).isEmpty) + XCTAssertNil(TopicMapper.cursor(from: data)) + XCTAssertNil(TopicMapper.info(from: data.topicInfo)) + XCTAssertFalse(data.hasMore) + } +} From cb54b40b348ce96b9ae456893bf15abc3acc8eb9 Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 16:16:26 +0800 Subject: [PATCH 08/11] fix(sign): skip the write-token handshake when nothing needs signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一键签到时所有吧都已签到,整轮没有任何写请求,但协调器仍然先解析写令牌。 那一步是一次登录握手,本次日志里它独自耗时 20.2s:用户看到的是「无需签到」, 却要盯着转圈等 20 秒才显示完成。现在待签列表为空时直接跳过令牌解析,并记一条 说明跳过原因的日志;有待签贴吧时行为不变。 同时给写令牌解析加慢速分步日志:客户端登录与网页兜底各自超过 3s 才记一条 WARN (成功的跳记、失败记原因),下一次日志就能指出这 20 秒花在哪一跳,而不是只有一个 总数。正常几百毫秒的解析不产生任何日志,点赞收藏不会刷屏。 夹具新增 signAllDone 场景与写令牌解析计数,新增两个单测:无待签贴吧时解析次数 必须为 0;有待签贴吧时整轮只解析一次(而不是每个吧一次)。 构建号 64 → 65。 --- TiebaPure/Core/Network/FixtureTiebaAPI.swift | 39 ++++++++++++++-- TiebaPure/Core/Network/TiebaSocialAPI.swift | 27 +++++++++++ .../Core/State/ForumSignCoordinator.swift | 33 ++++++++++---- TiebaPureTests/ForumSignTests.swift | 45 +++++++++++++++++++ 4 files changed, 132 insertions(+), 12 deletions(-) diff --git a/TiebaPure/Core/Network/FixtureTiebaAPI.swift b/TiebaPure/Core/Network/FixtureTiebaAPI.swift index 07cd707..71f0171 100644 --- a/TiebaPure/Core/Network/FixtureTiebaAPI.swift +++ b/TiebaPure/Core/Network/FixtureTiebaAPI.swift @@ -26,6 +26,7 @@ enum FixtureScenario: String { case voicePlayback case signFailure case signAlreadyDone + case signAllDone case readingPosition case scrollPerformance case layoutPreview @@ -597,18 +598,38 @@ struct FixtureTiebaAPI: TiebaAPIService { ) } + /// The write token a check-in run resolves once for the whole list. It is + /// counted rather than stubbed away because "how many handshakes did this + /// run cost" is the property the coordinator is judged on, and it is + /// invisible from the outside otherwise. + func signingTBS(account: Account) async throws -> String { + _ = account + try await prepare() + _ = await state.nextSigningTBSRequest() + return "fixture-tbs" + } + + func signingTBSRequestCount() async -> Int { + await state.signingTBSRequests() + } + /// The guide listing the check-in coordinator reads to skip forums that are /// already signed today. Services without that listing reject the call, so - /// only the dedicated scenario answers it: the first forum is done for the - /// day, the second still needs a request. + /// only the dedicated scenarios answer it: `signAlreadyDone` leaves the + /// second forum to sign, `signAllDone` leaves nothing to do at all. func followedForumStatuses(account: Account) async throws -> [FollowedForumStatus] { try await prepare() - guard scenario == .signAlreadyDone else { + guard scenario == .signAlreadyDone || scenario == .signAllDone else { throw UserProfileMutationError.unsupportedByService } + let everythingSigned = scenario == .signAllDone return [ FollowedForumStatus(forumID: Self.forum.id, level: 7, isSignedToday: true), - FollowedForumStatus(forumID: Self.forumTwo.id, level: 3, isSignedToday: false) + FollowedForumStatus( + forumID: Self.forumTwo.id, + level: 3, + isSignedToday: everythingSigned + ) ] } @@ -1247,6 +1268,7 @@ private actor FixtureRequestState { private var userFollowStates: [String: Bool] = [:] private var forumFollowStates: [String: Bool] = [:] private var signedForums: Set = [] + private var signingTBSRequestCount = 0 private var threadCollectStates: [String: Bool] = [:] private var submittedThreadValues: [Int64: ThreadSummary] = [:] private var submittedMainPostValues: [Int64: Post] = [:] @@ -1333,6 +1355,15 @@ private actor FixtureRequestState { signedForums.insert("\(accountID)|\(forumID)").inserted } + func nextSigningTBSRequest() -> Int { + signingTBSRequestCount += 1 + return signingTBSRequestCount + } + + func signingTBSRequests() -> Int { + signingTBSRequestCount + } + func setForumFollowed(_ followed: Bool, accountID: String, forumID: Int64) { forumFollowStates["\(accountID)|\(forumID)"] = followed } diff --git a/TiebaPure/Core/Network/TiebaSocialAPI.swift b/TiebaPure/Core/Network/TiebaSocialAPI.swift index c61e5cd..f232560 100644 --- a/TiebaPure/Core/Network/TiebaSocialAPI.swift +++ b/TiebaPure/Core/Network/TiebaSocialAPI.swift @@ -532,6 +532,7 @@ extension TiebaAPI { ) async throws -> String { var clientError: Error? var webError: Error? + let clientAttemptStarted = Date() do { let response = try await login( @@ -539,6 +540,7 @@ extension TiebaAPI { stoken: account.stoken, baiduID: account.baiduID ?? "" ) + await logSlowTokenHop("客户端登录", startedAt: clientAttemptStarted) try Task.checkCancellation() let code = Int(response.errorCode ?? "0") ?? 0 if code == 0 { @@ -562,17 +564,20 @@ extension TiebaAPI { } catch is CancellationError { throw CancellationError() } catch { + await logSlowTokenHop("客户端登录", startedAt: clientAttemptStarted, failure: error) clientError = error } try Task.checkCancellation() + let webAttemptStarted = Date() do { let webInfo = try await webMyInfo(cookies: BaiduCookies( bduss: account.bduss, stoken: account.stoken, baiduID: account.baiduID )) + await logSlowTokenHop("网页兜底", startedAt: webAttemptStarted) try Task.checkCancellation() if webInfo.data?.isLogin == false { @@ -594,11 +599,13 @@ extension TiebaAPI { } catch is CancellationError { throw CancellationError() } catch let apiError as TiebaAPIError { + await logSlowTokenHop("网页兜底", startedAt: webAttemptStarted, failure: apiError) if case .sessionExpired = apiError { throw apiError } webError = apiError } catch { + await logSlowTokenHop("网页兜底", startedAt: webAttemptStarted, failure: error) webError = error } @@ -617,6 +624,26 @@ extension TiebaAPI { throw TiebaMutationError.missingTBS } + /// A write token normally resolves in a few hundred milliseconds, but each + /// of the two hops behind it can stall on its own, and a twenty-second + /// check-in cannot be told from a slow write without knowing which hop ate + /// the time. Only the slow hops are recorded, so a like or a collection + /// does not add a line per tap. + private func logSlowTokenHop( + _ name: String, + startedAt: Date, + failure: Error? = nil + ) async { + let seconds = Date().timeIntervalSince(startedAt) + guard seconds >= Self.slowTokenHopSeconds else { return } + let cost = String(format: "%.1fs", seconds) + let outcome = failure.map { "失败:\(ReaderErrorMessage.message(for: $0))" } ?? "成功" + await AppLog.shared.record(.warning, "写令牌", "\(name) 耗时 \(cost) \(outcome)") + } + + /// Below this a hop is fast enough not to be worth a log line. + private static let slowTokenHopSeconds: TimeInterval = 3 + func setPostLiked( account: Account, threadID: Int64, diff --git a/TiebaPure/Core/State/ForumSignCoordinator.swift b/TiebaPure/Core/State/ForumSignCoordinator.swift index 04eef0f..57af311 100644 --- a/TiebaPure/Core/State/ForumSignCoordinator.swift +++ b/TiebaPure/Core/State/ForumSignCoordinator.swift @@ -102,15 +102,32 @@ final class ForumSignCoordinator: ObservableObject { // One write token for the whole run: the login handshake that // produces it costs more than the check-in write itself, and // doing it per forum is what made a long list feel stuck. + // + // It is also the single most expensive step of the run, so a + // run with nothing to write must not pay for it: with every + // forum already signed the loop below is empty, and resolving + // the token anyway turned "nothing to do" into a spinner that + // sat there for the whole handshake. + let tbs: String stepStarted = Date() - let tbs = (try? await api.signingTBS(account: account)) ?? "" - try Task.checkCancellation() - await AppLog.shared.record( - .info, - "一键签到", - "写令牌\(tbs.isEmpty ? "未取到,逐吧解析" : "已就绪,本轮回用")," - + "耗时 \(Self.seconds(Date().timeIntervalSince(stepStarted)))" - ) + if pending.isEmpty { + tbs = "" + await AppLog.shared.record( + .info, + "一键签到", + "无待签贴吧,跳过写令牌解析,耗时 " + + "\(Self.seconds(Date().timeIntervalSince(stepStarted)))" + ) + } else { + tbs = (try? await api.signingTBS(account: account)) ?? "" + try Task.checkCancellation() + await AppLog.shared.record( + .info, + "一键签到", + "写令牌\(tbs.isEmpty ? "未取到,逐吧解析" : "已就绪,本轮回用")," + + "耗时 \(Self.seconds(Date().timeIntervalSince(stepStarted)))" + ) + } let signingStarted = Date() for (index, forum) in pending.enumerated() { diff --git a/TiebaPureTests/ForumSignTests.swift b/TiebaPureTests/ForumSignTests.swift index df67c06..b59c139 100644 --- a/TiebaPureTests/ForumSignTests.swift +++ b/TiebaPureTests/ForumSignTests.swift @@ -267,6 +267,51 @@ final class ForumSignTests: XCTestCase { ) } + @MainActor + func testRunWithNothingToSignDoesNotResolveAWriteToken() async throws { + let defaults = try makeScratchDefaults() + let settings = ForumSignSettingsStore(defaults: defaults) + let api = FixtureTiebaAPI(scenario: .signAllDone) + let coordinator = ForumSignCoordinator( + api: api, + settings: settings, + requestSpacing: .zero + ) + + let summary = await coordinator.signAllFollowedForums(account: account) + let tokenRequests = await api.signingTBSRequestCount() + + XCTAssertEqual(summary.signedCount, 0) + XCTAssertEqual(summary.alreadySignedCount, 2, "两个吧都已签到,整轮没有写请求") + XCTAssertTrue(summary.failedForumNames.isEmpty) + XCTAssertEqual( + tokenRequests, + 0, + "没有待签贴吧时不应解析写令牌:那一步是登录握手,会让「无需签到」也卡住整轮" + ) + XCTAssertTrue( + settings.hasRunToday(accountID: account.id), + "全部已签到时也算今天跑过,否则明天之前会反复自动重试" + ) + } + + @MainActor + func testRunResolvesTheWriteTokenOnceForTheWholeList() async throws { + let defaults = try makeScratchDefaults() + let api = FixtureTiebaAPI(scenario: .success) + let coordinator = ForumSignCoordinator( + api: api, + settings: ForumSignSettingsStore(defaults: defaults), + requestSpacing: .zero + ) + + let summary = await coordinator.signAllFollowedForums(account: account) + let tokenRequests = await api.signingTBSRequestCount() + + XCTAssertEqual(summary.signedCount, 2) + XCTAssertEqual(tokenRequests, 1, "整轮只应握手一次,而不是每个吧一次") + } + @MainActor func testRunPublishesProgressWhileSigningAndClearsItAfterwards() async throws { let defaults = try makeScratchDefaults() From 6aac012a3f97b32a2dc08b49775fdec1d490edf9 Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 17:38:43 +0800 Subject: [PATCH 09/11] feat(settings): add a switch that turns the diagnostic log off completely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置 → 诊断 新增「记录诊断日志」开关,默认开启。关闭后 AppLog.record 直接返回, 一条都不再记录(不是隐藏),已记录的内容保留,仍可查看、导出或清空。 开关存在 UserDefaults(dev.infinityf4p.tiebapure.diagnostics.logging-enabled), 未设置过时为开启。AppLog.isEnabled 是不需要 await 的静态读取,所以昂贵的调用点 可以先问再构造:进吧等级那行要把 39KB 响应整份走一遍 DiagnosticJSON.skeleton, 现在关闭时直接跳过,而不是构造完再丢掉。设置页 footer 与诊断日志页的空态文案都 跟着开关变化,避免「已关闭却提示回首页刷新一次」这种误导。 补两个单测:关闭时 record 与 recordError 一条都不记、重新开启立即恢复;未设置过时 默认开启、关闭状态能跨实例读到。构建号 65 → 66。 --- TiebaPure/Core/Diagnostics/AppLog.swift | 45 ++++++++++++++++++- TiebaPure/Core/Network/TiebaSocialAPI.swift | 14 +++--- .../Settings/DiagnosticsLogView.swift | 15 ++++++- .../Features/Settings/SettingsView.swift | 30 ++++++++++++- TiebaPureTests/AppLogTests.swift | 41 +++++++++++++++++ 5 files changed, 135 insertions(+), 10 deletions(-) diff --git a/TiebaPure/Core/Diagnostics/AppLog.swift b/TiebaPure/Core/Diagnostics/AppLog.swift index acdd83f..473e79b 100644 --- a/TiebaPure/Core/Diagnostics/AppLog.swift +++ b/TiebaPure/Core/Diagnostics/AppLog.swift @@ -1,5 +1,38 @@ import Foundation +/// Whether the app keeps a diagnostic log at all. +/// +/// The log exists to be handed back when a screen shows the wrong thing, so it +/// is on until the user says otherwise. Turning it off has to stop the record +/// rather than hide it, and the flag is read straight from `UserDefaults` so a +/// call site can ask before building a message that would only be thrown away. +struct DiagnosticLogSettings { + static let loggingKey = "dev.infinityf4p.tiebapure.diagnostics.logging-enabled" + + private let defaults: UserDefaults + private let loggingKey: String + + init( + defaults: UserDefaults = .standard, + loggingKey: String = DiagnosticLogSettings.loggingKey + ) { + self.defaults = defaults + self.loggingKey = loggingKey + } + + /// Unset means on: a fresh install has to be able to record without being + /// asked first, or the first report of a broken screen arrives with no + /// evidence at all. + var isEnabled: Bool { + guard defaults.object(forKey: loggingKey) != nil else { return true } + return defaults.bool(forKey: loggingKey) + } + + func setEnabled(_ enabled: Bool) { + defaults.set(enabled, forKey: loggingKey) + } +} + /// A bounded, in-memory diagnostic log the user can export from Settings. /// /// The app talks to undocumented Tieba endpoints whose response shapes are only @@ -8,11 +41,15 @@ import Foundation /// layer records what it actually received and the user can hand the file back. /// /// Everything here is best-effort: recording must never throw and never block a -/// network call, and the buffer is capped so a long session cannot grow without -/// bound. +/// network call, the buffer is capped so a long session cannot grow without +/// bound, and the whole log can be switched off from Settings. actor AppLog { static let shared = AppLog() + /// Readable without awaiting the actor: a caller that would have to build an + /// expensive message first can skip the work instead of discarding it. + static var isEnabled: Bool { DiagnosticLogSettings().isEnabled } + enum Level: String, Sendable { case info = "INFO" case warning = "WARN" @@ -38,6 +75,10 @@ actor AppLog { _ category: String, _ message: String ) { + // The switch is checked here rather than at each call site: a disabled + // log must record nothing at all, and the entry never exists to be + // exported or counted. + guard Self.isEnabled else { return } entries.append( Entry( id: nextID, diff --git a/TiebaPure/Core/Network/TiebaSocialAPI.swift b/TiebaPure/Core/Network/TiebaSocialAPI.swift index f232560..4f7fb95 100644 --- a/TiebaPure/Core/Network/TiebaSocialAPI.swift +++ b/TiebaPure/Core/Network/TiebaSocialAPI.swift @@ -496,11 +496,15 @@ extension TiebaAPI { // forum list key, the level key and the check-in key all come from // observation, so the raw skeleton is recorded before decoding rather // than after, where a mismatch would already be a silent empty list. - await AppLog.shared.record( - .info, - "进吧等级", - "第\(page)页 原始\(data.count)字节 结构=\(DiagnosticJSON.skeleton(data))" - ) + // Building that skeleton walks the whole 39 KB payload, so it is skipped + // outright when the log is switched off. + if AppLog.isEnabled { + await AppLog.shared.record( + .info, + "进吧等级", + "第\(page)页 原始\(data.count)字节 结构=\(DiagnosticJSON.skeleton(data))" + ) + } do { return try JSONDecoder().decode(FollowedForumGuideResponseDTO.self, from: data) diff --git a/TiebaPure/Features/Settings/DiagnosticsLogView.swift b/TiebaPure/Features/Settings/DiagnosticsLogView.swift index 64db40d..6a2fadc 100644 --- a/TiebaPure/Features/Settings/DiagnosticsLogView.swift +++ b/TiebaPure/Features/Settings/DiagnosticsLogView.swift @@ -9,6 +9,7 @@ import UIKit struct DiagnosticsLogView: View { @State private var entries: [AppLog.Entry] = [] @State private var exportURL: URL? + @State private var isRecordingEnabled = true var body: some View { List { @@ -43,12 +44,21 @@ struct DiagnosticsLogView: View { } header: { Text("诊断") } footer: { - Text("只保存在本机内存,退出应用即清空。导出的内容已自动去掉 BDUSS、STOKEN、tbs 等凭据。") + Text( + isRecordingEnabled + ? "只保存在本机内存,退出应用即清空。导出的内容已自动去掉 BDUSS、STOKEN、tbs 等凭据。" + : "记录已关闭,不会再有新内容。可在「设置 → 诊断」里重新开启;已记录的内容仍可导出或清空。" + ) + .accessibilityIdentifier("diagnostics-recording-footer") } Section("最近记录") { if entries.isEmpty { - Text("暂无记录。回到首页或进吧刷新一次,再回来查看。") + Text( + isRecordingEnabled + ? "暂无记录。回到首页或进吧刷新一次,再回来查看。" + : "记录已关闭,所以这里是空的。" + ) .foregroundStyle(.secondary) .accessibilityIdentifier("diagnostics-empty") } else { @@ -85,6 +95,7 @@ struct DiagnosticsLogView: View { } private func reload() async { + isRecordingEnabled = AppLog.isEnabled entries = await AppLog.shared.recent(200) } diff --git a/TiebaPure/Features/Settings/SettingsView.swift b/TiebaPure/Features/Settings/SettingsView.swift index 93e5235..dcafa6a 100644 --- a/TiebaPure/Features/Settings/SettingsView.swift +++ b/TiebaPure/Features/Settings/SettingsView.swift @@ -12,6 +12,10 @@ struct SettingsView: View { @State private var confirmsLogout = false @State private var isLoggingOut = false @State private var logoutErrorMessage: String? + /// Mirrored into view state so the footer text follows the switch: a custom + /// `Binding` writing straight to `UserDefaults` would leave the rest of the + /// form showing the old value. + @State private var isDiagnosticLogEnabled = DiagnosticLogSettings().isEnabled var body: some View { Form { @@ -95,6 +99,12 @@ struct SettingsView: View { } Section { + Toggle(isOn: diagnosticLogSelection) { + Label("记录诊断日志", systemImage: "record.circle") + } + .accessibilityHint("关闭后完全不记录任何诊断信息,已经记录的内容不受影响") + .accessibilityIdentifier("settings-diagnostics-logging-toggle") + NavigationLink { DiagnosticsLogView() } label: { @@ -105,7 +115,7 @@ struct SettingsView: View { } header: { Text("诊断") } footer: { - Text("记录首页和进吧相关接口的返回情况,用于排查「内容不对」或「等级不显示」的问题。日志只保存在本机内存,退出应用即清空。") + Text(diagnosticLogFooterText) } if let account { @@ -225,6 +235,24 @@ struct SettingsView: View { ) } + private var diagnosticLogSelection: Binding { + Binding( + get: { isDiagnosticLogEnabled }, + set: { enabled in + DiagnosticLogSettings().setEnabled(enabled) + isDiagnosticLogEnabled = enabled + } + ) + } + + private var diagnosticLogFooterText: String { + if isDiagnosticLogEnabled { + return "记录接口返回和耗时,用于排查「内容不对」「等级不显示」「签到慢」这类问题。" + + "只保存在本机内存,退出应用即清空;导出的内容已自动去掉凭据。" + } + return "已关闭,不再记录任何诊断信息;已经记录的内容仍可在诊断日志里查看、导出或清空。" + } + private var signFooterText: String { if let message = signStatusMessage { return message diff --git a/TiebaPureTests/AppLogTests.swift b/TiebaPureTests/AppLogTests.swift index c988b51..c94a18f 100644 --- a/TiebaPureTests/AppLogTests.swift +++ b/TiebaPureTests/AppLogTests.swift @@ -40,6 +40,47 @@ final class AppLogTests: XCTestCase { XCTAssertEqual(count, 0) } + func testSwitchingTheLogOffRecordsNothingAtAll() async { + let settings = DiagnosticLogSettings() + let previous = settings.isEnabled + settings.setEnabled(false) + addTeardownBlock { settings.setEnabled(previous) } + + let log = makeLog() + await log.record(.info, "分类", "不应出现") + await log.recordError("分类", "不应出现", error: URLError(.timedOut)) + + let count = await log.count() + let entries = await log.recent() + XCTAssertEqual(count, 0, "关闭后一条都不该记") + XCTAssertTrue(entries.isEmpty) + XCTAssertFalse(AppLog.isEnabled, "调用方应能同步读到关闭状态,从而跳过昂贵的消息构造") + + settings.setEnabled(true) + await log.record(.info, "分类", "重新开启后的消息") + let reopened = await log.recent() + XCTAssertEqual(reopened.map(\.message), ["重新开启后的消息"], "重新开启后立即恢复记录") + } + + func testDiagnosticLoggingIsOnUntilTheUserTurnsItOff() throws { + let name = "diagnostic-log-settings-\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: name)) + addTeardownBlock { defaults.removePersistentDomain(forName: name) } + + let settings = DiagnosticLogSettings(defaults: defaults) + XCTAssertTrue(settings.isEnabled, "从未设置过时必须默认开启,否则第一次排查拿不到任何证据") + + settings.setEnabled(false) + XCTAssertFalse(settings.isEnabled) + XCTAssertFalse( + DiagnosticLogSettings(defaults: defaults).isEnabled, + "关闭状态要能跨实例读到(设置页与日志页各建一个实例)" + ) + + settings.setEnabled(true) + XCTAssertTrue(settings.isEnabled) + } + func testExportContainsEveryRecordedMessage() async { let log = makeLog() await log.record(.info, "进吧等级", "第1页 error_code=0") From 7d68fa62c6987cf9bea179117a6c758917011b94 Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Tue, 29 Sep 2026 09:12:49 +0000 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20home=20tab=20gestures=20=E2=80=94?= =?UTF-8?q?=20single=20returns=20to=20root,=20double=20scrolls=20top,=20lo?= =?UTF-8?q?ng-press=20refreshes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split Home reselect into discernible intents driven by gesture recognizers on the tab bar: single tap collapses detail/search to the feed root, double tap additionally scrolls to top (no refresh), long press refreshes. HomeView now observes three tokens (returnToRoot/scrollToTop/refresh) instead of one. Update HomeNavigationTests and home-tab UI tests to the new gestures. --- TiebaPure/App/RootView.swift | 178 ++++++++++++++++++++--- TiebaPure/Features/Home/HomeView.swift | 38 +++-- TiebaPureTests/HomeNavigationTests.swift | 48 +++++- TiebaPureUITests/TiebaPureUITests.swift | 22 +-- 4 files changed, 233 insertions(+), 53 deletions(-) diff --git a/TiebaPure/App/RootView.swift b/TiebaPure/App/RootView.swift index 7a6b2da..d6a7839 100644 --- a/TiebaPure/App/RootView.swift +++ b/TiebaPure/App/RootView.swift @@ -289,6 +289,8 @@ private struct ExternalRouteView: View { private struct MainTabView: View { let account: Account? @State private var selectedTab: RootTab = .home + @State private var homeReturnToRootToken = 0 + @State private var homeScrollToTopToken = 0 @State private var homeRefreshToken = 0 var body: some View { @@ -300,9 +302,19 @@ private struct MainTabView: View { } } .background( - TabSelectionObserver { - homeRefreshToken += 1 - } + TabSelectionObserver( + onHomeSingleTap: { + homeReturnToRootToken &+= 1 + }, + onHomeDoubleTap: { + homeReturnToRootToken &+= 1 + homeScrollToTopToken &+= 1 + }, + onHomeLongPress: { + homeReturnToRootToken &+= 1 + homeRefreshToken &+= 1 + } + ) ) } @@ -310,7 +322,12 @@ private struct MainTabView: View { private var modernTabView: some View { TabView(selection: tabSelection) { Tab("首页", systemImage: "house", value: RootTab.home) { - HomeView(account: account, refreshToken: homeRefreshToken) + HomeView( + account: account, + returnToRootToken: homeReturnToRootToken, + scrollToTopToken: homeScrollToTopToken, + refreshToken: homeRefreshToken + ) } Tab("进吧", systemImage: "square.grid.2x2", value: RootTab.forums) { @@ -325,7 +342,12 @@ private struct MainTabView: View { private var legacyTabView: some View { TabView(selection: tabSelection) { - HomeView(account: account, refreshToken: homeRefreshToken) + HomeView( + account: account, + returnToRootToken: homeReturnToRootToken, + scrollToTopToken: homeScrollToTopToken, + refreshToken: homeRefreshToken + ) .tabItem { Label("首页", systemImage: "house") } @@ -362,10 +384,16 @@ enum RootTab: Hashable { } struct TabSelectionObserver: UIViewControllerRepresentable { - let onReselectHome: () -> Void + let onHomeSingleTap: () -> Void + let onHomeDoubleTap: () -> Void + let onHomeLongPress: () -> Void func makeCoordinator() -> Coordinator { - Coordinator(onReselectHome: onReselectHome) + Coordinator( + onHomeSingleTap: onHomeSingleTap, + onHomeDoubleTap: onHomeDoubleTap, + onHomeLongPress: onHomeLongPress + ) } func makeUIViewController(context: Context) -> Controller { @@ -373,7 +401,9 @@ struct TabSelectionObserver: UIViewControllerRepresentable { } func updateUIViewController(_ controller: Controller, context: Context) { - context.coordinator.onReselectHome = onReselectHome + context.coordinator.onHomeSingleTap = onHomeSingleTap + context.coordinator.onHomeDoubleTap = onHomeDoubleTap + context.coordinator.onHomeLongPress = onHomeLongPress controller.coordinator = context.coordinator controller.isObservationActive = true controller.attachToTabBarController() @@ -447,26 +477,38 @@ struct TabSelectionObserver: UIViewControllerRepresentable { } } - final class Coordinator: NSObject, UITabBarControllerDelegate { - var onReselectHome: () -> Void + final class Coordinator: NSObject, UITabBarControllerDelegate, UIGestureRecognizerDelegate { + var onHomeSingleTap: () -> Void + var onHomeDoubleTap: () -> Void + var onHomeLongPress: () -> Void private weak var observedController: UITabBarController? private weak var previousDelegate: UITabBarControllerDelegate? - - init(onReselectHome: @escaping () -> Void) { - self.onReselectHome = onReselectHome + private var singleTap: UITapGestureRecognizer? + private var doubleTap: UITapGestureRecognizer? + private var longPress: UILongPressGestureRecognizer? + + init( + onHomeSingleTap: @escaping () -> Void, + onHomeDoubleTap: @escaping () -> Void, + onHomeLongPress: @escaping () -> Void + ) { + self.onHomeSingleTap = onHomeSingleTap + self.onHomeDoubleTap = onHomeDoubleTap + self.onHomeLongPress = onHomeLongPress } func attach(to tabBarController: UITabBarController) { - guard observedController !== tabBarController || tabBarController.delegate !== self else { - return + if observedController !== tabBarController || tabBarController.delegate !== self { + detach() + previousDelegate = tabBarController.delegate + observedController = tabBarController + tabBarController.delegate = self } - detach() - previousDelegate = tabBarController.delegate - observedController = tabBarController - tabBarController.delegate = self + installGestureRecognizers(on: tabBarController) } func detach() { + removeGestureRecognizers() if let observedController, observedController.delegate === self { observedController.delegate = previousDelegate } @@ -474,16 +516,106 @@ struct TabSelectionObserver: UIViewControllerRepresentable { previousDelegate = nil } + private func installGestureRecognizers(on tabBarController: UITabBarController) { + let tabBar = tabBarController.tabBar + // Idempotent: a later re-attach must not duplicate recognizers. + if singleTap != nil && singleTap?.view === tabBar { return } + removeGestureRecognizers() + + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) + doubleTap.numberOfTapsRequired = 2 + doubleTap.cancelsTouchesInView = false + + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) + longPress.minimumPressDuration = 0.5 + longPress.cancelsTouchesInView = false + + let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap(_:))) + singleTap.numberOfTapsRequired = 1 + singleTap.cancelsTouchesInView = false + singleTap.require(toFail: doubleTap) + singleTap.require(toFail: longPress) + + self.doubleTap = doubleTap + self.singleTap = singleTap + self.longPress = longPress + for recognizer in [singleTap, doubleTap, longPress] { + recognizer.delegate = self + tabBar.addGestureRecognizer(recognizer) + } + } + + private func removeGestureRecognizers() { + guard let tabBar = observedController?.tabBar else { + singleTap = nil + doubleTap = nil + longPress = nil + return + } + for recognizer in [singleTap, doubleTap, longPress] { + if let recognizer { + tabBar.removeGestureRecognizer(recognizer) + } + } + singleTap = nil + doubleTap = nil + longPress = nil + } + + @objc private func handleSingleTap(_ gesture: UITapGestureRecognizer) { + onHomeSingleTap() + } + + @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + onHomeDoubleTap() + } + + @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began else { return } + onHomeLongPress() + } + + /// Restrict the recognizers to the Home tab's button so taps on the + /// other tabs keep their normal UIKit selection behavior. + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let tabBar = observedController?.tabBar else { return false } + let point = gestureRecognizer.location(in: tabBar) + return homeItemFrame(in: tabBar).contains(point) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + private func homeItemFrame(in tabBar: UITabBar) -> CGRect { + let buttons = tabBar.subviews + .filter { $0.frame.width > 0 && $0.frame.height > 0 } + .compactMap { $0 as? UIControl } + .sorted { $0.frame.minX < $1.frame.minX } + if let first = buttons.first, first.frame.minX >= tabBar.bounds.minX { + return first.frame + } + let count = CGFloat(max(tabBar.items?.count ?? 1, 1)) + return CGRect( + x: 0, + y: 0, + width: tabBar.bounds.width / max(count, 1), + height: tabBar.bounds.height + ) + } + func tabBarController( _ tabBarController: UITabBarController, shouldSelect viewController: UIViewController ) -> Bool { if tabBarController.selectedViewController === viewController, tabBarController.viewControllers?.first === viewController { - // Home owns both returning to its root and refreshing. Letting - // UIKit reselect first can pop the navigation path before the - // SwiftUI callback decides which action the user requested. - onReselectHome() + // Home's own gestures drive what a reselect tap does. Reject the + // native reselect so UIKit does not also pop or scroll behind + // the SwiftUI callback. return false } return previousDelegate?.tabBarController?( diff --git a/TiebaPure/Features/Home/HomeView.swift b/TiebaPure/Features/Home/HomeView.swift index a62e88d..062e7bb 100644 --- a/TiebaPure/Features/Home/HomeView.swift +++ b/TiebaPure/Features/Home/HomeView.swift @@ -8,6 +8,12 @@ struct HomeView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.readingPreferences) private var readingPreferences let account: Account? + /// Single tap on the Home tab: collapse any detail/search back to the feed + /// root without refreshing. + var returnToRootToken: Int = 0 + /// Double tap on the Home tab: return to the feed root and scroll to top. + var scrollToTopToken: Int = 0 + /// Long press on the Home tab: return to the feed root and refresh. var refreshToken: Int = 0 @ObservedObject private var blocklistStore = BlocklistStore.shared @@ -58,18 +64,20 @@ struct HomeView: View { .onChange(of: horizontalSizeClass) { sizeClass in foldNavigationForSizeClassChange(to: sizeClass) } + .onChange(of: returnToRootToken) { _ in + // Single tap: collapse any pushed detail/search back to the feed + // root without refreshing. Already at the root feed -> no-op. + collapseHomeNavigation() + } + .onChange(of: scrollToTopToken) { _ in + // Double tap: return to the feed root and scroll to top, no + // refresh so the user can re-read what is already loaded. + collapseHomeNavigation() + scrollToTopRequest &+= 1 + } .onChange(of: refreshToken) { _ in - // Observe outside the feed's navigation destination so reselecting - // Home also works while a detail or search view covers the list. - if navigationPath.isEmpty == false || activeSearch != nil - || splitDetailPath.isEmpty == false { - navigationPath = [] - activeSearch = nil - splitDetailPath = [] - return - } - // Returning to the top must still work during an existing refresh; - // the refresh modifier separately coalesces duplicate requests. + // Long press: return to the feed root, scroll to top, then refresh. + collapseHomeNavigation() scrollToTopRequest &+= 1 programmaticRefreshToken &+= 1 } @@ -300,6 +308,14 @@ struct HomeView: View { ) } + private func collapseHomeNavigation() { + guard navigationPath.isEmpty == false || activeSearch != nil + || splitDetailPath.isEmpty == false else { return } + navigationPath = [] + activeSearch = nil + splitDetailPath = [] + } + private func removeNavigationRouteIfCurrent(_ route: HomeNavigationRoute) { navigationPath = HomeNavigationPathPolicy.removingCurrent( route, diff --git a/TiebaPureTests/HomeNavigationTests.swift b/TiebaPureTests/HomeNavigationTests.swift index 64d65bb..e46e988 100644 --- a/TiebaPureTests/HomeNavigationTests.swift +++ b/TiebaPureTests/HomeNavigationTests.swift @@ -4,7 +4,7 @@ import UIKit final class HomeNavigationTests: XCTestCase { @MainActor - func testHomeReselectIsHandledBeforeNativeNavigationCanChange() { + func testHomeReselectIsVetoedWithoutRoutingAnAction() { let tabBar = UITabBarController() let home = UIViewController() let forums = UIViewController() @@ -12,16 +12,44 @@ final class HomeNavigationTests: XCTestCase { tabBar.selectedViewController = home let nativeDelegate = RecordingTabDelegate() tabBar.delegate = nativeDelegate - var reselectCount = 0 - let observer = TabSelectionObserver.Coordinator { reselectCount += 1 } + var singleCount = 0 + var doubleCount = 0 + var longPressCount = 0 + let observer = TabSelectionObserver.Coordinator( + onHomeSingleTap: { singleCount += 1 }, + onHomeDoubleTap: { doubleCount += 1 }, + onHomeLongPress: { longPressCount += 1 } + ) observer.attach(to: tabBar) + // A reselect of the already-selected Home tab is rejected locally (so + // UIKit cannot also pop/scroll), but it must not itself route refresh + // or scroll actions — those are delivered by the gesture recognizers. XCTAssertFalse(observer.tabBarController(tabBar, shouldSelect: home)) - XCTAssertEqual(reselectCount, 1, "Handle the tap synchronously before navigation changes") - XCTAssertEqual(nativeDelegate.selectionCount, 0, "Native reselection must not also pop or scroll") + XCTAssertEqual(singleCount, 0) + XCTAssertEqual(doubleCount, 0) + XCTAssertEqual(longPressCount, 0) + XCTAssertEqual(nativeDelegate.selectionCount, 0) XCTAssertTrue(tabBar.selectedViewController === home) } + @MainActor + func testMultiTouchCallbacksAreDeliveredInOrder() { + let observer = TabSelectionObserver.Coordinator( + onHomeSingleTap: {}, + onHomeDoubleTap: {}, + onHomeLongPress: {} + ) + var order: [String] = [] + observer.onHomeSingleTap = { order.append("single") } + observer.onHomeDoubleTap = { order.append("double") } + observer.onHomeLongPress = { order.append("long") } + observer.onHomeSingleTap() + observer.onHomeLongPress() + observer.onHomeDoubleTap() + XCTAssertEqual(order, ["single", "long", "double"]) + } + @MainActor func testSwitchingTabsPreservesNativeSelectionAndVeto() { let tabBar = UITabBarController() @@ -31,15 +59,19 @@ final class HomeNavigationTests: XCTestCase { tabBar.selectedViewController = forums let nativeDelegate = RecordingTabDelegate() tabBar.delegate = nativeDelegate - var reselectCount = 0 - let observer = TabSelectionObserver.Coordinator { reselectCount += 1 } + var singleCount = 0 + let observer = TabSelectionObserver.Coordinator( + onHomeSingleTap: { singleCount += 1 }, + onHomeDoubleTap: {}, + onHomeLongPress: {} + ) observer.attach(to: tabBar) XCTAssertTrue(observer.tabBarController(tabBar, shouldSelect: home)) nativeDelegate.permitsSelection = false XCTAssertFalse(observer.tabBarController(tabBar, shouldSelect: home)) XCTAssertEqual(nativeDelegate.selectionCount, 2) - XCTAssertEqual(reselectCount, 0, "Returning from another tab must not refresh Home") + XCTAssertEqual(singleCount, 0, "Returning from another tab must not route a Home action") observer.tabBarController(tabBar, didSelect: home) XCTAssertTrue(nativeDelegate.lastSelectedController === home) observer.detach() diff --git a/TiebaPureUITests/TiebaPureUITests.swift b/TiebaPureUITests/TiebaPureUITests.swift index eed8795..809feb6 100644 --- a/TiebaPureUITests/TiebaPureUITests.swift +++ b/TiebaPureUITests/TiebaPureUITests.swift @@ -1823,7 +1823,7 @@ final class TiebaPureUITests: XCTestCase { for cycle in 1...5 { scrollView.swipeUp() scrollView.swipeUp() - homeTabCoordinate.tap() + homeTabCoordinate.press(forDuration: 1.0) let refreshedTitle = app.buttons["下拉刷新已更新"] XCTAssertTrue(refreshedTitle.waitForExistence(timeout: 5)) XCTAssertTrue(waitForHittable(refreshedTitle, expected: true, timeout: 5), "第\(cycle)次点击首页后应回到新内容") @@ -1854,8 +1854,8 @@ final class TiebaPureUITests: XCTestCase { XCTAssertTrue(app.scrollViews["thread-detail-scroll-view"].waitForNonExistence(timeout: 5)) XCTAssertTrue(app.navigationBars["首页"].waitForExistence(timeout: 5), "点击首页应退出帖子详情") XCTAssertFalse(app.buttons["下拉刷新已更新"].waitForExistence(timeout: 2), "第一次点击只返回列表,不应同时刷新") - homeTabCoordinate.tap() - XCTAssertTrue(app.buttons["下拉刷新已更新"].waitForExistence(timeout: 5), "返回列表后再点首页应刷新") + homeTabCoordinate.press(forDuration: 1.0) + XCTAssertTrue(app.buttons["下拉刷新已更新"].waitForExistence(timeout: 5), "返回列表后再长按首页应刷新") } func testHomeTabReselectDuringInFlightRefreshReturnsToTop() { @@ -1866,7 +1866,7 @@ final class TiebaPureUITests: XCTestCase { let firstTitle = app.buttons["确定性主帖:回复筛选与媒体布局"] XCTAssertTrue(firstTitle.waitForExistence(timeout: 25)) let tabCoordinate = rootTabCoordinate("首页", in: app) - tabCoordinate.tap() + tabCoordinate.press(forDuration: 1.0) let indicator = app.descendants(matching: .any)["home-refresh-animation"] XCTAssertTrue(indicator.waitForExistence(timeout: 2)) let scrollView = app.scrollViews["home-feed-scroll-view"] @@ -1874,8 +1874,8 @@ final class TiebaPureUITests: XCTestCase { scrollView.swipeUp() XCTAssertFalse(firstTitle.isHittable, "应先滚离列表顶部") XCTAssertTrue(indicator.exists, "复现必须发生在请求仍未结束时") - tabCoordinate.tap() - XCTAssertTrue(waitForHittable(firstTitle, expected: true, timeout: 2), "刷新进行中再点首页,也应返回顶部") + tabCoordinate.doubleTap() + XCTAssertTrue(waitForHittable(firstTitle, expected: true, timeout: 2), "刷新进行中双击首页,也应返回顶部") XCTAssertTrue(app.buttons["下拉刷新已更新"].waitForExistence(timeout: 25)) XCTAssertTrue(indicator.waitForNonExistence(timeout: 5)) XCTAssertFalse(app.buttons["重复的首页刷新请求"].exists, "连续点击应共用进行中的刷新请求") @@ -1895,7 +1895,7 @@ final class TiebaPureUITests: XCTestCase { XCTAssertTrue(app.navigationBars["搜索"].waitForNonExistence(timeout: 5)) XCTAssertTrue(app.navigationBars["首页"].exists) XCTAssertFalse(app.buttons["下拉刷新已更新"].waitForExistence(timeout: 2)) - homeTabCoordinate.tap() + homeTabCoordinate.press(forDuration: 1.0) XCTAssertTrue(app.buttons["下拉刷新已更新"].waitForExistence(timeout: 5)) } @@ -1921,12 +1921,12 @@ final class TiebaPureUITests: XCTestCase { app.swipeUp() app.swipeUp() - homeTabCoordinate.tap() + homeTabCoordinate.press(forDuration: 1.0) let refreshAnimation = app.descendants(matching: .any)["home-refresh-animation"] XCTAssertTrue( refreshAnimation.waitForExistence(timeout: 2), - "重复点击首页后应通过统一刷新状态机显示顶部动画" + "长按首页后应通过统一刷新状态机显示顶部动画" ) XCTAssertTrue(app.buttons["下拉刷新已更新"].waitForExistence(timeout: 5)) XCTAssertTrue(app.buttons["确定性主帖:回复筛选与媒体布局"].exists) @@ -1994,12 +1994,12 @@ final class TiebaPureUITests: XCTestCase { app.coordinate(withNormalizedOffset: CGVector( dx: homeTabFrame.midX / appFrame.width, dy: homeTabFrame.midY / appFrame.height - )).tap() + )).press(forDuration: 1.0) XCTAssertTrue( app.descendants(matching: .any)["home-refresh-animation"] .waitForExistence(timeout: 2), - "分页中重复点击首页也必须立即进入统一刷新状态" + "分页中长按首页也必须立即进入统一刷新状态" ) XCTAssertTrue( app.buttons["下拉刷新已更新"].waitForExistence(timeout: 5), From f94b2e732fd5f1e62bf35501014337e56c09b6ea Mon Sep 17 00:00:00 2001 From: IamNewHands Date: Fri, 2 Oct 2026 22:32:19 +0800 Subject: [PATCH 11/11] =?UTF-8?q?feat(home):=20switch=20=E6=8E=A8=E8=8D=90?= =?UTF-8?q?=20/=20=E7=83=AD=E7=82=B9=20with=20a=20horizontal=20swipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 推荐 和 热点 是首页仅有的两个分页,所以在任一页面上左右滑动都切到另一页: 在推荐上右滑进入热点,在热点上左滑回到推荐。HomeFeedSwipePolicy 只把足够长、 且明显偏水平的拖动当作翻页;手势只挂在滚动内容上,热点子分页的横向标签栏 仍由它自己的横向滚动处理,不会被抢走拖动。分段控件保留,是无障碍和可发现 的主要切换方式。 两个分页改为常驻视图树,切换只改可见性:热点保留已加载列表与滚动位置,不再 每次右滑都重新请求;隐藏分页移出命中测试与无障碍树。HotFeedLoadPolicy 门控 「首次激活才加载」。首页 tab 手势对可见分页生效,与推荐一致:单击返回列表根、 双击回到顶部、长按刷新;HomeFeedGestureTargetPolicy 只把 token 投给当前分页, 避免后台分页被滚动或被偷偷重新请求。HotThreadsView 增加顶部滚动锚点,回顶复用 与推荐相同的无动画 transaction,避免 iOS 26 上懒加载布局在动画中反复更新。 --- README.md | 2 +- TiebaPure/Features/Home/HomeView.swift | 80 +++++- TiebaPure/Features/Home/HotThreadsView.swift | 273 ++++++++++++++----- TiebaPureTests/TiebaPureSmokeTests.swift | 76 ++++++ 4 files changed, 349 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index be52fee..deb1979 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ **浏览**(无需登录) -- 首页分页:推荐(个性化推荐流)与热点(贴吧热榜,可切换子分页) +- 首页分页:推荐(个性化推荐流)与热点(贴吧热榜,可切换子分页),在两个分页上左右滑动即可互相切换 - 进吧、吧内列表,「最新 / 精华」分类,可按回复时间或发帖时间排序 - 搜索主题与回复,吧内搜索 - 帖子详情、楼中楼、图片缩放与保存、视频播放 diff --git a/TiebaPure/Features/Home/HomeView.swift b/TiebaPure/Features/Home/HomeView.swift index 062e7bb..8298b4a 100644 --- a/TiebaPure/Features/Home/HomeView.swift +++ b/TiebaPure/Features/Home/HomeView.swift @@ -26,9 +26,11 @@ struct HomeView: View { @State private var didLoad = false @State private var errorMessage: String? @State private var navigationPath: [HomeNavigationRoute] = [] - @State private var programmaticRefreshToken = 0 + @State private var recommendedRefreshToken = 0 + @State private var hotRefreshToken = 0 @State private var lastScenePhase: ScenePhase = .inactive - @State private var scrollToTopRequest = 0 + @State private var recommendedScrollToTopRequest = 0 + @State private var hotScrollToTopRequest = 0 @State private var requestGeneration = 0 @State private var loadTask: Task<[ThreadSummary], Error>? @State private var pendingPaginationRequest = false @@ -70,16 +72,18 @@ struct HomeView: View { collapseHomeNavigation() } .onChange(of: scrollToTopToken) { _ in - // Double tap: return to the feed root and scroll to top, no - // refresh so the user can re-read what is already loaded. + // Double tap: return to the feed root and scroll the visible + // segment to top, no refresh so the user can re-read what is + // already loaded. collapseHomeNavigation() - scrollToTopRequest &+= 1 + requestScrollToTopForVisibleSegment() } .onChange(of: refreshToken) { _ in - // Long press: return to the feed root, scroll to top, then refresh. + // Long press: return to the feed root, scroll to top, then refresh + // the visible segment. collapseHomeNavigation() - scrollToTopRequest &+= 1 - programmaticRefreshToken &+= 1 + requestScrollToTopForVisibleSegment() + requestRefreshForVisibleSegment() } .alert("提示", isPresented: likeActionErrorIsPresented) { Button("好", role: .cancel) { likeActionError = nil } @@ -244,30 +248,78 @@ struct HomeView: View { .accessibilityIdentifier("home-feed-segment-picker") } + /// Both segments stay in the hierarchy, so switching only changes which one + /// is visible: 热点 keeps its listing and scroll position instead of + /// re-fetching every time the user swipes back to it. The hidden segment is + /// removed from hit testing and from the accessibility tree, so only the + /// visible feed is tappable, scrollable and readable. @ViewBuilder private var feedSegmentContent: some View { - switch feedSegment { - case .recommended: + ZStack { recommendedFeedColumn - case .hot: + .opacity(feedSegment == .recommended ? 1 : 0) + .allowsHitTesting(feedSegment == .recommended) + .accessibilityHidden(feedSegment != .recommended) + .homeFeedSwipeGesture { switchHomeFeedSegment() } + HotThreadsView( account: account, + isActive: feedSegment == .hot, + scrollToTopToken: hotScrollToTopRequest, + refreshToken: hotRefreshToken, onOpenThread: { openThread($0) }, onOpenComments: { openThread($0, initialDestination: .replies) }, onOpenForum: openForum, onOpenUser: { openUser($0, sourceThreadID: nil) }, - onOpenTopic: { openTopic($0) } + onOpenTopic: { openTopic($0) }, + onHorizontalSwipe: { switchHomeFeedSegment() } ) + .opacity(feedSegment == .hot ? 1 : 0) + .allowsHitTesting(feedSegment == .hot) + .accessibilityHidden(feedSegment != .hot) .accessibilityIdentifier("home-hot-feed") } } + /// A horizontal swipe moves to the other segment. Both feeds keep their own + /// vertical scrolling; the segmented picker stays the discoverable and + /// accessible way to switch, so the swipe only ever adds a shortcut. + private func switchHomeFeedSegment() { + feedSegment = HomeFeedSwipePolicy.toggled(feedSegment) + } + + /// The home-tab gestures address the segment on screen only; the hidden + /// feed keeps its position and is not re-fetched behind the user's back. + private func requestScrollToTopForVisibleSegment() { + let target = HomeFeedGestureTargetPolicy.targets( + forActiveSegment: feedSegment + ) + switch target.scrollToTopSegment { + case .recommended: + recommendedScrollToTopRequest &+= 1 + case .hot: + hotScrollToTopRequest &+= 1 + } + } + + private func requestRefreshForVisibleSegment() { + let target = HomeFeedGestureTargetPolicy.targets( + forActiveSegment: feedSegment + ) + switch target.refreshSegment { + case .recommended: + recommendedRefreshToken &+= 1 + case .hot: + hotRefreshToken &+= 1 + } + } + private var recommendedFeedColumn: some View { ScrollViewReader { scrollProxy in refreshableScrollView { feedContent } - .onChange(of: scrollToTopRequest) { _ in + .onChange(of: recommendedScrollToTopRequest) { _ in // Feed changes during an animated scroll-to-top can make the // lazy layout update endlessly on iOS 26. Finish the jump in // a transaction that cannot inherit the refresh animation. @@ -623,7 +675,7 @@ struct HomeView: View { isEnabled: didLoad && isLoading == false, surface: .grouped, accessibilityIdentifier: "home-refresh-animation", - programmaticRefreshToken: programmaticRefreshToken + programmaticRefreshToken: recommendedRefreshToken ) { source in if source == .pullGesture { guard isLoading == false else { return } diff --git a/TiebaPure/Features/Home/HotThreadsView.swift b/TiebaPure/Features/Home/HotThreadsView.swift index 34f780c..f7a43f9 100644 --- a/TiebaPure/Features/Home/HotThreadsView.swift +++ b/TiebaPure/Features/Home/HotThreadsView.swift @@ -28,6 +28,86 @@ enum HomeFeedSegment: String, CaseIterable, Identifiable, Sendable { } } +/// Decides whether a finished drag is a deliberate horizontal swipe between the +/// two home segments. 推荐 and 热点 are the only segments, so a swipe in either +/// direction moves to the other one: a right swipe leaves 推荐 for 热点, and a +/// left swipe leaves 热点 for 推荐. +/// +/// The feed itself scrolls vertically, so a drag only counts once it is clearly +/// horizontal — otherwise a normal scroll with a little sideways drift would +/// jump the user to the other segment mid-read. +enum HomeFeedSwipePolicy { + /// Drags shorter than this are scroll jitter, not a segment switch. + static let minimumHorizontalDistance: CGFloat = 60 + /// A drag this diagonal or steeper stays a scroll. + static let maximumVerticalToHorizontalRatio: CGFloat = 0.6 + /// The drag recognizer needs a little travel before it reports a gesture at + /// all; the thresholds above still decide whether that gesture counts. + static let gestureMinimumDistance: CGFloat = 12 + + static func isHorizontalSwipe(translation: CGSize) -> Bool { + let horizontalDistance = abs(translation.width) + let verticalDistance = abs(translation.height) + guard horizontalDistance >= minimumHorizontalDistance else { return false } + return verticalDistance <= horizontalDistance * maximumVerticalToHorizontalRatio + } + + static func toggled(_ segment: HomeFeedSegment) -> HomeFeedSegment { + switch segment { + case .recommended: + return .hot + case .hot: + return .recommended + } + } +} + +extension View { + /// Reports only drags that end as a deliberate horizontal swipe. Attach it + /// to the scrolling feed, never to a horizontally scrollable bar, so the + /// bar keeps owning its own drags. + func homeFeedSwipeGesture(onSwipe: @escaping () -> Void) -> some View { + simultaneousGesture( + DragGesture(minimumDistance: HomeFeedSwipePolicy.gestureMinimumDistance) + .onEnded { value in + guard HomeFeedSwipePolicy.isHorizontalSwipe( + translation: value.translation + ) else { return } + onSwipe() + } + ) + } +} + +/// 热点 keeps its listing for the whole session. Switching to 推荐 and back only +/// hides and shows the same view, so re-activation must reuse the loaded +/// listing instead of firing another request; the listing is dropped only when +/// the account changes. +enum HotFeedLoadPolicy { + static func shouldLoadOnActivation(isActive: Bool, didLoad: Bool) -> Bool { + isActive && didLoad == false + } +} + +/// Which per-segment request counters a home-tab gesture must bump. +/// +/// The home-tab gestures act on the segment that is on screen. Addressing the +/// hidden segment as well would scroll it away from where the user left it and +/// re-fetch a list nobody can see. +struct HomeFeedGestureTargets: Equatable { + var scrollToTopSegment: HomeFeedSegment + var refreshSegment: HomeFeedSegment +} + +enum HomeFeedGestureTargetPolicy { + static func targets(forActiveSegment active: HomeFeedSegment) -> HomeFeedGestureTargets { + HomeFeedGestureTargets( + scrollToTopSegment: active, + refreshSegment: active + ) + } +} + /// The 热点 half of the home feed. /// /// The endpoint answers one listing per sub-tab and takes no page parameter, so @@ -39,11 +119,23 @@ struct HotThreadsView: View { @ObservedObject private var blocklistStore = BlocklistStore.shared let account: Account? + /// Whether 热点 is the segment currently on screen. The view stays in the + /// hierarchy while 推荐 is shown, so this decides when the first load may + /// start; a later switch back must not fetch the listing again. + let isActive: Bool + /// Changed by a double tap on the 首页 tab: scroll the listing back to top. + let scrollToTopToken: Int + /// Changed by a long press on the 首页 tab: refresh the listing. + let refreshToken: Int let onOpenThread: (ThreadSummary) -> Void let onOpenComments: (ThreadSummary) -> Void let onOpenForum: (Forum) -> Void let onOpenUser: (UserSummary) -> Void let onOpenTopic: (HotTopic) -> Void + /// A horizontal swipe on the thread list switches the home segment. The + /// sub-tab bar is deliberately outside this gesture: its own horizontal + /// scroll owns drags that start there. + let onHorizontalSwipe: () -> Void @State private var tabs: [HotTab] = [] @State private var topics: [HotTopic] = [] @@ -62,13 +154,26 @@ struct HotThreadsView: View { } feedContent + .homeFeedSwipeGesture(onSwipe: onHorizontalSwipe) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(TiebaPureTheme.ColorToken.readerGroupedBackground) .task { - guard didLoad == false else { return } + guard HotFeedLoadPolicy.shouldLoadOnActivation( + isActive: isActive, + didLoad: didLoad + ) else { return } await load(tabCode: selectedTabCode) } + .onChange(of: isActive) { active in + // First switch to 热点 loads once; every later switch back reuses + // the listing already on screen. + guard HotFeedLoadPolicy.shouldLoadOnActivation( + isActive: active, + didLoad: didLoad + ) else { return } + Task { await load(tabCode: selectedTabCode) } + } .onChange(of: account?.sessionIdentity) { _ in requestGeneration += 1 loadTask?.cancel() @@ -79,6 +184,12 @@ struct HotThreadsView: View { errorMessage = nil didLoad = false isLoading = false + // A hidden segment only drops its stale listing; the next + // activation loads the new account's data. + guard HotFeedLoadPolicy.shouldLoadOnActivation( + isActive: isActive, + didLoad: false + ) else { return } Task { await load(tabCode: selectedTabCode) } } .onDisappear { @@ -205,79 +316,103 @@ struct HotThreadsView: View { ) } } else { - ScrollView { - LazyVStack(spacing: TiebaPureTheme.Spacing.sm, pinnedViews: []) { - if topics.isEmpty == false { - hotTopicSection - } + ScrollViewReader { scrollProxy in + ScrollView { + VStack(spacing: 0) { + // Scroll anchor for the double tap on the 首页 tab. It + // sits outside the LazyVStack so it adds no spacing. + Color.clear + .frame(height: 0) + .id(HotFeedScrollTarget.top) - ForEach(threads) { thread in - ForumThreadRow( - thread: thread, - presentation: .homeFeed, - onOpenThread: { onOpenThread(thread) }, - onOpenForum: onOpenForum, - onOpenUser: { onOpenUser($0) }, - onBlockForum: { blockedThread in - blocklistStore.addForum( - id: blockedThread.forumID, - named: blockedThread.forumName - ) - }, - onOpenMedia: { item, mediaItems, sourceFrame, sourceImage, sourceAnchor in - switch HomeMediaActionPolicy.action(for: item, in: mediaItems) { - case let .previewImages(images, index): - ImagePreviewCoordinator.shared.present( - ImagePreviewSession( - images: images, - initialIndex: index, - sourceFrame: sourceFrame, - sourceImage: sourceImage, - sourceAnchor: sourceAnchor, - prefetchesAdjacentPages: readingPreferences.mediaLoading != .manual - ) - ) - case let .playVideo(video): - VideoPreviewCoordinator.shared.present( - VideoPreviewSession( - video: video, - sourceFrame: sourceFrame, - sourceImage: sourceImage, - sourceAnchor: sourceAnchor + LazyVStack(spacing: TiebaPureTheme.Spacing.sm, pinnedViews: []) { + if topics.isEmpty == false { + hotTopicSection + } + + ForEach(threads) { thread in + ForumThreadRow( + thread: thread, + presentation: .homeFeed, + onOpenThread: { onOpenThread(thread) }, + onOpenForum: onOpenForum, + onOpenUser: { onOpenUser($0) }, + onBlockForum: { blockedThread in + blocklistStore.addForum( + id: blockedThread.forumID, + named: blockedThread.forumName ) - ) - case .openThread: - onOpenThread(thread) + }, + onOpenMedia: { item, mediaItems, sourceFrame, sourceImage, sourceAnchor in + switch HomeMediaActionPolicy.action(for: item, in: mediaItems) { + case let .previewImages(images, index): + ImagePreviewCoordinator.shared.present( + ImagePreviewSession( + images: images, + initialIndex: index, + sourceFrame: sourceFrame, + sourceImage: sourceImage, + sourceAnchor: sourceAnchor, + prefetchesAdjacentPages: readingPreferences.mediaLoading != .manual + ) + ) + case let .playVideo(video): + VideoPreviewCoordinator.shared.present( + VideoPreviewSession( + video: video, + sourceFrame: sourceFrame, + sourceImage: sourceImage, + sourceAnchor: sourceAnchor + ) + ) + case .openThread: + onOpenThread(thread) + } + }, + onOpenComments: { onOpenComments(thread) }, + commentsAccessibilityIdentifier: "hot-comments-button-\(thread.id)" + ) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("thread-row") + } + + if let errorMessage { + InlineLoadErrorView(message: errorMessage) { + Task { await reload() } } - }, - onOpenComments: { onOpenComments(thread) }, - commentsAccessibilityIdentifier: "hot-comments-button-\(thread.id)" - ) - .accessibilityElement(children: .contain) - .accessibilityIdentifier("thread-row") - } + } - if let errorMessage { - InlineLoadErrorView(message: errorMessage) { - Task { await reload() } + Color.clear + .frame(height: 64) + .accessibilityHidden(true) } + .padding(.horizontal, TiebaPureTheme.Spacing.sm) + .padding(.vertical, TiebaPureTheme.Spacing.sm) + .readableWidth() + } + .frame(maxWidth: .infinity) + } + .accessibilityIdentifier("hot-thread-scroll-view") + .shortPullRefresh( + isEnabled: didLoad && isLoading == false, + surface: .grouped, + accessibilityIdentifier: "hot-thread-refresh-animation", + programmaticRefreshToken: refreshToken + ) { source in + if source == .pullGesture { + guard isLoading == false else { return } + } + await reload() + } + .onChange(of: scrollToTopToken) { _ in + // Same transaction rules as the recommended feed: a lazy + // layout update during an animated jump can loop on iOS 26. + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { + scrollProxy.scrollTo(HotFeedScrollTarget.top, anchor: .top) } - - Color.clear - .frame(height: 64) - .accessibilityHidden(true) } - .padding(.horizontal, TiebaPureTheme.Spacing.sm) - .padding(.vertical, TiebaPureTheme.Spacing.sm) - .readableWidth() - } - .accessibilityIdentifier("hot-thread-scroll-view") - .shortPullRefresh( - isEnabled: didLoad && isLoading == false, - surface: .grouped, - accessibilityIdentifier: "hot-thread-refresh-animation" - ) { - await reload() } } } @@ -329,4 +464,8 @@ struct HotThreadsView: View { isLoading = false didLoad = true } +} + +private enum HotFeedScrollTarget { + case top } \ No newline at end of file diff --git a/TiebaPureTests/TiebaPureSmokeTests.swift b/TiebaPureTests/TiebaPureSmokeTests.swift index da9b195..af8e86e 100644 --- a/TiebaPureTests/TiebaPureSmokeTests.swift +++ b/TiebaPureTests/TiebaPureSmokeTests.swift @@ -3137,6 +3137,82 @@ final class TiebaPureSmokeTests: XCTestCase { )) } + func testHomeFeedSwipeMovesBetweenTheOnlyTwoSegments() { + XCTAssertEqual(HomeFeedSwipePolicy.toggled(.recommended), .hot) + XCTAssertEqual(HomeFeedSwipePolicy.toggled(.hot), .recommended) + } + + func testHomeFeedSwipeAcceptsDeliberateHorizontalDragsInEitherDirection() { + // A right swipe leaves 推荐 for 热点; a left swipe leaves 热点 for 推荐. + // Both directions are the same toggle, so both must be accepted. + XCTAssertTrue(HomeFeedSwipePolicy.isHorizontalSwipe( + translation: CGSize(width: 120, height: 8) + )) + XCTAssertTrue(HomeFeedSwipePolicy.isHorizontalSwipe( + translation: CGSize(width: -120, height: 8) + )) + XCTAssertTrue(HomeFeedSwipePolicy.isHorizontalSwipe( + translation: CGSize( + width: HomeFeedSwipePolicy.minimumHorizontalDistance, + height: 0 + ) + )) + } + + func testHomeFeedSwipeRejectsVerticalScrollsShortDragsAndDiagonals() { + // A vertical scroll with a little sideways drift must stay a scroll. + XCTAssertFalse(HomeFeedSwipePolicy.isHorizontalSwipe( + translation: CGSize(width: 4, height: 220) + )) + XCTAssertFalse(HomeFeedSwipePolicy.isHorizontalSwipe( + translation: CGSize( + width: HomeFeedSwipePolicy.minimumHorizontalDistance - 1, + height: 0 + ) + )) + XCTAssertFalse(HomeFeedSwipePolicy.isHorizontalSwipe( + translation: CGSize(width: 30, height: 0) + )) + XCTAssertFalse(HomeFeedSwipePolicy.isHorizontalSwipe( + translation: CGSize(width: 80, height: 80) + )) + XCTAssertFalse(HomeFeedSwipePolicy.isHorizontalSwipe(translation: .zero)) + } + + func testHotFeedLoadsOnItsFirstActivationOnly() { + // First switch to 热点 must load its listing… + XCTAssertTrue(HotFeedLoadPolicy.shouldLoadOnActivation( + isActive: true, + didLoad: false + )) + // …and swiping to 推荐 and back must reuse it instead of re-fetching. + XCTAssertFalse(HotFeedLoadPolicy.shouldLoadOnActivation( + isActive: true, + didLoad: true + )) + // The hidden segment never loads in the background. + XCTAssertFalse(HotFeedLoadPolicy.shouldLoadOnActivation( + isActive: false, + didLoad: false + )) + XCTAssertFalse(HotFeedLoadPolicy.shouldLoadOnActivation( + isActive: false, + didLoad: true + )) + } + + func testHomeTabGesturesAddressTheVisibleSegmentOnly() { + let recommended = HomeFeedGestureTargetPolicy.targets( + forActiveSegment: .recommended + ) + XCTAssertEqual(recommended.scrollToTopSegment, .recommended) + XCTAssertEqual(recommended.refreshSegment, .recommended) + + let hot = HomeFeedGestureTargetPolicy.targets(forActiveSegment: .hot) + XCTAssertEqual(hot.scrollToTopSegment, .hot) + XCTAssertEqual(hot.refreshSegment, .hot) + } + func testRootTabHitTesterMapsBottomTabRegions() { let centeredIPadFrames = [ CGRect(x: 280, y: 0, width: 80, height: 49),