From 0767bf927f26d25828844e11081053ce9c03f748 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 18:15:34 +0300 Subject: [PATCH 01/44] Add hide topic endpoint --- Modules/Sources/APIClient/APIClient.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index b717b3e5..85e7997a 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 hideTopic: @Sendable (_ id: Int, _ isUndo: Bool) async throws -> Bool 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 @@ -350,6 +351,12 @@ extension APIClient: DependencyKey { let response = try await api.send(ForumCommand.Topic.view(data: request)) return try await parser.parseTopic(response) }, + hideTopic: { id, isUndo in + let command = ForumCommand.Topic.setHidden(id: id, isHidden: !isUndo) + let response = try await api.send(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, getTopicViewers: { topicId in let command = MemberCommand.sessions( pageType: .topic, @@ -669,6 +676,9 @@ extension APIClient: DependencyKey { getTopic: { _, _, _, _ in return .mock }, + hideTopic: { _, _ in + return true + }, getTopicViewers: { _ in return .mock }, From f8c2c2ccbdae241180b8c503f0bb91ab6efed432 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 18:15:58 +0300 Subject: [PATCH 02/44] Add undo option to topic posts delete endpoint --- Modules/Sources/APIClient/APIClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 85e7997a..2789079b 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -439,7 +439,7 @@ extension APIClient: DependencyKey { }, deletePosts: { ids in - let command = ForumCommand.Post.delete(postIds: ids) + let command = ForumCommand.Post.delete(postIds: ids, isUndo: false) let response = try await api.send(command) let status = Int(response.getResponseStatus())! return status == 0 From b6c3361d700424c9d426f31c8f8e621c4b67a169 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 18:17:33 +0300 Subject: [PATCH 03/44] Add hide option to topic context menu --- Modules/Sources/Models/Forum/Topic.swift | 6 +++- .../Resources/Localizable.xcstrings | 30 +++++++++++++++++++ .../Sources/TopicFeature/TopicFeature.swift | 16 ++++++++++ .../Sources/TopicFeature/TopicScreen.swift | 24 +++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index 7ff5218f..82022a7d 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 isHidden: Bool { + return flag.contains(.hidden) + } + public var isClosed: Bool { return flag.contains(.closed) } @@ -117,7 +121,7 @@ public extension Topic { id: 3242552, name: "ForPDA", description: "Unofficial 4PDA client for iOS.", - flag: .canPost, + flag: [.canEdit, .canPost, .canDelete, .canModerate, .canProtect], createdAt: Date(timeIntervalSince1970: 1725706883), authorId: 3640948, authorName: "4spander", diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 2afca264..eabae5f1 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -101,6 +101,16 @@ } } }, + "Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть тему" + } + } + } + }, "Link copied" : { "localizations" : { "ru" : { @@ -221,6 +231,16 @@ } } }, + "Remove Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Убрать скрытие темы" + } + } + } + }, "Removed from favorites" : { "localizations" : { "ru" : { @@ -271,6 +291,16 @@ } } }, + "Tools" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструменты" + } + } + } + }, "Topic Hat" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 1cc6e8de..4d8348d7 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -140,6 +140,7 @@ public struct TopicFeature: Reducer, Sendable { case imageTapped(URL) case textQuoted(UIPost, String) case contextMenu(TopicContextMenuAction) + case contextToolsMenu(TopicToolsContextMenuAction) case contextPostMenu(PostMenuAction) } @@ -343,6 +344,21 @@ public struct TopicFeature: Reducer, Sendable { return .send(.pageNavigation(.lastPageTapped)) } + case let .view(.contextToolsMenu(action)): + guard let topic = state.topic else { return .none } + switch action { + case .hide: + return .run { [id = state.topicId] send in + _ = try await apiClient.hideTopic(id: id, isUndo: !topic.isHidden) + + await send(.internal(.refresh)) + await toastClient.showToast(.actionCompleted) + } catch: { error, send in + analyticsClient.capture(error) + await toastClient.showToast(.whoopsSomethingWentWrong) + } + } + case let .view(.contextPostMenu(action)): switch action { case let .reply(postId, authorName): diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 88d8e266..914720fa 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -199,6 +199,8 @@ public struct TopicScreen: View { if topic.canModerate { Section { + ToolsOptionsMenu(topic: topic) + Menu { Picker(String(), selection: $store.postsFilter) { ForEach(TopicPostsFilter.allCases) { mode in @@ -221,6 +223,28 @@ public struct TopicScreen: View { } } + // MARK: - Tools Options Menu + + @ViewBuilder + private func ToolsOptionsMenu(topic: Topic) -> some View { + Menu { + ContextButton( + text: topic.isHidden + ? LocalizedStringResource("Remove Hide", bundle: .module) + : LocalizedStringResource("Hide", bundle: .module), + symbol: topic.isHidden ? .eyeSlashFill : .eyeSlash + ) { + send(.contextToolsMenu(.hide)) + } + + } label: { + HStack { + Text("Tools", bundle: .module) + Image(systemSymbol: .shield) + } + } + } + @available(iOS, deprecated: 26.0) private func foregroundStyle() -> AnyShapeStyle { if isLiquidGlass { From 2eff094c8e7e73c4e97aa27ccf0cca22c678a3fb Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 18:26:11 +0300 Subject: [PATCH 04/44] Add close topic endpoint --- Modules/Sources/APIClient/APIClient.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 2789079b..50b7f87c 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -63,6 +63,7 @@ public struct APIClient: Sendable { 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 hideTopic: @Sendable (_ id: Int, _ isUndo: Bool) async throws -> Bool + public var closeTopic: @Sendable (_ id: Int, _ isUndo: Bool) async throws -> Bool 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 @@ -357,6 +358,12 @@ extension APIClient: DependencyKey { let status = Int(response.getResponseStatus())! return status == 0 }, + closeTopic: { id, isUndo in + let command = ForumCommand.Topic.setClosed(id: id, isClosed: !isUndo) + let response = try await api.send(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, getTopicViewers: { topicId in let command = MemberCommand.sessions( pageType: .topic, @@ -679,6 +686,9 @@ extension APIClient: DependencyKey { hideTopic: { _, _ in return true }, + closeTopic: { _, _ in + return true + }, getTopicViewers: { _ in return .mock }, From 71c8778f4fdcde408f305411f06c2c0a2be3d38d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 18:27:04 +0300 Subject: [PATCH 05/44] Add close option to topic context menu --- .../Analytics/TopicFeature+Analytics.swift | 4 ++++ .../Models/TopicToolsContextMenuAction.swift | 11 ++++++++++ .../Resources/Localizable.xcstrings | 20 +++++++++++++++++++ .../Sources/TopicFeature/TopicFeature.swift | 11 ++++++++++ .../Sources/TopicFeature/TopicScreen.swift | 9 +++++++++ 5 files changed, 55 insertions(+) create mode 100644 Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index ff565b11..989314cb 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -95,6 +95,10 @@ extension TopicFeature { case .writePostWithTemplate: analytics.log(TopicEvent.menuWritePostWithTemplate) } + + case .view(.contextToolsMenu(_)): + // TODO: At now moment, moderator tools are skip analytics + break case let .view(.textQuoted(post, _)): analytics.log(TopicEvent.textQuoted(post.id)) diff --git a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift new file mode 100644 index 00000000..f19886cc --- /dev/null +++ b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift @@ -0,0 +1,11 @@ +// +// TopicToolsContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 7.04.26. +// + +public enum TopicToolsContextMenuAction { + case hide + case close +} diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index eabae5f1..05295a0d 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -61,6 +61,16 @@ } } }, + "Close Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть тему" + } + } + } + }, "Copy Link" : { "localizations" : { "ru" : { @@ -181,6 +191,16 @@ } } }, + "Open Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть тему" + } + } + } + }, "Poll" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 4d8348d7..974aef0b 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -351,6 +351,17 @@ public struct TopicFeature: Reducer, Sendable { return .run { [id = state.topicId] send in _ = try await apiClient.hideTopic(id: id, isUndo: !topic.isHidden) + await send(.internal(.refresh)) + await toastClient.showToast(.actionCompleted) + } catch: { error, send in + analyticsClient.capture(error) + await toastClient.showToast(.whoopsSomethingWentWrong) + } + + case .close: + return .run { [id = state.topicId] send in + _ = try await apiClient.hideTopic(id: id, isUndo: !topic.isClosed) + await send(.internal(.refresh)) await toastClient.showToast(.actionCompleted) } catch: { error, send in diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 914720fa..d8e980e3 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -237,6 +237,15 @@ public struct TopicScreen: View { send(.contextToolsMenu(.hide)) } + ContextButton( + text: topic.isClosed + ? LocalizedStringResource("Open Topic", bundle: .module) + : LocalizedStringResource("Close Topic", bundle: .module), + symbol: topic.isClosed ? .lockFill : .lock + ) { + send(.contextToolsMenu(.close)) + } + } label: { HStack { Text("Tools", bundle: .module) From 060aaec2d3dc9661a4d36ac8cb56aec131106f34 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 18:43:49 +0300 Subject: [PATCH 06/44] Add topic delete endpoint --- Modules/Sources/APIClient/APIClient.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 50b7f87c..02a26dfc 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -64,6 +64,7 @@ public struct APIClient: Sendable { public var getTopic: @Sendable (_ id: Int, _ page: Int, _ perPage: Int, _ postsFilter: TopicPostsFilter) async throws -> Topic public var hideTopic: @Sendable (_ id: Int, _ isUndo: Bool) async throws -> Bool public var closeTopic: @Sendable (_ id: Int, _ isUndo: Bool) async throws -> Bool + public var deleteTopic: @Sendable (_ id: Int) async throws -> Bool 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 @@ -364,6 +365,11 @@ extension APIClient: DependencyKey { let status = Int(response.getResponseStatus())! return status == 0 }, + deleteTopic: { id in + let response = try await api.send(ForumCommand.Topic.delete(id: id)) + let status = Int(response.getResponseStatus())! + return status == 0 + }, getTopicViewers: { topicId in let command = MemberCommand.sessions( pageType: .topic, @@ -689,6 +695,9 @@ extension APIClient: DependencyKey { closeTopic: { _, _ in return true }, + deleteTopic: { _ in + return true + }, getTopicViewers: { _ in return .mock }, From d67d3bc804fc5f001ad870e1079b1d808c6627a1 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 18:44:18 +0300 Subject: [PATCH 07/44] Add delete option to topic context menu --- Modules/Sources/Models/Forum/Topic.swift | 4 +++ .../Models/TopicToolsContextMenuAction.swift | 1 + .../Resources/Localizable.xcstrings | 30 +++++++++++++++++++ .../Sources/TopicFeature/TopicFeature.swift | 14 +++++++++ .../Sources/TopicFeature/TopicScreen.swift | 5 ++++ 5 files changed, 54 insertions(+) diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index 82022a7d..4810e73f 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -27,6 +27,10 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { return flag.contains(.canPost) && !flag.contains(.marker) } + public var canDelete: Bool { + return flag.contains(.canDelete) + } + public var canModerate: Bool { return flag.contains(.canModerate) } diff --git a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift index f19886cc..295ef6df 100644 --- a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift +++ b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift @@ -8,4 +8,5 @@ public enum TopicToolsContextMenuAction { case hide case close + case delete } diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 05295a0d..06c1e2a3 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -81,6 +81,16 @@ } } }, + "Delete Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить тему" + } + } + } + }, "Down" : { "localizations" : { "ru" : { @@ -321,6 +331,16 @@ } } }, + "Topic deleted" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема удалена" + } + } + } + }, "Topic Hat" : { "localizations" : { "ru" : { @@ -331,6 +351,16 @@ } } }, + "Unable to delete topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось удалить тему" + } + } + } + }, "Up" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 974aef0b..af7f526f 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -36,6 +36,8 @@ public struct TopicFeature: Reducer, Sendable { static let reportSent = LocalizedStringResource("Report sent", bundle: .module) static let favoriteAdded = LocalizedStringResource("Added to favorites", bundle: .module) static let favoriteRemoved = LocalizedStringResource("Removed from favorites", bundle: .module) + static let topicDeleted = LocalizedStringResource("Topic deleted", bundle: .module) + static let topicDeleteError = LocalizedStringResource("Unable to delete topic", bundle: .module) static let postDeleted = LocalizedStringResource("Post deleted", bundle: .module) static let postKarmaChanged = LocalizedStringResource("Post karma changed", bundle: .module) static let topicVoteApproved = LocalizedStringResource("Vote approved", bundle: .module) @@ -368,6 +370,18 @@ public struct TopicFeature: Reducer, Sendable { analyticsClient.capture(error) await toastClient.showToast(.whoopsSomethingWentWrong) } + + case .delete: + return .run { [id = state.topicId] send in + let status = try await apiClient.deleteTopic(id: id) + + await send(.internal(.refresh)) + let text = status ? Localization.topicDeleted : Localization.topicDeleteError + await toastClient.showToast(ToastMessage(text: text, isError: !status)) + } catch: { error, send in + analyticsClient.capture(error) + await toastClient.showToast(.whoopsSomethingWentWrong) + } } case let .view(.contextPostMenu(action)): diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index d8e980e3..8c6c193d 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -246,6 +246,11 @@ public struct TopicScreen: View { send(.contextToolsMenu(.close)) } + if topic.canDelete { + ContextButton(text: LocalizedStringResource("Delete Topic", bundle: .module), symbol: .trash) { + send(.contextToolsMenu(.delete)) + } + } } label: { HStack { Text("Tools", bundle: .module) From 83b11ab068f515f698add345c12c38a9cad0ba00 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 18:54:34 +0300 Subject: [PATCH 08/44] Add move topic endpoint --- Modules/Sources/APIClient/APIClient.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 02a26dfc..ff05b1f3 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -65,6 +65,7 @@ public struct APIClient: Sendable { public var hideTopic: @Sendable (_ id: Int, _ isUndo: Bool) async throws -> Bool public var closeTopic: @Sendable (_ id: Int, _ isUndo: Bool) async throws -> Bool public var deleteTopic: @Sendable (_ id: Int) async throws -> Bool + public var moveTopic: @Sendable (_ id: Int, _ toForumId: Int, _ saveLink: Bool) async throws -> Bool 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 @@ -370,6 +371,16 @@ extension APIClient: DependencyKey { let status = Int(response.getResponseStatus())! return status == 0 }, + moveTopic: { id, toForumId, saveLink in + let command = ForumCommand.Topic.move( + id: id, + toForumId: toForumId, + saveLink: saveLink + ) + let response = try await api.send(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, getTopicViewers: { topicId in let command = MemberCommand.sessions( pageType: .topic, @@ -698,6 +709,9 @@ extension APIClient: DependencyKey { deleteTopic: { _ in return true }, + moveTopic: { _, _, _ in + return true + }, getTopicViewers: { _ in return .mock }, From 595edc3b5113b08ac45f95f1c77e5f88504908fd Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 19:05:41 +0300 Subject: [PATCH 09/44] Add confirmation for topic delete --- .../Resources/Localizable.xcstrings | 10 ++++++ .../Sources/TopicFeature/TopicFeature.swift | 34 +++++++++++++------ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 06c1e2a3..3f7b4a64 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -61,6 +61,16 @@ } } }, + "Are you sure, that you want to delete this topic?" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить эту тему?" + } + } + } + }, "Close Topic" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index af7f526f..386624af 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -60,6 +60,7 @@ public struct TopicFeature: Reducer, Sendable { @CasePathable public enum Alert: Equatable { case deletePost(Int) + case deleteTopic } } @@ -231,6 +232,15 @@ public struct TopicFeature: Reducer, Sendable { } .cancellable(id: CancelID.loading) + case .destination(.presented(.alert(.deleteTopic))): + return .run { [id = state.topicId] send in + let status = try await apiClient.deleteTopic(id: id) + let text = status ? Localization.topicDeleted : Localization.topicDeleteError + await toastClient.showToast(ToastMessage(text: text, isError: !status)) + await send(.internal(.refresh)) + } + .cancellable(id: CancelID.loading) + case .binding(\.postsFilter): return .send(.internal(.load)) @@ -372,16 +382,8 @@ public struct TopicFeature: Reducer, Sendable { } case .delete: - return .run { [id = state.topicId] send in - let status = try await apiClient.deleteTopic(id: id) - - await send(.internal(.refresh)) - let text = status ? Localization.topicDeleted : Localization.topicDeleteError - await toastClient.showToast(ToastMessage(text: text, isError: !status)) - } catch: { error, send in - analyticsClient.capture(error) - await toastClient.showToast(.whoopsSomethingWentWrong) - } + state.destination = .alert(.deleteTopicConfirmation) + return .none } case let .view(.contextPostMenu(action)): @@ -756,6 +758,18 @@ extension AlertState where Action == TopicFeature.Destination.Alert { } ) } + + nonisolated(unsafe) static var deleteTopicConfirmation = AlertState( + title: { TextState("Are you sure, that you want to delete this topic?", bundle: .module) }, + actions: { + ButtonState(role: .destructive, action: .deleteTopic) { + TextState("Yes", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("No", bundle: .module) + } + } + ) } // MARK: - Helpers From 8f080b3f74f9a2b72477c627a6af1ef05c939732 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 19:15:09 +0300 Subject: [PATCH 10/44] Fix topic close option in context menu --- Modules/Sources/TopicFeature/TopicFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 386624af..fae17807 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -372,7 +372,7 @@ public struct TopicFeature: Reducer, Sendable { case .close: return .run { [id = state.topicId] send in - _ = try await apiClient.hideTopic(id: id, isUndo: !topic.isClosed) + _ = try await apiClient.closeTopic(id: id, isUndo: !topic.isClosed) await send(.internal(.refresh)) await toastClient.showToast(.actionCompleted) From 97fa90dbbfdb0a1e8a6281f1e1c83cfefe752e21 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 19:39:17 +0300 Subject: [PATCH 11/44] Extract CheckBoxToggleStyle to SharedUI --- .../Fields/FormCheckBoxListFeature.swift | 3 +- .../Sources/Views/EditReasonView.swift | 2 +- .../CheckBoxToggleStyle.swift} | 28 ++++++++++++++++--- .../Sources/TopicFeature/Views/PollView.swift | 28 ------------------- 4 files changed, 27 insertions(+), 34 deletions(-) rename Modules/Sources/{FormFeature/Sources/Views/CheckBox.swift => SharedUI/CheckBoxToggleStyle.swift} (56%) diff --git a/Modules/Sources/FormFeature/Sources/Fields/FormCheckBoxListFeature.swift b/Modules/Sources/FormFeature/Sources/Fields/FormCheckBoxListFeature.swift index 9f4ac3b4..eece248e 100644 --- a/Modules/Sources/FormFeature/Sources/Fields/FormCheckBoxListFeature.swift +++ b/Modules/Sources/FormFeature/Sources/Fields/FormCheckBoxListFeature.swift @@ -8,6 +8,7 @@ import SwiftUI import ComposableArchitecture import Models +import SharedUI // MARK: - Feature @@ -113,7 +114,7 @@ struct FormCheckBoxListRow: View { .font(.subheadline) .frame(maxWidth: .infinity, alignment: .leading) } - .toggleStyle(CheckBox()) + .toggleStyle(CheckBoxToggleStyle()) .padding(6) } } diff --git a/Modules/Sources/FormFeature/Sources/Views/EditReasonView.swift b/Modules/Sources/FormFeature/Sources/Views/EditReasonView.swift index 4eaa6ab3..da952aff 100644 --- a/Modules/Sources/FormFeature/Sources/Views/EditReasonView.swift +++ b/Modules/Sources/FormFeature/Sources/Views/EditReasonView.swift @@ -53,7 +53,7 @@ struct EditReasonView: View { .foregroundStyle(Color(.Labels.secondary)) .frame(maxWidth: .infinity, alignment: .leading) } - .toggleStyle(CheckBox()) + .toggleStyle(CheckBoxToggleStyle()) .tint(tintColor) .padding(6) } diff --git a/Modules/Sources/FormFeature/Sources/Views/CheckBox.swift b/Modules/Sources/SharedUI/CheckBoxToggleStyle.swift similarity index 56% rename from Modules/Sources/FormFeature/Sources/Views/CheckBox.swift rename to Modules/Sources/SharedUI/CheckBoxToggleStyle.swift index 4573d9f4..9e4a00a9 100644 --- a/Modules/Sources/FormFeature/Sources/Views/CheckBox.swift +++ b/Modules/Sources/SharedUI/CheckBoxToggleStyle.swift @@ -1,14 +1,17 @@ // -// CheckBox.swift +// CheckBoxToggleStyle.swift // ForPDA // -// Created by Ilia Lubianoi on 13.08.2025. +// Created by Xialtal on 7.04.26. // import SwiftUI -struct CheckBox: ToggleStyle { - func makeBody(configuration: Configuration) -> some View { +public struct CheckBoxToggleStyle: ToggleStyle { + + public init() {} + + public func makeBody(configuration: Configuration) -> some View { HStack { Button(action: { configuration.isOn.toggle() @@ -33,3 +36,20 @@ struct CheckBox: ToggleStyle { } } } + +@available(iOS 17.0, *) +#Preview { + @Previewable @State var isEnabled = false + VStack { + Toggle(isOn: $isEnabled) { + Text(verbatim: "Show mark") + .font(.subheadline) + .foregroundStyle(Color(.Labels.secondary)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .toggleStyle(CheckBoxToggleStyle()) + .padding(6) + } + .padding(15) + .environment(\.tintColor, Color(.Theme.primary)) +} diff --git a/Modules/Sources/TopicFeature/Views/PollView.swift b/Modules/Sources/TopicFeature/Views/PollView.swift index 8257e945..460b2ab9 100644 --- a/Modules/Sources/TopicFeature/Views/PollView.swift +++ b/Modules/Sources/TopicFeature/Views/PollView.swift @@ -253,34 +253,6 @@ struct PollView: View { } } -// MARK: - CheckBox Toggle Style - -struct CheckBoxToggleStyle: ToggleStyle { - func makeBody(configuration: Configuration) -> some View { - HStack { - Button(action: { - configuration.isOn.toggle() - }, label: { - if !configuration.isOn { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(.Separator.secondary), lineWidth: 1.5) - .frame(width: 22, height: 22) - } else { - RoundedRectangle(cornerRadius: 6) - .frame(width: 22, height: 22) - .overlay { - Image(systemSymbol: .checkmark) - .font(.footnote) - .fontWeight(.semibold) - .foregroundStyle(Color(.white)) - } - } - }) - - configuration.label - } - } -} // MARK: - Previews From 6b44b712d75565aafa484fc6d019b0af133d5886 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 7 Apr 2026 20:04:57 +0300 Subject: [PATCH 12/44] Add set topic curator endpoint --- Modules/Sources/APIClient/APIClient.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index ff05b1f3..3b94d711 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -67,6 +67,7 @@ public struct APIClient: Sendable { public var deleteTopic: @Sendable (_ id: Int) async throws -> Bool public var moveTopic: @Sendable (_ id: Int, _ toForumId: Int, _ saveLink: Bool) async throws -> Bool public var getTopicViewers: @Sendable (_ id: Int) async throws -> TopicViewers + public var setTopicCurator: @Sendable (_ topicId: Int, _ userId: Int, _ reason: String) async throws -> Bool 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 @@ -389,6 +390,16 @@ extension APIClient: DependencyKey { let response = try await api.send(command) return try await parser.parseTopicViewers(response) }, + setTopicCurator: { topicId, userId, reason in + let command = ForumCommand.Topic.setCurator( + topicId: topicId, + memberId: userId, + reason: reason + ) + let response = try await api.send(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, getTemplate: { request, isTopic in let command = ForumCommand.template( @@ -715,6 +726,9 @@ extension APIClient: DependencyKey { getTopicViewers: { _ in return .mock }, + setTopicCurator: { _, _, _ in + return true + }, getTemplate: { _, _ in return [.mockTitle, .mockRequiredText, .mockRequiredEditor, .mockEditor, .mockUploadBox] }, From a0f126cc5d354b3d3e6c0a407d4761c8abe6a21a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 8 Apr 2026 20:59:58 +0300 Subject: [PATCH 13/44] Improve auth check for post context menu --- Modules/Sources/SharedUI/Post/PostRowView.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/SharedUI/Post/PostRowView.swift b/Modules/Sources/SharedUI/Post/PostRowView.swift index f83585c4..12c0316e 100644 --- a/Modules/Sources/SharedUI/Post/PostRowView.swift +++ b/Modules/Sources/SharedUI/Post/PostRowView.swift @@ -119,7 +119,7 @@ public struct PostRowView: View { } } - if state.isContextMenuAvailable, state.isUserAuthorized, state.canPostInTopic { + if state.isContextMenuAvailable { ContextMenu() } } @@ -174,9 +174,11 @@ public struct PostRowView: View { private func ContextMenu() -> some View { Menu { - Section { - ContextButton(text: LocalizedStringResource("Reply", bundle: .module), symbol: .arrowTurnUpRight) { - menuAction(.reply(state.post.id, state.post.post.author.name)) + if state.canPostInTopic { + Section { + ContextButton(text: LocalizedStringResource("Reply", bundle: .module), symbol: .arrowTurnUpRight) { + menuAction(.reply(state.post.id, state.post.post.author.name)) + } } } @@ -192,8 +194,10 @@ public struct PostRowView: View { } } - ContextButton(text: LocalizedStringResource("Report", bundle: .module), symbol: .exclamationmarkTriangle) { - menuAction(.report(state.post.id)) + if state.isUserAuthorized { + ContextButton(text: LocalizedStringResource("Report", bundle: .module), symbol: .exclamationmarkTriangle) { + menuAction(.report(state.post.id)) + } } if state.post.post.canDelete { From ee9a210b701ff2364ccd32bee2785ca2de18f886 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 8 Apr 2026 21:13:25 +0300 Subject: [PATCH 14/44] [WIP] Topic & Post tools --- Modules/Sources/APIClient/APIClient.swift | 42 ++-------- .../APIClient/Models/ForumModifyType.swift | 47 +++++++++++ Modules/Sources/Models/Forum/Topic.swift | 4 + .../Models/Forum/TopicModifyAction.swift | 13 +++ Modules/Sources/Models/Post/Post.swift | 10 ++- .../Sources/Models/Post/PostMenuAction.swift | 2 +- .../Models/Post/PostModifyAction.swift | 13 +++ .../Sources/SharedUI/Post/PostRowView.swift | 51 +++++++++++- .../SharedUI/Resources/Localizable.xcstrings | 80 +++++++++++++++++++ .../Analytics/TopicFeature+Analytics.swift | 16 +++- .../Models/TopicToolsContextMenuAction.swift | 12 --- .../Sources/TopicFeature/TopicFeature.swift | 47 ++++++----- .../Sources/TopicFeature/TopicScreen.swift | 10 +-- 13 files changed, 268 insertions(+), 79 deletions(-) create mode 100644 Modules/Sources/APIClient/Models/ForumModifyType.swift create mode 100644 Modules/Sources/Models/Forum/TopicModifyAction.swift create mode 100644 Modules/Sources/Models/Post/PostModifyAction.swift delete mode 100644 Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 3b94d711..1cbaf3a4 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -62,9 +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 hideTopic: @Sendable (_ id: Int, _ isUndo: Bool) async throws -> Bool - public var closeTopic: @Sendable (_ id: Int, _ isUndo: Bool) async throws -> Bool - public var deleteTopic: @Sendable (_ id: Int) async throws -> Bool + public var modifyForum: @Sendable (_ ids: [Int], _ type: ForumModifyType, _ isUndo: Bool) async throws -> Bool public var moveTopic: @Sendable (_ id: Int, _ toForumId: Int, _ saveLink: Bool) async throws -> Bool public var getTopicViewers: @Sendable (_ id: Int) async throws -> TopicViewers public var setTopicCurator: @Sendable (_ topicId: Int, _ userId: Int, _ reason: String) async throws -> Bool @@ -76,7 +74,6 @@ public struct APIClient: Sendable { public var previewTemplate: @Sendable (_ id: Int, _ content: PDAPIDocument, _ isTopic: Bool) async throws -> PreviewResponse public var sendPost: @Sendable (_ request: PostRequest) async throws -> PostSendResponse public var editPost: @Sendable (_ request: PostEditRequest) async throws -> PostSendResponse - public var deletePosts: @Sendable (_ postIds: [Int]) async throws -> Bool public var postKarma: @Sendable (_ postId: Int, _ isUp: Bool) async throws -> Bool public var voteInTopicPoll: @Sendable (_ topicId: Int, _ selections: [[Int]]) async throws -> Bool @@ -355,23 +352,16 @@ extension APIClient: DependencyKey { let response = try await api.send(ForumCommand.Topic.view(data: request)) return try await parser.parseTopic(response) }, - hideTopic: { id, isUndo in - let command = ForumCommand.Topic.setHidden(id: id, isHidden: !isUndo) - let response = try await api.send(command) - let status = Int(response.getResponseStatus())! - return status == 0 - }, - closeTopic: { id, isUndo in - let command = ForumCommand.Topic.setClosed(id: id, isClosed: !isUndo) + modifyForum: { ids, type, isUndo in + let command = ForumCommand.modify( + ids: ids, + type: type.transfer, + isUndo: isUndo + ) let response = try await api.send(command) let status = Int(response.getResponseStatus())! return status == 0 }, - deleteTopic: { id in - let response = try await api.send(ForumCommand.Topic.delete(id: id)) - let status = Int(response.getResponseStatus())! - return status == 0 - }, moveTopic: { id, toForumId, saveLink in let command = ForumCommand.Topic.move( id: id, @@ -473,13 +463,6 @@ extension APIClient: DependencyKey { return try await parser.parsePostSendResponse(response) }, - deletePosts: { ids in - let command = ForumCommand.Post.delete(postIds: ids, isUndo: false) - let response = try await api.send(command) - let status = Int(response.getResponseStatus())! - return status == 0 - }, - postKarma: { id, isUp in let command = ForumCommand.Post.karma( postId: id, @@ -711,13 +694,7 @@ extension APIClient: DependencyKey { getTopic: { _, _, _, _ in return .mock }, - hideTopic: { _, _ in - return true - }, - closeTopic: { _, _ in - return true - }, - deleteTopic: { _ in + modifyForum: { _, _, _ in return true }, moveTopic: { _, _, _ in @@ -753,9 +730,6 @@ extension APIClient: DependencyKey { editPost: { _ in return .success(PostSend(id: 0, topicId: 1, offset: 2)) }, - deletePosts: { _ in - return true - }, postKarma: { _, _ in return true }, diff --git a/Modules/Sources/APIClient/Models/ForumModifyType.swift b/Modules/Sources/APIClient/Models/ForumModifyType.swift new file mode 100644 index 00000000..1ab9ade8 --- /dev/null +++ b/Modules/Sources/APIClient/Models/ForumModifyType.swift @@ -0,0 +1,47 @@ +// +// ForumModifyType.swift +// ForPDA +// +// Created by Xialtal on 8.04.26. +// + +import PDAPI +import Models + +public enum ForumModifyType: Sendable { + case post(PostModifyAction) + case topic(TopicModifyAction) +} + +extension ForumModifyType { + var transfer: ForumCommand.ModifyType { + switch self { + case .post(let action): + .post(action: action.transfer) + case .topic(let action): + .topic(action: action.transfer) + } + } +} + +fileprivate extension PostModifyAction { + var transfer: ForumCommand.ModifyPostAction { + switch self { + case .pin: .pin + case .hide: .hide + case .delete: .delete + case .protect: .protect + } + } +} + +fileprivate extension TopicModifyAction { + var transfer: ForumCommand.ModifyTopicAction { + switch self { + case .pin: .pin + case .hide: .hide + case .close: .close + case .delete: .delete + } + } +} diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index 4810e73f..bfdb8259 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -35,6 +35,10 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { return flag.contains(.canModerate) } + public var isPinned: Bool { + return flag.contains(.pinned) + } + public var isHidden: Bool { return flag.contains(.hidden) } diff --git a/Modules/Sources/Models/Forum/TopicModifyAction.swift b/Modules/Sources/Models/Forum/TopicModifyAction.swift new file mode 100644 index 00000000..a04d4f94 --- /dev/null +++ b/Modules/Sources/Models/Forum/TopicModifyAction.swift @@ -0,0 +1,13 @@ +// +// TopicModifyAction.swift +// ForPDA +// +// Created by Xialtal on 8.04.26. +// + +public enum TopicModifyAction: Sendable { + case pin + case hide + case close + case delete +} diff --git a/Modules/Sources/Models/Post/Post.swift b/Modules/Sources/Models/Post/Post.swift index bac6fe8b..f9a59f55 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -20,10 +20,18 @@ public struct Post: Sendable, Hashable, Identifiable, Codable { public let lastEdit: LastEdit? private let rawKarma: Int - public var isDeleted: Bool { + public var isPinned: Bool { + return flag.contains(.pinned) + } + + public var isHidden: Bool { return flag.contains(.hidden) } + public var isDeleted: Bool { + return flag.contains(.closed) + } + public var isProtected: Bool { return flag.contains(.canProtect) } diff --git a/Modules/Sources/Models/Post/PostMenuAction.swift b/Modules/Sources/Models/Post/PostMenuAction.swift index 1d663b30..b557e130 100644 --- a/Modules/Sources/Models/Post/PostMenuAction.swift +++ b/Modules/Sources/Models/Post/PostMenuAction.swift @@ -8,11 +8,11 @@ public enum PostMenuAction { case reply(Int, String) case edit(Post) - case delete(Int) case karma(Int) case report(Int) case changeReputation(Int, Int, String) case userPostsInTopic(Int) case mentions(Int) case copyLink(Int) + case tools(PostModifyAction, Int, Bool) } diff --git a/Modules/Sources/Models/Post/PostModifyAction.swift b/Modules/Sources/Models/Post/PostModifyAction.swift new file mode 100644 index 00000000..e021c31d --- /dev/null +++ b/Modules/Sources/Models/Post/PostModifyAction.swift @@ -0,0 +1,13 @@ +// +// PostModifyAction.swift +// ForPDA +// +// Created by Xialtal on 8.04.26. +// + +public enum PostModifyAction: Sendable { + case pin + case hide + case delete + case protect +} diff --git a/Modules/Sources/SharedUI/Post/PostRowView.swift b/Modules/Sources/SharedUI/Post/PostRowView.swift index 12c0316e..b5afe8d4 100644 --- a/Modules/Sources/SharedUI/Post/PostRowView.swift +++ b/Modules/Sources/SharedUI/Post/PostRowView.swift @@ -202,10 +202,14 @@ public struct PostRowView: View { if state.post.post.canDelete { ContextButton(text: LocalizedStringResource("Delete", bundle: .module), symbol: .trash) { - menuAction(.delete(state.post.id)) + menuAction(.tools(.delete, state.post.id, false)) } } + if state.post.post.canModerate { + ToolsContextMenu() + } + Section { if state.isUserAuthorized, state.post.post.author.id != state.sessionUserId { ContextButton(text: LocalizedStringResource("Reputation", bundle: .module), symbol: .plusminus) { @@ -241,6 +245,51 @@ public struct PostRowView: View { .onTapGesture {} // DO NOT DELETE, FIX FOR IOS 17 .frame(width: 8, height: 22) } + + // MARK: - Tools Context Menu + + @ViewBuilder + private func ToolsContextMenu() -> some View { + Menu { + if state.post.post.isDeleted { + ContextButton(text: LocalizedStringResource("Recover", bundle: .module), symbol: .trashSlash) { + menuAction(.tools(.delete, state.post.id, true)) + } + } + + ContextButton( + text: state.post.post.isHidden + ? LocalizedStringResource("Remove Hide", bundle: .module) + : LocalizedStringResource("Hide", bundle: .module), + symbol: state.post.post.isHidden ? .eyeSlashFill : .eyeSlash + ) { + menuAction(.tools(.hide, state.post.id, !state.post.post.isHidden)) + } + + ContextButton( + text: state.post.post.isPinned + ? LocalizedStringResource("Unpin", bundle: .module) + : LocalizedStringResource("Pin", bundle: .module), + symbol: state.post.post.isPinned ? .pinFill : .pin + ) { + menuAction(.tools(.pin, state.post.id, !state.post.post.isPinned)) + } + + ContextButton( + text: state.post.post.isProtected + ? LocalizedStringResource("Remove Protection", bundle: .module) + : LocalizedStringResource("Protect", bundle: .module), + symbol: state.post.post.isProtected ? .shieldFill : .shield + ) { + menuAction(.tools(.protect, state.post.id, !state.post.post.isProtected)) + } + } label: { + HStack { + Text("Tools", bundle: .module) + Image(systemSymbol: .shield) + } + } + } } // MARK: - User Posts In Topic Icon diff --git a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings index 2ea9754e..85bf50ce 100644 --- a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings +++ b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings @@ -77,6 +77,16 @@ } } }, + "Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть" + } + } + } + }, "IN DEVELOPMENT" : { "localizations" : { "ru" : { @@ -97,6 +107,16 @@ } } }, + "Pin" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепить пост" + } + } + } + }, "Post Mentions" : { "localizations" : { "ru" : { @@ -107,6 +127,16 @@ } } }, + "Protect" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Защитить" + } + } + } + }, "Quote" : { "localizations" : { "ru" : { @@ -137,6 +167,36 @@ } } }, + "Recover" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановить" + } + } + } + }, + "Remove Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать" + } + } + } + }, + "Remove Protection" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Снять защиту" + } + } + } + }, "Reply" : { "localizations" : { "ru" : { @@ -187,6 +247,16 @@ } } }, + "Tools" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструменты" + } + } + } + }, "Understood" : { "localizations" : { "ru" : { @@ -197,6 +267,16 @@ } } }, + "Unpin" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открепить пост" + } + } + } + }, "Yesterday, %@" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index 989314cb..24188b0a 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -66,8 +66,6 @@ extension TopicFeature { analytics.log(TopicEvent.menuPostEdit(post.id)) case .report(let postId): analytics.log(TopicEvent.menuPostReport(postId)) - case .delete(let postId): - analytics.log(TopicEvent.menuPostDelete(postId)) case .changeReputation(let postId, let userId, _): analytics.log(TopicEvent.menuChangeReputation(postId, userId)) case .userPostsInTopic(let userId): @@ -76,6 +74,16 @@ extension TopicFeature { analytics.log(TopicEvent.menuPostMentions(postId)) case .copyLink(let postId): analytics.log(TopicEvent.menuPostCopyLink(postId)) + case .tools(let action, let postId, let isUndo): + switch action { + case .delete: + if !isUndo { + analytics.log(TopicEvent.menuPostDelete(postId)) + } + default: + // MARK: Moderator tools are skip analytics + break + } } case let .view(.contextMenu(option)): @@ -96,8 +104,8 @@ extension TopicFeature { analytics.log(TopicEvent.menuWritePostWithTemplate) } - case .view(.contextToolsMenu(_)): - // TODO: At now moment, moderator tools are skip analytics + case .view(.contextToolsMenu(_, _)): + // MARK: Moderator tools are skip analytics break case let .view(.textQuoted(post, _)): diff --git a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift deleted file mode 100644 index 295ef6df..00000000 --- a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// TopicToolsContextMenuAction.swift -// ForPDA -// -// Created by Xialtal on 7.04.26. -// - -public enum TopicToolsContextMenuAction { - case hide - case close - case delete -} diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index fae17807..41c7ba4b 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -143,7 +143,7 @@ public struct TopicFeature: Reducer, Sendable { case imageTapped(URL) case textQuoted(UIPost, String) case contextMenu(TopicContextMenuAction) - case contextToolsMenu(TopicToolsContextMenuAction) + case contextToolsMenu(TopicModifyAction, Bool) case contextPostMenu(PostMenuAction) } @@ -225,7 +225,7 @@ public struct TopicFeature: Reducer, Sendable { case let .destination(.presented(.alert(.deletePost(id)))): return .run { send in - let status = try await apiClient.deletePosts(postIds: [id]) + let status = try await apiClient.modifyForum(ids: [id], type: .post(.delete), isUndo: false) let postDeletedToast = ToastMessage(text: Localization.postDeleted, haptic: .success) await toastClient.showToast(status ? postDeletedToast : .whoopsSomethingWentWrong) await send(.internal(.refresh)) @@ -234,7 +234,7 @@ public struct TopicFeature: Reducer, Sendable { case .destination(.presented(.alert(.deleteTopic))): return .run { [id = state.topicId] send in - let status = try await apiClient.deleteTopic(id: id) + let status = try await apiClient.modifyForum(ids: [id], type: .topic(.delete), isUndo: false) let text = status ? Localization.topicDeleted : Localization.topicDeleteError await toastClient.showToast(ToastMessage(text: text, isError: !status)) await send(.internal(.refresh)) @@ -356,23 +356,12 @@ public struct TopicFeature: Reducer, Sendable { return .send(.pageNavigation(.lastPageTapped)) } - case let .view(.contextToolsMenu(action)): + case let .view(.contextToolsMenu(action, isUndo)): guard let topic = state.topic else { return .none } switch action { - case .hide: + case .hide, .close: return .run { [id = state.topicId] send in - _ = try await apiClient.hideTopic(id: id, isUndo: !topic.isHidden) - - await send(.internal(.refresh)) - await toastClient.showToast(.actionCompleted) - } catch: { error, send in - analyticsClient.capture(error) - await toastClient.showToast(.whoopsSomethingWentWrong) - } - - case .close: - return .run { [id = state.topicId] send in - _ = try await apiClient.closeTopic(id: id, isUndo: !topic.isClosed) + _ = try await apiClient.modifyForum(ids: [id], type: .topic(action), isUndo: isUndo) await send(.internal(.refresh)) await toastClient.showToast(.actionCompleted) @@ -384,6 +373,9 @@ public struct TopicFeature: Reducer, Sendable { case .delete: state.destination = .alert(.deleteTopicConfirmation) return .none + + default: + return .none } case let .view(.contextPostMenu(action)): @@ -417,10 +409,6 @@ public struct TopicFeature: Reducer, Sendable { state.destination = .form(feature) return .none - case .delete(let id): - state.destination = .alert(.deletePostConfirmation(postId: id)) - return .none - case .karma(let id): state.destination = .karmaChange(id) return .none @@ -456,6 +444,23 @@ public struct TopicFeature: Reducer, Sendable { return .run { _ in await toastClient.showToast(ToastMessage(text: Localization.linkCopied, haptic: .success)) } + + case .tools(let action, let postId, let isUndo): + switch action { + case .pin, .hide, .protect: + return .run { [id = state.topicId] send in + _ = try await apiClient.modifyForum(ids: [id], type: .post(action), isUndo: isUndo) + + await send(.internal(.refresh)) + await toastClient.showToast(.actionCompleted) + } catch: { error, send in + analyticsClient.capture(error) + await toastClient.showToast(.whoopsSomethingWentWrong) + } + case .delete: + state.destination = .alert(.deletePostConfirmation(postId: postId)) + return .none + } } case .view(.changeKarmaTapped(let postId, let isUp)): diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 8c6c193d..63f85803 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -234,7 +234,7 @@ public struct TopicScreen: View { : LocalizedStringResource("Hide", bundle: .module), symbol: topic.isHidden ? .eyeSlashFill : .eyeSlash ) { - send(.contextToolsMenu(.hide)) + send(.contextToolsMenu(.hide, !topic.isHidden)) } ContextButton( @@ -243,12 +243,12 @@ public struct TopicScreen: View { : LocalizedStringResource("Close Topic", bundle: .module), symbol: topic.isClosed ? .lockFill : .lock ) { - send(.contextToolsMenu(.close)) + send(.contextToolsMenu(.close, !topic.isClosed)) } if topic.canDelete { ContextButton(text: LocalizedStringResource("Delete Topic", bundle: .module), symbol: .trash) { - send(.contextToolsMenu(.delete)) + send(.contextToolsMenu(.delete, false)) } } } label: { @@ -385,8 +385,6 @@ public struct TopicScreen: View { send(.contextPostMenu(.reply(id, authorName))) case .edit(let post): send(.contextPostMenu(.edit(post))) - case .delete(let postId): - send(.contextPostMenu(.delete(postId))) case .karma(let postId): send(.contextPostMenu(.karma(postId))) case .report(let postId): @@ -399,6 +397,8 @@ public struct TopicScreen: View { send(.contextPostMenu(.mentions(postId))) case .copyLink(let postId): send(.contextPostMenu(.copyLink(postId))) + case .tools(let action, let postId, let isUndo): + send(.contextPostMenu(.tools(action, postId, isUndo))) } } ) From 0dcf2a24cef230e066c26bfdfc8a47d9b79d3396 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 8 Apr 2026 21:27:43 +0300 Subject: [PATCH 15/44] Improve Post model flag mock --- Modules/Sources/Models/Post/Post.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Models/Post/Post.swift b/Modules/Sources/Models/Post/Post.swift index f9a59f55..e83ecf10 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -159,7 +159,7 @@ public extension Post { static func mock(id: Int = 0) -> Post { return Post( id: id, - flag: [.canDelete, .canEdit, .canModerate], + flag: [.closed, .hidden, .canDelete, .canEdit, .canModerate, .canProtect], content: "[snapback]123[/snapback], Lorem ipsum...\n[font=fontello]4[/font]", author: Author( id: 6176341, From b72bec994b6205c8be7e8d2e3757d4e1e064e828 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 8 Apr 2026 21:28:59 +0300 Subject: [PATCH 16/44] Add topic post restore context menu option --- .../Sources/SharedUI/Post/PostRowView.swift | 5 +++- .../SharedUI/Resources/Localizable.xcstrings | 20 +++++++------- .../Resources/Localizable.xcstrings | 20 ++++++++++++++ .../Sources/TopicFeature/TopicFeature.swift | 27 ++++++++++++------- 4 files changed, 52 insertions(+), 20 deletions(-) diff --git a/Modules/Sources/SharedUI/Post/PostRowView.swift b/Modules/Sources/SharedUI/Post/PostRowView.swift index b5afe8d4..1e97a5fe 100644 --- a/Modules/Sources/SharedUI/Post/PostRowView.swift +++ b/Modules/Sources/SharedUI/Post/PostRowView.swift @@ -252,7 +252,10 @@ public struct PostRowView: View { private func ToolsContextMenu() -> some View { Menu { if state.post.post.isDeleted { - ContextButton(text: LocalizedStringResource("Recover", bundle: .module), symbol: .trashSlash) { + ContextButton( + text: LocalizedStringResource("Restore", bundle: .module), + symbol: .arrowCounterclockwiseCircle + ) { menuAction(.tools(.delete, state.post.id, true)) } } diff --git a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings index 85bf50ce..f4d71ac1 100644 --- a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings +++ b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings @@ -167,16 +167,6 @@ } } }, - "Recover" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Восстановить" - } - } - } - }, "Remove Hide" : { "localizations" : { "ru" : { @@ -227,6 +217,16 @@ } } }, + "Restore" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановить" + } + } + } + }, "Search «%@» posts" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 3f7b4a64..9508e0bf 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -71,6 +71,16 @@ } } }, + "Are you sure, that you want to restore this post?" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите восстановить этот пост?" + } + } + } + }, "Close Topic" : { "localizations" : { "ru" : { @@ -251,6 +261,16 @@ } } }, + "Post restored" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пост восстановлен" + } + } + } + }, "Posts Filter" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 41c7ba4b..6001361a 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -39,6 +39,7 @@ public struct TopicFeature: Reducer, Sendable { static let topicDeleted = LocalizedStringResource("Topic deleted", bundle: .module) static let topicDeleteError = LocalizedStringResource("Unable to delete topic", bundle: .module) static let postDeleted = LocalizedStringResource("Post deleted", bundle: .module) + static let postRestored = LocalizedStringResource("Post restored", bundle: .module) static let postKarmaChanged = LocalizedStringResource("Post karma changed", bundle: .module) static let topicVoteApproved = LocalizedStringResource("Vote approved", bundle: .module) static let showingNearestPost = LocalizedStringResource("The post has been deleted, showing the nearest one", bundle: .module) @@ -59,7 +60,7 @@ public struct TopicFeature: Reducer, Sendable { @CasePathable public enum Alert: Equatable { - case deletePost(Int) + case deletePost(Int, Bool) case deleteTopic } } @@ -223,10 +224,13 @@ public struct TopicFeature: Reducer, Sendable { case let .destination(.presented(.stat(.delegate(.userTapped(id))))): return .send(.delegate(.openUser(id: id))) - case let .destination(.presented(.alert(.deletePost(id)))): + case let .destination(.presented(.alert(.deletePost(id, isUndo)))): return .run { send in - let status = try await apiClient.modifyForum(ids: [id], type: .post(.delete), isUndo: false) - let postDeletedToast = ToastMessage(text: Localization.postDeleted, haptic: .success) + let status = try await apiClient.modifyForum(ids: [id], type: .post(.delete), isUndo: isUndo) + let postDeletedToast = ToastMessage( + text: isUndo ? Localization.postRestored : Localization.postDeleted, + haptic: .success + ) await toastClient.showToast(status ? postDeletedToast : .whoopsSomethingWentWrong) await send(.internal(.refresh)) } @@ -357,7 +361,6 @@ public struct TopicFeature: Reducer, Sendable { } case let .view(.contextToolsMenu(action, isUndo)): - guard let topic = state.topic else { return .none } switch action { case .hide, .close: return .run { [id = state.topicId] send in @@ -458,7 +461,7 @@ public struct TopicFeature: Reducer, Sendable { await toastClient.showToast(.whoopsSomethingWentWrong) } case .delete: - state.destination = .alert(.deletePostConfirmation(postId: postId)) + state.destination = .alert(.deletePostConfirmation(postId: postId, isUndo: isUndo)) return .none } } @@ -750,11 +753,17 @@ extension TopicFeature.Destination.State: Equatable {} extension AlertState where Action == TopicFeature.Destination.Alert { - nonisolated static func deletePostConfirmation(postId: Int) -> AlertState { + nonisolated static func deletePostConfirmation(postId: Int, isUndo: Bool) -> AlertState { return AlertState( - title: { TextState("Are you sure, that you want to delete this post?", bundle: .module) }, + title: { + if isUndo { + TextState("Are you sure, that you want to restore this post?", bundle: .module) + } else { + TextState("Are you sure, that you want to delete this post?", bundle: .module) + } + }, actions: { - ButtonState(role: .destructive, action: .deletePost(postId)) { + ButtonState(role: .destructive, action: .deletePost(postId, isUndo)) { TextState("Yes", bundle: .module) } ButtonState(role: .cancel) { From 0ae07f35c448d2940ca29a244e0e5e8643ecb285 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 8 Apr 2026 22:09:44 +0300 Subject: [PATCH 17/44] Improve flag in TopicInfo mocks --- Modules/Sources/Models/Forum/TopicInfo.swift | 24 +++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/Models/Forum/TopicInfo.swift b/Modules/Sources/Models/Forum/TopicInfo.swift index 1dc47264..e9874020 100644 --- a/Modules/Sources/Models/Forum/TopicInfo.swift +++ b/Modules/Sources/Models/Forum/TopicInfo.swift @@ -28,9 +28,21 @@ public struct TopicInfo: Sendable, Hashable, Codable, Identifiable { return flag.contains(.pinned) } + public var isHidden: Bool { + return flag.contains(.hidden) + } + public var isFavorite: Bool { return flag.contains(.favorite) } + + public var canDelete: Bool { + return flag.contains(.canDelete) + } + + public var canModerate: Bool { + return flag.contains(.canModerate) + } public init(id: Int, name: String, description: String, flag: ForumFlag, postsCount: Int, lastPost: LastPost) { self.id = id @@ -73,7 +85,7 @@ public extension TopicInfo { id: 21, name: "Example of pinned topic", description: "", - flag: .pinned, + flag: [.pinned, .canEdit, .canPost, .canDelete, .canModerate], postsCount: 1, lastPost: TopicInfo.LastPost( date: Date(timeIntervalSince1970: 1768475013), @@ -86,7 +98,7 @@ public extension TopicInfo { id: Int.random(in: 1..<1000000), name: "Topic example. Topic example. Topic example. Topic example. Topic example. Topic example.", description: "", - flag: .canPost, + flag: [.canEdit, .canPost, .canDelete, .canModerate], postsCount: 10, lastPost: TopicInfo.LastPost( date: .now, @@ -99,7 +111,7 @@ public extension TopicInfo { id: Int.random(in: 1..<1000000), name: "Topic example", description: "", - flag: .canPost, + flag: [.canEdit, .canPost, .canDelete, .canModerate], postsCount: 10, lastPost: TopicInfo.LastPost( date: .now, @@ -112,7 +124,7 @@ public extension TopicInfo { id: Int.random(in: 1..<1000000), name: "Topic example", description: "", - flag: [.updated, .canPost], + flag: [.updated, .canEdit, .canPost, .canDelete, .canModerate], postsCount: 10, lastPost: TopicInfo.LastPost( date: .now, @@ -125,7 +137,7 @@ public extension TopicInfo { id: Int.random(in: 1..<1000000), name: "Topic example", description: "", - flag: .canPost, + flag: [.canEdit, .canPost, .canDelete, .canModerate], postsCount: 10, lastPost: TopicInfo.LastPost( date: .now.addingTimeInterval(-86400), @@ -138,7 +150,7 @@ public extension TopicInfo { id: Int.random(in: 1..<1000000), name: "Topic example", description: "", - flag: .canPost, + flag: [.canEdit, .canPost, .canDelete, .canModerate], postsCount: 10, lastPost: TopicInfo.LastPost( date: .now.addingTimeInterval(-86400 * 7), From 22e82214bce0efe3e27a19612d70c50687e4a132 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 8 Apr 2026 22:10:05 +0300 Subject: [PATCH 18/44] Fix ForumScreen preview --- Modules/Sources/APIClient/APIClient.swift | 4 +++- Modules/Sources/ForumFeature/ForumScreen.swift | 4 ---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 1cbaf3a4..529a5a9c 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -677,7 +677,9 @@ extension APIClient: DependencyKey { return .finished() }, getForum: { _, _, _, _ in - return .finished() + let (stream, continuation) = AsyncThrowingStream.makeStream(of: Forum.self) + continuation.yield(with: .success(.mock)) + return stream }, getForumStat: { _ in return .mock diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index 47cd3c65..883b1ce9 100644 --- a/Modules/Sources/ForumFeature/ForumScreen.swift +++ b/Modules/Sources/ForumFeature/ForumScreen.swift @@ -409,10 +409,6 @@ extension Forum { ) ) { ForumFeature() - } withDependencies: { - $0.apiClient.getForum = { @Sendable _, _, _, _ in - return .finished() - } } ) } From c17d6df65dd2d1f01e16a53ba1397f0a10dab205 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 8 Apr 2026 22:12:57 +0300 Subject: [PATCH 19/44] Add tools action to topic context menu in ForumFeature --- .../Analytics/ForumFeature+Analytics.swift | 4 ++ .../Sources/ForumFeature/ForumFeature.swift | 15 ++++ .../Sources/ForumFeature/ForumScreen.swift | 49 +++++++++++++ .../Resources/Localizable.xcstrings | 70 +++++++++++++++++++ 4 files changed, 138 insertions(+) diff --git a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index e0ca4d3a..2dbec935 100644 --- a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift +++ b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift @@ -56,6 +56,10 @@ extension ForumFeature { break // TODO: Add } + case .view(.contextTopicToolsMenu(_, _, _)): + // MARK: Moderator tools are skip analytics + break + case let .view(.contextTopicMenu(option, topic)): switch option { case .open: diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index fa6579af..40355835 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -120,6 +120,7 @@ public struct ForumFeature: Reducer, Sendable { case contextOptionMenu(ForumOptionContextMenuAction) case contextTopicMenu(ForumTopicContextMenuAction, TopicInfo) + case contextTopicToolsMenu(TopicModifyAction, Int, Bool) case contextCommonMenu(ForumCommonContextMenuAction, Int, Bool) } @@ -244,6 +245,20 @@ public struct ForumFeature: Reducer, Sendable { ) } + case let .view(.contextTopicToolsMenu(action, topicId, isUndo)): + return .run { send in + let status = try await apiClient.modifyForum( + ids: [topicId], + type: .topic(action), + isUndo: isUndo + ) + await send(.internal(.refresh)) + await toastClient.showToast(status ? .actionCompleted : .whoopsSomethingWentWrong) + } catch: { error, send in + analyticsClient.capture(error) + await toastClient.showToast(.whoopsSomethingWentWrong) + } + case .view(.contextCommonMenu(let action, let id, let isForum)): switch action { case .copyLink: diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index 883b1ce9..045d171a 100644 --- a/Modules/Sources/ForumFeature/ForumScreen.swift +++ b/Modules/Sources/ForumFeature/ForumScreen.swift @@ -212,6 +212,10 @@ public struct ForumScreen: View { Section { CommonContextMenu(id: topic.id, isFavorite: topic.isFavorite, isUnread: topic.isUnread, isForum: false) + + if topic.canModerate { + TopicToolsContextMenu(topic: topic) + } } } .listRowBackground( @@ -254,6 +258,51 @@ public struct ForumScreen: View { } } + // MARK: - Topic Tools Context Menu + + @ViewBuilder + private func TopicToolsContextMenu(topic: TopicInfo) -> some View { + Menu { + ContextButton( + text: topic.isPinned + ? LocalizedStringResource("Unpin", bundle: .module) + : LocalizedStringResource("Pin", bundle: .module), + symbol: topic.isPinned ? .pinFill : .pin + ) { + send(.contextTopicToolsMenu(.pin, topic.id, !topic.isPinned)) + } + + ContextButton( + text: topic.isHidden + ? LocalizedStringResource("Remove Hide", bundle: .module) + : LocalizedStringResource("Hide", bundle: .module), + symbol: topic.isHidden ? .eyeSlashFill : .eyeSlash + ) { + send(.contextTopicToolsMenu(.hide, topic.id, !topic.isHidden)) + } + + ContextButton( + text: topic.isClosed + ? LocalizedStringResource("Open", bundle: .module) + : LocalizedStringResource("Close", bundle: .module), + symbol: topic.isClosed ? .lockFill : .lock + ) { + send(.contextTopicToolsMenu(.close, topic.id, !topic.isClosed)) + } + + if topic.canDelete { + ContextButton(text: LocalizedStringResource("Delete", bundle: .module), symbol: .trash) { + send(.contextTopicToolsMenu(.delete, topic.id, false)) + } + } + } label: { + HStack { + Text("Tools", bundle: .module) + Image(systemSymbol: .shield) + } + } + } + // MARK: - Navigation @ViewBuilder diff --git a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings index 650e7440..3b229049 100644 --- a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings @@ -31,6 +31,16 @@ } } }, + "Close" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть тему" + } + } + } + }, "Copy Link" : { "localizations" : { "ru" : { @@ -51,6 +61,16 @@ } } }, + "Delete" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить тему" + } + } + } + }, "Go To End" : { "localizations" : { "ru" : { @@ -61,6 +81,16 @@ } } }, + "Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть тему" + } + } + } + }, "Link copied" : { "localizations" : { "ru" : { @@ -111,6 +141,16 @@ } } }, + "Pin" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепить тему" + } + } + } + }, "Pinned topics" : { "localizations" : { "ru" : { @@ -131,6 +171,16 @@ } } }, + "Remove Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать тему" + } + } + } + }, "Subforums" : { "localizations" : { "ru" : { @@ -141,6 +191,16 @@ } } }, + "Tools" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструменты" + } + } + } + }, "Topics" : { "localizations" : { "ru" : { @@ -150,6 +210,16 @@ } } } + }, + "Unpin" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открепить тему" + } + } + } } }, "version" : "1.0" From 63c2c4379a85c671e124cb19009cec05f186fcd6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 8 Apr 2026 22:22:09 +0300 Subject: [PATCH 20/44] Bump API version --- Tuist/Package.resolved | 6 +++--- Tuist/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index bd4ed5ac..b7fa6d1e 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e6d5a9007a3b62d455ccc82ac03ba92905cf6642417339c37fafd1de6e2d8d8d", + "originHash" : "cc40dc30d3430b0dade2377537d90920dfb2a9298dfc61bec72b010262226379", "pins" : [ { "identity" : "activityindicatorview", @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SubvertDev/PDAPI_SPM.git", "state" : { - "revision" : "a5c09f445c880c45921257a2bbfa0a03d17775f5", - "version" : "0.7.3" + "revision" : "c267b86000b49eaf7620ab05c8e42420fda2720c", + "version" : "0.7.4" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index e732086b..9e077420 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -92,7 +92,7 @@ let package = Package( // Forks & stuff .package(url: "https://github.com/SubvertDev/AlertToast.git", revision: "d0f7d6b"), .package(url: "https://github.com/SubvertDev/Chat", branch: "main"), - .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.7.3"), + .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.7.4"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), ] ) From 6466cab1239f730ec6b7f94f4745d7565976f9fd Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 9 Apr 2026 21:35:42 +0300 Subject: [PATCH 21/44] Add redAlpha color to SharedUI --- .../Main/redAlpha.colorset/Contents.json | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/redAlpha.colorset/Contents.json diff --git a/Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/redAlpha.colorset/Contents.json b/Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/redAlpha.colorset/Contents.json new file mode 100644 index 00000000..7e9f3d37 --- /dev/null +++ b/Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/redAlpha.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.080", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xD1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.080", + "blue" : "0x00", + "green" : "0x00", + "red" : "0xD1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} From 0832bd06630dabd7989289c73292b82e21d2304f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 9 Apr 2026 21:36:12 +0300 Subject: [PATCH 22/44] Improve ForumFlag model --- Modules/Sources/Models/Common/ForumFlag.swift | 18 +++++++++--------- Modules/Sources/Models/Forum/Topic.swift | 2 +- Modules/Sources/Models/Post/Post.swift | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Modules/Sources/Models/Common/ForumFlag.swift b/Modules/Sources/Models/Common/ForumFlag.swift index a9b5698a..945c8455 100644 --- a/Modules/Sources/Models/Common/ForumFlag.swift +++ b/Modules/Sources/Models/Common/ForumFlag.swift @@ -12,16 +12,16 @@ public struct ForumFlag: OptionSet, Sendable, Hashable, Codable { self.rawValue = rawValue } - public static let pinned = ForumFlag(rawValue: 1) - public static let hidden = ForumFlag(rawValue: 2) - public static let closed = ForumFlag(rawValue: 4) - public static let favorite = ForumFlag(rawValue: 8) - public static let marker = ForumFlag(rawValue: 16) - public static let updated = ForumFlag(rawValue: 32) + public static let pinned = ForumFlag(rawValue: 1) + public static let hidden = ForumFlag(rawValue: 2) + public static let closed = ForumFlag(rawValue: 4) + public static let favorite = ForumFlag(rawValue: 8) + public static let marker = ForumFlag(rawValue: 16) + public static let updated = ForumFlag(rawValue: 32) + public static let protected = ForumFlag(rawValue: 2048) - public static let canPost = ForumFlag(rawValue: 64) - public static let canEdit = ForumFlag(rawValue: 128) + public static let canPost = ForumFlag(rawValue: 64) + public static let canEdit = ForumFlag(rawValue: 128) public static let canDelete = ForumFlag(rawValue: 256) public static let canModerate = ForumFlag(rawValue: 512) - public static let canProtect = ForumFlag(rawValue: 2048) } diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index bfdb8259..702d5ab8 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -129,7 +129,7 @@ public extension Topic { id: 3242552, name: "ForPDA", description: "Unofficial 4PDA client for iOS.", - flag: [.canEdit, .canPost, .canDelete, .canModerate, .canProtect], + flag: [.canEdit, .canPost, .canDelete, .canModerate], createdAt: Date(timeIntervalSince1970: 1725706883), authorId: 3640948, authorName: "4spander", diff --git a/Modules/Sources/Models/Post/Post.swift b/Modules/Sources/Models/Post/Post.swift index e83ecf10..bba35b3e 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -33,7 +33,7 @@ public struct Post: Sendable, Hashable, Identifiable, Codable { } public var isProtected: Bool { - return flag.contains(.canProtect) + return flag.contains(.protected) } public var canModerate: Bool { @@ -159,7 +159,7 @@ public extension Post { static func mock(id: Int = 0) -> Post { return Post( id: id, - flag: [.closed, .hidden, .canDelete, .canEdit, .canModerate, .canProtect], + flag: [.closed, .hidden, .protected, .canDelete, .canEdit, .canModerate], content: "[snapback]123[/snapback], Lorem ipsum...\n[font=fontello]4[/font]", author: Author( id: 6176341, From 591c30036452d668260a419c2eaf3b71cff1d994 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 10 Apr 2026 17:57:26 +0300 Subject: [PATCH 23/44] Add post status to PostRowView --- .../Sources/SharedUI/Post/PostRowView.swift | 22 +++++++++++++++++++ .../SharedUI/Resources/Localizable.xcstrings | 20 +++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/Modules/Sources/SharedUI/Post/PostRowView.swift b/Modules/Sources/SharedUI/Post/PostRowView.swift index 1e97a5fe..2823816c 100644 --- a/Modules/Sources/SharedUI/Post/PostRowView.swift +++ b/Modules/Sources/SharedUI/Post/PostRowView.swift @@ -46,6 +46,7 @@ public struct PostRowView: View { if let lastEdit = state.post.post.lastEdit { Footer(lastEdit) } + PostStatus() } } @@ -148,6 +149,27 @@ public struct PostRowView: View { } } + // MARK: - Post Status + + @ViewBuilder + private func PostStatus() -> some View { + if state.post.post.isDeleted { + PostStatusLabel(icon: .trash, text: "This post deleted") + } else if state.post.post.isHidden { + PostStatusLabel(icon: .eyeSlash, text: "This post hidden") + } + } + + private func PostStatusLabel(icon: SFSymbol, text: LocalizedStringKey) -> some View { + HStack(spacing: 6) { + Image(systemSymbol: icon) + Text(text, bundle: .module) + } + .font(.caption2) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + // MARK: - Footer @ViewBuilder diff --git a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings index f4d71ac1..649ef742 100644 --- a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings +++ b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings @@ -237,6 +237,26 @@ } } }, + "This post deleted" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот пост удалён" + } + } + } + }, + "This post hidden" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот пост скрыт" + } + } + } + }, "Today, %@" : { "localizations" : { "ru" : { From 9d222c1d2bdb5a2534a9beb5bd442057600fd1b6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 10 Apr 2026 22:26:36 +0300 Subject: [PATCH 24/44] Add post karma history endpoint --- Modules/Sources/APIClient/APIClient.swift | 9 +++ .../Sources/Models/Post/PostKarmaVote.swift | 66 +++++++++++++++++++ .../ParsingClient/Parsers/TopicParser.swift | 31 +++++++++ .../Sources/ParsingClient/ParsingClient.swift | 4 ++ 4 files changed, 110 insertions(+) create mode 100644 Modules/Sources/Models/Post/PostKarmaVote.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 529a5a9c..12c43060 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -75,6 +75,7 @@ public struct APIClient: Sendable { public var sendPost: @Sendable (_ request: PostRequest) async throws -> PostSendResponse public var editPost: @Sendable (_ request: PostEditRequest) async throws -> PostSendResponse public var postKarma: @Sendable (_ postId: Int, _ isUp: Bool) async throws -> Bool + public var postKarmaHistory: @Sendable (_ postId: Int) async throws -> [PostKarmaVote] public var voteInTopicPoll: @Sendable (_ topicId: Int, _ selections: [[Int]]) async throws -> Bool // Favorites @@ -472,6 +473,11 @@ extension APIClient: DependencyKey { let status = Int(response.getResponseStatus())! return status == 0 }, + postKarmaHistory: { postId in + let command = ForumCommand.Post.history(id: postId) + let response = try await api.send(command) + return try await parser.parsePostKarmaHistory(response) + }, voteInTopicPoll: { topicId, selections in let command = ForumCommand.Topic.Poll.vote(topicId: topicId, selections: selections) @@ -735,6 +741,9 @@ extension APIClient: DependencyKey { postKarma: { _, _ in return true }, + postKarmaHistory: { _ in + return .mock + }, voteInTopicPoll: { _, _ in return true }, diff --git a/Modules/Sources/Models/Post/PostKarmaVote.swift b/Modules/Sources/Models/Post/PostKarmaVote.swift new file mode 100644 index 00000000..6caa85e5 --- /dev/null +++ b/Modules/Sources/Models/Post/PostKarmaVote.swift @@ -0,0 +1,66 @@ +// +// PostKarmaVote.swift +// ForPDA +// +// Created by Xialtal on 10.04.26. +// + +import Foundation + +public struct PostKarmaVote: Sendable, Identifiable { + public let userId: Int + public let nickname: String + public let voteDate: Date + public let vote: Int + + public var id: Int { + return userId + } + + public init( + userId: Int, + nickname: String, + voteDate: Date, + vote: Int + ) { + self.userId = userId + self.nickname = nickname + self.voteDate = voteDate + self.vote = vote + } +} + +public extension Array where Array == Array { + static let mock: [PostKarmaVote] = [ + .init( + userId: 6176341, + nickname: "AirFlare", + voteDate: Date.now, + vote: 1 + ), + .init( + userId: 3640948, + nickname: "subvertd", + voteDate: Date.now, + vote: 1 + ), + .init( + userId: 16072016, + nickname: "Abracadabra", + voteDate: Date(timeIntervalSince1970: 1703656574), + vote: -1 + ), + .init( + userId: 15072016, + nickname: "Lia", + voteDate: Date(timeIntervalSince1970: 1503656574), + vote: -1 + ), + .init( + userId: 14072016, + nickname: "FocusPokus", + voteDate: Date(timeIntervalSince1970: 1603656574), + vote: +2 + ), + ] +} diff --git a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift index f509a21a..3f5a4cdd 100644 --- a/Modules/Sources/ParsingClient/Parsers/TopicParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TopicParser.swift @@ -57,6 +57,37 @@ public struct TopicParser { ) } + // MARK: - Post Karma History + + public static func parsePostKarmaHistory(from string: String) throws (ParsingError) -> [PostKarmaVote] { + 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 votesRaw = array[safe: 2] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return try! votesRaw.map { vote in + guard let timestamp = vote[safe: 0] as? Int, + let userId = vote[safe: 1] as? Int, + let nickname = vote[safe: 2] as? String, + let vote = vote[safe: 3] as? Int else { + throw ParsingError.failedToCastFields + } + return PostKarmaVote( + userId: userId, + nickname: nickname, + voteDate: Date(timeIntervalSince1970: TimeInterval(timestamp)), + vote: vote + ) + } + } + // MARK: - Viewers public static func parseTopicViewers(from string: String) throws (ParsingError) -> TopicViewers { diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index b1c1bfe9..7a9272a8 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -43,6 +43,7 @@ public struct ParsingClient: Sendable { public var parseMentions: @Sendable (_ response: String) async throws -> Mentions public var parsePostPreview: @Sendable (_ response: String) async throws -> PreviewResponse public var parsePostSendResponse: @Sendable (_ response: String) async throws -> PostSendResponse + public var parsePostKarmaHistory: @Sendable (_ response: String) async throws -> [PostKarmaVote] public var parseTemplatePreview: @Sendable (_ response: String) async throws -> PreviewResponse public var parseTemplateSend: @Sendable (_ response: String) async throws -> TemplateSend @@ -135,6 +136,9 @@ extension ParsingClient: DependencyKey { parsePostSendResponse: { response in return try TopicParser.parsePostSendResponse(from: response) }, + parsePostKarmaHistory: { response in + return try TopicParser.parsePostKarmaHistory(from: response) + }, parseTemplatePreview: { response in return try FormParser.parseTemplatePreview(from: response) }, From 65f26d3aa06e4ac4d824cbb43f07172f3586f90a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 11 Apr 2026 17:01:31 +0300 Subject: [PATCH 25/44] Improve karma value for Post model mock --- Modules/Sources/Models/Post/Post.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Models/Post/Post.swift b/Modules/Sources/Models/Post/Post.swift index bba35b3e..36f01a7f 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -170,7 +170,7 @@ public extension Post { signature: "", reputationCount: 312 ), - karma: 1, + karma: 15, attachments: [ Attachment( id: 14308454, From 54e6ade16435235ff095ab2e1c666badad6029f1 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 11 Apr 2026 17:25:02 +0300 Subject: [PATCH 26/44] Fix karma field in Post model --- Modules/Sources/Models/Post/Post.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/Models/Post/Post.swift b/Modules/Sources/Models/Post/Post.swift index 36f01a7f..7c140c70 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -48,8 +48,17 @@ public struct Post: Sendable, Hashable, Identifiable, Codable { return flag.contains(.canDelete) } - public var karma: Int { - return rawKarma >> 3 + public var karma: Int? { + if rawKarma & 1 > 0 { + let karma = rawKarma >> 3 + if karma != 0 { + return karma + } + if rawKarma & 2 > 0 && karma == 0 { + return 0 + } + } + return nil } public var canChangeKarma: Bool { From 60b67130d522b30b137f8482289e61816027bc2f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 11 Apr 2026 17:26:40 +0300 Subject: [PATCH 27/44] Add post karma history --- .../Sources/Models/Post/PostKarmaVote.swift | 2 +- .../Sources/SharedUI/Post/PostRowView.swift | 16 +- .../Analytics/TopicFeature+Analytics.swift | 1 + .../PostKarmaHistoryFeature.swift | 98 ++++++++++++ .../PostKarmaHistoryView.swift | 146 ++++++++++++++++++ .../Resources/Localizable.xcstrings | 10 ++ .../Sources/TopicFeature/TopicFeature.swift | 6 + .../Sources/TopicFeature/TopicScreen.swift | 9 +- 8 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryFeature.swift create mode 100644 Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryView.swift diff --git a/Modules/Sources/Models/Post/PostKarmaVote.swift b/Modules/Sources/Models/Post/PostKarmaVote.swift index 6caa85e5..6b9a8dd5 100644 --- a/Modules/Sources/Models/Post/PostKarmaVote.swift +++ b/Modules/Sources/Models/Post/PostKarmaVote.swift @@ -7,7 +7,7 @@ import Foundation -public struct PostKarmaVote: Sendable, Identifiable { +public struct PostKarmaVote: Sendable, Identifiable, Equatable { public let userId: Int public let nickname: String public let voteDate: Date diff --git a/Modules/Sources/SharedUI/Post/PostRowView.swift b/Modules/Sources/SharedUI/Post/PostRowView.swift index 2823816c..ed2645c8 100644 --- a/Modules/Sources/SharedUI/Post/PostRowView.swift +++ b/Modules/Sources/SharedUI/Post/PostRowView.swift @@ -19,6 +19,7 @@ public struct PostRowView: View { case urlTapped(URL) case imageTapped(URL) case textQuoted(String) + case karmaHistoryTapped } // MARK: - Properties @@ -93,10 +94,17 @@ public struct PostRowView: View { Spacer() - if state.post.post.karma != 0 { - Text(String(state.post.post.karma)) - .font(.caption) - .foregroundStyle(Color(.Labels.primary)) + if let karma = state.post.post.karma { + Button { + action(.karmaHistoryTapped) + } label: { + Text(verbatim: String(karma)) + .font(.caption) + .foregroundStyle(Color(.Labels.primary)) + } + .buttonStyle(.plain) + .allowsHitTesting(state.post.post.canModerate) + .frame(width: 8, height: 22) } } diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index 24188b0a..a1484356 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -23,6 +23,7 @@ extension TopicFeature { .view(.onNextAppear), .view(.finishedPostAnimation), .view(.changeKarmaTapped), + .view(.karmaHistoryTapped), .view(.topicPollVoteButtonTapped), .view(.searchButtonTapped), .internal(.loadTypes), diff --git a/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryFeature.swift b/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryFeature.swift new file mode 100644 index 00000000..5101a1b7 --- /dev/null +++ b/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryFeature.swift @@ -0,0 +1,98 @@ +// +// PostKarmaHistoryFeature.swift +// ForPDA +// +// Created by Xialtal on 10.04.26. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct PostKarmaHistoryFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public let postId: Int + + var history: [PostKarmaVote] = [] + var isLoading = false + + public init(postId: Int) { + self.postId = postId + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + + case cancelButtonTapped + case userButtonTapped(Int) + } + + case `internal`(Internal) + public enum Internal { + case loadKarmaHistory + case karmaHistoryResponse(Result<[PostKarmaVote], any Error>) + } + + case delegate(Delegate) + public enum Delegate { + case openUser(Int) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.dismiss) var dismiss + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .send(.internal(.loadKarmaHistory)) + + case .view(.cancelButtonTapped): + return .run { _ in await dismiss() } + + case let .view(.userButtonTapped(id)): + return .send(.delegate(.openUser(id))) + + case .internal(.loadKarmaHistory): + state.isLoading = false + return .run { [postId = state.postId] send in + let response = try await apiClient.postKarmaHistory(postId: postId) + await send(.internal(.karmaHistoryResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.karmaHistoryResponse(.failure(error)))) + } + + case let .internal(.karmaHistoryResponse(.success(response))): + state.history = response + state.isLoading = false + return .none + + case let .internal(.karmaHistoryResponse(.failure(error))): + print(error) + state.isLoading = false + return .run { _ in await dismiss() } + + case .delegate: + return .none + } + } + } +} diff --git a/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryView.swift b/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryView.swift new file mode 100644 index 00000000..4a00d3aa --- /dev/null +++ b/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryView.swift @@ -0,0 +1,146 @@ +// +// PostKarmaHistoryView.swift +// ForPDA +// +// Created by Xialtal on 10.04.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI +import SFSafeSymbols + +@ViewAction(for: PostKarmaHistoryFeature.self) +public struct PostKarmaHistoryView: 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 { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + List(store.history, id: \.id) { vote in + VoteRow(vote) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + ._toolbarTitleDisplayMode(.inline) + .navigationTitle(Text("Karma History", bundle: .module)) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + send(.cancelButtonTapped) + } label: { + if isLiquidGlass { + Image(systemSymbol: .xmark) + } else { + Image(systemSymbol: .xmark) + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.teritary)) + .frame(width: 30, height: 30) + .background( + Circle() + .fill(Color(.Background.quaternary)) + .clipShape(Circle()) + ) + } + } + } + } + .overlay { + if store.isLoading { + PDALoader() + .frame(width: 24, height: 24) + } + } + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - Vote Row + + private func VoteRow(_ vote: PostKarmaVote) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + send(.userButtonTapped(vote.userId)) + } label: { + HStack(spacing: 6) { + Text(verbatim: vote.nickname) + .foregroundStyle(Color(.Labels.primary)) + + Image(systemSymbol: .chevronRight) + .foregroundStyle(Color(.Labels.quaternary)) + } + .font(.callout) + .fontWeight(.semibold) + } + .buttonStyle(.plain) + + Spacer() + + HStack(spacing: 4) { + let color = Color(vote.vote > 0 ? .Main.green : .Main.red) + let voteText = vote.vote > 0 ? "+\(vote.vote)" : "\(vote.vote)" + Text(verbatim: voteText) + .foregroundStyle(color) + + Image(systemSymbol: vote.arrowSymbol) + .foregroundStyle(color) + } + .font(.callout) + .fontWeight(.semibold) + } + + Text(vote.voteDate.formattedDate(), bundle: .module) + .font(.caption) + .foregroundStyle(Color(.Labels.quaternary)) + } + } +} + +// MARK: - Extensions + +fileprivate extension PostKarmaVote { + var arrowSymbol: SFSymbol { + if #available(iOS 17.0, *) { + return vote > 0 ? .arrowshapeUpFill : .arrowshapeDownFill + } else { + return vote > 0 ? .arrowUp : .arrowDown + } + } +} + +// MARK: - Previews + +#Preview { + NavigationStack { + PostKarmaHistoryView( + store: Store( + initialState: PostKarmaHistoryFeature.State( + postId: 1 + ) + ) { + PostKarmaHistoryFeature() + } + ) + } +} diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 9508e0bf..2e74e809 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -151,6 +151,16 @@ } } }, + "Karma History" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История кармы" + } + } + } + }, "Link copied" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 6001361a..e1106e3f 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -53,6 +53,7 @@ public struct TopicFeature: Reducer, Sendable { case gallery([URL], [Int], Int) @ReducerCaseIgnored case karmaChange(Int) + case karmaHistory(PostKarmaHistoryFeature) case form(FormFeature) case stat(ForumStatFeature) case changeReputation(ReputationChangeFeature) @@ -142,6 +143,7 @@ public struct TopicFeature: Reducer, Sendable { case userTapped(Int) case urlTapped(URL) case imageTapped(URL) + case karmaHistoryTapped(Int) case textQuoted(UIPost, String) case contextMenu(TopicContextMenuAction) case contextToolsMenu(TopicModifyAction, Bool) @@ -488,6 +490,10 @@ public struct TopicFeature: Reducer, Sendable { } return .none + case let .view(.karmaHistoryTapped(postId)): + state.destination = .karmaHistory(PostKarmaHistoryFeature.State(postId: postId)) + return .none + case let .view(.textQuoted(post, quotedText)): guard state.topic != nil else { return .none } diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 63f85803..f70c8f21 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -377,6 +377,8 @@ public struct TopicScreen: View { send(.imageTapped(url)) case .textQuoted(let text): send(.textQuoted(post, text)) + case .karmaHistoryTapped: + send(.karmaHistoryTapped(post.id)) } }, menuAction: { action in @@ -509,6 +511,11 @@ struct NavigationModifier: ViewModifier { ) { store in ReputationChangeView(store: store) } + .sheet(item: $store.scope(state: \.destination?.karmaHistory, action: \.destination.karmaHistory)) { store in + NavigationStack { + PostKarmaHistoryView(store: store) + } + } .sheet(item: $store.scope(state: \.destination?.stat, action: \.destination.stat)) { store in NavigationStack { ForumStatView(store: store) @@ -528,7 +535,7 @@ extension View { // MARK: - Extensions // TODO: Move to extensions? -private extension Date { +extension Date { func formattedDate() -> LocalizedStringKey { let formatter = DateFormatter() formatter.dateFormat = "HH:mm" From b16aeabc86c8fb74b6efececb317f130a1673813 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 11 Apr 2026 17:38:06 +0300 Subject: [PATCH 28/44] Fix user profile not opening from post karma history --- Modules/Sources/TopicFeature/TopicFeature.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index e1106e3f..0af765ca 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -226,6 +226,9 @@ public struct TopicFeature: Reducer, Sendable { case let .destination(.presented(.stat(.delegate(.userTapped(id))))): return .send(.delegate(.openUser(id: id))) + case let .destination(.presented(.karmaHistory(.delegate(.openUser(id))))): + return .send(.delegate(.openUser(id: id))) + case let .destination(.presented(.alert(.deletePost(id, isUndo)))): return .run { send in let status = try await apiClient.modifyForum(ids: [id], type: .post(.delete), isUndo: isUndo) From 9b47bdae1a331821dae67120ea8dfae171444839 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 11 Apr 2026 18:11:15 +0300 Subject: [PATCH 29/44] Add "last edit hidden" tag support for posts --- Modules/Sources/Models/Post/Post.swift | 6 +++- .../Sources/SharedUI/Post/PostRowView.swift | 31 ++++++++++++++----- .../SharedUI/Resources/Localizable.xcstrings | 10 ++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/Models/Post/Post.swift b/Modules/Sources/Models/Post/Post.swift index 7c140c70..ace81665 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -36,6 +36,10 @@ public struct Post: Sendable, Hashable, Identifiable, Codable { return flag.contains(.protected) } + public var isLastEditHidden: Bool { + return flag.contains(.marker) + } + public var canModerate: Bool { return flag.contains(.canModerate) } @@ -168,7 +172,7 @@ public extension Post { static func mock(id: Int = 0) -> Post { return Post( id: id, - flag: [.closed, .hidden, .protected, .canDelete, .canEdit, .canModerate], + flag: [.closed, .hidden, .marker, .protected, .canDelete, .canEdit, .canModerate], content: "[snapback]123[/snapback], Lorem ipsum...\n[font=fontello]4[/font]", author: Author( id: 6176341, diff --git a/Modules/Sources/SharedUI/Post/PostRowView.swift b/Modules/Sources/SharedUI/Post/PostRowView.swift index ed2645c8..737ef28b 100644 --- a/Modules/Sources/SharedUI/Post/PostRowView.swift +++ b/Modules/Sources/SharedUI/Post/PostRowView.swift @@ -183,13 +183,18 @@ public struct PostRowView: View { @ViewBuilder private func Footer(_ lastEdit: Post.LastEdit) -> some View { VStack(alignment: .leading, spacing: 6) { - let link = "https://4pda.to/forum/index.php?showuser=\(lastEdit.userId)" - Text(LocalizedStringResource("Edited: [\(lastEdit.username)](\(link)) • \(lastEdit.date.formatted())", bundle: .module)) - .environment(\.openURL, OpenURLAction(handler: { url in - action(.urlTapped(url)) - return .handled - })) - + HStack(spacing: 6) { + let link = "https://4pda.to/forum/index.php?showuser=\(lastEdit.userId)" + Text(LocalizedStringResource("Edited: [\(lastEdit.username)](\(link)) • \(lastEdit.date.formatted())", bundle: .module)) + .environment(\.openURL, OpenURLAction(handler: { url in + action(.urlTapped(url)) + return .handled + })) + + if state.post.post.isLastEditHidden { + LastEditHiddenTag() + } + } if !lastEdit.reason.isEmpty { Text("Reason: \(lastEdit.reason)", bundle: .module) } @@ -200,6 +205,18 @@ public struct PostRowView: View { .frame(maxWidth: .infinity, alignment: .leading) } + private func LastEditHiddenTag() -> some View { + Text("Hidden", bundle: .module) + .font(.caption) + .foregroundStyle(Color(.Labels.teritary)) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + Color(.Background.teritary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + ) + } + // MARK: - Context Menu private func ContextMenu() -> some View { diff --git a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings index 649ef742..e344d378 100644 --- a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings +++ b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings @@ -67,6 +67,16 @@ } } }, + "Hidden" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыто" + } + } + } + }, "Hidden text" : { "localizations" : { "ru" : { From 8f76e1298bb3ad9cc7f8342c6b33f37a75f1efa1 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 11 Apr 2026 22:27:48 +0300 Subject: [PATCH 30/44] Add move posts endpoint --- Modules/Sources/APIClient/APIClient.swift | 11 +++++++++++ .../Models/TopicToolsContextMenuAction.swift | 0 2 files changed, 11 insertions(+) create mode 100644 Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 12c43060..65cbf6d0 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -74,6 +74,7 @@ public struct APIClient: Sendable { public var previewTemplate: @Sendable (_ id: Int, _ content: PDAPIDocument, _ isTopic: Bool) async throws -> PreviewResponse public var sendPost: @Sendable (_ request: PostRequest) async throws -> PostSendResponse public var editPost: @Sendable (_ request: PostEditRequest) async throws -> PostSendResponse + public var movePosts: @Sendable (_ ids: [Int], _ toTopicId: Int) async throws -> Bool public var postKarma: @Sendable (_ postId: Int, _ isUp: Bool) async throws -> Bool public var postKarmaHistory: @Sendable (_ postId: Int) async throws -> [PostKarmaVote] public var voteInTopicPoll: @Sendable (_ topicId: Int, _ selections: [[Int]]) async throws -> Bool @@ -464,6 +465,13 @@ extension APIClient: DependencyKey { return try await parser.parsePostSendResponse(response) }, + movePosts: { ids, toTopicId in + let command = ForumCommand.Post.move(ids: ids, toTopicId: toTopicId) + let response = try await api.send(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, + postKarma: { id, isUp in let command = ForumCommand.Post.karma( postId: id, @@ -738,6 +746,9 @@ extension APIClient: DependencyKey { editPost: { _ in return .success(PostSend(id: 0, topicId: 1, offset: 2)) }, + movePosts: { _, _ in + return true + }, postKarma: { _, _ in return true }, diff --git a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift new file mode 100644 index 00000000..e69de29b From 977c188bd080af0d0254739ae230bfb5caa93d9c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 15:46:16 +0300 Subject: [PATCH 31/44] [WIP] ForumMoveFeature --- .../ForumMoveFeature/ForumMoveFeature.swift | 202 +++++++++++++++ .../ForumMoveFeature/ForumMoveView.swift | 240 ++++++++++++++++++ .../Models/ForumMoveType.swift | 14 + .../Resources/Localizable.xcstrings | 126 +++++++++ Project.swift | 15 ++ 5 files changed, 597 insertions(+) create mode 100644 Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift create mode 100644 Modules/Sources/ForumMoveFeature/ForumMoveView.swift create mode 100644 Modules/Sources/ForumMoveFeature/Models/ForumMoveType.swift create mode 100644 Modules/Sources/ForumMoveFeature/Resources/Localizable.xcstrings diff --git a/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift new file mode 100644 index 00000000..43a46a1a --- /dev/null +++ b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift @@ -0,0 +1,202 @@ +// +// ForumMoveFeature.swift +// ForPDA +// +// Created by Xialtal on 11.04.26. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models +import DeeplinkHandler +import ToastClient + +@Reducer +public struct ForumMoveFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Localization + + private enum Localization { + static let errorMovingTopic = LocalizedStringResource("Error moving topic", bundle: .module) + static let errorMovingPosts = LocalizedStringResource("Error moving posts", bundle: .module) + } + + // MARK: - URL Validation Error Reason + + public enum URLValidationErrorReason { + case badURL + case needTopicUrl + case needForumUrl + case unableToExtractTopicId + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public enum Field { case url } + + public let type: ForumMoveType + + var focus: Field? = .url + var error: URLValidationErrorReason? + var isSending = false + + var inputUrl = "" + var isSaveLinkForTopic: Bool = false + + var isMoveButtonDisabled: Bool { + return error != nil || inputUrl.isEmpty + } + + public init( + type: ForumMoveType + ) { + self.type = type + } + } + + // MARK: - Action + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + + case view(View) + public enum View { + case onAppear + + case unlockMoveButton + + case moveButtonTapped + case cancelButtonTapped + } + + case `internal`(Internal) + public enum Internal { + case movePosts([Int], toTopicid: Int) + case moveTopic(Int, toForumid: Int) + + case movePostsResponse(Result<(Bool, Int), any Error>) + case moveTopicResponse(Result<(Bool, Int), any Error>) + } + + case delegate(Delegate) + public enum Delegate { + case openDeeplink(Deeplink) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.dismiss) private var dismiss + @Dependency(\.toastClient) private var toastClient + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .view(.onAppear): + return .none + + case .view(.unlockMoveButton): + state.error = nil + return .none + + case .view(.cancelButtonTapped): + return .run { _ in await dismiss() } + + case .view(.moveButtonTapped): + if let url = URL(string: state.inputUrl.trimmingCharacters(in: .whitespacesAndNewlines)), + let artefact = try? DeeplinkHandler().handleInnerToInnerURL(url) { + switch artefact { + case .topic(let topicId, _): + guard case .posts(let ids) = state.type else { + state.error = .needTopicUrl + break + } + guard let topicId = topicId else { + state.error = .unableToExtractTopicId + break + } + return .send(.internal(.movePosts(ids, toTopicid: topicId))) + + case .forum(let forumId, _): + guard case .topic(let topicId) = state.type else { + state.error = .needForumUrl + break + } + return .send(.internal(.moveTopic(topicId, toForumid: forumId))) + + default: break + } + } + state.error = .badURL + return .none + + case let .internal(.movePosts(ids, toTopicId)): + state.isSending = true + return .run { send in + let status = try await apiClient.movePosts(ids: ids, toTopicId: toTopicId) + await send(.internal(.movePostsResponse(.success((status, toTopicId: toTopicId))))) + } catch: { error, send in + await send(.internal(.movePostsResponse(.failure(error)))) + } + + case let .internal(.moveTopic(topicId, toForumId)): + state.isSending = true + return .run { [saveLink = state.isSaveLinkForTopic] send in + let status = try await apiClient.moveTopic( + id: topicId, + toForumId: toForumId, + saveLink: saveLink + ) + await send(.internal(.moveTopicResponse(.success((status, toForumId: toForumId))))) + } catch: { error, send in + await send(.internal(.moveTopicResponse(.failure(error)))) + } + + case let .internal(.movePostsResponse(.success((status, toTopicId)))): + if status { + return .send(.delegate(.openDeeplink(.topic(id: toTopicId, goTo: .last)))) + } + return .send(.internal(.movePostsResponse(.failure(NSError(domain: "MP", code: -1))))) + + case let .internal(.movePostsResponse(.failure(error))): + print(error) + return .merge( + .run { _ in await dismiss() }, + .run { _ in + let toast = ToastMessage(text: Localization.errorMovingPosts, isError: true) + await toastClient.showToast(toast) + } + ) + + case let .internal(.moveTopicResponse(.success((status, toForumId)))): + if status { + return .send(.delegate(.openDeeplink(.forum(id: toForumId, page: 0)))) + } + return .send(.internal(.movePostsResponse(.failure(NSError(domain: "MT", code: -1))))) + + case let .internal(.moveTopicResponse(.failure(error))): + print(error) + return .merge( + .run { _ in await dismiss() }, + .run { _ in + let toast = ToastMessage(text: Localization.errorMovingTopic, isError: true) + await toastClient.showToast(toast) + } + ) + + case .delegate, .binding: + return .none + } + } + } +} diff --git a/Modules/Sources/ForumMoveFeature/ForumMoveView.swift b/Modules/Sources/ForumMoveFeature/ForumMoveView.swift new file mode 100644 index 00000000..b3fc5b8c --- /dev/null +++ b/Modules/Sources/ForumMoveFeature/ForumMoveView.swift @@ -0,0 +1,240 @@ +// +// ForumMoveView.swift +// ForPDA +// +// Created by Xialtal on 11.04.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI + +@ViewAction(for: ForumMoveFeature.self) +public struct ForumMoveView: View { + + // MARK: - Properties + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + @FocusState private var focus: ForumMoveFeature.State.Field? + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + WithPerceptionTracking { + VStack(alignment: .leading, spacing: 0) { + InputField() + .padding(.bottom, 28) + + if case .topic = store.type { + Row("Save link", value: $store.isSaveLinkForTopic) + .padding(.bottom, 64) + } + + ActionButtons() + } + .padding(.horizontal, 16) + .background { + if !isLiquidGlass { + Color(.Background.primary) + } + } + ._toolbarTitleDisplayMode(.inline) + .modifier(NavigationTitle(title: navigationTitleText())) + .toolbar { + ToolbarItem(placement: isLiquidGlass ? .topBarLeading : .topBarTrailing) { + Button { + send(.cancelButtonTapped) + } label: { + if isLiquidGlass { + Image(systemSymbol: .xmark) + } else { + Image(systemSymbol: .xmark) + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(Color(.Labels.teritary)) + .frame(width: 30, height: 30) + .background( + Circle() + .fill(Color(.Background.quaternary)) + .clipShape(Circle()) + ) + } + } + .disabled(store.isSending) + } + } + .bind($store.focus, to: $focus) + .onTapGesture { + focus = nil + } + .onAppear { + send(.onAppear) + } + } + } + + @available(iOS, deprecated: 26.0) + private struct NavigationTitle: ViewModifier { + let title: LocalizedStringKey + + func body(content: Content) -> some View { + if isLiquidGlass { + content + .navigationTitle(Text(title, bundle: .module)) + } else { + content + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Text(title, bundle: .module) + .font(.title3) + .fontWeight(.semibold) + } + } + } + } + } + + // MARK: - Input Field + + private func InputField() -> some View { + VStack(spacing: 6) { + let header = switch store.type { + case .topic: "Enter the forum link" + case .posts: "Enter the topic link" + } + Header(title: LocalizedStringKey(header)) + + Field( + content: $store.inputUrl, + placeholder: LocalizedStringResource("Input...", bundle: .module), + focusEqual: ForumMoveFeature.State.Field.url, + focus: $focus + ) + + if let error = store.error { + Text(error.title, bundle: .module) + .font(.caption) + .foregroundStyle(Color(.Main.red)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 12) + } + } + .animation(.default, value: store.error) + .onChange(of: store.inputUrl) { _ in + if store.error != nil { + send(.unlockMoveButton) + } + } + } + + // MARK: - Action Buttons + + @ViewBuilder + private func ActionButtons() -> some View { + HStack { + Button { + send(.cancelButtonTapped) + } label: { + Text("Cancel", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + } + .buttonStyle(.bordered) + .disabled(store.isSending) + .frame(height: 48) + + Button { + send(.moveButtonTapped) + } label: { + if store.isSending { + ProgressView() + .progressViewStyle(.circular) + .frame(maxWidth: .infinity) + .padding(8) + } else { + Text("Move", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + } + } + .buttonStyle(.borderedProminent) + .disabled(store.isSending || store.isMoveButtonDisabled) + .frame(height: 48) + } + .padding(.vertical, 8) + } + + // MARK: - Row + + @ViewBuilder + private func Row(_ title: LocalizedStringKey, value: Binding) -> some View { + HStack(spacing: 0) { + Text(title, bundle: .module) + .foregroundStyle(Color(.Labels.teritary)) + .font(.subheadline) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle(String(""), isOn: value) + .labelsHidden() + } + } + + // MARK: - Header + + private func Header(title: LocalizedStringKey) -> some View { + Text(title, bundle: .module) + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.teritary)) + .textCase(nil) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Helpers + + private func navigationTitleText() -> LocalizedStringKey { + return switch store.type { + case .posts: "Move Posts" + case .topic: "Move Topic" + } + } +} + +// MARK: - Extensions + +private extension ForumMoveFeature.URLValidationErrorReason { + var title: LocalizedStringKey { + switch self { + case .badURL: "Incorrect URL" + case .needTopicUrl: "Inputted URL is not topic URL" + case .needForumUrl: "Inputted URL is not forum URL" + case .unableToExtractTopicId: "Unable to extract topic id from URL" + } + } +} + +// MARK: - Previews + +#Preview { + NavigationStack { + ForumMoveView( + store: Store( + initialState: ForumMoveFeature.State( + type: .topic(1) + ) + ) { + ForumMoveFeature() + } + ) + } +} diff --git a/Modules/Sources/ForumMoveFeature/Models/ForumMoveType.swift b/Modules/Sources/ForumMoveFeature/Models/ForumMoveType.swift new file mode 100644 index 00000000..c0afa8e1 --- /dev/null +++ b/Modules/Sources/ForumMoveFeature/Models/ForumMoveType.swift @@ -0,0 +1,14 @@ +// +// ForumMoveType.swift +// ForPDA +// +// Created by Xialtal on 11.04.26. +// + +import Foundation + +public enum ForumMoveType: Equatable { + case topic(Int) + case posts([Int]) +} + diff --git a/Modules/Sources/ForumMoveFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumMoveFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..13084875 --- /dev/null +++ b/Modules/Sources/ForumMoveFeature/Resources/Localizable.xcstrings @@ -0,0 +1,126 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Cancel" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "Error moving posts" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка перемещения постов" + } + } + } + }, + "Error moving topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка перемещения темы" + } + } + } + }, + "Incorrect URL" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Некорректный URL-адрес" + } + } + } + }, + "Input..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите…" + } + } + } + }, + "Inputted URL is not forum URL" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введеный URL-адрес не является адресом форума" + } + } + } + }, + "Inputted URL is not topic URL" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введеный URL-адрес не является адресом темы" + } + } + } + }, + "Move" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить" + } + } + } + }, + "Move Posts" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перемещение постов" + } + } + } + }, + "Move Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перемещение темы" + } + } + } + }, + "Save link" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Оставить ссылку" + } + } + } + }, + "Unable to extract topic id from URL" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось получить идентификатор темы" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Project.swift b/Project.swift index 702677ea..f785d043 100644 --- a/Project.swift +++ b/Project.swift @@ -262,6 +262,7 @@ let project = Project( .Internal.TCAExtensions, .Internal.ToastClient, .Internal.FormFeature, + .Internal.ForumMoveFeature, .Internal.ForumStatFeature, .SPM.NukeUI, .SPM.SFSafeSymbols, @@ -281,6 +282,18 @@ let project = Project( .SPM.TCA, ] ), + + .feature( + name: "ForumMoveFeature", + dependencies: [ + .Internal.APIClient, + .Internal.DeeplinkHandler, + .Internal.Models, + .Internal.SharedUI, + .Internal.ToastClient, + .SPM.TCA, + ] + ), .feature( name: "ForumStatFeature", @@ -513,6 +526,7 @@ let project = Project( .Internal.ToastClient, .Internal.TopicBuilder, .Internal.FormFeature, + .Internal.ForumMoveFeature, .Internal.ForumStatFeature, .SPM.MemberwiseInit, .SPM.NukeUI, @@ -1048,6 +1062,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 ForumMoveFeature = TargetDependency.target(name: "ForumMoveFeature") static let ForumStatFeature = TargetDependency.target(name: "ForumStatFeature") static let GalleryFeature = TargetDependency.target(name: "GalleryFeature") static let HistoryFeature = TargetDependency.target(name: "HistoryFeature") From 632047fa074cc9c35bfbfd61ab2d08e13dd4cd6e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 15:49:09 +0300 Subject: [PATCH 32/44] Add move option to post context menu --- .../Sources/Models/Post/PostMenuAction.swift | 1 - .../Models/Post/PostToolsMenuAction.swift | 11 ++++ .../SearchResultScreen.swift | 3 +- .../Sources/SharedUI/Post/PostRowView.swift | 22 +++++--- .../SharedUI/Resources/Localizable.xcstrings | 10 ++++ .../Analytics/TopicFeature+Analytics.swift | 12 +---- .../Models/TopicToolsContextMenuAction.swift | 13 +++++ .../Resources/Localizable.xcstrings | 10 ++++ .../Sources/TopicFeature/TopicFeature.swift | 54 ++++++++++++------- .../Sources/TopicFeature/TopicScreen.swift | 30 +++++++++-- 10 files changed, 124 insertions(+), 42 deletions(-) create mode 100644 Modules/Sources/Models/Post/PostToolsMenuAction.swift diff --git a/Modules/Sources/Models/Post/PostMenuAction.swift b/Modules/Sources/Models/Post/PostMenuAction.swift index b557e130..34e2faa3 100644 --- a/Modules/Sources/Models/Post/PostMenuAction.swift +++ b/Modules/Sources/Models/Post/PostMenuAction.swift @@ -14,5 +14,4 @@ public enum PostMenuAction { case userPostsInTopic(Int) case mentions(Int) case copyLink(Int) - case tools(PostModifyAction, Int, Bool) } diff --git a/Modules/Sources/Models/Post/PostToolsMenuAction.swift b/Modules/Sources/Models/Post/PostToolsMenuAction.swift new file mode 100644 index 00000000..c07c33d9 --- /dev/null +++ b/Modules/Sources/Models/Post/PostToolsMenuAction.swift @@ -0,0 +1,11 @@ +// +// PostToolsMenuAction.swift +// ForPDA +// +// Created by Xialtal on 11.04.26. +// + +public enum PostToolsMenuAction { + case move(Int) + case modify(PostModifyAction, Int, Bool) +} diff --git a/Modules/Sources/SearchResultFeature/SearchResultScreen.swift b/Modules/Sources/SearchResultFeature/SearchResultScreen.swift index 3a4c1040..b69bdaca 100644 --- a/Modules/Sources/SearchResultFeature/SearchResultScreen.swift +++ b/Modules/Sources/SearchResultFeature/SearchResultScreen.swift @@ -134,7 +134,8 @@ public struct SearchResultScreen: View { PostRowView( state: .init(post: post.post), action: { _ in }, - menuAction: { _ in } + menuAction: { _ in }, + toolsMenuAction: { _ in } ) .highPriorityGesture( TapGesture() diff --git a/Modules/Sources/SharedUI/Post/PostRowView.swift b/Modules/Sources/SharedUI/Post/PostRowView.swift index 737ef28b..c5818190 100644 --- a/Modules/Sources/SharedUI/Post/PostRowView.swift +++ b/Modules/Sources/SharedUI/Post/PostRowView.swift @@ -27,17 +27,20 @@ public struct PostRowView: View { public let state: State public let action: (PostAction) -> Void public let menuAction: (PostMenuAction) -> Void + public let toolsMenuAction: (PostToolsMenuAction) -> Void // MARK: - Init public init( state: State, action: @escaping (PostAction) -> Void, - menuAction: @escaping (PostMenuAction) -> Void + menuAction: @escaping (PostMenuAction) -> Void, + toolsMenuAction: @escaping (PostToolsMenuAction) -> Void ) { self.state = state self.action = action self.menuAction = menuAction + self.toolsMenuAction = toolsMenuAction } public var body: some View { @@ -249,7 +252,7 @@ public struct PostRowView: View { if state.post.post.canDelete { ContextButton(text: LocalizedStringResource("Delete", bundle: .module), symbol: .trash) { - menuAction(.tools(.delete, state.post.id, false)) + toolsMenuAction(.modify(.delete, state.post.id, false)) } } @@ -303,7 +306,7 @@ public struct PostRowView: View { text: LocalizedStringResource("Restore", bundle: .module), symbol: .arrowCounterclockwiseCircle ) { - menuAction(.tools(.delete, state.post.id, true)) + toolsMenuAction(.modify(.delete, state.post.id, true)) } } @@ -313,7 +316,7 @@ public struct PostRowView: View { : LocalizedStringResource("Hide", bundle: .module), symbol: state.post.post.isHidden ? .eyeSlashFill : .eyeSlash ) { - menuAction(.tools(.hide, state.post.id, !state.post.post.isHidden)) + toolsMenuAction(.modify(.hide, state.post.id, !state.post.post.isHidden)) } ContextButton( @@ -322,7 +325,7 @@ public struct PostRowView: View { : LocalizedStringResource("Pin", bundle: .module), symbol: state.post.post.isPinned ? .pinFill : .pin ) { - menuAction(.tools(.pin, state.post.id, !state.post.post.isPinned)) + toolsMenuAction(.modify(.pin, state.post.id, !state.post.post.isPinned)) } ContextButton( @@ -331,7 +334,14 @@ public struct PostRowView: View { : LocalizedStringResource("Protect", bundle: .module), symbol: state.post.post.isProtected ? .shieldFill : .shield ) { - menuAction(.tools(.protect, state.post.id, !state.post.post.isProtected)) + toolsMenuAction(.modify(.protect, state.post.id, !state.post.post.isProtected)) + } + + ContextButton( + text: LocalizedStringResource("Move", bundle: .module), + symbol: .arrowRight + ) { + toolsMenuAction(.move(state.post.id)) } } label: { HStack { diff --git a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings index e344d378..bedcb7b1 100644 --- a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings +++ b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings @@ -107,6 +107,16 @@ } } }, + "Move" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить" + } + } + } + }, "Open In Browser" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index a1484356..43680466 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -75,16 +75,6 @@ extension TopicFeature { analytics.log(TopicEvent.menuPostMentions(postId)) case .copyLink(let postId): analytics.log(TopicEvent.menuPostCopyLink(postId)) - case .tools(let action, let postId, let isUndo): - switch action { - case .delete: - if !isUndo { - analytics.log(TopicEvent.menuPostDelete(postId)) - } - default: - // MARK: Moderator tools are skip analytics - break - } } case let .view(.contextMenu(option)): @@ -105,7 +95,7 @@ extension TopicFeature { analytics.log(TopicEvent.menuWritePostWithTemplate) } - case .view(.contextToolsMenu(_, _)): + case .view(.contextToolsMenu), .view(.contextPostToolsMenu): // MARK: Moderator tools are skip analytics break diff --git a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift index e69de29b..5f4ff1c4 100644 --- a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift +++ b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift @@ -0,0 +1,13 @@ +// +// TopicToolsContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 11.04.26. +// + +import Models + +public enum TopicToolsContextMenuAction { + case move + case modify(TopicModifyAction, Bool) +} diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 2e74e809..c536abf3 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -181,6 +181,16 @@ } } }, + "Move" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить" + } + } + } + }, "No" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 0af765ca..4e667937 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -23,6 +23,7 @@ import TopicBuilder import ToastClient import NotificationsClient import ForumStatFeature +import ForumMoveFeature @Reducer public struct TopicFeature: Reducer, Sendable { @@ -56,6 +57,7 @@ public struct TopicFeature: Reducer, Sendable { case karmaHistory(PostKarmaHistoryFeature) case form(FormFeature) case stat(ForumStatFeature) + case move(ForumMoveFeature) case changeReputation(ReputationChangeFeature) case alert(AlertState) @@ -146,8 +148,9 @@ public struct TopicFeature: Reducer, Sendable { case karmaHistoryTapped(Int) case textQuoted(UIPost, String) case contextMenu(TopicContextMenuAction) - case contextToolsMenu(TopicModifyAction, Bool) + case contextToolsMenu(TopicToolsContextMenuAction) case contextPostMenu(PostMenuAction) + case contextPostToolsMenu(PostToolsMenuAction) } case `internal`(Internal) @@ -365,25 +368,33 @@ public struct TopicFeature: Reducer, Sendable { return .send(.pageNavigation(.lastPageTapped)) } - case let .view(.contextToolsMenu(action, isUndo)): + case let .view(.contextToolsMenu(action)): + guard let topic = state.topic else { return .none } switch action { - case .hide, .close: - return .run { [id = state.topicId] send in - _ = try await apiClient.modifyForum(ids: [id], type: .topic(action), isUndo: isUndo) - - await send(.internal(.refresh)) - await toastClient.showToast(.actionCompleted) - } catch: { error, send in - analyticsClient.capture(error) - await toastClient.showToast(.whoopsSomethingWentWrong) - } - - case .delete: - state.destination = .alert(.deleteTopicConfirmation) + case .move: + state.destination = .move(ForumMoveFeature.State(type: .topic(topic.id))) return .none - default: - return .none + case .modify(let action, let isUndo): + switch action { + case .hide, .close: + return .run { [id = state.topicId] send in + _ = try await apiClient.modifyForum(ids: [id], type: .topic(action), isUndo: isUndo) + + await send(.internal(.refresh)) + await toastClient.showToast(.actionCompleted) + } catch: { error, send in + analyticsClient.capture(error) + await toastClient.showToast(.whoopsSomethingWentWrong) + } + + case .delete: + state.destination = .alert(.deleteTopicConfirmation) + return .none + + default: + return .none + } } case let .view(.contextPostMenu(action)): @@ -452,8 +463,15 @@ public struct TopicFeature: Reducer, Sendable { return .run { _ in await toastClient.showToast(ToastMessage(text: Localization.linkCopied, haptic: .success)) } + } + + case let .view(.contextPostToolsMenu(action)): + switch action { + case .move(let postId): + state.destination = .move(ForumMoveFeature.State(type: .posts([postId]))) + return .none - case .tools(let action, let postId, let isUndo): + case .modify(let action, let postId, let isUndo): switch action { case .pin, .hide, .protect: return .run { [id = state.topicId] send in diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index f70c8f21..e566b098 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -18,6 +18,7 @@ import ReputationChangeFeature import TopicBuilder import GalleryFeature import ForumStatFeature +import ForumMoveFeature @ViewAction(for: TopicFeature.self) public struct TopicScreen: View { @@ -234,7 +235,7 @@ public struct TopicScreen: View { : LocalizedStringResource("Hide", bundle: .module), symbol: topic.isHidden ? .eyeSlashFill : .eyeSlash ) { - send(.contextToolsMenu(.hide, !topic.isHidden)) + send(.contextToolsMenu(.modify(.hide, !topic.isHidden))) } ContextButton( @@ -243,14 +244,21 @@ public struct TopicScreen: View { : LocalizedStringResource("Close Topic", bundle: .module), symbol: topic.isClosed ? .lockFill : .lock ) { - send(.contextToolsMenu(.close, !topic.isClosed)) + send(.contextToolsMenu(.modify(.close, !topic.isClosed))) } if topic.canDelete { ContextButton(text: LocalizedStringResource("Delete Topic", bundle: .module), symbol: .trash) { - send(.contextToolsMenu(.delete, false)) + send(.contextToolsMenu(.modify(.delete, false))) } } + + ContextButton( + text: LocalizedStringResource("Move", bundle: .module), + symbol: .arrowRight + ) { + send(.contextToolsMenu(.move)) + } } label: { HStack { Text("Tools", bundle: .module) @@ -399,8 +407,14 @@ public struct TopicScreen: View { send(.contextPostMenu(.mentions(postId))) case .copyLink(let postId): send(.contextPostMenu(.copyLink(postId))) - case .tools(let action, let postId, let isUndo): - send(.contextPostMenu(.tools(action, postId, isUndo))) + } + }, + toolsMenuAction: { action in + switch action { + case .move(let postId): + send(.contextPostToolsMenu(.move(postId))) + case .modify(let action, let postId, let isUndo): + send(.contextPostToolsMenu(.modify(action, postId, isUndo))) } } ) @@ -511,6 +525,12 @@ struct NavigationModifier: ViewModifier { ) { store in ReputationChangeView(store: store) } + .fittedSheet( + item: $store.scope(state: \.destination?.move, action: \.destination.move), + embedIntoNavStack: true + ) { store in + ForumMoveView(store: store) + } .sheet(item: $store.scope(state: \.destination?.karmaHistory, action: \.destination.karmaHistory)) { store in NavigationStack { PostKarmaHistoryView(store: store) From 12d7d054f871e3cd287c260b67d1606d457680a3 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 15:50:33 +0300 Subject: [PATCH 33/44] Add move option to topic context menu --- .../Analytics/ForumFeature+Analytics.swift | 2 +- .../Sources/ForumFeature/ForumFeature.swift | 35 ++++++++++++------- .../Sources/ForumFeature/ForumScreen.swift | 22 +++++++++--- .../ForumTopicToolsContextMenuAction.swift | 13 +++++++ .../Resources/Localizable.xcstrings | 10 ++++++ 5 files changed, 64 insertions(+), 18 deletions(-) create mode 100644 Modules/Sources/ForumFeature/Models/ForumTopicToolsContextMenuAction.swift diff --git a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index 2dbec935..6a8a38fc 100644 --- a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift +++ b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift @@ -56,7 +56,7 @@ extension ForumFeature { break // TODO: Add } - case .view(.contextTopicToolsMenu(_, _, _)): + case .view(.contextTopicToolsMenu): // MARK: Moderator tools are skip analytics break diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 40355835..258a5427 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -17,6 +17,7 @@ import TCAExtensions import ToastClient import FormFeature import ForumStatFeature +import ForumMoveFeature @Reducer public struct ForumFeature: Reducer, Sendable { @@ -56,6 +57,7 @@ public struct ForumFeature: Reducer, Sendable { @Reducer public enum Destination { case form(FormFeature) + case move(ForumMoveFeature) case stat(ForumStatFeature) } @@ -120,7 +122,7 @@ public struct ForumFeature: Reducer, Sendable { case contextOptionMenu(ForumOptionContextMenuAction) case contextTopicMenu(ForumTopicContextMenuAction, TopicInfo) - case contextTopicToolsMenu(TopicModifyAction, Int, Bool) + case contextTopicToolsMenu(ForumTopicToolsContextMenuAction) case contextCommonMenu(ForumCommonContextMenuAction, Int, Bool) } @@ -245,18 +247,25 @@ public struct ForumFeature: Reducer, Sendable { ) } - case let .view(.contextTopicToolsMenu(action, topicId, isUndo)): - return .run { send in - let status = try await apiClient.modifyForum( - ids: [topicId], - type: .topic(action), - isUndo: isUndo - ) - await send(.internal(.refresh)) - await toastClient.showToast(status ? .actionCompleted : .whoopsSomethingWentWrong) - } catch: { error, send in - analyticsClient.capture(error) - await toastClient.showToast(.whoopsSomethingWentWrong) + case let .view(.contextTopicToolsMenu(action)): + switch action { + case .move(let topicId): + state.destination = .move(ForumMoveFeature.State(type: .topic(topicId))) + return .none + + case .modify(let action, let topicId, let isUndo): + return .run { send in + let status = try await apiClient.modifyForum( + ids: [topicId], + type: .topic(action), + isUndo: isUndo + ) + await send(.internal(.refresh)) + await toastClient.showToast(status ? .actionCompleted : .whoopsSomethingWentWrong) + } catch: { error, send in + analyticsClient.capture(error) + await toastClient.showToast(.whoopsSomethingWentWrong) + } } case .view(.contextCommonMenu(let action, let id, let isForum)): diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index 045d171a..473fca89 100644 --- a/Modules/Sources/ForumFeature/ForumScreen.swift +++ b/Modules/Sources/ForumFeature/ForumScreen.swift @@ -14,6 +14,7 @@ import Models import BBBuilder import FormFeature import ForumStatFeature +import ForumMoveFeature @ViewAction(for: ForumFeature.self) public struct ForumScreen: View { @@ -102,6 +103,12 @@ public struct ForumScreen: View { ForumStatView(store: store) } } + .fittedSheet( + item: $store.scope(state: \.destination?.move, action: \.destination.move), + embedIntoNavStack: true + ) { store in + ForumMoveView(store: store) + } .toolbar { ToolbarItem { Button { @@ -269,7 +276,7 @@ public struct ForumScreen: View { : LocalizedStringResource("Pin", bundle: .module), symbol: topic.isPinned ? .pinFill : .pin ) { - send(.contextTopicToolsMenu(.pin, topic.id, !topic.isPinned)) + send(.contextTopicToolsMenu(.modify(.pin, topic.id, !topic.isPinned))) } ContextButton( @@ -278,7 +285,7 @@ public struct ForumScreen: View { : LocalizedStringResource("Hide", bundle: .module), symbol: topic.isHidden ? .eyeSlashFill : .eyeSlash ) { - send(.contextTopicToolsMenu(.hide, topic.id, !topic.isHidden)) + send(.contextTopicToolsMenu(.modify(.hide, topic.id, !topic.isHidden))) } ContextButton( @@ -287,14 +294,21 @@ public struct ForumScreen: View { : LocalizedStringResource("Close", bundle: .module), symbol: topic.isClosed ? .lockFill : .lock ) { - send(.contextTopicToolsMenu(.close, topic.id, !topic.isClosed)) + send(.contextTopicToolsMenu(.modify(.close, topic.id, !topic.isClosed))) } if topic.canDelete { ContextButton(text: LocalizedStringResource("Delete", bundle: .module), symbol: .trash) { - send(.contextTopicToolsMenu(.delete, topic.id, false)) + send(.contextTopicToolsMenu(.modify(.delete, topic.id, false))) } } + + ContextButton( + text: LocalizedStringResource("Move", bundle: .module), + symbol: .arrowRight + ) { + send(.contextTopicToolsMenu(.move(topic.id))) + } } label: { HStack { Text("Tools", bundle: .module) diff --git a/Modules/Sources/ForumFeature/Models/ForumTopicToolsContextMenuAction.swift b/Modules/Sources/ForumFeature/Models/ForumTopicToolsContextMenuAction.swift new file mode 100644 index 00000000..54917da3 --- /dev/null +++ b/Modules/Sources/ForumFeature/Models/ForumTopicToolsContextMenuAction.swift @@ -0,0 +1,13 @@ +// +// ForumTopicToolsContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 12.04.26. +// + +import Models + +public enum ForumTopicToolsContextMenuAction { + case move(Int) + case modify(TopicModifyAction, Int, Bool) +} diff --git a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings index 3b229049..a2bf1455 100644 --- a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings @@ -121,6 +121,16 @@ } } }, + "Move" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить" + } + } + } + }, "Open" : { "localizations" : { "ru" : { From e8657f622565a7ebfb6f2b4d23189bacf8344562 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 17:33:35 +0300 Subject: [PATCH 34/44] Add user note endpoint --- Modules/Sources/APIClient/APIClient.swift | 10 ++++++++++ .../Models/Profile/UserNoteResponse.swift | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 Modules/Sources/Models/Profile/UserNoteResponse.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 65cbf6d0..d28dd763 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -46,6 +46,7 @@ public struct APIClient: Sendable { // User public var getUser: @Sendable (_ userId: Int, _ policy: CachePolicy) async throws -> AsyncThrowingStream public var editUserProfile: @Sendable (_ request: UserProfileEditRequest) async throws -> Bool + public var addUserNote: @Sendable (_ userId: Int, _ content: String) async throws -> UserNoteResponse public var getReputationVotes: @Sendable (_ data: ReputationVotesRequest) async throws -> ReputationVotes public var changeReputation: @Sendable (_ data: ReputationChangeRequest) async throws -> ReputationChangeResponseType public var updateUserAvatar: @Sendable (_ userId: Int, _ image: Data) async throws -> UserAvatarResponseType @@ -237,6 +238,12 @@ extension APIClient: DependencyKey { let status = Int(response.getResponseStatus())! return status == 0 }, + addUserNote: { userId, message in + let command = MemberCommand.notice(memberId: userId, message: message) + let response = try await api.send(command) + let status = Int(response.getResponseStatus())! + return UserNoteResponse(rawValue: status) + }, getReputationVotes: { request in let command = MemberCommand.reputationVotes(data: MemberReputationVotesRequest( memberId: request.userId, @@ -672,6 +679,9 @@ extension APIClient: DependencyKey { editUserProfile: { _ in return true }, + addUserNote: { _, _ in + return .success + }, getReputationVotes: { _ in return .mock }, diff --git a/Modules/Sources/Models/Profile/UserNoteResponse.swift b/Modules/Sources/Models/Profile/UserNoteResponse.swift new file mode 100644 index 00000000..f0222a54 --- /dev/null +++ b/Modules/Sources/Models/Profile/UserNoteResponse.swift @@ -0,0 +1,20 @@ +// +// UserNoteResponse.swift +// ForPDA +// +// Created by Xialtal on 12.04.26. +// + +public enum UserNoteResponse: Int, Sendable { + case reasonNotSet = 5 + case success = 0 + case error = -1 + + public init(rawValue: Int) { + switch rawValue { + case 0: self = .success + case 5: self = .reasonNotSet + default: self = .error + } + } +} From fb5fa16877d93270e70578e700daf2f2bbdb0b77 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 17:33:49 +0300 Subject: [PATCH 35/44] Improve User model mock --- Modules/Sources/Models/Profile/User.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Models/Profile/User.swift b/Modules/Sources/Models/Profile/User.swift index e359ab2e..53ba40a9 100644 --- a/Modules/Sources/Models/Profile/User.swift +++ b/Modules/Sources/Models/Profile/User.swift @@ -367,7 +367,7 @@ public extension User { id: 0, nickname: "Test Nickname", imageUrl: Links.defaultAvatar, - group: .active, + group: .admin, status: "Just a status", signature: "[b][color=blue]Developer[/color][/b]", aboutMe: "A lot of text about me. A lot of text about me. A lot of text about me. A lot of text about me. A lot of text about me.", From 66eb1e0ec8656c503d05d2e1d5820f88424f7e6f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 17:51:45 +0300 Subject: [PATCH 36/44] Add note support to FormFeature --- .../Resources/Localizable.xcstrings | 20 +++++++++ .../FormFeature/Sources/FormFeature.swift | 42 ++++++++++++++++++- .../FormFeature/Sources/FormScreen.swift | 1 + .../Sources/Preview/FormPreviewFeature.swift | 2 +- .../Sources/Support/FormType.swift | 1 + Modules/Sources/Models/Form/FormSend.swift | 1 + 6 files changed, 64 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings index 38049a85..f0ba382e 100644 --- a/Modules/Sources/FormFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/FormFeature/Resources/Localizable.xcstrings @@ -67,6 +67,16 @@ } } }, + "New note" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новая заметка" + } + } + } + }, "New post" : { "localizations" : { "en" : { @@ -119,6 +129,16 @@ } } }, + "Not set reason for note" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не указана причина" + } + } + } + }, "OK" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/FormFeature/Sources/FormFeature.swift b/Modules/Sources/FormFeature/Sources/FormFeature.swift index 3dcba8a4..0d7e6c24 100644 --- a/Modules/Sources/FormFeature/Sources/FormFeature.swift +++ b/Modules/Sources/FormFeature/Sources/FormFeature.swift @@ -132,6 +132,7 @@ public struct FormFeature: Reducer, Sendable { case loadForm(id: Int, isTopic: Bool) case formResponse(Result<[FormFieldType], any Error>) case reportResponse(Result) + case noteResponse(Result) case simplePostResponse(Result) case templateResponse(Result) case publishForm(flag: PostSendFlag) @@ -234,7 +235,7 @@ public struct FormFeature: Reducer, Sendable { state.isFormLoading = true return .send(.internal(.loadForm(id: forumId, isTopic: true))) - case .report: + case .report, .note: let editorState = FormEditorFeature.State(id: 0, flag: .required, uploadBox: nil) state.rows.append(.editor(editorState)) state.focusedField = 0 @@ -265,7 +266,7 @@ public struct FormFeature: Reducer, Sendable { formType: .post(type: type, topicId: topicId, content: content) ) - case .report: + case .report, .note: let content = if case let .string(text) = state.content.first { text } else { fatalError("Report content field should contains only one .string()!") } @@ -441,10 +442,37 @@ public struct FormFeature: Reducer, Sendable { await send(.internal(.reportResponse(result))) } + case let .note(userId: userId): + let content = if case let .string(text) = state.content.first { text } else { + fatalError("Bad note content: \(state.content)") + } + return .run { [content = content] send in + let result = await Result { try await apiClient.addUserNote( + userId: userId, + content: content + ) } + await send(.internal(.noteResponse(result))) + } + default: fatalError() } + case let .internal(.noteResponse(.success(result))): + switch result { + case .error: + state.destination = .alert(.unknownError) + case .reasonNotSet: + state.destination = .alert(.noteWithoutReason) + case .success: + return .send(.delegate(.formSent(.note))) + } + + case let .internal(.noteResponse(.failure(error))): + state.isPublishing = false + state.destination = .alert(.unknownError) + analyticsClient.capture(error) + case let .internal(.reportResponse(.success(result))): switch result { case .error: @@ -613,6 +641,16 @@ public extension AlertState where Action == FormFeature.Destination.Alert { } } + // Note + + nonisolated(unsafe) static let noteWithoutReason = AlertState { + TextState("Not set reason for note") + } actions: { + ButtonState { + TextState("OK") + } + } + // Common nonisolated(unsafe) static let unknownError = AlertState { diff --git a/Modules/Sources/FormFeature/Sources/FormScreen.swift b/Modules/Sources/FormFeature/Sources/FormScreen.swift index fbf42b0d..525db5f0 100644 --- a/Modules/Sources/FormFeature/Sources/FormScreen.swift +++ b/Modules/Sources/FormFeature/Sources/FormScreen.swift @@ -146,6 +146,7 @@ public struct FormScreen: View { } case .topic: "New topic" case .report: "Send report" + case .note: "New note" } } } diff --git a/Modules/Sources/FormFeature/Sources/Preview/FormPreviewFeature.swift b/Modules/Sources/FormFeature/Sources/Preview/FormPreviewFeature.swift index e5ee57a7..a46d4331 100644 --- a/Modules/Sources/FormFeature/Sources/Preview/FormPreviewFeature.swift +++ b/Modules/Sources/FormFeature/Sources/Preview/FormPreviewFeature.swift @@ -84,7 +84,7 @@ public struct FormPreviewFeature: Reducer, Sendable { return .send(.internal(.loadPreview(id: topicId, content: content))) } - case .report(_, _): + case .report, .note: // handling as .post break } diff --git a/Modules/Sources/FormFeature/Sources/Support/FormType.swift b/Modules/Sources/FormFeature/Sources/Support/FormType.swift index 0f1e889f..2dd86e1e 100644 --- a/Modules/Sources/FormFeature/Sources/Support/FormType.swift +++ b/Modules/Sources/FormFeature/Sources/Support/FormType.swift @@ -11,6 +11,7 @@ public enum FormType: Sendable, Equatable { case post(type: PostType, topicId: Int, content: PostContentType) case report(id: Int, type: ReportType) case topic(forumId: Int, content: [FormValue]) + case note(userId: Int) public enum PostType: Sendable, Equatable { case new diff --git a/Modules/Sources/Models/Form/FormSend.swift b/Modules/Sources/Models/Form/FormSend.swift index 500f2acc..bdf8826e 100644 --- a/Modules/Sources/Models/Form/FormSend.swift +++ b/Modules/Sources/Models/Form/FormSend.swift @@ -9,4 +9,5 @@ public enum FormSend: Sendable { case post(PostSend) case topic(Int) case report + case note } From 8efba5c22fe6c2cf7c3f19b74003c84fb8f8f453 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 17:54:07 +0300 Subject: [PATCH 37/44] Allow avatar upload only for session user --- .../ProfileFeature/Edit/EditFeature.swift | 3 +++ .../Sources/ProfileFeature/Edit/EditScreen.swift | 16 +++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift index 5d9411e7..71ae43bf 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditFeature.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditFeature.swift @@ -8,6 +8,7 @@ import Foundation import ComposableArchitecture import APIClient +import PersistenceKeys import Models import ToastClient import BBPanelFeature @@ -30,6 +31,8 @@ public struct EditFeature: Reducer, Sendable { public struct State: Equatable { public enum Field: CaseIterable { case status, signature, about, city } + @Shared(.userSession) public var userSession: UserSession? + @Presents public var destination: Destination.State? @Presents public var alert: AlertState? diff --git a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift index cb0ecec8..11c1a733 100644 --- a/Modules/Sources/ProfileFeature/Edit/EditScreen.swift +++ b/Modules/Sources/ProfileFeature/Edit/EditScreen.swift @@ -225,7 +225,7 @@ public struct EditScreen: View { .overlay(alignment: .bottomTrailing) { if store.isUserSetAvatar { AvatarContextMenu() - } else { + } else if let session = store.userSession, session.userId == store.user.id { AvatarUploadButton() } } @@ -313,12 +313,14 @@ public struct EditScreen: View { @ViewBuilder private func AvatarContextMenu() -> some View { Menu { - Button { - send(.addAvatarButtonTapped) - } label: { - HStack { - Text("Update avatar", bundle: .module) - Image(systemSymbol: .plusCircle) + if let session = store.userSession, session.userId == store.user.id { + Button { + send(.addAvatarButtonTapped) + } label: { + HStack { + Text("Update avatar", bundle: .module) + Image(systemSymbol: .plusCircle) + } } } From ed837e580bd655239bfb1ee4654d7070f86f41db Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 18:03:05 +0300 Subject: [PATCH 38/44] Add context menu to user profile With actions: - Edit Profile - Add Note --- .../Analytics/ProfileFeature+Analytics.swift | 12 +++-- .../Models/ProfileContextMenuAction.swift | 11 ++++ .../ProfileFeature/ProfileFeature.swift | 46 ++++++++++++++-- .../ProfileFeature/ProfileScreen.swift | 53 ++++++++++++++++--- .../Resources/Localizable.xcstrings | 12 ++++- Project.swift | 1 + 6 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 Modules/Sources/ProfileFeature/Models/ProfileContextMenuAction.swift diff --git a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift index 6bc9d315..5600a524 100644 --- a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift +++ b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift @@ -28,9 +28,6 @@ extension ProfileFeature { case .view(.qmsButtonTapped): analyticsClient.log(ProfileEvent.qmsTapped) - - case .view(.editButtonTapped): - analyticsClient.log(ProfileEvent.editTapped) case .view(.settingsButtonTapped): analyticsClient.log(ProfileEvent.settingsTapped) @@ -59,6 +56,15 @@ extension ProfileFeature { case .view(.deviceButtonTapped(let tag)): analyticsClient.log(ProfileEvent.deviceButtonTapped(tag)) + case .view(.contextMenu(let action)): + switch action { + case .edit: + analyticsClient.log(ProfileEvent.editTapped) + case .addNotice: + // MARK: Moderator tools are skip analytics + break + } + case .view(.deeplinkTapped(_, let type)): switch type { case .about: diff --git a/Modules/Sources/ProfileFeature/Models/ProfileContextMenuAction.swift b/Modules/Sources/ProfileFeature/Models/ProfileContextMenuAction.swift new file mode 100644 index 00000000..efaf851c --- /dev/null +++ b/Modules/Sources/ProfileFeature/Models/ProfileContextMenuAction.swift @@ -0,0 +1,11 @@ +// +// ProfileContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 12.04.26. +// + +public enum ProfileContextMenuAction { + case edit + case addNotice +} diff --git a/Modules/Sources/ProfileFeature/ProfileFeature.swift b/Modules/Sources/ProfileFeature/ProfileFeature.swift index 36a02fc7..5eba8c40 100644 --- a/Modules/Sources/ProfileFeature/ProfileFeature.swift +++ b/Modules/Sources/ProfileFeature/ProfileFeature.swift @@ -13,6 +13,7 @@ import Models import AnalyticsClient import ToastClient import NotificationsClient +import FormFeature @Reducer public struct ProfileFeature: Reducer, Sendable { @@ -22,6 +23,7 @@ public struct ProfileFeature: Reducer, Sendable { // MARK: - Localizations private enum Localization { + static let noteAdded = LocalizedStringResource("Note added", bundle: .module) static let profileUpdated = LocalizedStringResource("Profile updated", bundle: .module) static let profileUpdateError = LocalizedStringResource("Profile update error", bundle: .module) } @@ -31,6 +33,7 @@ public struct ProfileFeature: Reducer, Sendable { @Reducer public enum Destination { case alert(AlertState) + case note(FormFeature) case editProfile(EditFeature) } @@ -40,6 +43,8 @@ public struct ProfileFeature: Reducer, Sendable { public struct State: Equatable { @Presents public var destination: Destination.State? @Shared(.userSession) public var userSession: UserSession? + public var userSessionGroup: User.Group? + public let userId: Int? public var isLoading: Bool public var user: User? @@ -50,6 +55,15 @@ public struct ProfileFeature: Reducer, Sendable { return userSession != nil && user?.id == userSession?.userId } + var shouldShowOptionsToolbarButton: Bool { + return userSessionGroup == .admin + || userSessionGroup == .supermoderator + || userSessionGroup == .moderator + || userSessionGroup == .moderatorHelper + || userSessionGroup == .moderatorSchool + || userSession?.userId == userId + } + var didLoadOnce = false public init( @@ -72,7 +86,6 @@ public struct ProfileFeature: Reducer, Sendable { public enum View { case onAppear case qmsButtonTapped - case editButtonTapped case settingsButtonTapped case logoutButtonTapped case historyButtonTapped @@ -83,12 +96,15 @@ public struct ProfileFeature: Reducer, Sendable { case deviceButtonTapped(String) case curatedTopicButtonTapped(Int) case deeplinkTapped(URL, ProfileDeeplinkType) + + case contextMenu(ProfileContextMenuAction) } case `internal`(Internal) public enum Internal { case userResponse(Result) case updateBadgeCounts(Unread) + case updateUserSessionGroup(User.Group) } case destination(PresentationAction) @@ -114,6 +130,7 @@ public struct ProfileFeature: Reducer, Sendable { @Dependency(\.apiClient) private var apiClient @Dependency(\.analyticsClient) private var analyticsClient + @Dependency(\.cacheClient) private var cacheClient @Dependency(\.notificationCenter) private var notificationCenter @Dependency(\.notificationsClient) private var notificationsClient @Dependency(\.toastClient) private var toastClient @@ -137,6 +154,11 @@ public struct ProfileFeature: Reducer, Sendable { } catch: { error, send in await send(.internal(.userResponse(.failure(error)))) }, + .run { [session = state.userSession] send in + if let session, let user = cacheClient.getUser(session.userId) { + await send(.internal(.updateUserSessionGroup(user.group))) + } + }, .run { send in let unread = try await apiClient.getUnread(type: .all) await notificationsClient.showUnreadNotifications(unread, skipCategories: []) @@ -185,11 +207,17 @@ public struct ProfileFeature: Reducer, Sendable { sort: .dateDescSort )))) - case .view(.editButtonTapped): - if let user = state.user { + case let .view(.contextMenu(action)): + guard let user = state.user else { return .none } + switch action { + case .edit: state.destination = .editProfile(EditFeature.State(user: user)) + return .none + + case .addNotice: + state.destination = .note(FormFeature.State(type: .note(userId: user.id))) + return .none } - return .none case .view(.qmsButtonTapped): return .send(.delegate(.openQms)) @@ -225,6 +253,16 @@ public struct ProfileFeature: Reducer, Sendable { state.mentionsBadgeCount = unread.mentionsUnreadCount return .none + case let .internal(.updateUserSessionGroup(group)): + state.userSessionGroup = group + return .none + + case let .destination(.presented(.note(.delegate(.formSent(.note))))): + return .run { send in + await toastClient.showToast(ToastMessage(text: Localization.noteAdded)) + await send(.view(.onAppear)) + } + case .destination(.presented(.editProfile(.delegate(.profileUpdated(let status))))): return .concatenate( .run { _ in diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index a49a6267..1575f9c2 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -15,6 +15,7 @@ import Models import RichTextKit import ParsingClient import BBBuilder +import FormFeature @ViewAction(for: ProfileFeature.self) public struct ProfileScreen: View { @@ -81,6 +82,11 @@ public struct ProfileScreen: View { EditScreen(store: store) } } + .fullScreenCover(item: $store.scope(state: \.destination?.note, action: \.destination.note)) { store in + NavigationStack { + FormScreen(store: store) + } + } .toolbar { ToolbarButtons() } @@ -106,15 +112,15 @@ public struct ProfileScreen: View { if #available(iOS 26.0, *) { ToolbarSpacer(.fixed) } - + } + + if store.shouldShowOptionsToolbarButton { ToolbarItem { - Button { - send(.editButtonTapped) - } label: { - Image(systemSymbol: .pencil) - } + OptionsMenu() } - + } + + if store.shouldShowToolbarButtons { ToolbarItem { Button { send(.settingsButtonTapped) @@ -125,6 +131,37 @@ public struct ProfileScreen: View { } } + @ViewBuilder + private func OptionsMenu() -> some View { + Menu { + let canEditProfile = store.userSessionGroup == .admin + || store.userSessionGroup == .supermoderator + || store.userSessionGroup == .moderator + if store.shouldShowToolbarButtons || canEditProfile { + ContextButton( + text: LocalizedStringResource("Edit profile", bundle: .module), + symbol: .pencil + ) { + send(.contextMenu(.edit)) + } + } + + let canAddNotice = canEditProfile + || store.userSessionGroup == .moderatorHelper + || store.userSessionGroup == .moderatorSchool + if canAddNotice, !store.shouldShowToolbarButtons { + ContextButton( + text: LocalizedStringResource("Add notice", bundle: .module), + symbol: .scribble + ) { + send(.contextMenu(.addNotice)) + } + } + } label: { + Image(systemSymbol: .ellipsisCircle) + } + } + // MARK: - Profile Header @ViewBuilder @@ -797,7 +834,7 @@ extension User { ProfileScreen( store: Store( initialState: ProfileFeature.State( - userId: 3640948 + userId: 0 ) ) { ProfileFeature() diff --git a/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings b/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings index af865f0f..77fe1b63 100644 --- a/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings @@ -31,6 +31,16 @@ } } }, + "Add notice" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить заметку" + } + } + } + }, "always" : { "localizations" : { "ru" : { @@ -406,7 +416,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Добавлена заметка" + "value" : "Заметка добавлена" } } } diff --git a/Project.swift b/Project.swift index f785d043..fee21ee0 100644 --- a/Project.swift +++ b/Project.swift @@ -376,6 +376,7 @@ let project = Project( .Internal.PersistenceKeys, .Internal.SharedUI, .Internal.ToastClient, + .Internal.FormFeature, .SPM.NukeUI, .SPM.RichTextKit, .SPM.SFSafeSymbols, From 6277e9dcce614544950b40b458d4e04231cc9f73 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 18:55:47 +0300 Subject: [PATCH 39/44] Fix isLastEditHidden check in Post model --- Modules/Sources/Models/Post/Post.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Models/Post/Post.swift b/Modules/Sources/Models/Post/Post.swift index ace81665..cf3af0a6 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -37,7 +37,7 @@ public struct Post: Sendable, Hashable, Identifiable, Codable { } public var isLastEditHidden: Bool { - return flag.contains(.marker) + return !flag.contains(.marker) } public var canModerate: Bool { From b3d073112b4cbcb6598974f7b4873250ef2a75a3 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 19:38:53 +0300 Subject: [PATCH 40/44] Use icon if needed for segment picker in ProfileScreen --- .../ProfileFeature/ProfileScreen.swift | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index 1575f9c2..d8bc0935 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -241,24 +241,26 @@ public struct ProfileScreen: View { @ViewBuilder private func SegmentPicker() -> some View { + let useIcon = !store.user!.achievements.isEmpty && !store.user!.curatedTopics.isEmpty Picker(String(""), selection: $pickerSelection) { - Text("General", bundle: .module) + SegmentLabel("General", .house, useIcon) .tag(PickerSelection.general) - Text("Statistics", bundle: .module) + + SegmentLabel("Statistics", .chartBar, useIcon) .tag(PickerSelection.statistics) if !store.user!.achievements.isEmpty { - Text("Achievements", bundle: .module) + SegmentLabel("Achievements", .trophy, useIcon) .tag(PickerSelection.achievements) } if !store.user!.curatedTopics.isEmpty { - Text("Curation", bundle: .module) + SegmentLabel("Curation", .eyeglasses, useIcon) .tag(PickerSelection.curation) } if !store.user!.warningLogs.isEmpty { - Text("Logging", bundle: .module) + SegmentLabel("Logging", .serverRack, useIcon) .tag(PickerSelection.logging) } } @@ -267,6 +269,15 @@ public struct ProfileScreen: View { .listRowBackground(Color.clear) } + @ViewBuilder + private func SegmentLabel(_ text: LocalizedStringKey, _ icon: SFSymbol, _ useIcon: Bool) -> some View { + if useIcon { + Image(systemSymbol: icon) + } else { + Text(text, bundle: .module) + } + } + // MARK: - General Segment @ViewBuilder From eda43c40af25c1bcf1839f3a6f07cbb45828e300 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 12 Apr 2026 20:49:46 +0300 Subject: [PATCH 41/44] Profile logging section improvements --- Modules/Sources/ProfileFeature/ProfileScreen.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index d8bc0935..0cd84e74 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -614,6 +614,7 @@ public struct ProfileScreen: View { .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } + .listRowBackground(Color(.clear)) } Section { @@ -628,6 +629,7 @@ public struct ProfileScreen: View { } .listRowSeparator(.visible) } + .listRowBackground(Color(.clear)) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } From 466ba12359f568b4c493d5248c50853a07ed436c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 13 Apr 2026 14:30:41 +0300 Subject: [PATCH 42/44] Improve ru localization for ForumFeature --- .../ForumFeature/Resources/Localizable.xcstrings | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings index a2bf1455..0389dc93 100644 --- a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings @@ -36,7 +36,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Закрыть тему" + "value" : "Закрыть" } } } @@ -66,7 +66,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Удалить тему" + "value" : "Удалить" } } } @@ -86,7 +86,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Скрыть тему" + "value" : "Скрыть" } } } @@ -156,7 +156,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Закрепить тему" + "value" : "Закрепить" } } } @@ -186,7 +186,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Показать тему" + "value" : "Показать" } } } @@ -226,7 +226,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Открепить тему" + "value" : "Открепить" } } } From e2b9fb65dfbbdd7243dee9f44e7df07b4c280822 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 13 Apr 2026 14:38:15 +0300 Subject: [PATCH 43/44] ForumMoveFeature improvements --- .../ForumMoveFeature/ForumMoveFeature.swift | 2 +- .../ForumMoveFeature/ForumMoveView.swift | 6 ++--- .../Resources/Localizable.xcstrings | 24 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift index 43a46a1a..b96e4a6f 100644 --- a/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift +++ b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift @@ -46,7 +46,7 @@ public struct ForumMoveFeature: Reducer, Sendable { var isSending = false var inputUrl = "" - var isSaveLinkForTopic: Bool = false + var isSaveLinkForTopic = false var isMoveButtonDisabled: Bool { return error != nil || inputUrl.isEmpty diff --git a/Modules/Sources/ForumMoveFeature/ForumMoveView.swift b/Modules/Sources/ForumMoveFeature/ForumMoveView.swift index b3fc5b8c..30bb8c8d 100644 --- a/Modules/Sources/ForumMoveFeature/ForumMoveView.swift +++ b/Modules/Sources/ForumMoveFeature/ForumMoveView.swift @@ -115,7 +115,7 @@ public struct ForumMoveView: View { Field( content: $store.inputUrl, - placeholder: LocalizedStringResource("Input...", bundle: .module), + placeholder: LocalizedStringResource("Enter...", bundle: .module), focusEqual: ForumMoveFeature.State.Field.url, focus: $focus ) @@ -216,8 +216,8 @@ private extension ForumMoveFeature.URLValidationErrorReason { var title: LocalizedStringKey { switch self { case .badURL: "Incorrect URL" - case .needTopicUrl: "Inputted URL is not topic URL" - case .needForumUrl: "Inputted URL is not forum URL" + case .needTopicUrl: "Entered URL is not topic URL" + case .needForumUrl: "Entered URL is not forum URL" case .unableToExtractTopicId: "Unable to extract topic id from URL" } } diff --git a/Modules/Sources/ForumMoveFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumMoveFeature/Resources/Localizable.xcstrings index 13084875..5287df6e 100644 --- a/Modules/Sources/ForumMoveFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumMoveFeature/Resources/Localizable.xcstrings @@ -11,62 +11,62 @@ } } }, - "Error moving posts" : { + "Enter..." : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Ошибка перемещения постов" + "value" : "Введите…" } } } }, - "Error moving topic" : { + "Entered URL is not forum URL" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Ошибка перемещения темы" + "value" : "Введеный URL-адрес не является адресом форума" } } } }, - "Incorrect URL" : { + "Entered URL is not topic URL" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Некорректный URL-адрес" + "value" : "Введеный URL-адрес не является адресом темы" } } } }, - "Input..." : { + "Error moving posts" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Введите…" + "value" : "Ошибка перемещения постов" } } } }, - "Inputted URL is not forum URL" : { + "Error moving topic" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Введеный URL-адрес не является адресом форума" + "value" : "Ошибка перемещения темы" } } } }, - "Inputted URL is not topic URL" : { + "Incorrect URL" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Введеный URL-адрес не является адресом темы" + "value" : "Некорректный URL-адрес" } } } From d07252cdd75fb9d42b493d5a37ef6e2a195e80e4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 13 Apr 2026 14:59:20 +0300 Subject: [PATCH 44/44] Improve karma field in Post model --- Modules/Sources/Models/Post/Post.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/Models/Post/Post.swift b/Modules/Sources/Models/Post/Post.swift index cf3af0a6..6256d8bb 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -53,12 +53,14 @@ public struct Post: Sendable, Hashable, Identifiable, Codable { } public var karma: Int? { - if rawKarma & 1 > 0 { - let karma = rawKarma >> 3 + let karma = rawKarma >> 3 + let hasVotes = rawKarma & 2 > 0 + let canBeChanged = rawKarma & 1 > 0 + if canBeChanged { if karma != 0 { return karma } - if rawKarma & 2 > 0 && karma == 0 { + if hasVotes && karma == 0 { return 0 } }