diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index b717b3e5..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 @@ -62,7 +63,10 @@ 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 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 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 @@ -71,8 +75,9 @@ 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 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 // Favorites @@ -233,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, @@ -350,6 +361,26 @@ extension APIClient: DependencyKey { let response = try await api.send(ForumCommand.Topic.view(data: request)) return try await parser.parseTopic(response) }, + 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 + }, + 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, @@ -358,6 +389,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( @@ -431,8 +472,8 @@ extension APIClient: DependencyKey { return try await parser.parsePostSendResponse(response) }, - deletePosts: { ids in - let command = ForumCommand.Post.delete(postIds: ids) + 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 @@ -447,6 +488,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) @@ -633,6 +679,9 @@ extension APIClient: DependencyKey { editUserProfile: { _ in return true }, + addUserNote: { _, _ in + return .success + }, getReputationVotes: { _ in return .mock }, @@ -652,7 +701,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 @@ -669,9 +720,18 @@ extension APIClient: DependencyKey { getTopic: { _, _, _, _ in return .mock }, + modifyForum: { _, _, _ in + return true + }, + moveTopic: { _, _, _ in + return true + }, getTopicViewers: { _ in return .mock }, + setTopicCurator: { _, _, _ in + return true + }, getTemplate: { _, _ in return [.mockTitle, .mockRequiredText, .mockRequiredEditor, .mockEditor, .mockUploadBox] }, @@ -696,12 +756,15 @@ extension APIClient: DependencyKey { editPost: { _ in return .success(PostSend(id: 0, topicId: 1, offset: 2)) }, - deletePosts: { _ in + movePosts: { _, _ in return true }, postKarma: { _, _ in return true }, + postKarmaHistory: { _ in + return .mock + }, voteInTopicPoll: { _, _ 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/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/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/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/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/ForumFeature/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index e0ca4d3a..6a8a38fc 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..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,6 +122,7 @@ public struct ForumFeature: Reducer, Sendable { case contextOptionMenu(ForumOptionContextMenuAction) case contextTopicMenu(ForumTopicContextMenuAction, TopicInfo) + case contextTopicToolsMenu(ForumTopicToolsContextMenuAction) case contextCommonMenu(ForumCommonContextMenuAction, Int, Bool) } @@ -244,6 +247,27 @@ public struct ForumFeature: Reducer, Sendable { ) } + 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)): switch action { case .copyLink: diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index 47cd3c65..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 { @@ -212,6 +219,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 +265,58 @@ 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(.modify(.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(.modify(.hide, topic.id, !topic.isHidden))) + } + + ContextButton( + text: topic.isClosed + ? LocalizedStringResource("Open", bundle: .module) + : LocalizedStringResource("Close", bundle: .module), + symbol: topic.isClosed ? .lockFill : .lock + ) { + send(.contextTopicToolsMenu(.modify(.close, topic.id, !topic.isClosed))) + } + + if topic.canDelete { + ContextButton(text: LocalizedStringResource("Delete", bundle: .module), symbol: .trash) { + 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) + Image(systemSymbol: .shield) + } + } + } + // MARK: - Navigation @ViewBuilder @@ -409,10 +472,6 @@ extension Forum { ) ) { ForumFeature() - } withDependencies: { - $0.apiClient.getForum = { @Sendable _, _, _, _ in - return .finished() - } } ) } 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 650e7440..0389dc93 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" : { @@ -91,6 +121,16 @@ } } }, + "Move" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить" + } + } + } + }, "Open" : { "localizations" : { "ru" : { @@ -111,6 +151,16 @@ } } }, + "Pin" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепить" + } + } + } + }, "Pinned topics" : { "localizations" : { "ru" : { @@ -131,6 +181,16 @@ } } }, + "Remove Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать" + } + } + } + }, "Subforums" : { "localizations" : { "ru" : { @@ -141,6 +201,16 @@ } } }, + "Tools" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструменты" + } + } + } + }, "Topics" : { "localizations" : { "ru" : { @@ -150,6 +220,16 @@ } } } + }, + "Unpin" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открепить" + } + } + } } }, "version" : "1.0" diff --git a/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift new file mode 100644 index 00000000..b96e4a6f --- /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 = 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..30bb8c8d --- /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("Enter...", 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: "Entered URL is not topic URL" + case .needForumUrl: "Entered 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..5287df6e --- /dev/null +++ b/Modules/Sources/ForumMoveFeature/Resources/Localizable.xcstrings @@ -0,0 +1,126 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Cancel" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "Enter..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите…" + } + } + } + }, + "Entered URL is not forum URL" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введеный URL-адрес не является адресом форума" + } + } + } + }, + "Entered URL is not topic URL" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введеный URL-адрес не является адресом темы" + } + } + } + }, + "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-адрес" + } + } + } + }, + "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/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/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 } diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index 7ff5218f..702d5ab8 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -27,10 +27,22 @@ 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) } + public var isPinned: Bool { + return flag.contains(.pinned) + } + + public var isHidden: Bool { + return flag.contains(.hidden) + } + public var isClosed: Bool { return flag.contains(.closed) } @@ -117,7 +129,7 @@ public extension Topic { id: 3242552, name: "ForPDA", description: "Unofficial 4PDA client for iOS.", - flag: .canPost, + flag: [.canEdit, .canPost, .canDelete, .canModerate], createdAt: Date(timeIntervalSince1970: 1725706883), authorId: 3640948, authorName: "4spander", 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), 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..6256d8bb 100644 --- a/Modules/Sources/Models/Post/Post.swift +++ b/Modules/Sources/Models/Post/Post.swift @@ -20,12 +20,24 @@ 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) + return flag.contains(.protected) + } + + public var isLastEditHidden: Bool { + return !flag.contains(.marker) } public var canModerate: Bool { @@ -40,8 +52,19 @@ public struct Post: Sendable, Hashable, Identifiable, Codable { return flag.contains(.canDelete) } - public var karma: Int { - return rawKarma >> 3 + public var karma: Int? { + let karma = rawKarma >> 3 + let hasVotes = rawKarma & 2 > 0 + let canBeChanged = rawKarma & 1 > 0 + if canBeChanged { + if karma != 0 { + return karma + } + if hasVotes && karma == 0 { + return 0 + } + } + return nil } public var canChangeKarma: Bool { @@ -151,7 +174,7 @@ public extension Post { static func mock(id: Int = 0) -> Post { return Post( id: id, - flag: [.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, @@ -162,7 +185,7 @@ public extension Post { signature: "", reputationCount: 312 ), - karma: 1, + karma: 15, attachments: [ Attachment( id: 14308454, diff --git a/Modules/Sources/Models/Post/PostKarmaVote.swift b/Modules/Sources/Models/Post/PostKarmaVote.swift new file mode 100644 index 00000000..6b9a8dd5 --- /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, Equatable { + 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/Models/Post/PostMenuAction.swift b/Modules/Sources/Models/Post/PostMenuAction.swift index 1d663b30..34e2faa3 100644 --- a/Modules/Sources/Models/Post/PostMenuAction.swift +++ b/Modules/Sources/Models/Post/PostMenuAction.swift @@ -8,7 +8,6 @@ public enum PostMenuAction { case reply(Int, String) case edit(Post) - case delete(Int) case karma(Int) case report(Int) case changeReputation(Int, Int, String) 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/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/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.", 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 + } + } +} 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) }, 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/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) + } } } 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..0cd84e74 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 @@ -204,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) } } @@ -230,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 @@ -566,6 +614,7 @@ public struct ProfileScreen: View { .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } + .listRowBackground(Color(.clear)) } Section { @@ -580,6 +629,7 @@ public struct ProfileScreen: View { } .listRowSeparator(.visible) } + .listRowBackground(Color(.clear)) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } @@ -797,7 +847,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/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/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/SharedUI/Post/PostRowView.swift b/Modules/Sources/SharedUI/Post/PostRowView.swift index f83585c4..c5818190 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 @@ -26,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 { @@ -46,6 +50,7 @@ public struct PostRowView: View { if let lastEdit = state.post.post.lastEdit { Footer(lastEdit) } + PostStatus() } } @@ -92,10 +97,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) } } @@ -119,7 +131,7 @@ public struct PostRowView: View { } } - if state.isContextMenuAvailable, state.isUserAuthorized, state.canPostInTopic { + if state.isContextMenuAvailable { ContextMenu() } } @@ -148,18 +160,44 @@ 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 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) } @@ -170,13 +208,27 @@ 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 { 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,16 +244,22 @@ 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 { ContextButton(text: LocalizedStringResource("Delete", bundle: .module), symbol: .trash) { - menuAction(.delete(state.post.id)) + toolsMenuAction(.modify(.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) { @@ -237,6 +295,61 @@ 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("Restore", bundle: .module), + symbol: .arrowCounterclockwiseCircle + ) { + toolsMenuAction(.modify(.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 + ) { + toolsMenuAction(.modify(.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 + ) { + toolsMenuAction(.modify(.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 + ) { + 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 { + Text("Tools", bundle: .module) + Image(systemSymbol: .shield) + } + } + } } // MARK: - User Posts In Topic Icon 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 + } +} diff --git a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings index 2ea9754e..bedcb7b1 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" : { @@ -77,6 +87,16 @@ } } }, + "Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть" + } + } + } + }, "IN DEVELOPMENT" : { "localizations" : { "ru" : { @@ -87,6 +107,16 @@ } } }, + "Move" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить" + } + } + } + }, "Open In Browser" : { "localizations" : { "ru" : { @@ -97,6 +127,16 @@ } } }, + "Pin" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепить пост" + } + } + } + }, "Post Mentions" : { "localizations" : { "ru" : { @@ -107,6 +147,16 @@ } } }, + "Protect" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Защитить" + } + } + } + }, "Quote" : { "localizations" : { "ru" : { @@ -137,6 +187,26 @@ } } }, + "Remove Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показать" + } + } + } + }, + "Remove Protection" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Снять защиту" + } + } + } + }, "Reply" : { "localizations" : { "ru" : { @@ -167,6 +237,16 @@ } } }, + "Restore" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановить" + } + } + } + }, "Search «%@» posts" : { "localizations" : { "ru" : { @@ -177,6 +257,26 @@ } } }, + "This post deleted" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот пост удалён" + } + } + } + }, + "This post hidden" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот пост скрыт" + } + } + } + }, "Today, %@" : { "localizations" : { "ru" : { @@ -187,6 +287,16 @@ } } }, + "Tools" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструменты" + } + } + } + }, "Understood" : { "localizations" : { "ru" : { @@ -197,6 +307,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 ff565b11..43680466 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), @@ -66,8 +67,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): @@ -95,6 +94,10 @@ extension TopicFeature { case .writePostWithTemplate: analytics.log(TopicEvent.menuWritePostWithTemplate) } + + case .view(.contextToolsMenu), .view(.contextPostToolsMenu): + // MARK: 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..5f4ff1c4 --- /dev/null +++ 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/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 2afca264..c536abf3 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -61,6 +61,36 @@ } } }, + "Are you sure, that you want to delete this topic?" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить эту тему?" + } + } + } + }, + "Are you sure, that you want to restore this post?" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите восстановить этот пост?" + } + } + } + }, + "Close Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть тему" + } + } + } + }, "Copy Link" : { "localizations" : { "ru" : { @@ -71,6 +101,16 @@ } } }, + "Delete Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить тему" + } + } + } + }, "Down" : { "localizations" : { "ru" : { @@ -101,6 +141,26 @@ } } }, + "Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть тему" + } + } + } + }, + "Karma History" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История кармы" + } + } + } + }, "Link copied" : { "localizations" : { "ru" : { @@ -121,6 +181,16 @@ } } }, + "Move" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Переместить" + } + } + } + }, "No" : { "localizations" : { "ru" : { @@ -171,6 +241,16 @@ } } }, + "Open Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открыть тему" + } + } + } + }, "Poll" : { "localizations" : { "ru" : { @@ -201,6 +281,16 @@ } } }, + "Post restored" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пост восстановлен" + } + } + } + }, "Posts Filter" : { "localizations" : { "ru" : { @@ -221,6 +311,16 @@ } } }, + "Remove Hide" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Убрать скрытие темы" + } + } + } + }, "Removed from favorites" : { "localizations" : { "ru" : { @@ -271,6 +371,26 @@ } } }, + "Tools" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Инструменты" + } + } + } + }, + "Topic deleted" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема удалена" + } + } + } + }, "Topic Hat" : { "localizations" : { "ru" : { @@ -281,6 +401,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 1cc6e8de..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 { @@ -36,7 +37,10 @@ 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 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) @@ -50,14 +54,17 @@ public struct TopicFeature: Reducer, Sendable { case gallery([URL], [Int], Int) @ReducerCaseIgnored case karmaChange(Int) + case karmaHistory(PostKarmaHistoryFeature) case form(FormFeature) case stat(ForumStatFeature) + case move(ForumMoveFeature) case changeReputation(ReputationChangeFeature) case alert(AlertState) @CasePathable public enum Alert: Equatable { - case deletePost(Int) + case deletePost(Int, Bool) + case deleteTopic } } @@ -138,9 +145,12 @@ 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(TopicToolsContextMenuAction) case contextPostMenu(PostMenuAction) + case contextPostToolsMenu(PostToolsMenuAction) } case `internal`(Internal) @@ -219,15 +229,30 @@ 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(.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.deletePosts(postIds: [id]) - 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)) } .cancellable(id: CancelID.loading) + case .destination(.presented(.alert(.deleteTopic))): + return .run { [id = state.topicId] send in + 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)) + } + .cancellable(id: CancelID.loading) + case .binding(\.postsFilter): return .send(.internal(.load)) @@ -343,6 +368,35 @@ 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 .move: + state.destination = .move(ForumMoveFeature.State(type: .topic(topic.id))) + 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)): switch action { case let .reply(postId, authorName): @@ -374,10 +428,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 @@ -415,6 +465,30 @@ public struct TopicFeature: Reducer, Sendable { } } + case let .view(.contextPostToolsMenu(action)): + switch action { + case .move(let postId): + state.destination = .move(ForumMoveFeature.State(type: .posts([postId]))) + return .none + + case .modify(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, isUndo: isUndo)) + return .none + } + } + case .view(.changeKarmaTapped(let postId, let isUp)): return .send(.internal(.changeKarma(postId: postId, isUp: isUp))) @@ -437,6 +511,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 } @@ -702,11 +780,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) { @@ -715,6 +799,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 diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 88d8e266..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 { @@ -199,6 +200,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 +224,49 @@ 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(.modify(.hide, !topic.isHidden))) + } + + ContextButton( + text: topic.isClosed + ? LocalizedStringResource("Open Topic", bundle: .module) + : LocalizedStringResource("Close Topic", bundle: .module), + symbol: topic.isClosed ? .lockFill : .lock + ) { + send(.contextToolsMenu(.modify(.close, !topic.isClosed))) + } + + if topic.canDelete { + ContextButton(text: LocalizedStringResource("Delete Topic", bundle: .module), symbol: .trash) { + send(.contextToolsMenu(.modify(.delete, false))) + } + } + + ContextButton( + text: LocalizedStringResource("Move", bundle: .module), + symbol: .arrowRight + ) { + send(.contextToolsMenu(.move)) + } + } label: { + HStack { + Text("Tools", bundle: .module) + Image(systemSymbol: .shield) + } + } + } + @available(iOS, deprecated: 26.0) private func foregroundStyle() -> AnyShapeStyle { if isLiquidGlass { @@ -339,6 +385,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 @@ -347,8 +395,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): @@ -362,6 +408,14 @@ public struct TopicScreen: View { case .copyLink(let postId): send(.contextPostMenu(.copyLink(postId))) } + }, + 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))) + } } ) .listRowBackground(Color.clear) @@ -471,6 +525,17 @@ 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) + } + } .sheet(item: $store.scope(state: \.destination?.stat, action: \.destination.stat)) { store in NavigationStack { ForumStatView(store: store) @@ -490,7 +555,7 @@ extension View { // MARK: - Extensions // TODO: Move to extensions? -private extension Date { +extension Date { func formattedDate() -> LocalizedStringKey { let formatter = DateFormatter() formatter.dateFormat = "HH:mm" 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 diff --git a/Project.swift b/Project.swift index 702677ea..fee21ee0 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", @@ -363,6 +376,7 @@ let project = Project( .Internal.PersistenceKeys, .Internal.SharedUI, .Internal.ToastClient, + .Internal.FormFeature, .SPM.NukeUI, .SPM.RichTextKit, .SPM.SFSafeSymbols, @@ -513,6 +527,7 @@ let project = Project( .Internal.ToastClient, .Internal.TopicBuilder, .Internal.FormFeature, + .Internal.ForumMoveFeature, .Internal.ForumStatFeature, .SPM.MemberwiseInit, .SPM.NukeUI, @@ -1048,6 +1063,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") 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"), ] )