From 27e8b412a055112faeb541ee535a7885eb40e7d7 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 14 Nov 2025 22:02:23 +0300 Subject: [PATCH 01/21] Add forum stat endpoint --- Modules/Sources/APIClient/APIClient.swift | 10 +++ Modules/Sources/Models/Forum/ForumStat.swift | 69 +++++++++++++++++++ .../ParsingClient/Parsers/ForumParser.swift | 38 ++++++++++ .../Sources/ParsingClient/ParsingClient.swift | 4 ++ 4 files changed, 121 insertions(+) create mode 100644 Modules/Sources/Models/Forum/ForumStat.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index e73ce80e..5e8495af 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -52,6 +52,7 @@ public struct APIClient: Sendable { // Forum public var getForumsList: @Sendable (_ policy: CachePolicy) async throws -> AsyncThrowingStream<[ForumInfo], any Error> public var getForum: @Sendable (_ id: Int, _ page: Int, _ perPage: Int, _ policy: CachePolicy) async throws -> AsyncThrowingStream + public var getForumStat: @Sendable (_ id: Int) async throws -> ForumStat public var jumpForum: @Sendable (_ request: JumpForumRequest) async throws -> ForumJump public var markRead: @Sendable (_ id: Int, _ isTopic: Bool) async throws -> Bool public var getAnnouncement: @Sendable (_ id: Int) async throws -> Announcement @@ -260,6 +261,12 @@ extension APIClient: DependencyKey { ) }, + getForumStat: { id in + let command = ForumCommand.info(id: id) + let response = try await api.send(command) + return try await parser.parseForumStat(response) + }, + jumpForum: { request in let command = ForumCommand.jump(data: ForumJumpRequest( type: request.transferType, @@ -534,6 +541,9 @@ extension APIClient: DependencyKey { getForum: { _, _, _, _ in return .finished() }, + getForumStat: { _ in + return .mock + }, jumpForum: { _ in return .mock }, diff --git a/Modules/Sources/Models/Forum/ForumStat.swift b/Modules/Sources/Models/Forum/ForumStat.swift new file mode 100644 index 00000000..28fe399d --- /dev/null +++ b/Modules/Sources/Models/Forum/ForumStat.swift @@ -0,0 +1,69 @@ +// +// ForumStat.swift +// ForPDA +// +// Created by Xialtal on 14.06.25. +// + +public struct ForumStat: Sendable, Equatable { + public let id: Int + public let name: String + public let description: String + public let flag: Int + public let globalAnnouncement: String // TODO: Think about good naming & rename in TopicInfo/Topic (I forgot xD) + public let subforumsCount: Int + public let topicsCount: Int + public let postsCount: Int + public let moderators: [ForumModerator] + + public struct ForumModerator: Sendable, Equatable, Identifiable { + public let id: Int + public let name: String + public let group: User.Group + + public init(id: Int, name: String, group: User.Group) { + self.id = id + self.name = name + self.group = group + } + } + + public init( + id: Int, + name: String, + description: String, + flag: Int, + globalAnnouncement: String, + subforumsCount: Int, + topicsCount: Int, + postsCount: Int, + moderators: [ForumModerator] + ) { + self.id = id + self.name = name + self.description = description + self.flag = flag + self.globalAnnouncement = globalAnnouncement + self.subforumsCount = subforumsCount + self.topicsCount = topicsCount + self.postsCount = postsCount + self.moderators = moderators + } +} + +public extension ForumStat { + static let mock = ForumStat( + id: 5, + name: "4PDA - Administrative", + description: "Simple description.", + flag: 100, + globalAnnouncement: "This is global announcement title.", + subforumsCount: 3, + topicsCount: 1456, + postsCount: 81734, + moderators: [ + .init(id: 0, name: "Admins", group: .admin), + .init(id: 1, name: "AirFlare", group: .regular) + ] + ) +} diff --git a/Modules/Sources/ParsingClient/Parsers/ForumParser.swift b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift index 32096f9b..2bded64e 100644 --- a/Modules/Sources/ParsingClient/Parsers/ForumParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift @@ -46,6 +46,44 @@ public struct ForumParser { } } + public static func parseForumStat(from string: String) throws -> ForumStat { + if let data = string.data(using: .utf8) { + do { + guard let array = try JSONSerialization.jsonObject(with: data, options: []) as? [Any] else { throw ParsingError.failedToCastDataToAny } + + if array.count == 2 { + throw ParsingError.failedToFindPost + } + + return ForumStat( + id: array[3] as! Int, + name: array[4] as! String, + description: array[5] as! String, + flag: array[6] as! Int, + globalAnnouncement: array[7] as! String, + subforumsCount: array[8] as! Int, + topicsCount: array[9] as! Int, + postsCount: array[10] as! Int, + moderators: parseForumStatModerators(array[11] as! [[Any]]) + ) + } catch { + throw ParsingError.failedToSerializeData(error) + } + } else { + throw ParsingError.failedToCreateDataFromString + } + } + + internal static func parseForumStatModerators(_ array: [[Any]]) -> [ForumStat.ForumModerator] { + return array.map { moderator in + return ForumStat.ForumModerator( + id: moderator[0] as! Int, + name: moderator[1] as! String, + group: User.Group(rawValue: moderator[2] as! Int)! + ) + } + } + public static func parseForumJump(from string: String) throws -> ForumJump { if let data = string.data(using: .utf8) { do { diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 1bba3c86..265ade27 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -33,6 +33,7 @@ public struct ParsingClient: Sendable { public var parseForumsList: @Sendable (_ response: String) async throws -> [ForumInfo] public var parseForumJump: @Sendable (_ response: String) async throws -> ForumJump public var parseForum: @Sendable (_ response: String) async throws -> Forum + public var parseForumStat: @Sendable (_ response: String) async throws -> ForumStat public var parseTopic: @Sendable (_ response: String) async throws -> Topic public var parseAnnouncement: @Sendable (_ response: String) async throws -> Announcement public var parseFavorites: @Sendable (_ response: String) async throws -> Favorite @@ -92,6 +93,9 @@ extension ParsingClient: DependencyKey { parseForum: { response in return try ForumParser.parse(from: response) }, + parseForumStat: { response in + return try ForumParser.parseForumStat(from: response) + }, parseTopic: { response in return try TopicParser.parse(from: response) }, From 27ea8e183462b8f376866b42a80d7a8c6fc2ec81 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 15 Nov 2025 20:01:05 +0300 Subject: [PATCH 02/21] [WIP] Forum Stat --- .../AnalyticsClient/Events/ForumEvent.swift | 4 + .../Analytics/ForumFeature+Analytics.swift | 4 +- .../Sources/ForumFeature/ForumFeature.swift | 18 +- .../Sources/ForumFeature/ForumScreen.swift | 25 ++- .../Models/ForumCommonContextMenuAction.swift | 1 + .../Resources/Localizable.xcstrings | 40 ++++ .../ForumFeature/Stat/StatFeature.swift | 126 +++++++++++++ .../Sources/ForumFeature/Stat/StatView.swift | 174 ++++++++++++++++++ 8 files changed, 383 insertions(+), 9 deletions(-) create mode 100644 Modules/Sources/ForumFeature/Stat/StatFeature.swift create mode 100644 Modules/Sources/ForumFeature/Stat/StatView.swift diff --git a/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift b/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift index 017d13eb..4024e574 100644 --- a/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift @@ -21,6 +21,7 @@ public enum ForumEvent: Event { case menuOpen(Int) case menuGoToEnd(Int) + case menuStat(Int, Bool) case menuMarkRead(Int, Bool) case menuCopyLink(Int, Bool) case menuOpenInBrowser(Int, Bool) @@ -57,6 +58,9 @@ public enum ForumEvent: Event { case let .menuGoToEnd(id): return ["id": String(id)] + case let .menuStat(id, isForum): + return ["id": String(id), "isForum": String(isForum)] + case let .menuMarkRead(id, isForum): return ["id": String(id), "isForum": String(isForum)] diff --git a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index 4884108f..1621959d 100644 --- a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift +++ b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift @@ -61,6 +61,8 @@ extension ForumFeature { case let .view(.contextCommonMenu(option, id, isForum)): switch option { + case .stat: + analytics.log(ForumEvent.menuStat(id, isForum)) case .markRead: analytics.log(ForumEvent.menuMarkRead(id, isForum)) case .copyLink: @@ -85,7 +87,7 @@ extension ForumFeature { analytics.log(ForumEvent.loadingFailure(error)) } - case .delegate: + case .delegate, .destination: break } diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 560a6be3..bb1083e3 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -21,6 +21,13 @@ public struct ForumFeature: Reducer, Sendable { public init() {} + // MARK: - Destinations + + @Reducer(state: .equatable) + public enum Destination { + case stat(StatFeature) + } + // MARK: - Enums public struct SectionExpand: Equatable { @@ -46,6 +53,8 @@ public struct ForumFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { + @Presents public var destination: Destination.State? + @Shared(.appSettings) var appSettings: AppSettings @Shared(.userSession) var userSession: UserSession? @@ -100,6 +109,8 @@ public struct ForumFeature: Reducer, Sendable { case contextTopicMenu(ForumTopicContextMenuAction, TopicInfo) case contextCommonMenu(ForumCommonContextMenuAction, Int, Bool) } + + case destination(PresentationAction) case `internal`(Internal) public enum Internal { @@ -210,6 +221,10 @@ public struct ForumFeature: Reducer, Sendable { await send(.internal(.refresh)) } + case .stat: + state.destination = .stat(StatFeature.State(forumId: id)) + return .none + case .setFavorite(let isFavorite): return .run { [id = id, isFavorite = isFavorite, isForum = isForum] send in let request = SetFavoriteRequest( @@ -286,10 +301,11 @@ public struct ForumFeature: Reducer, Sendable { reportFullyDisplayed(&state) return .run { _ in await toastClient.showToast(.whoopsSomethingWentWrong) } - case .delegate: + case .delegate, .destination: return .none } } + .ifLet(\.$destination, action: \.destination) Analytics() } diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index 2ee259b1..904a57a9 100644 --- a/Modules/Sources/ForumFeature/ForumScreen.swift +++ b/Modules/Sources/ForumFeature/ForumScreen.swift @@ -85,6 +85,11 @@ public struct ForumScreen: View { .padding(.bottom, 8) } } + .sheet(item: $store.scope(state: \.destination?.stat, action: \.destination.stat)) { store in + NavigationStack { + StatView(store: store) + } + } .toolbar { OptionsMenu() } @@ -266,14 +271,14 @@ public struct ForumScreen: View { send(.contextCommonMenu(.openInBrowser, id, isForum)) } - if store.isUserAuthorized { - if isUnread { - ContextButton(text: LocalizedStringResource("Mark Read", bundle: .module), symbol: .checkmarkCircle) { - send(.contextCommonMenu(.markRead, id, isForum)) - } + if store.isUserAuthorized, isUnread { + ContextButton(text: LocalizedStringResource("Mark Read", bundle: .module), symbol: .checkmarkCircle) { + send(.contextCommonMenu(.markRead, id, isForum)) } - - Section { + } + + Section { + if store.isUserAuthorized { ContextButton( text: isFavorite ? LocalizedStringResource("Remove from favorites", bundle: .module) @@ -283,6 +288,12 @@ public struct ForumScreen: View { send(.contextCommonMenu(.setFavorite(isFavorite), id, isForum)) } } + + if isForum { + ContextButton(text: LocalizedStringResource("About Forum", bundle: .module), symbol: .infoCircle) { + send(.contextCommonMenu(.stat, id, isForum)) + } + } } } diff --git a/Modules/Sources/ForumFeature/Models/ForumCommonContextMenuAction.swift b/Modules/Sources/ForumFeature/Models/ForumCommonContextMenuAction.swift index 9e48bd1a..3f4516ad 100644 --- a/Modules/Sources/ForumFeature/Models/ForumCommonContextMenuAction.swift +++ b/Modules/Sources/ForumFeature/Models/ForumCommonContextMenuAction.swift @@ -6,6 +6,7 @@ // public enum ForumCommonContextMenuAction { + case stat case markRead case copyLink case openInBrowser diff --git a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings index 864386cb..aeedb16e 100644 --- a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings @@ -1,6 +1,16 @@ { "sourceLanguage" : "en", "strings" : { + "About Forum" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О форуме" + } + } + } + }, "Add to favorites" : { "localizations" : { "ru" : { @@ -21,6 +31,16 @@ } } }, + "Close" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + } + } + }, "Copy Link" : { "localizations" : { "ru" : { @@ -51,6 +71,16 @@ } } }, + "Moderators" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модераторы" + } + } + } + }, "Open" : { "localizations" : { "ru" : { @@ -81,6 +111,16 @@ } } }, + "Posts" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Посты" + } + } + } + }, "Remove from favorites" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/ForumFeature/Stat/StatFeature.swift b/Modules/Sources/ForumFeature/Stat/StatFeature.swift new file mode 100644 index 00000000..acddff5e --- /dev/null +++ b/Modules/Sources/ForumFeature/Stat/StatFeature.swift @@ -0,0 +1,126 @@ +// +// StatFeature.swift +// ForPDA +// +// Created by Xialtal on 14.06.25. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct StatFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Destinations + + @Reducer(state: .equatable) + public enum Destination: Hashable { + @ReducerCaseIgnored + case share(URL) + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Presents public var destination: Destination.State? + + let forumId: Int + + var isLoading = false + + public var stat: ForumStat? + + public var shareLink: String { + return "https://4pda.to/forum/index.php?showforum=\(forumId)" + } + + public init( + forumId: Int + ) { + self.forumId = forumId + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + + case linkShared + + case closeButtonTapped + case shareLinkButtonTapped + case openInBrowserButtonTapped + } + + case destination(PresentationAction) + + case `internal`(Internal) + public enum Internal { + case loadForumStat + case forumStatResponse(Result) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.openURL) var openURL + @Dependency(\.dismiss) var dismiss + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .send(.internal(.loadForumStat)) + + case .view(.closeButtonTapped): + return .run { _ in await dismiss() } + + case .view(.linkShared): + state.destination = nil + return .none + + case .view(.shareLinkButtonTapped): + state.destination = .share(URL(string: state.shareLink)!) + return .none + + case .view(.openInBrowserButtonTapped): + return .run { [shareLink = state.shareLink] _ in + await openURL(URL(string: shareLink)!) + } + + case .internal(.loadForumStat): + state.isLoading = true + return .run { [id = state.forumId] send in + let response = try await apiClient.getForumStat(id) + await send(.internal(.forumStatResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.forumStatResponse(.failure(error)))) + } + + case let .internal(.forumStatResponse(.success(response))): + state.stat = response + state.isLoading = false + return .none + + case let .internal(.forumStatResponse(.failure(error))): + print(error) + return .none + + case .destination: + return .none + } + } + .ifLet(\.$destination, action: \.destination) + } +} diff --git a/Modules/Sources/ForumFeature/Stat/StatView.swift b/Modules/Sources/ForumFeature/Stat/StatView.swift new file mode 100644 index 00000000..a87359a9 --- /dev/null +++ b/Modules/Sources/ForumFeature/Stat/StatView.swift @@ -0,0 +1,174 @@ +// +// StatView.swift +// ForPDA +// +// Created by Xialtal on 14.06.25. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI + +@ViewAction(for: StatFeature.self) +public struct StatView: View { + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + ScrollView { + if !store.isLoading, let stat = store.stat { + VStack(spacing: 0) { + VStack { + Text(stat.name) + .font(.title2) + .fontWeight(.bold) + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(stat.description) + .font(.callout) + .foregroundStyle(Color(.Labels.secondary)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.bottom, 28) + + VStack { + HStack(spacing: 12) { + InformationRow(LocalizedStringKey("Subforums"), .number(stat.subforumsCount)) + + InformationRow(LocalizedStringKey("Topics"), .number(stat.topicsCount)) + } + + InformationRow(LocalizedStringKey("Posts"), .number(stat.postsCount)) + + InformationRow(LocalizedStringKey("Moderators"), .moderators(stat.moderators)) + } + } + .padding(16) + .navigationBarTitleDisplayMode(.inline) + } + } + .sheet(item: $store.scope(state: \.destination?.share, action: \.destination.share)) { rawUrl in + ShareActivityView(url: rawUrl.withState { $0 }) { _ in + send(.linkShared) + } + .presentationDetents([.medium]) + } + .background(Color(.Background.primary)) + .safeAreaInset(edge: .bottom) { + OpenInBrowserButton() + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + send(.closeButtonTapped) + } label: { + if isLiquidGlass { + Image(systemSymbol: .xmark) + } else { + Text("Close", bundle: .module) + } + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + send(.shareLinkButtonTapped) + } label: { + Image(systemSymbol: .squareAndArrowUp) + } + } + } + .overlay { + if store.isLoading, store.stat == nil { + PDALoader() + .frame(width: 24, height: 24) + } + } + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - Open In Browser Button + + @ViewBuilder + private func OpenInBrowserButton() -> some View { + Button { + send(.openInBrowserButtonTapped) + } label: { + HStack { + Text(verbatim: store.shareLink) + .font(.footnote) + .foregroundStyle(Color(.Labels.teritary)) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemSymbol: .arrowUpRight) + .font(.callout) + .foregroundStyle(tintColor) + } + } + .padding(16) + } + + // MARK: - Row + + enum RowType { + case number(Int) + case moderators([ForumStat.ForumModerator]) + } + + private func InformationRow(_ header: LocalizedStringKey, _ content: RowType) -> some View { + VStack(spacing: 2) { + Text(header, bundle: .module) + .font(.footnote) + .foregroundStyle(Color(.Labels.teritary)) + + switch content { + case .number(let content): + Text(content, format: .number) + .font(.body) + .multilineTextAlignment(.center) + + case .moderators(let moderators): + Group { + ForEach(moderators) { moderator in + Text(verbatim: "\(moderator.name)") + .font(.body) + .multilineTextAlignment(.center) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(12) + .background( + Color(.Background.teritary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + } +} + +// MARK: - Previews + +#Preview { + NavigationStack { + StatView( + store: Store( + initialState: StatFeature.State( + forumId: 0 + ) + ) { + StatFeature() + } + ) + } +} From 016c41e82c0e597383fcf3c8c778444f59eb653a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 19 Nov 2025 13:07:55 +0300 Subject: [PATCH 03/21] Improve forum stat mock --- Modules/Sources/Models/Forum/ForumStat.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Models/Forum/ForumStat.swift b/Modules/Sources/Models/Forum/ForumStat.swift index 28fe399d..c6e68921 100644 --- a/Modules/Sources/Models/Forum/ForumStat.swift +++ b/Modules/Sources/Models/Forum/ForumStat.swift @@ -63,7 +63,11 @@ public extension ForumStat { postsCount: 81734, moderators: [ .init(id: 0, name: "Admins", group: .admin), - .init(id: 1, name: "AirFlare", group: .regular) + .init(id: 1, name: "AirFlare", group: .regular), + .init(id: 2, name: "subvertd", group: .regular), + .init(id: 3, name: "Test1", group: .moderator), + .init(id: 4, name: "LongNickName999", group: .moderator), + .init(id: 5, name: "Lia", group: .supermoderator) ] ) } From 53bf5ebadac7d182db33b410227a77cb7b655bf0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Mar 2026 14:01:49 +0300 Subject: [PATCH 04/21] Post-merge fix --- Modules/Sources/ForumFeature/ForumFeature.swift | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 7fa1e77b..13151255 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -29,12 +29,6 @@ public struct ForumFeature: Reducer, Sendable { static let markAsReadSuccess = LocalizedStringResource("Marked as read", bundle: .module) } - // MARK: - Destinations - - @Reducer(state: .equatable) - public enum Destination { - case stat(StatFeature) - // MARK: - Enums public struct SectionExpand: Equatable { @@ -68,8 +62,6 @@ public struct ForumFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { - @Presents public var destination: Destination.State? - @Shared(.appSettings) var appSettings: AppSettings @Shared(.userSession) var userSession: UserSession? @@ -130,8 +122,6 @@ public struct ForumFeature: Reducer, Sendable { case contextCommonMenu(ForumCommonContextMenuAction, Int, Bool) } - case destination(PresentationAction) - case `internal`(Internal) public enum Internal { case refresh From 906ca19a58e85f73f4a273612d9562d1fc76c3f1 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Mar 2026 14:06:38 +0300 Subject: [PATCH 05/21] Fix forum stat localizable --- .../Resources/Localizable.xcstrings | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings index d4b11de1..21c01488 100644 --- a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings @@ -1,6 +1,16 @@ { "sourceLanguage" : "en", "strings" : { + "About Forum" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О форуме" + } + } + } + }, "Add to favorites" : { "localizations" : { "ru" : { @@ -21,6 +31,16 @@ } } }, + "Close" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + } + } + }, "Copy Link" : { "localizations" : { "ru" : { @@ -81,6 +101,16 @@ } } }, + "Moderators" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модераторы" + } + } + } + }, "Open" : { "localizations" : { "ru" : { @@ -111,6 +141,16 @@ } } }, + "Posts" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Постов" + } + } + } + }, "Remove from favorites" : { "localizations" : { "ru" : { From 6323a5d96051edcea27f728d641b2e2a2c480a82 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Mar 2026 14:09:40 +0300 Subject: [PATCH 06/21] Use ForumFlag model for flag field in ForumStat --- Modules/Sources/Models/Forum/ForumStat.swift | 8 ++++---- Modules/Sources/ParsingClient/Parsers/ForumParser.swift | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/Models/Forum/ForumStat.swift b/Modules/Sources/Models/Forum/ForumStat.swift index c6e68921..0cc7d5ff 100644 --- a/Modules/Sources/Models/Forum/ForumStat.swift +++ b/Modules/Sources/Models/Forum/ForumStat.swift @@ -9,8 +9,8 @@ public struct ForumStat: Sendable, Equatable { public let id: Int public let name: String public let description: String - public let flag: Int - public let globalAnnouncement: String // TODO: Think about good naming & rename in TopicInfo/Topic (I forgot xD) + public let flag: ForumFlag + public let globalAnnouncement: String public let subforumsCount: Int public let topicsCount: Int public let postsCount: Int @@ -32,7 +32,7 @@ public struct ForumStat: Sendable, Equatable { id: Int, name: String, description: String, - flag: Int, + flag: ForumFlag, globalAnnouncement: String, subforumsCount: Int, topicsCount: Int, @@ -56,7 +56,7 @@ public extension ForumStat { id: 5, name: "4PDA - Administrative", description: "Simple description.", - flag: 100, + flag: [.canPost, .updated], globalAnnouncement: "This is global announcement title.", subforumsCount: 3, topicsCount: 1456, diff --git a/Modules/Sources/ParsingClient/Parsers/ForumParser.swift b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift index 69d825b0..98d88966 100644 --- a/Modules/Sources/ParsingClient/Parsers/ForumParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift @@ -59,7 +59,7 @@ public struct ForumParser { id: array[3] as! Int, name: array[4] as! String, description: array[5] as! String, - flag: array[6] as! Int, + flag: ForumFlag(rawValue: array[6] as! Int), globalAnnouncement: array[7] as! String, subforumsCount: array[8] as! Int, topicsCount: array[9] as! Int, From d3f1e771df267a41c040ee26508911e97d1bb028 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Mar 2026 14:14:08 +0300 Subject: [PATCH 07/21] Post-merge fix --- .../Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index c95070dc..e0ca4d3a 100644 --- a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift +++ b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift @@ -92,7 +92,7 @@ extension ForumFeature { analytics.log(ForumEvent.loadingFailure(error)) } - case .delegate, .destination: + case .delegate: break } From 3d2d55b0f02fa93c82f6b291cceda71715192e14 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Mar 2026 14:16:59 +0300 Subject: [PATCH 08/21] Post-merge fix --- Modules/Sources/ForumFeature/ForumFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 13151255..2c5f2f52 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -333,7 +333,7 @@ public struct ForumFeature: Reducer, Sendable { reportFullyDisplayed(&state) return .run { _ in await toastClient.showToast(.whoopsSomethingWentWrong) } - case .delegate, .destination: + case .delegate: return .none } } From 9835239206b8b20858db4e2df456209e43154077 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Mar 2026 14:18:24 +0300 Subject: [PATCH 09/21] Fix deprecation --- Modules/Sources/ForumFeature/Stat/StatFeature.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/ForumFeature/Stat/StatFeature.swift b/Modules/Sources/ForumFeature/Stat/StatFeature.swift index acddff5e..e70e2273 100644 --- a/Modules/Sources/ForumFeature/Stat/StatFeature.swift +++ b/Modules/Sources/ForumFeature/Stat/StatFeature.swift @@ -17,7 +17,7 @@ public struct StatFeature: Reducer, Sendable { // MARK: - Destinations - @Reducer(state: .equatable) + @Reducer public enum Destination: Hashable { @ReducerCaseIgnored case share(URL) @@ -124,3 +124,5 @@ public struct StatFeature: Reducer, Sendable { .ifLet(\.$destination, action: \.destination) } } + +extension StatFeature.Destination.State: Equatable {} From 3a6e629b94e506f14dea94849ac0676cb78cd0ce Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 23 Mar 2026 14:35:44 +0300 Subject: [PATCH 10/21] Add marks to forum StatView --- Modules/Sources/ForumFeature/Stat/StatView.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Modules/Sources/ForumFeature/Stat/StatView.swift b/Modules/Sources/ForumFeature/Stat/StatView.swift index a87359a9..c545e4d4 100644 --- a/Modules/Sources/ForumFeature/Stat/StatView.swift +++ b/Modules/Sources/ForumFeature/Stat/StatView.swift @@ -13,13 +13,19 @@ import SharedUI @ViewAction(for: StatFeature.self) public struct StatView: View { + // MARK: - Properties + @Perception.Bindable public var store: StoreOf @Environment(\.tintColor) private var tintColor + // MARK: - Init + public init(store: StoreOf) { self.store = store } + // MARK: - Body + public var body: some View { WithPerceptionTracking { ScrollView { From f9f0b94cdf9b9d517141aa892f34c4792a5327b6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 14:40:36 +0300 Subject: [PATCH 11/21] Add BrickLayout to SharedUI --- Modules/Sources/SharedUI/BrickLayout.swift | 82 ++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 Modules/Sources/SharedUI/BrickLayout.swift diff --git a/Modules/Sources/SharedUI/BrickLayout.swift b/Modules/Sources/SharedUI/BrickLayout.swift new file mode 100644 index 00000000..8c1f7759 --- /dev/null +++ b/Modules/Sources/SharedUI/BrickLayout.swift @@ -0,0 +1,82 @@ +// +// BrickLayout.swift +// ForPDA +// +// Created by Xialtal on 24.03.26. +// + +import SwiftUI + +public struct BrickLayout: Layout { + private let verticalSpacing: CGFloat + private let horizontalSpacing: CGFloat + + public init(verticalSpacing: CGFloat = 0, horizontalSpacing: CGFloat = 0) { + self.verticalSpacing = verticalSpacing + self.horizontalSpacing = horizontalSpacing + } + + public func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + if let w = proposal.width, w > 0 { + let h = coordinates(boundsWidth: w, proposal: proposal, subviews: subviews).reduce(0, { max($0, $1.maxY) }) + return CGSize(width: w, height: h) + } + + return proposal.replacingUnspecifiedDimensions() + } + + private func coordinates(boundsWidth: CGFloat, proposal: ProposedViewSize, subviews: Subviews) -> [CGRect] { + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + + var rectangles = [CGRect]() + + for (_, subview) in subviews.enumerated() { + let viewDimensions = subview.dimensions(in: proposal) + // Find a vector with an appropriate size and rotation. + + if x > 0, x + viewDimensions.width > boundsWidth { + y += rowHeight + verticalSpacing + x = 0 + rowHeight = 0 + } + + rowHeight = max(rowHeight, viewDimensions.height) + + rectangles.append(CGRect(x: x, y: y, width: viewDimensions.width, height: viewDimensions.height)) + + x += viewDimensions.width + horizontalSpacing + } + + return rectangles + } + + public func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + + for (_, subview) in subviews.enumerated() { + let viewDimensions = subview.dimensions(in: proposal) + // Find a vector with an appropriate size and rotation. + + if x > 0, x + viewDimensions.width > bounds.width { + y += rowHeight + verticalSpacing + x = 0 + rowHeight = 0 + } + + rowHeight = max(rowHeight, viewDimensions.height) + + var point = CGPoint(x: bounds.minX + x, y: bounds.minY + y) + point.x += viewDimensions.width / 2 + point.y += viewDimensions.height / 2 + + // Place the subview. + subview.place(at: point, anchor: .center, proposal: .unspecified) + + x += viewDimensions.width + horizontalSpacing + } + } +} From 2df9f05b12776c90df56ab528c9e5b890a0a74f4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 14:41:12 +0300 Subject: [PATCH 12/21] [WIP] StatView --- .../Sources/ForumFeature/Stat/StatView.swift | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/ForumFeature/Stat/StatView.swift b/Modules/Sources/ForumFeature/Stat/StatView.swift index c545e4d4..e99d009a 100644 --- a/Modules/Sources/ForumFeature/Stat/StatView.swift +++ b/Modules/Sources/ForumFeature/Stat/StatView.swift @@ -145,11 +145,9 @@ public struct StatView: View { .multilineTextAlignment(.center) case .moderators(let moderators): - Group { + BrickLayout(verticalSpacing: 6, horizontalSpacing: 8) { ForEach(moderators) { moderator in - Text(verbatim: "\(moderator.name)") - .font(.body) - .multilineTextAlignment(.center) + UserBrickButton(moderator) } } } @@ -161,6 +159,25 @@ public struct StatView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) ) } + + @ViewBuilder + private func UserBrickButton(_ moderator: ForumStat.ForumModerator) -> some View { + Button { + // TODO: Handle click + } label: { + Text(verbatim: "\(moderator.name)") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(tintColor) + } + .buttonStyle(.plain) + .padding(.vertical, 9) + .padding(.horizontal, 8) + .background( + Color(.Background.teritary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + ) + } } // MARK: - Previews From 588b0d18848710519d3a92e279b6bd7290158693 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 14:52:16 +0300 Subject: [PATCH 13/21] Extract forum stat to own feature --- Modules/Sources/ForumFeature/ForumFeature.swift | 5 +++-- Modules/Sources/ForumFeature/ForumScreen.swift | 3 ++- .../ForumStatFeature.swift} | 6 +++--- .../ForumStatView.swift} | 16 ++++++++-------- .../Resources/Localizable.xcstrings | 7 +++++++ Project.swift | 13 +++++++++++++ 6 files changed, 36 insertions(+), 14 deletions(-) rename Modules/Sources/{ForumFeature/Stat/StatFeature.swift => ForumStatFeature/ForumStatFeature.swift} (96%) rename Modules/Sources/{ForumFeature/Stat/StatView.swift => ForumStatFeature/ForumStatView.swift} (94%) create mode 100644 Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 2c5f2f52..379fa096 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -16,6 +16,7 @@ import PersistenceKeys import TCAExtensions import ToastClient import FormFeature +import ForumStatFeature @Reducer public struct ForumFeature: Reducer, Sendable { @@ -55,7 +56,7 @@ public struct ForumFeature: Reducer, Sendable { @Reducer public enum Destination { case form(FormFeature) - case stat(StatFeature) + case stat(ForumStatFeature) } // MARK: - State @@ -262,7 +263,7 @@ public struct ForumFeature: Reducer, Sendable { } case .stat: - state.destination = .stat(StatFeature.State(forumId: id)) + state.destination = .stat(ForumStatFeature.State(forumId: id)) return .none case .setFavorite(let isFavorite): diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index b968ca6e..47cd3c65 100644 --- a/Modules/Sources/ForumFeature/ForumScreen.swift +++ b/Modules/Sources/ForumFeature/ForumScreen.swift @@ -13,6 +13,7 @@ import SharedUI import Models import BBBuilder import FormFeature +import ForumStatFeature @ViewAction(for: ForumFeature.self) public struct ForumScreen: View { @@ -98,7 +99,7 @@ public struct ForumScreen: View { } .sheet(item: $store.scope(state: \.destination?.stat, action: \.destination.stat)) { store in NavigationStack { - StatView(store: store) + ForumStatView(store: store) } } .toolbar { diff --git a/Modules/Sources/ForumFeature/Stat/StatFeature.swift b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift similarity index 96% rename from Modules/Sources/ForumFeature/Stat/StatFeature.swift rename to Modules/Sources/ForumStatFeature/ForumStatFeature.swift index e70e2273..32a4d401 100644 --- a/Modules/Sources/ForumFeature/Stat/StatFeature.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift @@ -1,5 +1,5 @@ // -// StatFeature.swift +// ForumStatFeature.swift // ForPDA // // Created by Xialtal on 14.06.25. @@ -11,7 +11,7 @@ import APIClient import Models @Reducer -public struct StatFeature: Reducer, Sendable { +public struct ForumStatFeature: Reducer, Sendable { public init() {} @@ -125,4 +125,4 @@ public struct StatFeature: Reducer, Sendable { } } -extension StatFeature.Destination.State: Equatable {} +extension ForumStatFeature.Destination.State: Equatable {} diff --git a/Modules/Sources/ForumFeature/Stat/StatView.swift b/Modules/Sources/ForumStatFeature/ForumStatView.swift similarity index 94% rename from Modules/Sources/ForumFeature/Stat/StatView.swift rename to Modules/Sources/ForumStatFeature/ForumStatView.swift index e99d009a..06e4843f 100644 --- a/Modules/Sources/ForumFeature/Stat/StatView.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatView.swift @@ -1,5 +1,5 @@ // -// StatView.swift +// ForumStatView.swift // ForPDA // // Created by Xialtal on 14.06.25. @@ -10,17 +10,17 @@ import ComposableArchitecture import Models import SharedUI -@ViewAction(for: StatFeature.self) -public struct StatView: View { +@ViewAction(for: ForumStatFeature.self) +public struct ForumStatView: View { // MARK: - Properties - @Perception.Bindable public var store: StoreOf + @Perception.Bindable public var store: StoreOf @Environment(\.tintColor) private var tintColor // MARK: - Init - public init(store: StoreOf) { + public init(store: StoreOf) { self.store = store } @@ -184,13 +184,13 @@ public struct StatView: View { #Preview { NavigationStack { - StatView( + ForumStatView( store: Store( - initialState: StatFeature.State( + initialState: ForumStatFeature.State( forumId: 0 ) ) { - StatFeature() + ForumStatFeature() } ) } diff --git a/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..900453da --- /dev/null +++ b/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings @@ -0,0 +1,7 @@ +{ + "sourceLanguage" : "en", + "strings" : { + + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Project.swift b/Project.swift index b6b8e6e7..fed6e584 100644 --- a/Project.swift +++ b/Project.swift @@ -249,6 +249,7 @@ let project = Project( .Internal.TCAExtensions, .Internal.ToastClient, .Internal.FormFeature, + .Internal.ForumStatFeature, .SPM.NukeUI, .SPM.SFSafeSymbols, .SPM.TCA, @@ -267,6 +268,17 @@ let project = Project( .SPM.TCA, ] ), + + .feature( + name: "ForumStatFeature", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.SharedUI, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), .feature( name: "GalleryFeature", @@ -1020,6 +1032,7 @@ extension TargetDependency.Internal { static let FormFeature = TargetDependency.target(name: "FormFeature") static let ForumFeature = TargetDependency.target(name: "ForumFeature") static let ForumsListFeature = TargetDependency.target(name: "ForumsListFeature") + static let ForumStatFeature = TargetDependency.target(name: "ForumStatFeature") static let GalleryFeature = TargetDependency.target(name: "GalleryFeature") static let HistoryFeature = TargetDependency.target(name: "HistoryFeature") static let MentionsFeature = TargetDependency.target(name: "MentionsFeature") From 6b8bc3b8a1ff04db9a242bcc41599376f3dbd416 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 14:54:08 +0300 Subject: [PATCH 14/21] Fix localization --- .../Resources/Localizable.xcstrings | 30 ----------- .../Resources/Localizable.xcstrings | 51 ++++++++++++++++++- 2 files changed, 50 insertions(+), 31 deletions(-) diff --git a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings index 21c01488..650e7440 100644 --- a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings @@ -31,16 +31,6 @@ } } }, - "Close" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Закрыть" - } - } - } - }, "Copy Link" : { "localizations" : { "ru" : { @@ -101,16 +91,6 @@ } } }, - "Moderators" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Модераторы" - } - } - } - }, "Open" : { "localizations" : { "ru" : { @@ -141,16 +121,6 @@ } } }, - "Posts" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Постов" - } - } - } - }, "Remove from favorites" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings index 900453da..d9cb836f 100644 --- a/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings @@ -1,7 +1,56 @@ { "sourceLanguage" : "en", "strings" : { - + "Close" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + } + } + }, + "Moderators" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модераторы" + } + } + } + }, + "Posts" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Постов" + } + } + } + }, + "Subforums" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подфорумы" + } + } + } + }, + "Topics" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Темы" + } + } + } + } }, "version" : "1.1" } \ No newline at end of file From d97490058132b4afa37fa76979080440b5bd8288 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 15:06:13 +0300 Subject: [PATCH 15/21] Handle click on moderators in ForumStatFeature --- .../Sources/AppFeature/Navigation/StackTab.swift | 3 +++ Modules/Sources/ForumFeature/ForumFeature.swift | 4 ++++ .../ForumStatFeature/ForumStatFeature.swift | 14 +++++++++++++- .../Sources/ForumStatFeature/ForumStatView.swift | 2 +- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index bfd7b986..1a1e78d5 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -230,6 +230,9 @@ public struct StackTab: Reducer, Sendable { case let .forum(.delegate(.openSearch(on, navigation))): state.path.append(.search(.search(SearchFeature.State(on: on, navigation: navigation)))) + case let .forum(.delegate(.openUser(id))): + state.path.append(.profile(.profile(ProfileFeature.State(userId: id)))) + case let .forum(.delegate(.handleRedirect(url))): return handleDeeplink(url: url, state: &state) diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 379fa096..30b8a309 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -132,6 +132,7 @@ public struct ForumFeature: Reducer, Sendable { case delegate(Delegate) public enum Delegate { + case openUser(id: Int) case openTopic(id: Int, name: String, goTo: GoTo) case openForum(id: Int, name: String) case openAnnouncement(id: Int, name: String) @@ -163,6 +164,9 @@ public struct ForumFeature: Reducer, Sendable { case let .destination(.presented(.form(.delegate(.formSent(.topic(id)))))): return .send(.delegate(.openTopic(id: id, name: "", goTo: .first))) + case let .destination(.presented(.stat(.delegate(.userTapped(id))))): + return .send(.delegate(.openUser(id: id))) + case .destination, .pageNavigation: return .none diff --git a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift index 32a4d401..741f151f 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift @@ -54,6 +54,7 @@ public struct ForumStatFeature: Reducer, Sendable { case onAppear case linkShared + case userButtonTapped(Int) case closeButtonTapped case shareLinkButtonTapped @@ -67,6 +68,11 @@ public struct ForumStatFeature: Reducer, Sendable { case loadForumStat case forumStatResponse(Result) } + + case delegate(Delegate) + public enum Delegate { + case userTapped(Int) + } } // MARK: - Dependencies @@ -90,6 +96,12 @@ public struct ForumStatFeature: Reducer, Sendable { state.destination = nil return .none + case let .view(.userButtonTapped(id)): + return .run { send in + await send(.delegate(.userTapped(id))) + await dismiss() + } + case .view(.shareLinkButtonTapped): state.destination = .share(URL(string: state.shareLink)!) return .none @@ -117,7 +129,7 @@ public struct ForumStatFeature: Reducer, Sendable { print(error) return .none - case .destination: + case .destination, .delegate: return .none } } diff --git a/Modules/Sources/ForumStatFeature/ForumStatView.swift b/Modules/Sources/ForumStatFeature/ForumStatView.swift index 06e4843f..b101894a 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatView.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatView.swift @@ -163,7 +163,7 @@ public struct ForumStatView: View { @ViewBuilder private func UserBrickButton(_ moderator: ForumStat.ForumModerator) -> some View { Button { - // TODO: Handle click + send(.userButtonTapped(moderator.id)) } label: { Text(verbatim: "\(moderator.name)") .font(.footnote) From 7aa53e5857714342e5f92fbf2faa176288cc5cbe Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 15:59:03 +0300 Subject: [PATCH 16/21] Add topic viewers endpoint --- Modules/Sources/APIClient/APIClient.swift | 12 +++++ .../Sources/Models/Forum/TopicViewers.swift | 50 +++++++++++++++++++ Modules/Sources/Models/Post/Post.swift | 2 +- .../ParsingClient/Parsers/TopicParser.swift | 34 +++++++++++++ .../Sources/ParsingClient/ParsingClient.swift | 4 ++ 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/Models/Forum/TopicViewers.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index d1cbedaf..22c102f3 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -62,6 +62,7 @@ public struct APIClient: Sendable { public var markRead: @Sendable (_ id: Int, _ isTopic: Bool) async throws -> Bool public var getAnnouncement: @Sendable (_ id: Int) async throws -> Announcement public var getTopic: @Sendable (_ id: Int, _ page: Int, _ perPage: Int, _ postsFilter: TopicPostsFilter) async throws -> Topic + public var getTopicViewers: @Sendable (_ id: Int) async throws -> TopicViewers public var getTemplate: @Sendable (_ request: ForumTemplateRequest, _ isTopic: Bool) async throws -> [FormFieldType] public var sendTemplate: @Sendable (_ id: Int, _ content: PDAPIDocument, _ isTopic: Bool) async throws -> TemplateSend public var getHistory: @Sendable (_ offset: Int, _ perPage: Int) async throws -> History @@ -346,6 +347,14 @@ extension APIClient: DependencyKey { let response = try await api.send(ForumCommand.Topic.view(data: request)) return try await parser.parseTopic(response) }, + getTopicViewers: { topicId in + let command = MemberCommand.sessions( + pageType: .topic, + pageId: topicId + ) + let response = try await api.send(command) + return try await parser.parseTopicViewers(response) + }, getTemplate: { request, isTopic in let command = ForumCommand.template( @@ -649,6 +658,9 @@ extension APIClient: DependencyKey { getTopic: { _, _, _, _ in return .mock }, + getTopicViewers: { _ in + return .mock + }, getTemplate: { _, _ in return [.mockTitle, .mockRequiredText, .mockRequiredEditor, .mockEditor, .mockUploadBox] }, diff --git a/Modules/Sources/Models/Forum/TopicViewers.swift b/Modules/Sources/Models/Forum/TopicViewers.swift new file mode 100644 index 00000000..b4cca2e0 --- /dev/null +++ b/Modules/Sources/Models/Forum/TopicViewers.swift @@ -0,0 +1,50 @@ +// +// TopicViewers.swift +// ForPDA +// +// Created by Xialtal on 24.03.26. +// + +public struct TopicViewers: Sendable { + public let guestsCount: Int + public let hiddenUsersCount: Int + public let users: [SimplifiedUser] + + public struct SimplifiedUser: Sendable, Identifiable { + public let id: Int + public let name: String + public let group: User.Group + + public init( + id: Int, + name: String, + group: User.Group + ) { + self.id = id + self.name = name + self.group = group + } + } + + public init( + guestsCount: Int, + hiddenUsersCount: Int, + users: [SimplifiedUser] + ) { + self.guestsCount = guestsCount + self.hiddenUsersCount = hiddenUsersCount + self.users = users + } +} + +public extension TopicViewers { + static let mock = TopicViewers( + guestsCount: 1, + hiddenUsersCount: 2, + users: [ + .init(id: 0, name: "AirFlare", group: .regular), + .init(id: 1, name: "subvertd", group: .regular), + .init(id: 2, name: "Another", group: .active) + ] + ) +} diff --git a/Modules/Sources/Models/Post/Post.swift b/Modules/Sources/Models/Post/Post.swift index 00aec9b9..79b1b1c1 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -139,7 +139,7 @@ public struct Post: Sendable, Hashable, Identifiable, Codable { // MARK: - Mocks -extension Post { +public extension Post { static func mock(id: Int = 0) -> Post { return Post( id: id, diff --git a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift index 3b4b9dc3..f509a21a 100644 --- a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift @@ -57,6 +57,40 @@ public struct TopicParser { ) } + // MARK: - Viewers + + public static func parseTopicViewers(from string: String) throws (ParsingError) -> TopicViewers { + guard let data = string.data(using: .utf8) else { + throw ParsingError.failedToCreateDataFromString + } + + guard let array = try? JSONSerialization.jsonObject(with: data, options: []) as? [Any] else { + throw ParsingError.failedToCastDataToAny + } + + guard let guestsCount = array[safe: 2] as? Int, + let hiddenUsersCount = array[safe: 3] as? Int, + let usersRaw = array[safe: 4] as? [[Any]], + let users = try? parseTopicViewer(usersRaw) else { + throw ParsingError.failedToCastFields + } + + return TopicViewers(guestsCount: guestsCount, hiddenUsersCount: hiddenUsersCount, users: users) + } + + private static func parseTopicViewer(_ usersRaw: [[Any]]) throws -> [TopicViewers.SimplifiedUser] { + return try usersRaw.map { user in + guard let id = user[safe: 0] as? Int, + let name = user[safe: 1] as? String, + let group = user[2] as? Int else { + throw ParsingError.failedToCastFields + } + return TopicViewers.SimplifiedUser(id: id, name: name, group: User.Group(rawValue: group)!) + } + } + + // MARK: - Form + public static func parsePostPreview(from string: String) throws(ParsingError) -> PreviewResponse { guard let data = string.data(using: .utf8) else { throw ParsingError.failedToCreateDataFromString diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index aa267436..d457eb82 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -36,6 +36,7 @@ public struct ParsingClient: Sendable { public var parseForum: @Sendable (_ response: String) async throws -> Forum public var parseForumStat: @Sendable (_ response: String) async throws -> ForumStat public var parseTopic: @Sendable (_ response: String) async throws -> Topic + public var parseTopicViewers: @Sendable (_ response: String) async throws -> TopicViewers public var parseAnnouncement: @Sendable (_ response: String) async throws -> Announcement public var parseFavorites: @Sendable (_ response: String) async throws -> Favorite public var parseHistory: @Sendable (_ response: String) async throws -> History @@ -110,6 +111,9 @@ extension ParsingClient: DependencyKey { parseTopic: { response in return try TopicParser.parse(from: response) }, + parseTopicViewers: { response in + return try TopicParser.parseTopicViewers(from: response) + }, parseAnnouncement: { response in return try ForumParser.parseAnnouncement(from: response) }, From f008468842d376ba59372ab63cb8b7accbed4688 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 17:13:44 +0300 Subject: [PATCH 17/21] Add isClosed field to Topic model --- Modules/Sources/Models/Forum/Topic.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index 6313f64a..7ff5218f 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -31,6 +31,10 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { return flag.contains(.canModerate) } + public var isClosed: Bool { + return flag.contains(.closed) + } + public var isFavorite: Bool public struct Poll: Sendable, Codable, Hashable { From 0f2a3e75aa9586eb4702f75a7271b0c74be45f80 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 17:14:33 +0300 Subject: [PATCH 18/21] Add topic support to ForumStatFeature --- .../Sources/ForumFeature/ForumFeature.swift | 2 +- .../ForumStatFeature/ForumStatFeature.swift | 50 +++- .../ForumStatFeature/ForumStatView.swift | 222 +++++++++++++----- .../Models/ForumStatType.swift | 13 + .../Resources/Localizable.xcstrings | 100 ++++++++ .../Sources/Models/Forum/TopicViewers.swift | 8 +- 6 files changed, 328 insertions(+), 67 deletions(-) create mode 100644 Modules/Sources/ForumStatFeature/Models/ForumStatType.swift diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 30b8a309..fa6579af 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -267,7 +267,7 @@ public struct ForumFeature: Reducer, Sendable { } case .stat: - state.destination = .stat(ForumStatFeature.State(forumId: id)) + state.destination = .stat(ForumStatFeature.State(type: .forum(id: state.forumId))) return .none case .setFavorite(let isFavorite): diff --git a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift index 741f151f..7e8d96c3 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift @@ -29,20 +29,25 @@ public struct ForumStatFeature: Reducer, Sendable { public struct State: Equatable { @Presents public var destination: Destination.State? - let forumId: Int + let type: ForumStatType var isLoading = false public var stat: ForumStat? + public var topicViewers: TopicViewers? - public var shareLink: String { - return "https://4pda.to/forum/index.php?showforum=\(forumId)" + var shareLink: String { + let show = switch type { + case .forum(let id): "showforum=\(id)" + case .topic(let topic): "showtopic=\(topic.id)" + } + return "https://4pda.to/forum/index.php?\(show)" } public init( - forumId: Int + type: ForumStatType ) { - self.forumId = forumId + self.type = type } } @@ -65,8 +70,11 @@ public struct ForumStatFeature: Reducer, Sendable { case `internal`(Internal) public enum Internal { - case loadForumStat + case loadTopicStat(Topic) + case loadTopicViewers(Int) + case loadForumStat(id: Int) case forumStatResponse(Result) + case topicViewersResponse(TopicViewers) } case delegate(Delegate) @@ -78,8 +86,8 @@ public struct ForumStatFeature: Reducer, Sendable { // MARK: - Dependencies @Dependency(\.apiClient) private var apiClient - @Dependency(\.openURL) var openURL - @Dependency(\.dismiss) var dismiss + @Dependency(\.openURL) private var openURL + @Dependency(\.dismiss) private var dismiss // MARK: - Body @@ -87,7 +95,12 @@ public struct ForumStatFeature: Reducer, Sendable { Reduce { state, action in switch action { case .view(.onAppear): - return .send(.internal(.loadForumStat)) + switch state.type { + case .forum(let id): + return .send(.internal(.loadForumStat(id: id))) + case .topic(let topic): + return .send(.internal(.loadTopicStat(topic))) + } case .view(.closeButtonTapped): return .run { _ in await dismiss() } @@ -111,9 +124,24 @@ public struct ForumStatFeature: Reducer, Sendable { await openURL(URL(string: shareLink)!) } - case .internal(.loadForumStat): + case let .internal(.loadTopicStat(topic)): + return .send(.internal(.loadTopicViewers(topic.id))) + + case let .internal(.loadTopicViewers(topicId)): state.isLoading = true - return .run { [id = state.forumId] send in + return .run { send in + let response = try await apiClient.getTopicViewers(id: topicId) + await send(.internal(.topicViewersResponse(response))) + } + + case let .internal(.topicViewersResponse(response)): + state.topicViewers = response + state.isLoading = false + return .none + + case let .internal(.loadForumStat(id)): + state.isLoading = true + return .run { send in let response = try await apiClient.getForumStat(id) await send(.internal(.forumStatResponse(.success(response)))) } catch: { error, send in diff --git a/Modules/Sources/ForumStatFeature/ForumStatView.swift b/Modules/Sources/ForumStatFeature/ForumStatView.swift index b101894a..bad2849c 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatView.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatView.swift @@ -29,37 +29,20 @@ public struct ForumStatView: View { public var body: some View { WithPerceptionTracking { ScrollView { - if !store.isLoading, let stat = store.stat { - VStack(spacing: 0) { - VStack { - Text(stat.name) - .font(.title2) - .fontWeight(.bold) - .padding(.bottom, 4) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(stat.description) - .font(.callout) - .foregroundStyle(Color(.Labels.secondary)) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.bottom, 28) - - VStack { - HStack(spacing: 12) { - InformationRow(LocalizedStringKey("Subforums"), .number(stat.subforumsCount)) - - InformationRow(LocalizedStringKey("Topics"), .number(stat.topicsCount)) + VStack(spacing: 0) { + if !store.isLoading { + switch store.type { + case .forum: + if let stat = store.stat { + ForumStat(stat) } - - InformationRow(LocalizedStringKey("Posts"), .number(stat.postsCount)) - - InformationRow(LocalizedStringKey("Moderators"), .moderators(stat.moderators)) + case .topic(let topic): + TopicStat(topic) } } - .padding(16) - .navigationBarTitleDisplayMode(.inline) } + .padding(16) + .navigationBarTitleDisplayMode(.inline) } .sheet(item: $store.scope(state: \.destination?.share, action: \.destination.share)) { rawUrl in ShareActivityView(url: rawUrl.withState { $0 }) { _ in @@ -72,25 +55,7 @@ public struct ForumStatView: View { OpenInBrowserButton() } .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - send(.closeButtonTapped) - } label: { - if isLiquidGlass { - Image(systemSymbol: .xmark) - } else { - Text("Close", bundle: .module) - } - } - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button { - send(.shareLinkButtonTapped) - } label: { - Image(systemSymbol: .squareAndArrowUp) - } - } + Toolbar() } .overlay { if store.isLoading, store.stat == nil { @@ -104,9 +69,89 @@ public struct ForumStatView: View { } } - // MARK: - Open In Browser Button + // MARK: - Forum Stat @ViewBuilder + private func ForumStat(_ stat: ForumStat) -> some View { + Header(name: stat.name, description: stat.description) + + VStack { + HStack(spacing: 12) { + InformationRow(LocalizedStringKey("Subforums"), .number(stat.subforumsCount)) + + InformationRow(LocalizedStringKey("Topics"), .number(stat.topicsCount)) + } + + InformationRow(LocalizedStringKey("Posts"), .number(stat.postsCount)) + + InformationRow(LocalizedStringKey("Moderators"), .moderators(stat.moderators)) + } + } + + // MARK: - Topic Stat + + @ViewBuilder + private func TopicStat(_ topic: Topic) -> some View { + Header(name: topic.name, description: topic.description) + + VStack { + HStack(spacing: 12) { + InformationRow(LocalizedStringKey("Created At"), .text(topic.createdAt.formatted())) + + InformationRow(LocalizedStringKey("Author"), .text(topic.authorName)) + } + + HStack(spacing: 12) { + let curator: RowType = if topic.curatorId == 0 { + .localizedText(LocalizedStringKey("no")) + } else { + .text(topic.curatorName) + } + InformationRow(LocalizedStringKey("Curator"), curator) + + InformationRow( + LocalizedStringKey("Status"), + .localizedText(topic.isClosed ? LocalizedStringKey("Closed") : LocalizedStringKey("Open")) + ) + } + + if let viewers = store.topicViewers, !store.isLoading { + TopicViewers(viewers) + } else { + PDALoader() + .frame(width: 24, height: 24) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 28) + } + } + } + + @ViewBuilder + private func TopicViewers(_ viewers: TopicViewers) -> some View { + VStack { + Group { + Text("Reading this topic: **\(viewers.allCount) people**", bundle: .module) + + Text("Guests: **\(viewers.guestsCount)**", bundle: .module) + + Text("Hidden users: **\(viewers.hiddenUsersCount)**", bundle: .module) + } + .font(.footnote) + .foregroundStyle(Color(.Labels.secondary)) + .frame(maxWidth: .infinity, alignment: .leading) + + BrickLayout(verticalSpacing: 6, horizontalSpacing: 8) { + ForEach(viewers.users) { user in + UserBrickButton(id: user.id, name: user.name) + } + } + .padding(.top, 12) + } + .padding(.top, 18) + } + + // MARK: - Open In Browser Button + private func OpenInBrowserButton() -> some View { Button { send(.openInBrowserButtonTapped) @@ -125,10 +170,30 @@ public struct ForumStatView: View { .padding(16) } + // MARK: - Header + + private func Header(name: String, description: String) -> some View { + VStack { + Text(name) + .font(.title2) + .fontWeight(.bold) + .padding(.bottom, 4) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(description) + .font(.callout) + .foregroundStyle(Color(.Labels.secondary)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.bottom, 28) + } + // MARK: - Row enum RowType { case number(Int) + case text(String) + case localizedText(LocalizedStringKey) case moderators([ForumStat.ForumModerator]) } @@ -144,10 +209,20 @@ public struct ForumStatView: View { .font(.body) .multilineTextAlignment(.center) + case .text(let content): + Text(verbatim: content) + .font(.body) + .multilineTextAlignment(.center) + + case .localizedText(let content): + Text(content, bundle: .module) + .font(.body) + .multilineTextAlignment(.center) + case .moderators(let moderators): BrickLayout(verticalSpacing: 6, horizontalSpacing: 8) { ForEach(moderators) { moderator in - UserBrickButton(moderator) + UserBrickButton(id: moderator.id, name: moderator.name) } } } @@ -160,12 +235,14 @@ public struct ForumStatView: View { ) } + // MARK: - User Brick Button + @ViewBuilder - private func UserBrickButton(_ moderator: ForumStat.ForumModerator) -> some View { + private func UserBrickButton(id: Int, name: String) -> some View { Button { - send(.userButtonTapped(moderator.id)) + send(.userButtonTapped(id)) } label: { - Text(verbatim: "\(moderator.name)") + Text(verbatim: "\(name)") .font(.footnote) .multilineTextAlignment(.center) .foregroundStyle(tintColor) @@ -178,16 +255,55 @@ public struct ForumStatView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) ) } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private func Toolbar() -> some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button { + send(.closeButtonTapped) + } label: { + if isLiquidGlass { + Image(systemSymbol: .xmark) + } else { + Text("Close", bundle: .module) + } + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + send(.shareLinkButtonTapped) + } label: { + Image(systemSymbol: .squareAndArrowUp) + } + } + } } // MARK: - Previews -#Preview { +#Preview("Forum Stat") { + NavigationStack { + ForumStatView( + store: Store( + initialState: ForumStatFeature.State( + type: .forum(id: 0) + ) + ) { + ForumStatFeature() + } + ) + } +} + +#Preview("Topic Stat") { NavigationStack { ForumStatView( store: Store( initialState: ForumStatFeature.State( - forumId: 0 + type: .topic(.mock) ) ) { ForumStatFeature() diff --git a/Modules/Sources/ForumStatFeature/Models/ForumStatType.swift b/Modules/Sources/ForumStatFeature/Models/ForumStatType.swift new file mode 100644 index 00000000..8fb5b0f7 --- /dev/null +++ b/Modules/Sources/ForumStatFeature/Models/ForumStatType.swift @@ -0,0 +1,13 @@ +// +// ForumStatType.swift +// ForPDA +// +// Created by Xialtal on 24.03.26. +// + +import Models + +public enum ForumStatType: Equatable { + case topic(Topic) + case forum(id: Int) +} diff --git a/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings index d9cb836f..e7b24b27 100644 --- a/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings @@ -1,6 +1,16 @@ { "sourceLanguage" : "en", "strings" : { + "Author" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автор" + } + } + } + }, "Close" : { "localizations" : { "ru" : { @@ -11,6 +21,56 @@ } } }, + "Closed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыта" + } + } + } + }, + "Created At" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата создания" + } + } + } + }, + "Curator" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Куратор" + } + } + } + }, + "Guests: **%lld**" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Гостей: **%lld**" + } + } + } + }, + "Hidden users: **%lld**" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрытых пользователей: **%lld**" + } + } + } + }, "Moderators" : { "localizations" : { "ru" : { @@ -21,6 +81,26 @@ } } }, + "no" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "нет" + } + } + } + }, + "Open" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыта" + } + } + } + }, "Posts" : { "localizations" : { "ru" : { @@ -31,6 +111,26 @@ } } }, + "Reading this topic: **%lld people**" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Читают эту тему: **%lld чел.**" + } + } + } + }, + "Status" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус" + } + } + } + }, "Subforums" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/Models/Forum/TopicViewers.swift b/Modules/Sources/Models/Forum/TopicViewers.swift index b4cca2e0..a3f683de 100644 --- a/Modules/Sources/Models/Forum/TopicViewers.swift +++ b/Modules/Sources/Models/Forum/TopicViewers.swift @@ -5,12 +5,16 @@ // Created by Xialtal on 24.03.26. // -public struct TopicViewers: Sendable { +public struct TopicViewers: Sendable, Equatable { public let guestsCount: Int public let hiddenUsersCount: Int public let users: [SimplifiedUser] - public struct SimplifiedUser: Sendable, Identifiable { + public var allCount: Int { + return guestsCount + hiddenUsersCount + users.count + } + + public struct SimplifiedUser: Sendable, Identifiable, Equatable { public let id: Int public let name: String public let group: User.Group From adb417d8ae147384cbabaa5e79249cf5cc0d66e7 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 17:18:40 +0300 Subject: [PATCH 19/21] Add "About Topic" button for context menu to TopicFeature --- .../Sources/AnalyticsClient/Events/TopicEvent.swift | 1 + .../Analytics/TopicFeature+Analytics.swift | 2 ++ .../TopicFeature/Models/TopicContextMenuAction.swift | 1 + .../TopicFeature/Resources/Localizable.xcstrings | 10 ++++++++++ Modules/Sources/TopicFeature/TopicFeature.swift | 12 ++++++++++++ Modules/Sources/TopicFeature/TopicScreen.swift | 10 ++++++++++ Project.swift | 1 + 7 files changed, 37 insertions(+) diff --git a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift index dc00c1bd..b5956563 100644 --- a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift @@ -20,6 +20,7 @@ public enum TopicEvent: Event { case menuOpenInBrowser case menuGoToEnd case menuSetFavorite + case menuAboutTopic case menuWritePost case menuWritePostWithTemplate diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index 7ed9a1d9..ff565b11 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -88,6 +88,8 @@ extension TopicFeature { analytics.log(TopicEvent.menuGoToEnd) case .setFavorite: analytics.log(TopicEvent.menuSetFavorite) + case .about: + analytics.log(TopicEvent.menuAboutTopic) case .writePost: analytics.log(TopicEvent.menuWritePost) case .writePostWithTemplate: diff --git a/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift b/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift index d346fe33..a982e538 100644 --- a/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift +++ b/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift @@ -12,4 +12,5 @@ public enum TopicContextMenuAction { case openInBrowser case goToEnd case setFavorite + case about } diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 07fd08a5..2afca264 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -11,6 +11,16 @@ } } }, + "About Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "О теме" + } + } + } + }, "Add to favorites" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 724c5d2f..1cc6e8de 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -22,6 +22,7 @@ import AnalyticsClient import TopicBuilder import ToastClient import NotificationsClient +import ForumStatFeature @Reducer public struct TopicFeature: Reducer, Sendable { @@ -50,6 +51,7 @@ public struct TopicFeature: Reducer, Sendable { @ReducerCaseIgnored case karmaChange(Int) case form(FormFeature) + case stat(ForumStatFeature) case changeReputation(ReputationChangeFeature) case alert(AlertState) @@ -214,6 +216,9 @@ public struct TopicFeature: Reducer, Sendable { await toastClient.showToast(ToastMessage(text: Localization.reportSent, haptic: .success)) } + case let .destination(.presented(.stat(.delegate(.userTapped(id))))): + return .send(.delegate(.openUser(id: id))) + case let .destination(.presented(.alert(.deletePost(id)))): return .run { send in let status = try await apiClient.deletePosts(postIds: [id]) @@ -304,6 +309,13 @@ public struct TopicFeature: Reducer, Sendable { state.destination = .form(formState) return .none + case .about: + let statState = ForumStatFeature.State( + type: .topic(topic) + ) + state.destination = .stat(statState) + return .none + case .openInBrowser: let url = URL(string: "https://4pda.to/forum/index.php?showtopic=\(topic.id)")! return .run { _ in await open(url: url) } diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 85a3e786..88d8e266 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -17,6 +17,7 @@ import ParsingClient import ReputationChangeFeature import TopicBuilder import GalleryFeature +import ForumStatFeature @ViewAction(for: TopicFeature.self) public struct TopicScreen: View { @@ -190,6 +191,10 @@ public struct TopicScreen: View { ) { send(.contextMenu(.setFavorite)) } + + ContextButton(text: LocalizedStringResource("About Topic", bundle: .module), symbol: .infoCircle) { + send(.contextMenu(.about)) + } } if topic.canModerate { @@ -466,6 +471,11 @@ struct NavigationModifier: ViewModifier { ) { store in ReputationChangeView(store: store) } + .sheet(item: $store.scope(state: \.destination?.stat, action: \.destination.stat)) { store in + NavigationStack { + ForumStatView(store: store) + } + } } } } diff --git a/Project.swift b/Project.swift index fed6e584..a6aba954 100644 --- a/Project.swift +++ b/Project.swift @@ -499,6 +499,7 @@ let project = Project( .Internal.ToastClient, .Internal.TopicBuilder, .Internal.FormFeature, + .Internal.ForumStatFeature, .SPM.MemberwiseInit, .SPM.NukeUI, .SPM.RichTextKit, From 52b555229dc34764a8ec180cbca446983ed91ee2 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 17:27:40 +0300 Subject: [PATCH 20/21] Add topic viewers auth check --- .../ForumStatFeature/ForumStatFeature.swift | 12 +++++++++++- .../Sources/ForumStatFeature/ForumStatView.swift | 16 +++++++++------- Project.swift | 1 + 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift index 7e8d96c3..ef2ba92a 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift @@ -9,6 +9,7 @@ import Foundation import ComposableArchitecture import APIClient import Models +import PersistenceKeys @Reducer public struct ForumStatFeature: Reducer, Sendable { @@ -27,6 +28,8 @@ public struct ForumStatFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { + @Shared(.userSession) var userSession: UserSession? + @Presents public var destination: Destination.State? let type: ForumStatType @@ -36,6 +39,10 @@ public struct ForumStatFeature: Reducer, Sendable { public var stat: ForumStat? public var topicViewers: TopicViewers? + var isUserAuthorized: Bool { + return userSession != nil + } + var shareLink: String { let show = switch type { case .forum(let id): "showforum=\(id)" @@ -125,7 +132,10 @@ public struct ForumStatFeature: Reducer, Sendable { } case let .internal(.loadTopicStat(topic)): - return .send(.internal(.loadTopicViewers(topic.id))) + if state.isUserAuthorized { + return .send(.internal(.loadTopicViewers(topic.id))) + } + return .none case let .internal(.loadTopicViewers(topicId)): state.isLoading = true diff --git a/Modules/Sources/ForumStatFeature/ForumStatView.swift b/Modules/Sources/ForumStatFeature/ForumStatView.swift index bad2849c..f87af769 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatView.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatView.swift @@ -115,13 +115,15 @@ public struct ForumStatView: View { ) } - if let viewers = store.topicViewers, !store.isLoading { - TopicViewers(viewers) - } else { - PDALoader() - .frame(width: 24, height: 24) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 28) + if store.isUserAuthorized { + if let viewers = store.topicViewers, !store.isLoading { + TopicViewers(viewers) + } else { + PDALoader() + .frame(width: 24, height: 24) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 28) + } } } } diff --git a/Project.swift b/Project.swift index a6aba954..6636553c 100644 --- a/Project.swift +++ b/Project.swift @@ -274,6 +274,7 @@ let project = Project( dependencies: [ .Internal.APIClient, .Internal.Models, + .Internal.PersistenceKeys, .Internal.SharedUI, .SPM.SFSafeSymbols, .SPM.TCA From d0d59ecc76dc3116bf01bd83abb3871a798d5801 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 24 Mar 2026 17:31:23 +0300 Subject: [PATCH 21/21] Improve error handling for topic viewers --- .../Sources/ForumStatFeature/ForumStatFeature.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift index ef2ba92a..bb46b76d 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift @@ -81,7 +81,7 @@ public struct ForumStatFeature: Reducer, Sendable { case loadTopicViewers(Int) case loadForumStat(id: Int) case forumStatResponse(Result) - case topicViewersResponse(TopicViewers) + case topicViewersResponse(Result) } case delegate(Delegate) @@ -141,14 +141,21 @@ public struct ForumStatFeature: Reducer, Sendable { state.isLoading = true return .run { send in let response = try await apiClient.getTopicViewers(id: topicId) - await send(.internal(.topicViewersResponse(response))) + await send(.internal(.topicViewersResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.topicViewersResponse(.failure(error)))) } - case let .internal(.topicViewersResponse(response)): + case let .internal(.topicViewersResponse(.success(response))): state.topicViewers = response state.isLoading = false return .none + case let .internal(.topicViewersResponse(.failure(error))): + print(error) + state.isLoading = false + return .none + case let .internal(.loadForumStat(id)): state.isLoading = true return .run { send in