Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Protos/HotThreadList/HotThreadList.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 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;
}

// 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;
string tab_code = 3;
}

message HotThreadListRequest {
HotThreadListRequestData data = 1;
}

message HotThreadListResponseData {
repeated RecomTopicList topic_list = 1;
repeated tieba.ThreadInfo thread_info = 2;
repeated FrsTabInfo hot_thread_tab_info = 3;
}

message HotThreadListResponse {
tieba.Error error = 1;
HotThreadListResponseData data = 2;
}
3 changes: 3 additions & 0 deletions Protos/ThreadInfo.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@

**浏览**(无需登录)

- 首页推荐、进吧、吧内列表,「最新 / 精华」分类,可按回复时间或发帖时间排序
- 首页分页:推荐(个性化推荐流)与热点(贴吧热榜,可切换子分页),在两个分页上左右滑动即可互相切换
- 进吧、吧内列表,「最新 / 精华」分类,可按回复时间或发帖时间排序
- 搜索主题与回复,吧内搜索
- 帖子详情、楼中楼、图片缩放与保存、视频播放

**登录后**

- 消息:回复我的、@我的
- 关注或取消关注用户、贴吧,查看关注与粉丝列表
- 关注的贴吧在「进吧」里直接显示吧内等级和今日是否已签到
- 收藏帖子,收藏保存在贴吧账号里,与官方客户端同步;收藏列表支持搜索、按阅读进度筛选和批量取消
- 贴吧签到:一键为关注的贴吧签到,也可以设置每天第一次打开时自动签到
- 主贴、楼层、楼中楼点赞
Expand Down
68 changes: 68 additions & 0 deletions TiebaPure.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

178 changes: 155 additions & 23 deletions TiebaPure/App/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -300,17 +302,32 @@ private struct MainTabView: View {
}
}
.background(
TabSelectionObserver {
homeRefreshToken += 1
}
TabSelectionObserver(
onHomeSingleTap: {
homeReturnToRootToken &+= 1
},
onHomeDoubleTap: {
homeReturnToRootToken &+= 1
homeScrollToTopToken &+= 1
},
onHomeLongPress: {
homeReturnToRootToken &+= 1
homeRefreshToken &+= 1
}
)
)
}

@available(iOS 18.0, *)
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) {
Expand All @@ -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")
}
Expand Down Expand Up @@ -362,18 +384,26 @@ 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 {
Controller(coordinator: context.coordinator)
}

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()
Expand Down Expand Up @@ -447,43 +477,145 @@ 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
}
observedController = nil
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?(
Expand Down
49 changes: 49 additions & 0 deletions TiebaPure/Core/Auth/SessionExpirationMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,55 @@ 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)
}
}

func hotThreads(account: Account?, tabCode: String) async throws -> HotFeed {
try await monitored(account: account) {
try await base.hotThreads(account: account, tabCode: tabCode)
}
}

/// 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
Expand Down
Loading