diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 2ad4b8c1..22c102f3 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -57,10 +57,12 @@ 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 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 @@ -306,6 +308,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, @@ -339,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( @@ -627,6 +643,9 @@ extension APIClient: DependencyKey { getForum: { _, _, _, _ in return .finished() }, + getForumStat: { _ in + return .mock + }, jumpForum: { _ in return .mock }, @@ -639,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/AnalyticsClient/Events/ForumEvent.swift b/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift index 382f0c84..24ec77f8 100644 --- a/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift @@ -22,6 +22,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) @@ -59,6 +60,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/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/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/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index 6be4ed79..e0ca4d3a 100644 --- a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift +++ b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift @@ -66,6 +66,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: diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 4df20884..fa6579af 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -16,19 +16,20 @@ import PersistenceKeys import TCAExtensions import ToastClient import FormFeature +import ForumStatFeature @Reducer public struct ForumFeature: Reducer, Sendable { public init() {} - // MARK: - Localizations + // MARK: - Localizations public enum Localization { static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) static let markAsReadSuccess = LocalizedStringResource("Marked as read", bundle: .module) } - + // MARK: - Enums public struct SectionExpand: Equatable { @@ -55,6 +56,7 @@ public struct ForumFeature: Reducer, Sendable { @Reducer public enum Destination { case form(FormFeature) + case stat(ForumStatFeature) } // MARK: - State @@ -120,7 +122,7 @@ public struct ForumFeature: Reducer, Sendable { case contextTopicMenu(ForumTopicContextMenuAction, TopicInfo) case contextCommonMenu(ForumCommonContextMenuAction, Int, Bool) } - + case `internal`(Internal) public enum Internal { case refresh @@ -130,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) @@ -161,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 @@ -260,6 +266,10 @@ public struct ForumFeature: Reducer, Sendable { await send(.internal(.refresh)) } + case .stat: + state.destination = .stat(ForumStatFeature.State(type: .forum(id: state.forumId))) + return .none + case .setFavorite(let isFavorite): return .run { [id = id, isFavorite = isFavorite, isForum = isForum] send in let request = SetFavoriteRequest( diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index fd7db401..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 { @@ -96,6 +97,11 @@ public struct ForumScreen: View { .padding(.bottom, 8) } } + .sheet(item: $store.scope(state: \.destination?.stat, action: \.destination.stat)) { store in + NavigationStack { + ForumStatView(store: store) + } + } .toolbar { ToolbarItem { Button { @@ -323,14 +329,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) @@ -340,6 +346,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 d4b11de1..650e7440 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" : { diff --git a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift new file mode 100644 index 00000000..bb46b76d --- /dev/null +++ b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift @@ -0,0 +1,185 @@ +// +// ForumStatFeature.swift +// ForPDA +// +// Created by Xialtal on 14.06.25. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models +import PersistenceKeys + +@Reducer +public struct ForumStatFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Destinations + + @Reducer + public enum Destination: Hashable { + @ReducerCaseIgnored + case share(URL) + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Shared(.userSession) var userSession: UserSession? + + @Presents public var destination: Destination.State? + + let type: ForumStatType + + var isLoading = false + + 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)" + case .topic(let topic): "showtopic=\(topic.id)" + } + return "https://4pda.to/forum/index.php?\(show)" + } + + public init( + type: ForumStatType + ) { + self.type = type + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + + case linkShared + case userButtonTapped(Int) + + case closeButtonTapped + case shareLinkButtonTapped + case openInBrowserButtonTapped + } + + case destination(PresentationAction) + + case `internal`(Internal) + public enum Internal { + case loadTopicStat(Topic) + case loadTopicViewers(Int) + case loadForumStat(id: Int) + case forumStatResponse(Result) + case topicViewersResponse(Result) + } + + case delegate(Delegate) + public enum Delegate { + case userTapped(Int) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.openURL) private var openURL + @Dependency(\.dismiss) private var dismiss + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + 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() } + + case .view(.linkShared): + 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 + + case .view(.openInBrowserButtonTapped): + return .run { [shareLink = state.shareLink] _ in + await openURL(URL(string: shareLink)!) + } + + case let .internal(.loadTopicStat(topic)): + if state.isUserAuthorized { + return .send(.internal(.loadTopicViewers(topic.id))) + } + return .none + + case let .internal(.loadTopicViewers(topicId)): + state.isLoading = true + return .run { send in + let response = try await apiClient.getTopicViewers(id: topicId) + await send(.internal(.topicViewersResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.topicViewersResponse(.failure(error)))) + } + + 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 + 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, .delegate: + return .none + } + } + .ifLet(\.$destination, action: \.destination) + } +} + +extension ForumStatFeature.Destination.State: Equatable {} diff --git a/Modules/Sources/ForumStatFeature/ForumStatView.swift b/Modules/Sources/ForumStatFeature/ForumStatView.swift new file mode 100644 index 00000000..f87af769 --- /dev/null +++ b/Modules/Sources/ForumStatFeature/ForumStatView.swift @@ -0,0 +1,315 @@ +// +// ForumStatView.swift +// ForPDA +// +// Created by Xialtal on 14.06.25. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI + +@ViewAction(for: ForumStatFeature.self) +public struct ForumStatView: 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 { + VStack(spacing: 0) { + if !store.isLoading { + switch store.type { + case .forum: + if let stat = store.stat { + ForumStat(stat) + } + case .topic(let topic): + TopicStat(topic) + } + } + } + .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 { + Toolbar() + } + .overlay { + if store.isLoading, store.stat == nil { + PDALoader() + .frame(width: 24, height: 24) + } + } + .onAppear { + send(.onAppear) + } + } + } + + // 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 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) + } + } + } + } + + @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) + } 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: - 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]) + } + + 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 .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(id: moderator.id, name: moderator.name) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(12) + .background( + Color(.Background.teritary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + } + + // MARK: - User Brick Button + + @ViewBuilder + private func UserBrickButton(id: Int, name: String) -> some View { + Button { + send(.userButtonTapped(id)) + } label: { + Text(verbatim: "\(name)") + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(tintColor) + } + .buttonStyle(.plain) + .padding(.vertical, 9) + .padding(.horizontal, 8) + .background( + Color(.Background.teritary) + .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("Forum Stat") { + NavigationStack { + ForumStatView( + store: Store( + initialState: ForumStatFeature.State( + type: .forum(id: 0) + ) + ) { + ForumStatFeature() + } + ) + } +} + +#Preview("Topic Stat") { + NavigationStack { + ForumStatView( + store: Store( + initialState: ForumStatFeature.State( + 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 new file mode 100644 index 00000000..e7b24b27 --- /dev/null +++ b/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings @@ -0,0 +1,156 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Author" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Автор" + } + } + } + }, + "Close" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + } + } + }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модераторы" + } + } + } + }, + "no" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "нет" + } + } + } + }, + "Open" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыта" + } + } + } + }, + "Posts" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Постов" + } + } + } + }, + "Reading this topic: **%lld people**" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Читают эту тему: **%lld чел.**" + } + } + } + }, + "Status" : { + "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 diff --git a/Modules/Sources/Models/Forum/ForumStat.swift b/Modules/Sources/Models/Forum/ForumStat.swift new file mode 100644 index 00000000..0cc7d5ff --- /dev/null +++ b/Modules/Sources/Models/Forum/ForumStat.swift @@ -0,0 +1,73 @@ +// +// 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: ForumFlag + public let globalAnnouncement: String + 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: ForumFlag, + 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: [.canPost, .updated], + 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), + .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) + ] + ) +} 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 { diff --git a/Modules/Sources/Models/Forum/TopicViewers.swift b/Modules/Sources/Models/Forum/TopicViewers.swift new file mode 100644 index 00000000..a3f683de --- /dev/null +++ b/Modules/Sources/Models/Forum/TopicViewers.swift @@ -0,0 +1,54 @@ +// +// TopicViewers.swift +// ForPDA +// +// Created by Xialtal on 24.03.26. +// + +public struct TopicViewers: Sendable, Equatable { + public let guestsCount: Int + public let hiddenUsersCount: Int + public let users: [SimplifiedUser] + + 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 + + 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/ForumParser.swift b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift index 2009f26d..98d88966 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: ForumFlag(rawValue: 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/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 398f696a..d457eb82 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -34,7 +34,9 @@ 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 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 @@ -103,9 +105,15 @@ 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) }, + parseTopicViewers: { response in + return try TopicParser.parseTopicViewers(from: response) + }, parseAnnouncement: { response in return try ForumParser.parseAnnouncement(from: response) }, 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 + } + } +} 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 b6b8e6e7..6636553c 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,18 @@ let project = Project( .SPM.TCA, ] ), + + .feature( + name: "ForumStatFeature", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.PersistenceKeys, + .Internal.SharedUI, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), .feature( name: "GalleryFeature", @@ -487,6 +500,7 @@ let project = Project( .Internal.ToastClient, .Internal.TopicBuilder, .Internal.FormFeature, + .Internal.ForumStatFeature, .SPM.MemberwiseInit, .SPM.NukeUI, .SPM.RichTextKit, @@ -1020,6 +1034,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")