diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 7da2f856..5d908165 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -48,6 +48,7 @@ public struct APIClient: Sendable { 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 modifyReputation: @Sendable (_ id: Int, _ type: ReputationModifyActionType) async throws -> Bool public var changeReputation: @Sendable (_ data: ReputationChangeRequest) async throws -> ReputationChangeResponseType public var updateUserAvatar: @Sendable (_ userId: Int, _ image: Data) async throws -> UserAvatarResponseType public var updateUserDevice: @Sendable (_ userId: Int, _ action: UserDeviceAction, _ fullTag: String, _ isPrimary: Bool) async throws -> Bool @@ -59,6 +60,7 @@ public struct APIClient: Sendable { public var getForumsList: @Sendable (_ policy: CachePolicy) async throws -> AsyncThrowingStream<[ForumInfo], any Error> public var getForum: @Sendable (_ id: Int, _ page: Int, _ perPage: Int, _ policy: CachePolicy) async throws -> AsyncThrowingStream public var getForumStat: @Sendable (_ id: Int) async throws -> ForumStat + public var getForumEventLog: @Sendable (_ id: Int, _ type: ForumEventLogType) async throws -> [ForumEventLog] public var jumpForum: @Sendable (_ request: JumpForumRequest) async throws -> ForumJump public var markRead: @Sendable (_ id: Int, _ isTopic: Bool) async throws -> Bool public var getAnnouncement: @Sendable (_ id: Int) async throws -> Announcement @@ -259,6 +261,17 @@ extension APIClient: DependencyKey { return try await parser.parseReputationVotes(response) }, + modifyReputation: { id, type in + let command = MemberCommand.reputation(data: MemberReputationRequest( + memberId: 0, + action: type.transferType, + postId: id, + reason: "" + )) + let response = try await api.send(command) + let status = Int(response.getResponseStatus())! + return status == 0 + }, changeReputation: { request in let command = MemberCommand.reputation(data: MemberReputationRequest( memberId: request.userId, @@ -332,11 +345,17 @@ extension APIClient: DependencyKey { return try await parser.parseForumStat(response) }, + getForumEventLog: { id, type in + let command = ForumCommand.eventLog(type: type.rawValue, id: id) + let response = try await api.send(command) + return try await parser.parseForumEventLog(response) + }, + jumpForum: { request in let command = ForumCommand.jump(data: ForumJumpRequest( type: request.transferType, postId: request.postId, - allPosts: request.allPosts, + postsFilter: request.postsFilter.rawValue, topicId: request.topicId )) let response = try await api.send(command) @@ -504,7 +523,7 @@ extension APIClient: DependencyKey { return status == 0 }, postKarmaHistory: { postId in - let command = ForumCommand.Post.history(id: postId) + let command = ForumCommand.Post.karma(postId: postId, action: .history) let response = try await api.send(command) return try await parser.parsePostKarmaHistory(response) }, @@ -720,6 +739,9 @@ extension APIClient: DependencyKey { getReputationVotes: { _ in return .mock }, + modifyReputation: { _, _ in + return true + }, changeReputation: { _ in return .success }, @@ -743,6 +765,12 @@ extension APIClient: DependencyKey { getForumStat: { _ in return .mock }, + getForumEventLog: { _, type in + switch type { + case .post: return .mockPost + case .topic: return .mockTopic + } + }, jumpForum: { _ in return .mock }, diff --git a/Modules/Sources/APIClient/Models/Extensions/ReputationModifyActionType+Extension.swift b/Modules/Sources/APIClient/Models/Extensions/ReputationModifyActionType+Extension.swift new file mode 100644 index 00000000..833e51df --- /dev/null +++ b/Modules/Sources/APIClient/Models/Extensions/ReputationModifyActionType+Extension.swift @@ -0,0 +1,18 @@ +// +// ReputationModifyActionType+Extension.swift +// ForPDA +// +// Created by Xialtal on 16.05.26. +// + +import PDAPI +import Models + +extension ReputationModifyActionType { + nonisolated var transferType: MemberReputationRequest.ActionType { + switch self { + case .delete: .delete + case .restore: .restore + } + } +} diff --git a/Modules/Sources/APIClient/Requests/JumpForumRequest.swift b/Modules/Sources/APIClient/Requests/JumpForumRequest.swift index ba5049dc..b0da3829 100644 --- a/Modules/Sources/APIClient/Requests/JumpForumRequest.swift +++ b/Modules/Sources/APIClient/Requests/JumpForumRequest.swift @@ -7,11 +7,12 @@ import Foundation import PDAPI +import Models public struct JumpForumRequest { public let postId: Int public let topicId: Int - public let allPosts: Bool + public let postsFilter: TopicPostsFilter public let type: ForumJumpType nonisolated public var transferType: ForumJumpRequest.JumpType { @@ -25,12 +26,12 @@ public struct JumpForumRequest { public init( postId: Int, topicId: Int, - allPosts: Bool, + postsFilter: TopicPostsFilter, type: ForumJumpType ) { self.postId = postId self.topicId = topicId - self.allPosts = allPosts + self.postsFilter = postsFilter self.type = type } diff --git a/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift b/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift index 6dde1c7a..94430066 100644 --- a/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift +++ b/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift @@ -22,17 +22,12 @@ public struct ReputationChangeRequest: Sendable { public enum ChangeActionType: Sendable { case up case down - case delete - case recover } nonisolated var transferVoteType: MemberReputationRequest.ActionType { switch action { case .up: .plus case .down: .minus - - // TODO: Implement. - case .delete, .recover: .plus } } diff --git a/Modules/Sources/AnalyticsClient/Events/ReputationEvent.swift b/Modules/Sources/AnalyticsClient/Events/ReputationEvent.swift index 6b8184ed..56db8d09 100644 --- a/Modules/Sources/AnalyticsClient/Events/ReputationEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/ReputationEvent.swift @@ -17,6 +17,9 @@ public enum ReputationEvent: Event { case sourceTopicTapped(Int) case sourceArticleTapped(Int) + case voteMenuGoToAuthorTapped(Int) + case voteMenuComplainTapped(Int) + public var name: String { return "Reputation " + eventName(for: self).inProperCase } @@ -26,9 +29,12 @@ public enum ReputationEvent: Event { case let .profileTapped(profileId): return ["profileId": String(profileId)] - case let .complainTapped(voteId): + case let .voteMenuComplainTapped(voteId): return ["voteId": String(voteId)] + case let .voteMenuGoToAuthorTapped(profileId): + return ["profileId": String(profileId)] + case let .sourceProfileTapped(profileId): return ["profileId": String(profileId)] diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 624cff7e..c9a8ab67 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -39,6 +39,9 @@ import CacheClient import DeviceSpecificationsFeature import DeviceTypeFeature import MoreFeature +import TicketsListFeature +import TicketFeature +import ForumEventLogFeature @Reducer public struct AppFeature: Reducer, Sendable { @@ -588,10 +591,16 @@ public struct AppFeature: Reducer, Sendable { case .device(let tag, let subTag): .devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag))) } - case let .topic(id, goTo): - screen = .forum(.topic(TopicFeature.State(topicId: id!, goTo: goTo))) + case let .ticketsList(offset): + screen = .tickets(.ticketsList(TicketsListFeature.State(type: .list, initialOffset: offset))) + case let .ticket(id): + screen = .tickets(.ticket(TicketFeature.State(id: id))) + case let .topic(id, goTo, filter): + screen = .forum(.topic(TopicFeature.State(topicId: id!, goTo: goTo, postsFilter: filter))) case let .forum(id, page): screen = .forum(.forum(ForumFeature.State(forumId: id, initialPage: page))) + case let .eventLog(id, type): + screen = .forum(.eventLog(ForumEventLogFeature.State(id: id, type: type))) case let .user(id): screen = .more(.profile(ProfileFeature.State(userId: id))) case let .qms(id: id): diff --git a/Modules/Sources/AppFeature/AppView.swift b/Modules/Sources/AppFeature/AppView.swift index 56cbfbf5..c8f270c5 100644 --- a/Modules/Sources/AppFeature/AppView.swift +++ b/Modules/Sources/AppFeature/AppView.swift @@ -439,6 +439,14 @@ extension LiquidTabView { return nil } + case let .tickets(path): + switch path.case { + case let .ticketsList(store): + return store.scope(state: \.pageNavigation, action: \.pageNavigation) + default: + return nil + } + default: return nil } diff --git a/Modules/Sources/AppFeature/Navigation/Path.swift b/Modules/Sources/AppFeature/Navigation/Path.swift index 81a3b72b..3ad6ca25 100644 --- a/Modules/Sources/AppFeature/Navigation/Path.swift +++ b/Modules/Sources/AppFeature/Navigation/Path.swift @@ -15,6 +15,7 @@ import DeviceSpecificationsFeature import DeviceTypeFeature import FavoritesRootFeature import FavoritesFeature +import ForumEventLogFeature import ForumFeature import ForumsListFeature import HistoryFeature @@ -27,6 +28,8 @@ import ReputationFeature import SearchFeature import SearchResultFeature import SettingsFeature +import TicketFeature +import TicketsListFeature import TopicFeature import AuthFeature import MoreFeature @@ -37,6 +40,7 @@ public enum Path { case devDB(DevDB.Body = DevDB.body) case favorites(FavoritesFeature) case forum(Forum.Body = Forum.body) + case tickets(Tickets.Body = Tickets.body) case more(More.Body = More.body) case settings(Settings.Body = Settings.body) case search(Search.Body = Search.body) @@ -70,6 +74,13 @@ public enum Path { case forum(ForumFeature) case announcement(AnnouncementFeature) case topic(TopicFeature) + case eventLog(ForumEventLogFeature) + } + + @Reducer + public enum Tickets { + case ticketsList(TicketsListFeature) + case ticket(TicketFeature) } @Reducer @@ -98,6 +109,7 @@ extension Path.Articles.State: Equatable {} extension Path.DevDB.State: Equatable {} extension Path.More.State: Equatable {} extension Path.Forum.State: Equatable {} +extension Path.Tickets.State: Equatable {} extension Path.Settings.State: Equatable {} extension Path.Search.State: Equatable {} extension Path.QMS.State: Equatable {} @@ -122,6 +134,9 @@ extension Path { case let .forum(path): ForumViews(path) + case let .tickets(path): + TicketsViews(path) + case let .settings(path): SettingsViews(path) @@ -203,12 +218,26 @@ extension Path { TopicScreen(store: store) .tracking(for: TopicScreen.self, ["id": store.topicId]) + case let .eventLog(store): + ForumEventLogScreen(store: store) + case let .announcement(store): AnnouncementScreen(store: store) .tracking(for: AnnouncementScreen.self, ["id": store.announcementId]) } } + @MainActor @ViewBuilder + private static func TicketsViews(_ store: Store) -> some View { + switch store.case { + case let .ticketsList(store): + TicketsListScreen(store: store) + + case let .ticket(store): + TicketScreen(store: store) + } + } + @MainActor @ViewBuilder private static func SettingsViews(_ store: Store) -> some View { switch store.case { diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index e9e77104..f3cecae7 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -34,6 +34,9 @@ import SearchFeature import SearchResultFeature import DeviceSpecificationsFeature import DeviceTypeFeature +import TicketsListFeature +import TicketFeature +import ForumEventLogFeature @Reducer public struct StackTab: Reducer, Sendable { @@ -146,6 +149,9 @@ public struct StackTab: Reducer, Sendable { case let .forum(action): return handleForumPathNavigation(action: action, state: &state) + case let .tickets(action): + return handleTicketsPathNavigation(action: action, state: &state) + case let .more(action): return handleMorePathNavigation(action: action, state: &state) @@ -268,6 +274,15 @@ public struct StackTab: Reducer, Sendable { case let .topic(.delegate(.openUser(id: id))): state.path.append(.more(.profile(ProfileFeature.State(userId: id)))) + case let .topic(.delegate(.openTickets(id))): + state.path.append(.tickets(.ticketsList(TicketsListFeature.State(type: .topic(id))))) + + case let .topic(.delegate(.openTopic(id: id))): + state.path.append(.forum(.topic(TopicFeature.State(topicId: id, goTo: .last)))) + + case let .topic(.delegate(.openEventLog(id, type))): + state.path.append(.forum(.eventLog(ForumEventLogFeature.State(id: id, type: type)))) + case let .topic(.delegate(.openSearch(on, navigation))): state.path.append(.search(.search(SearchFeature.State(on: on, navigation: navigation)))) @@ -279,6 +294,17 @@ public struct StackTab: Reducer, Sendable { return .send(.path(.element(id: id, action: .forum(.forum(.internal(.refresh)))))) } + // Event Log + + case let .eventLog(.delegate(.openUser(id))): + state.path.append(.more(.profile(ProfileFeature.State(userId: id)))) + + case let .eventLog(.delegate(.openTopic(id))): + state.path.append(.forum(.topic(TopicFeature.State(topicId: id, goTo: .first)))) + + case let .eventLog(.delegate(.handleUrl(url))): + return handleDeeplink(url: url, state: &state) + // Announcement case let .announcement(.delegate(.handleUrl(url))): @@ -290,7 +316,29 @@ public struct StackTab: Reducer, Sendable { return .none } - // MARK: - More + // MARK: - Tickets + + private func handleTicketsPathNavigation(action: Path.Tickets.Action, state: inout State) -> Effect { + switch action { + case let .ticketsList(.delegate(.openTicket(id))): + state.path.append(.tickets(.ticket(TicketFeature.State(id: id)))) + + case let .ticketsList(.delegate(.openUser(id))): + state.path.append(.more(.profile(ProfileFeature.State(userId: id)))) + + case let .ticket(.delegate(.handleUrl(url))): + return handleDeeplink(url: url, state: &state) + + case let .ticket(.delegate(.openUser(id))): + state.path.append(.more(.profile(ProfileFeature.State(userId: id)))) + + default: + break + } + return .none + } + + // MARK: - Profile private func handleMorePathNavigation(action: Path.More.Action, state: inout State) -> Effect { switch action { @@ -309,6 +357,9 @@ public struct StackTab: Reducer, Sendable { case .more(.delegate(.openDevDB)): state.path.append(.devDB(.type(DeviceTypeFeature.State(content: .index)))) + case .more(.delegate(.openTickets)): + state.path.append(.tickets(.ticketsList(TicketsListFeature.State(type: .list)))) + case .more(.delegate(.openSettings)): state.path.append(.settings(.settings(SettingsFeature.State()))) @@ -441,7 +492,7 @@ public struct StackTab: Reducer, Sendable { do { let deeplink = try DeeplinkHandler().handleInnerToInnerURL(url) switch deeplink { - case let .topic(id: targetId, goTo: goTo): + case let .topic(id: targetId, goTo: goTo, filter: filter): if let targetId { // Deeplink in the same OR other topic if let (id, element) = state.path.last(is: \.forum.topic), let topicId = element.forum?.topic?.topicId, topicId == targetId { @@ -457,7 +508,7 @@ public struct StackTab: Reducer, Sendable { return .send(.path(.element(id: id, action: .forum(.topic(.internal(.load)))))) } else { // Post is NOT on the same page, opening new screen - state.path.append(.forum(.topic(TopicFeature.State(topicId: targetId, goTo: goTo)))) + state.path.append(.forum(.topic(TopicFeature.State(topicId: targetId, goTo: goTo, postsFilter: filter)))) return .none } } else { @@ -467,7 +518,7 @@ public struct StackTab: Reducer, Sendable { } // Different topic id or non-topic screen, pushing new screen instead - state.path.append(.forum(.topic(TopicFeature.State(topicId: targetId, goTo: goTo)))) + state.path.append(.forum(.topic(TopicFeature.State(topicId: targetId, goTo: goTo, postsFilter: filter)))) } else if let (id, _) = state.path.last(is: \.forum.topic) { // Deeplink in the same topic ONLY (inner-inner deeplink case) state.path[id: id, case: \.forum.topic]?.goTo = goTo @@ -481,6 +532,9 @@ public struct StackTab: Reducer, Sendable { case let .forum(id: id, page: page): state.path.append(.forum(.forum(ForumFeature.State(forumId: id, initialPage: page)))) + case let .eventLog(id, type): + state.path.append(.forum(.eventLog(ForumEventLogFeature.State(id: id, type: type)))) + case let .announcement(id: id): state.path.append(.forum(.announcement(AnnouncementFeature.State(id: id)))) @@ -490,6 +544,12 @@ public struct StackTab: Reducer, Sendable { case let .qms(id: id): state.path.append(.qms(.qms(QMSFeature.State(chatId: id)))) + case let .ticketsList(offset: offset): + state.path.append(.tickets(.ticketsList(TicketsListFeature.State(type: .list, initialOffset: offset)))) + + case let .ticket(id: id): + state.path.append(.tickets(.ticket(TicketFeature.State(id: id)))) + case let .search(options: options): state.path.append(.search(.searchResult(SearchResultFeature.State(search: options)))) diff --git a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift index 1b4629bc..d0282a98 100644 --- a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift +++ b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift @@ -14,12 +14,15 @@ import Models public enum Deeplink { case article(id: Int, title: String, imageUrl: URL, scrollToId: Int?) case announcement(id: Int) - case topic(id: Int?, goTo: GoTo) + case topic(id: Int?, goTo: GoTo, filter: TopicPostsFilter?) case forum(id: Int, page: Int) case user(id: Int) case qms(id: Int) case search(SearchResult) case device(DeviceGoTo) + case ticketsList(offset: Int) + case ticket(Int) + case eventLog(Int, ForumEventLogType) } public struct DeeplinkHandler { @@ -93,14 +96,14 @@ public struct DeeplinkHandler { guard let id = Int(url.lastPathComponent) else { throw .badIdOnMatch(in: url) } if let offset = components.queryItems?.first(where: { $0.name == "st" })?.value.flatMap(Int.init) { if let entry = components.queryItems?.first(where: { $0.name == "entry" })?.value.flatMap(Int.init) { - return .topic(id: id, goTo: .post(id: entry)) + return .topic(id: id, goTo: .post(id: entry), filter: nil) } else { @Shared(.appSettings) var appSettings: AppSettings let page = getPage(forOffset: offset, userPerPage: appSettings.topicPerPage) - return .topic(id: id, goTo: .page(page)) + return .topic(id: id, goTo: .page(page), filter: nil) } } else { - return .topic(id: id, goTo: .first) + return .topic(id: id, goTo: .first, filter: nil) } case "user": @@ -165,7 +168,8 @@ public struct DeeplinkHandler { // site search - if let siteSearchItem = queryItems.first(where: { $0.name == "s" }), let value = siteSearchItem.value, !value.isEmpty { + if let siteSearchItem = queryItems.first(where: { $0.name == "s" }), let value = siteSearchItem.value, !value.isEmpty, + (url.pathComponents.count == 0 || url.pathComponents.count == 1) { // https://4pda.to/?s=4pda let searchText = if let decodedSearchText = value.removingPercentEncoding { decodedSearchText @@ -181,31 +185,35 @@ public struct DeeplinkHandler { // showtopic if let topicItem = queryItems.first(where: { $0.name == "showtopic" }), let value = topicItem.value, let topicId = Int(value) { + let postsFilter: TopicPostsFilter? = if let modfilterItem = queryItems.first(where: { $0.name == "modfilter" }), + let postsFilter = TopicPostsFilter(rawValue: modfilterItem.value) { + postsFilter + } else { nil } if let viewType = queryItems.first(where: { $0.name == "view" })?.value { switch viewType { case "findpost": if let postItem = queryItems.first(where: { $0.name == "p" }), let value = postItem.value, let postId = Int(value) { // https://4pda.to/forum/index.php?showtopic=123456&view=findpost&p=123456789 - return .topic(id: topicId, goTo: .post(id: postId)) + return .topic(id: topicId, goTo: .post(id: postId), filter: postsFilter) } else { analytics.capture(DeeplinkError.noType(of: "p", for: url.absoluteString)) } case "getnewpost": // https://4pda.to/forum/index.php?showtopic=123456&view=getnewpost - return .topic(id: topicId, goTo: .unread) + return .topic(id: topicId, goTo: .unread, filter: postsFilter) case "getlastpost": // https://4pda.to/forum/index.php?showtopic=673755&view=getlastpost - return .topic(id: topicId, goTo: .last) + return .topic(id: topicId, goTo: .last, filter: postsFilter) default: analytics.capture(DeeplinkError.unknownType(type: viewType, for: url.absoluteString)) } } - // https://4pda.to/forum/index.php?showtopic=123456 - return .topic(id: topicId, goTo: .first) + // https://4pda.to/forum/index.php?showtopic=123456&modfilter=all-posts + return .topic(id: topicId, goTo: .first, filter: postsFilter) } // showforum @@ -232,11 +240,42 @@ public struct DeeplinkHandler { case "findpost": // https://4pda.to/forum/index.php?act=findpost&pid=136063497 if let postItem = queryItems.first(where: { $0.name == "pid" }), let value = postItem.value, let postId = Int(value) { - return .topic(id: nil, goTo: .post(id: postId)) + return .topic(id: nil, goTo: .post(id: postId), filter: nil) } else { analytics.capture(DeeplinkError.noType(of: "pid", for: url.absoluteString)) } + case "ticket": + if let ticketItem = queryItems.first(where: { $0.name == "t_id" }), let value = ticketItem.value, let ticketId = Int(value) { + // https://4pda.to/forum/index.php?act=ticket&s=thread&t_id=123456 + return .ticket(ticketId) + } else { + // https://4pda.to/forum/index.php?act=ticket&st=20 + let offset = if let ticketItem = queryItems.first(where: { $0.name == "st" }), + let value = ticketItem.value, let offset = Int(value) { + offset + } else { 0 } + return .ticketsList(offset: offset) + } + + case "mod": + // https://4pda.to/forum/index.php?act=mod&code=90&p=2121425241 + if let modItem = queryItems.first(where: { $0.name == "code" }), let value = modItem.value, let code = Int(value) { + switch code { + case 90: // topic/post event log + if let postIdItem = queryItems.first(where: { $0.name == "p" }), let value = postIdItem.value, let postId = Int(value) { + return .eventLog(postId, .post) + } else if let topicIdItem = queryItems.first(where: { $0.name == "t" }), let value = topicIdItem.value, let topicId = Int(value) { + return .eventLog(topicId, .topic) + } + + default: + analytics.capture(DeeplinkError.unknownType(type: "code:\(code)", for: url.absoluteString)) + } + } else { + analytics.capture(DeeplinkError.noType(of: "code", for: url.absoluteString)) + } + case "search": // https://4pda.to/forum/index.php?act=search&query=4pda&source=all&sort=dd&subforums=1&topics=673847&hl=0 // https://4pda.to/forum/index.php?act=search&query=Xiaomi+%25E0%25EA%25F1%25E5%25F1%25F1%25F3%25E0%25F0%25FB&username=AirFlare&forums%255B%255D=716&subforums=1&exclude_trash=1&source=top&sort=dd&result=topics @@ -344,10 +383,10 @@ public struct DeeplinkHandler { return Deeplink.forum(id: id, page: 1) case .topic: // Currently we don't have id of a post to jump due to limited api - return Deeplink.topic(id: id, goTo: .unread) + return Deeplink.topic(id: id, goTo: .unread, filter: nil) case .forumMention: // Forum mention has topic id in timestamp place - return Deeplink.topic(id: timestamp, goTo: .post(id: id)) + return Deeplink.topic(id: timestamp, goTo: .post(id: id), filter: nil) case .siteMention: return Deeplink.article(id: id, title: "", imageUrl: URL(string: "/")!, scrollToId: timestamp) } diff --git a/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift new file mode 100644 index 00000000..fb775baa --- /dev/null +++ b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift @@ -0,0 +1,131 @@ +// +// ForumEventLogFeature.swift +// ForPDA +// +// Created by Xialtal on 14.05.26. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models +import ToastClient +import PasteboardClient + +@Reducer +public struct ForumEventLogFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public let id: Int + public let type: ForumEventLogType + + var eventLog: [ForumEventLog] = [] + var isLoading = false + + public init( + id: Int, + type: ForumEventLogType + ) { + self.id = id + self.type = type + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + + case urlTapped(URL) + case userButtonTapped(Int) + + case contextMenu(ForumEventLogContextMenuAction) + } + + case `internal`(Internal) + public enum Internal { + case loadEventLog + case eventLogResponse(Result<[ForumEventLog], any Error>) + } + + case delegate(Delegate) + public enum Delegate { + case openUser(Int) + case openTopic(Int) + case handleUrl(URL) + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.toastClient) private var toastClient + @Dependency(\.pasteboardClient) private var pasteboardClient + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .send(.internal(.loadEventLog)) + + case let .view(.urlTapped(url)): + return .send(.delegate(.handleUrl(url))) + + case let .view(.userButtonTapped(id)): + return .send(.delegate(.openUser(id))) + + case let .view(.contextMenu(action)): + switch action { + case .goToSubject: + switch state.type { + case .post: + let link = "https://4pda.to/forum/index.php?act=findpost&pid=\(state.id)" + return .send(.delegate(.handleUrl(URL(string: link)!))) + case .topic: + return .send(.delegate(.openTopic(state.id))) + } + + case .copyLink: + let type = state.type == .post ? "p" : "t" + pasteboardClient.copy("https://4pda.to/forum/index.php?act=mod&code=90&\(type)=\(state.id)") + return .run { _ in + let message = ToastMessage(text: LocalizedStringResource("Link copied", bundle: .module), haptic: .success) + await toastClient.showToast(message) + } + } + + case .internal(.loadEventLog): + state.isLoading = true + return .run { [id = state.id, type = state.type] send in + let response = try await apiClient.getForumEventLog(id, type) + await send(.internal(.eventLogResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.eventLogResponse(.failure(error)))) + } + + case let .internal(.eventLogResponse(.success(response))): + state.eventLog = response + state.isLoading = false + return .none + + case let .internal(.eventLogResponse(.failure(error))): + print(error) + state.isLoading = false + return .none + + case .delegate: + return .none + } + } + } +} + diff --git a/Modules/Sources/ForumEventLogFeature/ForumEventLogScreen.swift b/Modules/Sources/ForumEventLogFeature/ForumEventLogScreen.swift new file mode 100644 index 00000000..beaf607f --- /dev/null +++ b/Modules/Sources/ForumEventLogFeature/ForumEventLogScreen.swift @@ -0,0 +1,169 @@ +// +// ForumEventLogScreen.swift +// ForPDA +// +// Created by Xialtal on 14.05.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI +import BBBuilder + +@ViewAction(for: ForumEventLogFeature.self) +public struct ForumEventLogScreen: View { + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + List(store.eventLog, id: \.self) { event in + EventRow(event) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + } + ._toolbarTitleDisplayMode(.inline) + .navigationTitle(Text(navigationTitleText(), bundle: .module)) + .toolbar { + ToolbarItem { + OptionsMenu() + } + } + .overlay { + if store.isLoading { + PDALoader() + .frame(width: 24, height: 24) + } + } + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - Options Menu + + @ViewBuilder + private func OptionsMenu() -> some View { + WithPerceptionTracking { + Menu { + Section { + let text = switch store.type { + case .post: LocalizedStringResource("Go to Post", bundle: .module) + case .topic: LocalizedStringResource("Go to Topic", bundle: .module) + } + ContextButton(text: text, symbol: .chevronRight2) { + send(.contextMenu(.goToSubject)) + } + } + + ContextButton(text: LocalizedStringResource("Copy Link", bundle: .module), symbol: .docOnDoc) { + send(.contextMenu(.copyLink)) + } + } label: { + Image(systemSymbol: .ellipsisCircle) + } + } + } + + // MARK: - Event Row + + private func EventRow(_ event: ForumEventLog) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Button { + send(.userButtonTapped(event.userId)) + } label: { + HStack(spacing: 6) { + Text(verbatim: event.userName) + .foregroundStyle(Color(.Labels.primary)) + + Image(systemSymbol: .chevronRight) + .foregroundStyle(Color(.Labels.quaternary)) + } + .font(.subheadline) + .fontWeight(.semibold) + } + .buttonStyle(.plain) + + Spacer() + + Text(verbatim: event.createdAt.formatted()) + .font(.caption) + .foregroundStyle(Color(.Labels.quaternary)) + } + + if let content = event.contentAttributed { + RichText(text: content, isSelectable: true, onUrlTap: { url in + send(.urlTapped(url)) + }) + } else { + Text(verbatim: event.content) + .font(.subheadline) + } + } + .listRowBackground(Color.clear) + } + + // MARK: - Helpers + + private func navigationTitleText() -> LocalizedStringKey { + return switch store.type { + case .post: "Post History \(String(store.id))" + case .topic: "Topic History \(String(store.id))" + } + } +} + +// MARK: - Extensions + +extension ForumEventLog { + var contentAttributed: NSAttributedString? { + guard !content.isEmpty else { return nil } + return BBRenderer(baseAttributes: [.font: UIFont.preferredFont(forTextStyle: .callout)]) + .render(text: content) + } +} + +// MARK: - Previews + +#Preview("Post Events") { + NavigationStack { + ForumEventLogScreen( + store: Store( + initialState: ForumEventLogFeature.State( + id: 0, + type: .post + ) + ) { + ForumEventLogFeature() + } + ) + } +} + +#Preview("Topic Events") { + NavigationStack { + ForumEventLogScreen( + store: Store( + initialState: ForumEventLogFeature.State( + id: 0, + type: .topic + ) + ) { + ForumEventLogFeature() + } + ) + } +} diff --git a/Modules/Sources/ForumEventLogFeature/Models/ForumEventLogContextMenuAction.swift b/Modules/Sources/ForumEventLogFeature/Models/ForumEventLogContextMenuAction.swift new file mode 100644 index 00000000..a56ca84a --- /dev/null +++ b/Modules/Sources/ForumEventLogFeature/Models/ForumEventLogContextMenuAction.swift @@ -0,0 +1,12 @@ +// +// ForumEventLogContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 15.05.26. +// + +public enum ForumEventLogContextMenuAction { + case copyLink + case goToSubject + // TODO: bookmarks +} diff --git a/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..66109351 --- /dev/null +++ b/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings @@ -0,0 +1,66 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Copy Link" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать ссылку" + } + } + } + }, + "Go to Post" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти к посту" + } + } + } + }, + "Go to Topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти в тему" + } + } + } + }, + "Link copied" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ссылка скопирована" + } + } + } + }, + "Post History %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История поста %@" + } + } + } + }, + "Topic History %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История темы %@" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 703c8111..36340181 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -29,6 +29,7 @@ public struct ForumFeature: Reducer, Sendable { public enum Localization { static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) + static let topicMoved = LocalizedStringResource("Topic moved", bundle: .module) static let topicEdited = LocalizedStringResource("The topic has been edited", bundle: .module) static let markAsReadSuccess = LocalizedStringResource("Marked as read", bundle: .module) } @@ -139,7 +140,7 @@ public struct ForumFeature: Reducer, Sendable { public enum Delegate { case openUser(id: Int) case openTopic(id: Int, name: String, goTo: GoTo) - case openForum(id: Int, name: String) + case openForum(id: Int, name: String?) case openAnnouncement(id: Int, name: String) case openSearch(on: SearchOn, navigation: ForumInfo?) case handleRedirect(URL) @@ -172,6 +173,12 @@ public struct ForumFeature: Reducer, Sendable { case let .destination(.presented(.stat(.delegate(.userTapped(id))))): return .send(.delegate(.openUser(id: id))) + case let .destination(.presented(.move(.delegate(.openForum(id))))): + return .run { send in + await toastClient.showToast(ToastMessage(text: Localization.topicMoved, haptic: .success)) + await send(.delegate(.openForum(id: id, name: nil))) + } + case .destination(.presented(.edit(.delegate(.topicEdited)))): return .run { _ in await toastClient.showToast(ToastMessage(text: Localization.topicEdited, haptic: .success)) diff --git a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings index 2240dc6a..5e9d904a 100644 --- a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings @@ -241,6 +241,16 @@ } } }, + "Topic moved" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема перемещена" + } + } + } + }, "Topics" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift index 0a3623e1..5db912cc 100644 --- a/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift +++ b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift @@ -85,7 +85,8 @@ public struct ForumMoveFeature: Reducer, Sendable { case delegate(Delegate) public enum Delegate { - case openDeeplink(Deeplink) + case openTopic(Int) + case openForum(Int) } } @@ -116,7 +117,7 @@ public struct ForumMoveFeature: Reducer, Sendable { if let url = URL(string: state.inputUrl.trimmingCharacters(in: .whitespacesAndNewlines)), let artefact = try? DeeplinkHandler().handleInnerToInnerURL(url) { switch artefact { - case .topic(let topicId, _): + case .topic(let topicId, _, _): guard case .posts(let ids) = state.type else { state.error = .needForumUrl break @@ -166,7 +167,7 @@ public struct ForumMoveFeature: Reducer, Sendable { case let .internal(.movePostsResponse(.success((status, toTopicId)))): if status { - return .send(.delegate(.openDeeplink(.topic(id: toTopicId, goTo: .last)))) + return .send(.delegate(.openTopic(toTopicId))) } return .send(.internal(.movePostsResponse(.failure(NSError(domain: "MP", code: -1))))) @@ -182,7 +183,7 @@ public struct ForumMoveFeature: Reducer, Sendable { case let .internal(.moveTopicResponse(.success((status, toForumId)))): if status { - return .send(.delegate(.openDeeplink(.forum(id: toForumId, page: 0)))) + return .send(.delegate(.openForum(toForumId))) } return .send(.internal(.moveTopicResponse(.failure(NSError(domain: "MT", code: -1))))) diff --git a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift index aeb18e87..ed41b419 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift @@ -78,7 +78,7 @@ public struct ForumStatFeature: Reducer, Sendable { case closeButtonTapped case shareLinkButtonTapped - case openInBrowserButtonTapped + case loadTopicHistoryButtonTapped } case destination(PresentationAction) @@ -97,6 +97,7 @@ public struct ForumStatFeature: Reducer, Sendable { case delegate(Delegate) public enum Delegate { case userTapped(Int) + case topicHistoryTapped } } @@ -143,9 +144,10 @@ public struct ForumStatFeature: Reducer, Sendable { state.destination = .share(IdentifiedURL(url)) return .none - case .view(.openInBrowserButtonTapped): - return .run { [shareLink = state.shareLink] _ in - await openURL(URL(string: shareLink)!) + case .view(.loadTopicHistoryButtonTapped): + return .run { send in + await send(.delegate(.topicHistoryTapped)) + await dismiss() } case let .internal(.loadTopicStat(topic)): diff --git a/Modules/Sources/ForumStatFeature/ForumStatView.swift b/Modules/Sources/ForumStatFeature/ForumStatView.swift index 51206b81..8ef9cd63 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatView.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatView.swift @@ -52,7 +52,9 @@ public struct ForumStatView: View { } .background(Color(.Background.primary)) .safeAreaInset(edge: .bottom) { - OpenInBrowserButton() + if case let .topic(topic) = store.type, topic.canModerate { + OpenTopicHistoryButton() + } } .toolbar { Toolbar() @@ -154,24 +156,22 @@ public struct ForumStatView: View { .padding(.top, 18) } - // MARK: - Open In Browser Button + // MARK: - Open Topic History Button - private func OpenInBrowserButton() -> some View { + private func OpenTopicHistoryButton() -> some View { Button { - send(.openInBrowserButtonTapped) + send(.loadTopicHistoryButtonTapped) } label: { - HStack { - Text(verbatim: store.shareLink) - .font(.footnote) - .foregroundStyle(Color(.Labels.teritary)) - .frame(maxWidth: .infinity, alignment: .leading) - - Image(systemSymbol: .arrowUpRight) - .font(.callout) - .foregroundStyle(tintColor) - } + Text("Load History", bundle: .module) + .padding(8) + .frame(maxWidth: .infinity) } - .padding(16) + .buttonStyle(.bordered) + .tint(tintColor) + .frame(height: 48) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color(.Background.primary)) } // MARK: - Header diff --git a/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings index e7b24b27..cc59dedd 100644 --- a/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumStatFeature/Resources/Localizable.xcstrings @@ -71,6 +71,16 @@ } } }, + "Load History" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузить историю" + } + } + } + }, "Moderators" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/Models/Forum/ForumEventLog.swift b/Modules/Sources/Models/Forum/ForumEventLog.swift new file mode 100644 index 00000000..2e33966b --- /dev/null +++ b/Modules/Sources/Models/Forum/ForumEventLog.swift @@ -0,0 +1,66 @@ +// +// ForumEventLog.swift +// ForPDA +// +// Created by Xialtal on 14.05.26. +// + +import Foundation + +public struct ForumEventLog: Sendable, Equatable, Hashable { + public let userId: Int + public let userName: String + public let userGroup: User.Group + public let content: String + public let createdAt: Date + + public init( + userId: Int, + userName: String, + userGroup: User.Group, + content: String, + createdAt: Date + ) { + self.userId = userId + self.userName = userName + self.userGroup = userGroup + self.content = content + self.createdAt = createdAt + } +} + +public extension Array where Array == [ForumEventLog] { + static let mockPost: [ForumEventLog] = [ + .init( + userId: 6176341, + userName: "AirFlare", + userGroup: .regular, + content: "Post changed: old name: ForPDA One Love", + createdAt: Date.now + ), + .init( + userId: 6176341, + userName: "AirFlare", + userGroup: .regular, + content: "Post hidden: ([url=\"https://4pda.to/forum/index.php?showtopic=1104159&view=findpost&p=139696274\"]139696274[/url])", + createdAt: Date.now - 17 + ) + ] + + static let mockTopic: [ForumEventLog] = [ + .init( + userId: 6176341, + userName: "AirFlare", + userGroup: .regular, + content: "Topic changed: old name: ForPDA [iOS]", + createdAt: Date.now + ), + .init( + userId: 6176341, + userName: "AirFlare", + userGroup: .regular, + content: "Post pinned: ([url=\"https://4pda.to/forum/index.php?showtopic=1104159&view=findpost&p=139696274\"]139696274[/url])", + createdAt: Date.now - 17 + ) + ] +} diff --git a/Modules/Sources/Models/Forum/ForumEventLogType.swift b/Modules/Sources/Models/Forum/ForumEventLogType.swift new file mode 100644 index 00000000..aa2f1611 --- /dev/null +++ b/Modules/Sources/Models/Forum/ForumEventLogType.swift @@ -0,0 +1,11 @@ +// +// ForumEventLogType.swift +// ForPDA +// +// Created by Xialtal on 14.05.26. +// + +public enum ForumEventLogType: Int, Sendable, Hashable, Equatable { + case post = 1 + case topic = 0 +} diff --git a/Modules/Sources/Models/Forum/ForumJump.swift b/Modules/Sources/Models/Forum/ForumJump.swift index 811805d0..e71e4efc 100644 --- a/Modules/Sources/Models/Forum/ForumJump.swift +++ b/Modules/Sources/Models/Forum/ForumJump.swift @@ -9,18 +9,18 @@ public struct ForumJump: Codable, Hashable, Sendable { public let id: Int public let offset: Int public let postId: Int - public let allPosts: Bool + public let postsFilter: TopicPostsFilter public init( id: Int, offset: Int, postId: Int, - allPosts: Bool + postsFilter: TopicPostsFilter ) { self.id = id self.offset = offset self.postId = postId - self.allPosts = allPosts + self.postsFilter = postsFilter } } @@ -29,6 +29,6 @@ public extension ForumJump { id: 0, offset: 12, postId: 21212, - allPosts: false + postsFilter: .onlyDefault ) } diff --git a/Modules/Sources/Models/Forum/TopicPostsFilter.swift b/Modules/Sources/Models/Forum/TopicPostsFilter.swift index b2d78bed..ba2ab50f 100644 --- a/Modules/Sources/Models/Forum/TopicPostsFilter.swift +++ b/Modules/Sources/Models/Forum/TopicPostsFilter.swift @@ -5,7 +5,7 @@ // Created by Xialtal on 28.12.25. // -public enum TopicPostsFilter: Int, Sendable, Identifiable, CaseIterable { +public enum TopicPostsFilter: Int, Sendable, Codable, Hashable, Identifiable, CaseIterable { case all = 3 case onlyHidden = 1 case onlyDefault = 4 @@ -15,4 +15,28 @@ public enum TopicPostsFilter: Int, Sendable, Identifiable, CaseIterable { public var id: Int { self.rawValue } + + public init?(rawValue: String?) { + switch rawValue { + case "all-posts": + self = .all + case "invisible-posts": + self = .onlyHidden + case "regular-posts": + self = .onlyDefault + case "deleted-posts": + self = .onlyDeleted + default: return nil + } + } + + public var modfilter: String? { + switch self { + case .all: "all-posts" + case .onlyHidden: "invisible-posts" + case .onlyDefault: "regular-posts" + case .onlyDeleted: "deleted-posts" + case .exceptDeleted: nil + } + } } diff --git a/Modules/Sources/Models/Post/PostToolsMenuAction.swift b/Modules/Sources/Models/Post/PostToolsMenuAction.swift index c07c33d9..b8436783 100644 --- a/Modules/Sources/Models/Post/PostToolsMenuAction.swift +++ b/Modules/Sources/Models/Post/PostToolsMenuAction.swift @@ -7,5 +7,6 @@ public enum PostToolsMenuAction { case move(Int) + case eventLog(Int) case modify(PostModifyAction, Int, Bool) } diff --git a/Modules/Sources/Models/Profile/ReputationModifyActionType.swift b/Modules/Sources/Models/Profile/ReputationModifyActionType.swift new file mode 100644 index 00000000..67808e58 --- /dev/null +++ b/Modules/Sources/Models/Profile/ReputationModifyActionType.swift @@ -0,0 +1,11 @@ +// +// ReputationModifyActionType.swift +// ForPDA +// +// Created by Xialtal on 16.05.26. +// + +public enum ReputationModifyActionType: Sendable, Equatable { + case delete + case restore +} diff --git a/Modules/Sources/Models/Profile/ReputationVote.swift b/Modules/Sources/Models/Profile/ReputationVote.swift index 7edb5655..d7de0a49 100644 --- a/Modules/Sources/Models/Profile/ReputationVote.swift +++ b/Modules/Sources/Models/Profile/ReputationVote.swift @@ -17,7 +17,7 @@ public struct ReputationVote: Decodable, Hashable, Sendable, Identifiable { public let authorId: Int public let authorName: String public let reason: String - public let modified: VoteModified? + public var modified: VoteModified? public let createdIn: VoteCreatedIn public let createdAt: Date public let isDown: Bool @@ -63,14 +63,14 @@ public struct ReputationVote: Decodable, Hashable, Sendable, Identifiable { } public var markLabel: String { - flag == 1 ? "Raised" : "Lowered" + !isDown ? "Raised" : "Lowered" } public var arrowSymbol: SFSymbol { if #available(iOS 17.0, *) { - return flag == 1 ? .arrowshapeUpFill : .arrowshapeDownFill + return !isDown ? .arrowshapeUpFill : .arrowshapeDownFill } else { - return flag == 1 ? .arrowUp : .arrowDown + return !isDown ? .arrowUp : .arrowDown } } @@ -109,11 +109,13 @@ public struct ReputationVote: Decodable, Hashable, Sendable, Identifiable { public struct VoteModified: Codable, Hashable, Sendable { public let userId: Int public let userName: String + public let modifiedAt: Date public let isDenied: Bool - public init(userId: Int, userName: String, isDenied: Bool) { + public init(userId: Int, userName: String, modifiedAt: Date, isDenied: Bool) { self.userId = userId self.userName = userName + self.modifiedAt = modifiedAt self.isDenied = isDenied } } @@ -123,7 +125,7 @@ public struct ReputationVote: Decodable, Hashable, Sendable, Identifiable { public extension ReputationVote { static let mock = ReputationVote( - id: 1, + id: Int.random(in: 1..<1000000), flag: 1, toId: 23232, toName: "AirFlare", @@ -135,4 +137,25 @@ public extension ReputationVote { createdAt: .now, isDown: false ) + + static func mockModified(isDenied: Bool) -> Self { + return ReputationVote( + id: Int.random(in: 1..<1000000), + flag: 1, + toId: 25266252, + toName: "Test", + authorId: 12345678, + authorName: "Author", + reason: "Noooooo", + modified: .init( + userId: 1709, + userName: "AirFlare", + modifiedAt: Date.now, + isDenied: isDenied + ), + createdIn: .profile, + createdAt: Date.now, + isDown: true + ) + } } diff --git a/Modules/Sources/Models/Profile/ReputationVotes.swift b/Modules/Sources/Models/Profile/ReputationVotes.swift index f420a2d9..3bf80217 100644 --- a/Modules/Sources/Models/Profile/ReputationVotes.swift +++ b/Modules/Sources/Models/Profile/ReputationVotes.swift @@ -20,7 +20,7 @@ public struct ReputationVotes: Decodable, Hashable, Sendable { public extension ReputationVotes { static let mock = ReputationVotes( - votes: [.mock], - votesCount: 1 + votes: [.mock, .mockModified(isDenied: true), .mockModified(isDenied: false)], + votesCount: 3 ) } diff --git a/Modules/Sources/Models/Resources/Localizable.xcstrings b/Modules/Sources/Models/Resources/Localizable.xcstrings index 823dc26a..26394260 100644 --- a/Modules/Sources/Models/Resources/Localizable.xcstrings +++ b/Modules/Sources/Models/Resources/Localizable.xcstrings @@ -101,6 +101,36 @@ } } }, + "Not processed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не обработан" + } + } + } + }, + "Processed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработан" + } + } + } + }, + "Processing" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В работе" + } + } + } + }, "Repeated comment" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/Models/Settings/AppSettings.swift b/Modules/Sources/Models/Settings/AppSettings.swift index 1c2078ec..fa042869 100644 --- a/Modules/Sources/Models/Settings/AppSettings.swift +++ b/Modules/Sources/Models/Settings/AppSettings.swift @@ -20,6 +20,7 @@ public struct AppSettings: Sendable, Equatable, Codable { public var articlesListRowType: ArticleListRowType public var bookmarksListRowType: ArticleListRowType public var startPage: AppTab + public var topicShowAllPostsFilter: Bool public var topicOpeningStrategy: TopicOpeningStrategy public var appColorScheme: AppColorScheme public var backgroundTheme: BackgroundTheme @@ -27,12 +28,14 @@ public struct AppSettings: Sendable, Equatable, Codable { public var notifications: NotificationsSettings public var backgroundNotifications2: Bool public var backupServer: Bool + public var tickets: TicketsSettings public var favorites: FavoritesSettings public var searchSort: SearchSort public var forumPerPage: Int public var topicPerPage: Int public var historyPerPage: Int public var mentionsPerPage: Int + public var ticketsPerPage: Int public var hideTabBarOnScroll: Bool public var floatingNavigation: Bool public var experimentalFloatingNavigation: Bool @@ -43,6 +46,7 @@ public struct AppSettings: Sendable, Equatable, Codable { articlesListRowType: ArticleListRowType, bookmarksListRowType: ArticleListRowType, startPage: AppTab, + topicShowAllPostsFilter: Bool, topicOpeningStrategy: TopicOpeningStrategy, appColorScheme: AppColorScheme, backgroundTheme: BackgroundTheme, @@ -50,12 +54,14 @@ public struct AppSettings: Sendable, Equatable, Codable { notifications: NotificationsSettings, backgroundNotifications2: Bool, backupServer: Bool, + tickets: TicketsSettings, favorites: FavoritesSettings, searchSort: SearchSort, forumPerPage: Int, topicPerPage: Int, historyPerPage: Int, mentionsPerPage: Int, + ticketsPerPage: Int, hideTabBarOnScroll: Bool, floatingNavigation: Bool, experimentalFloatingNavigation: Bool, @@ -65,6 +71,7 @@ public struct AppSettings: Sendable, Equatable, Codable { self.articlesListRowType = articlesListRowType self.bookmarksListRowType = bookmarksListRowType self.startPage = startPage + self.topicShowAllPostsFilter = topicShowAllPostsFilter self.topicOpeningStrategy = topicOpeningStrategy self.appColorScheme = appColorScheme self.backgroundTheme = backgroundTheme @@ -72,12 +79,14 @@ public struct AppSettings: Sendable, Equatable, Codable { self.notifications = notifications self.backgroundNotifications2 = backgroundNotifications2 self.backupServer = backupServer + self.tickets = tickets self.favorites = favorites self.searchSort = searchSort self.forumPerPage = forumPerPage self.topicPerPage = topicPerPage self.historyPerPage = historyPerPage self.mentionsPerPage = mentionsPerPage + self.ticketsPerPage = ticketsPerPage self.hideTabBarOnScroll = hideTabBarOnScroll self.floatingNavigation = floatingNavigation self.experimentalFloatingNavigation = experimentalFloatingNavigation @@ -90,6 +99,7 @@ public struct AppSettings: Sendable, Equatable, Codable { self.articlesListRowType = try container.decodeIfPresent(ArticleListRowType.self, forKey: .articlesListRowType) ?? AppSettings.default.articlesListRowType self.bookmarksListRowType = try container.decodeIfPresent(ArticleListRowType.self, forKey: .bookmarksListRowType) ?? AppSettings.default.bookmarksListRowType self.startPage = try container.decodeIfPresent(AppTab.self, forKey: .startPage) ?? AppSettings.default.startPage + self.topicShowAllPostsFilter = try container.decodeIfPresent(Bool.self, forKey: .topicShowAllPostsFilter) ?? AppSettings.default.topicShowAllPostsFilter self.topicOpeningStrategy = try container.decodeIfPresent(TopicOpeningStrategy.self, forKey: .topicOpeningStrategy) ?? AppSettings.default.topicOpeningStrategy self.appColorScheme = try container.decodeIfPresent(AppColorScheme.self, forKey: .appColorScheme) ?? AppSettings.default.appColorScheme self.backgroundTheme = try container.decodeIfPresent(BackgroundTheme.self, forKey: .backgroundTheme) ?? AppSettings.default.backgroundTheme @@ -97,12 +107,14 @@ public struct AppSettings: Sendable, Equatable, Codable { self.notifications = try container.decodeIfPresent(NotificationsSettings.self, forKey: .notifications) ?? AppSettings.default.notifications self.backgroundNotifications2 = try container.decodeIfPresent(Bool.self, forKey: .backgroundNotifications2) ?? AppSettings.default.backgroundNotifications2 self.backupServer = try container.decodeIfPresent(Bool.self, forKey: .backupServer) ?? AppSettings.default.backupServer + self.tickets = try container.decodeIfPresent(TicketsSettings.self, forKey: .tickets) ?? AppSettings.default.tickets self.favorites = try container.decodeIfPresent(FavoritesSettings.self, forKey: .favorites) ?? AppSettings.default.favorites self.searchSort = try container.decodeIfPresent(SearchSort.self, forKey: .searchSort) ?? AppSettings.default.searchSort self.forumPerPage = try container.decodeIfPresent(Int.self, forKey: .forumPerPage) ?? AppSettings.default.forumPerPage self.topicPerPage = try container.decodeIfPresent(Int.self, forKey: .topicPerPage) ?? AppSettings.default.topicPerPage self.historyPerPage = try container.decodeIfPresent(Int.self, forKey: .historyPerPage) ?? AppSettings.default.historyPerPage self.mentionsPerPage = try container.decodeIfPresent(Int.self, forKey: .mentionsPerPage) ?? AppSettings.default.mentionsPerPage + self.ticketsPerPage = try container.decodeIfPresent(Int.self, forKey: .ticketsPerPage) ?? AppSettings.default.ticketsPerPage self.hideTabBarOnScroll = try container.decodeIfPresent(Bool.self, forKey: .hideTabBarOnScroll) ?? AppSettings.default.hideTabBarOnScroll self.floatingNavigation = try container.decodeIfPresent(Bool.self, forKey: .floatingNavigation) ?? AppSettings.default.floatingNavigation self.experimentalFloatingNavigation = try container.decodeIfPresent(Bool.self, forKey: .experimentalFloatingNavigation) ?? AppSettings.default.experimentalFloatingNavigation @@ -117,6 +129,7 @@ public struct AppSettings: Sendable, Equatable, Codable { "articlesListRowType": articlesListRowType.rawValue, "bookmarksListRowType": bookmarksListRowType.rawValue, "startPage": startPage.rawValue, + "topicShowAllPostsFilter": topicShowAllPostsFilter, "topicOpeningStrategy": topicOpeningStrategy._rawValue, "appColorScheme": appColorScheme._rawValue, "backgroundTheme": backgroundTheme._rawValue, @@ -124,6 +137,7 @@ public struct AppSettings: Sendable, Equatable, Codable { "notifications": notifications.asDictionary(), "backgroundNotifications": backgroundNotifications2, "backupServer": backupServer, + "tickets": tickets.asDictionary(), "favorites": favorites.asDictionary(), "searchSort": searchSort._rawValue, "hideTabBarOnScroll": hideTabBarOnScroll, @@ -139,6 +153,7 @@ public extension AppSettings { articlesListRowType: .short, bookmarksListRowType: .short, startPage: .articles, + topicShowAllPostsFilter: false, topicOpeningStrategy: .first, appColorScheme: .system, backgroundTheme: .blue, @@ -146,12 +161,14 @@ public extension AppSettings { notifications: .default, backgroundNotifications2: true, backupServer: false, + tickets: .default, favorites: .default, searchSort: .relevance, forumPerPage: 30, topicPerPage: 20, historyPerPage: 20, mentionsPerPage: 20, + ticketsPerPage: 20, hideTabBarOnScroll: true, floatingNavigation: true, experimentalFloatingNavigation: false, diff --git a/Modules/Sources/Models/Settings/TicketsSettings.swift b/Modules/Sources/Models/Settings/TicketsSettings.swift new file mode 100644 index 00000000..92635fb2 --- /dev/null +++ b/Modules/Sources/Models/Settings/TicketsSettings.swift @@ -0,0 +1,33 @@ +// +// TicketsSettings.swift +// ForPDA +// +// Created by Xialtal on 9.05.26. +// + +public struct TicketsSettings: Sendable, Codable, Hashable { + public var isSortByForums: Bool + public var isShowOnlyMine: Bool + + public init( + isSortByForums: Bool, + isShowOnlyMine: Bool + ) { + self.isSortByForums = isSortByForums + self.isShowOnlyMine = isShowOnlyMine + } + + public func asDictionary() -> [String: Any] { + return [ + "isSortByForums": isSortByForums, + "isShowOnlyMine": isShowOnlyMine + ] + } +} + +extension TicketsSettings { + static let `default` = TicketsSettings( + isSortByForums: false, + isShowOnlyMine: false + ) +} diff --git a/Modules/Sources/Models/Ticket/Ticket.swift b/Modules/Sources/Models/Ticket/Ticket.swift new file mode 100644 index 00000000..2491bcdf --- /dev/null +++ b/Modules/Sources/Models/Ticket/Ticket.swift @@ -0,0 +1,65 @@ +// +// Ticket.swift +// ForPDA +// +// Created by Xialtal on 3.05.26. +// + +import Foundation + +public struct Ticket: Sendable, Equatable { + public let info: TicketInfo + public let comments: [Comment] + + public struct Comment: Sendable, Equatable, Identifiable { + public let id: Int + public let content: String + public let authorId: Int + public let authorName: String + public let createdAt: Date + + public init( + id: Int, + content: String, + authorId: Int, + authorName: String, + createdAt: Date + ) { + self.id = id + self.content = content + self.authorId = authorId + self.authorName = authorName + self.createdAt = createdAt + } + } + + public init( + info: TicketInfo, + comments: [Comment] + ) { + self.info = info + self.comments = comments + } +} + +public extension Ticket { + static let mock = Ticket( + info: .mock, + comments: [ + .init( + id: 0, + content: "New topic: ForPDA [iOS]. [B]Automatic notification.[/B]", + authorId: 6176341, + authorName: "AirFlare", + createdAt: Date.now + ), + .init( + id: 1, + content: "Wow, you are [B]genius[/B]!", + authorId: 3640948, + authorName: "subvertd", + createdAt: Date.now + ) + ] + ) +} diff --git a/Modules/Sources/Models/Ticket/TicketInfo.swift b/Modules/Sources/Models/Ticket/TicketInfo.swift new file mode 100644 index 00000000..f8d418d0 --- /dev/null +++ b/Modules/Sources/Models/Ticket/TicketInfo.swift @@ -0,0 +1,68 @@ +// +// TicketInfo.swift +// ForPDA +// +// Created by Xialtal on 3.05.26. +// + +import Foundation + +public struct TicketInfo: Sendable, Equatable { + public let title: String + public var status: TicketStatus + public let subjectId: Int + public let subjectElementId: Int + public let subjectRootId: Int + public let subjectRootName: String + public let authorId: Int + public let authorName: String + public var handlerId: Int + public var handlerName: String + public let createdAt: Date + public var processedAt: Date? + + public init( + title: String, + status: TicketStatus, + subjectId: Int, + subjectElementId: Int, + subjectRootId: Int, + subjectRootName: String, + authorId: Int, + authorName: String, + handlerId: Int, + handlerName: String, + createdAt: Date, + processedAt: Date? + ) { + self.title = title + self.status = status + self.subjectId = subjectId + self.subjectElementId = subjectElementId + self.subjectRootId = subjectRootId + self.subjectRootName = subjectRootName + self.authorId = authorId + self.authorName = authorName + self.handlerId = handlerId + self.handlerName = handlerName + self.createdAt = createdAt + self.processedAt = processedAt + } +} + +public extension TicketInfo { + static let mock = TicketInfo( + title: "New topic: ForPDA [iOS]", + status: .processing, + subjectId: 1104159, + subjectElementId: 136063497, + subjectRootId: 140, + subjectRootName: "iOS - Programs", + authorId: 6176341, + authorName: "AirFlare", + handlerId: 3640948, + handlerName: "subvertd", + createdAt: Date.now, + processedAt: nil + ) +} diff --git a/Modules/Sources/Models/Ticket/TicketStatus.swift b/Modules/Sources/Models/Ticket/TicketStatus.swift new file mode 100644 index 00000000..b5b28f68 --- /dev/null +++ b/Modules/Sources/Models/Ticket/TicketStatus.swift @@ -0,0 +1,29 @@ +// +// TicketStatus.swift +// ForPDA +// +// Created by Xialtal on 3.05.26. +// + +import Foundation + +public enum TicketStatus: Int, Sendable, CaseIterable, Identifiable { + case notProcessed = 0 + case processing = 1 + case processed = 2 + + public var id: Int { + return self.rawValue + } + + public var title: LocalizedStringResource { + switch self { + case .notProcessed: + return .init("Not processed", bundle: .module) + case .processing: + return .init("Processing", bundle: .module) + case .processed: + return .init("Processed", bundle: .module) + } + } +} diff --git a/Modules/Sources/Models/Ticket/TicketStatusChangeResponse.swift b/Modules/Sources/Models/Ticket/TicketStatusChangeResponse.swift new file mode 100644 index 00000000..ec95bc13 --- /dev/null +++ b/Modules/Sources/Models/Ticket/TicketStatusChangeResponse.swift @@ -0,0 +1,16 @@ +// +// TicketStatusChangeResponse.swift +// ForPDA +// +// Created by Xialtal on 8.05.26. +// + +public enum TicketStatusChangeResponse: Sendable { + case success + case failure(TicketStatusChangeError) + + public enum TicketStatusChangeError: Sendable { + case handlerChanged(id: Int, name: String) + case other + } +} diff --git a/Modules/Sources/Models/Ticket/TicketStatusHistory.swift b/Modules/Sources/Models/Ticket/TicketStatusHistory.swift new file mode 100644 index 00000000..f5682ec7 --- /dev/null +++ b/Modules/Sources/Models/Ticket/TicketStatusHistory.swift @@ -0,0 +1,54 @@ +// +// TicketStatusHistory.swift +// ForPDA +// +// Created by Xialtal on 10.05.26. +// + +import Foundation + +public struct TicketStatusHistory: Sendable, Identifiable, Equatable { + public let status: TicketStatus + public let handlerId: Int + public let handlerName: String + public let changedAt: Date + + public var id: Int { + return Int(changedAt.timeIntervalSince1970) | handlerId + } + + public init( + status: TicketStatus, + handlerId: Int, + handlerName: String, + changedAt: Date + ) { + self.status = status + self.handlerId = handlerId + self.handlerName = handlerName + self.changedAt = changedAt + } +} + +public extension TicketStatusHistory { + static let mockNotProcessed = TicketStatusHistory( + status: .notProcessed, + handlerId: 0, + handlerName: "", + changedAt: Date.distantPast + ) + + static let mockProcessing = TicketStatusHistory( + status: .processing, + handlerId: 6176341, + handlerName: "AirFlare", + changedAt: Date.now - 6176341 + ) + + static let mockProcessed = TicketStatusHistory( + status: .processed, + handlerId: 6176341, + handlerName: "AirFlare", + changedAt: Date.now + ) +} diff --git a/Modules/Sources/Models/Ticket/TicketsList.swift b/Modules/Sources/Models/Ticket/TicketsList.swift new file mode 100644 index 00000000..a026b89e --- /dev/null +++ b/Modules/Sources/Models/Ticket/TicketsList.swift @@ -0,0 +1,35 @@ +// +// TicketsList.swift +// ForPDA +// +// Created by Xialtal on 3.05.26. +// + +public struct TicketsList: Sendable, Equatable { + public let tickets: [TicketSimplified] + public let availableCount: Int + + public struct TicketSimplified: Sendable, Identifiable, Equatable { + public let id: Int + public var info: TicketInfo + + public init(id: Int, info: TicketInfo) { + self.id = id + self.info = info + } + } + + public init(tickets: [TicketSimplified], availableCount: Int) { + self.tickets = tickets + self.availableCount = availableCount + } +} + +public extension TicketsList { + static let mock = TicketsList( + tickets: [ + .init(id: 0, info: .mock) + ], + availableCount: 1 + ) +} diff --git a/Modules/Sources/MoreFeature/Resources/Localizable.xcstrings b/Modules/Sources/MoreFeature/Resources/Localizable.xcstrings index dfbba415..9c84dd6f 100644 --- a/Modules/Sources/MoreFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/MoreFeature/Resources/Localizable.xcstrings @@ -131,6 +131,16 @@ } } }, + "Tickets" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тикеты" + } + } + } + }, "Topic on 4PDA" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/MoreFeature/Sources/Analytics/MoreFeature+Analytics.swift b/Modules/Sources/MoreFeature/Sources/Analytics/MoreFeature+Analytics.swift index 636b1d15..026c96a1 100644 --- a/Modules/Sources/MoreFeature/Sources/Analytics/MoreFeature+Analytics.swift +++ b/Modules/Sources/MoreFeature/Sources/Analytics/MoreFeature+Analytics.swift @@ -37,6 +37,10 @@ extension MoreFeature { case .view(.devDBButtonTapped): analytics.log(MoreEvent.devDBTapped) + case .view(.ticketsButtonTapped): + // MARK: Moderator tools are skip analytics + break + case .view(.settingsButtonTapped): analytics.log(MoreEvent.settingsTapped) diff --git a/Modules/Sources/MoreFeature/Sources/MoreFeature.swift b/Modules/Sources/MoreFeature/Sources/MoreFeature.swift index db95f820..0be0ccfd 100644 --- a/Modules/Sources/MoreFeature/Sources/MoreFeature.swift +++ b/Modules/Sources/MoreFeature/Sources/MoreFeature.swift @@ -39,6 +39,16 @@ public struct MoreFeature: Reducer, Sendable { return userSession != nil } + var isTicketsAvailable: Bool { + guard let user else { return false } + return user.group == .admin + || user.group == .supermoderator + || user.group == .moderator + || user.group == .moderatorHelper + || user.group == .moderatorSchool + || user.group == .curator + } + public init() {} } @@ -57,6 +67,7 @@ public struct MoreFeature: Reducer, Sendable { case mentionsButtonTapped case historyButtonTapped case devDBButtonTapped + case ticketsButtonTapped case settingsButtonTapped @@ -92,6 +103,7 @@ public struct MoreFeature: Reducer, Sendable { case openMentions case openHistory case openDevDB + case openTickets case openSettings case openDeeplink(URL) } @@ -149,6 +161,9 @@ public struct MoreFeature: Reducer, Sendable { case .view(.devDBButtonTapped): return .send(.delegate(.openDevDB)) + case .view(.ticketsButtonTapped): + return .send(.delegate(.openTickets)) + case .view(.settingsButtonTapped): return .send(.delegate(.openSettings)) diff --git a/Modules/Sources/MoreFeature/Sources/MoreScreen.swift b/Modules/Sources/MoreFeature/Sources/MoreScreen.swift index 0441173f..a61208e4 100644 --- a/Modules/Sources/MoreFeature/Sources/MoreScreen.swift +++ b/Modules/Sources/MoreFeature/Sources/MoreScreen.swift @@ -203,6 +203,10 @@ public struct MoreScreen: View { Row(symbol: ._smartphone, title: "DevDB") { send(.devDBButtonTapped) } + + Row(symbol: .exclamationmarkBubble, title: "Tickets") { + send(.ticketsButtonTapped) + } } } .listRowBackground(Color(.Background.teritary)) diff --git a/Modules/Sources/PageNavigationFeature/PageNavigationFeature.swift b/Modules/Sources/PageNavigationFeature/PageNavigationFeature.swift index ab724c04..016b0933 100644 --- a/Modules/Sources/PageNavigationFeature/PageNavigationFeature.swift +++ b/Modules/Sources/PageNavigationFeature/PageNavigationFeature.swift @@ -16,6 +16,7 @@ public enum PageNavigationType { case topic case history case mentions + case tickets } @Reducer @@ -67,6 +68,7 @@ public struct PageNavigationFeature: Reducer, Sendable { case .topic: self.perPage = _appSettings.topicPerPage.wrappedValue case .history: self.perPage = _appSettings.historyPerPage.wrappedValue case .mentions: self.perPage = _appSettings.mentionsPerPage.wrappedValue + case .tickets: self.perPage = _appSettings.ticketsPerPage.wrappedValue } } } diff --git a/Modules/Sources/ParsingClient/Parsers/ForumParser.swift b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift index 49c568ba..7203e516 100644 --- a/Modules/Sources/ParsingClient/Parsers/ForumParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift @@ -74,6 +74,28 @@ public struct ForumParser { } } + public static func parseForumEventLog(from string: String) throws -> [ForumEventLog] { + if let data = string.data(using: .utf8) { + do { + guard let array = try JSONSerialization.jsonObject(with: data, options: []) as? [Any] else { throw ParsingError.failedToCastDataToAny } + + return (array[2] as! [[Any]]).map { event in + return ForumEventLog( + userId: event[1] as! Int, + userName: (event[2] as! String).convertCodes(), + userGroup: User.Group(rawValue: event[3] as! Int)!, + content: event[4] as! String, + createdAt: Date(timeIntervalSince1970: TimeInterval(event[0] as! Int)) + ) + } + } catch { + throw ParsingError.failedToSerializeData(error) + } + } else { + throw ParsingError.failedToCreateDataFromString + } + } + internal static func parseForumStatModerators(_ array: [[Any]]) -> [ForumStat.ForumModerator] { return array.map { moderator in return ForumStat.ForumModerator( @@ -97,7 +119,7 @@ public struct ForumParser { id: array[2] as! Int, offset: array[3] as! Int, postId: array[4] as! Int, - allPosts: array[5] as! Int == 1 ? true : false + postsFilter: TopicPostsFilter(rawValue: array[5] as! Int)! ) } catch { throw ParsingError.failedToSerializeData(error) diff --git a/Modules/Sources/ParsingClient/Parsers/ReputationParser.swift b/Modules/Sources/ParsingClient/Parsers/ReputationParser.swift index 22adbbc5..c8ade56f 100644 --- a/Modules/Sources/ParsingClient/Parsers/ReputationParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/ReputationParser.swift @@ -52,10 +52,10 @@ public struct ReputationParser { id: id, flag: flag, toId: toId, - toName: try toName.convertHtmlCodes(), + toName: toName.convertCodes(), authorId: authorId, - authorName: try authorName.convertHtmlCodes(), - reason: try reason.convertHtmlCodes(), + authorName: authorName.convertCodes(), + reason: reason.convertCodes(), modified: try parseVoteModified(vote, flag), createdIn: try parseVoteCreatedIn(vote), createdAt: Date(timeIntervalSince1970: createdAt), @@ -73,14 +73,16 @@ public struct ReputationParser { return nil } - guard let userId = vote[safe: 12] as? Int, + guard let modifiedAt = vote[safe: 11] as? Int, + let userId = vote[safe: 12] as? Int, let userName = vote[safe: 13] as? String else { throw ParsingError.failedToCastFields } return ReputationVote.VoteModified( userId: userId, - userName: userName, + userName: userName.convertCodes(), + modifiedAt: Date(timeIntervalSince1970: TimeInterval(modifiedAt)), isDenied: flag & 2 != 0 ) } @@ -96,9 +98,9 @@ public struct ReputationParser { return if mainId == 0 { .profile } else { if id > 0 { - .topic(id: mainId, topicName: mainName, postId: id) + .topic(id: mainId, topicName: mainName.convertCodes(), postId: id) } else { - .site(id: mainId, articleName: mainName, commentId: abs(id)) + .site(id: mainId, articleName: mainName.convertCodes(), commentId: abs(id)) } } } diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift new file mode 100644 index 00000000..81ebd1ba --- /dev/null +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -0,0 +1,209 @@ +// +// TicketParser.swift +// ForPDA +// +// Created by Xialtal on 3.05.26. +// + +import Foundation +import Models + +public struct TicketParser { + + // MARK: - Ticket Response + + public static func parse(from string: String) throws(ParsingError) -> Ticket { + 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 title = array[safe: 3] as? String, + let subjectId = array[safe: 14] as? Int, + let subjectElementId = array[safe: 15] as? Int, + let subjectRootId = array[safe: 4] as? Int, + let subjectRootName = array[safe: 5] as? String, + let createdAt = array[safe: 6] as? Int, + let processedAt = array[safe: 7] as? Int, + let authorId = array[safe: 8] as? Int, + let authorName = array[safe: 9] as? String, + let handlerId = array[safe: 10] as? Int, + let handlerName = array[safe: 11] as? String, + let commentsRaw = array[safe: 13] as? [[Any]], + let statusRaw = array[safe: 2] as? Int else { + throw ParsingError.failedToCastFields + } + + return Ticket( + info: TicketInfo( + title: title.convertCodes(), + status: TicketStatus(rawValue: statusRaw)!, + subjectId: subjectId, + subjectElementId: subjectElementId, + subjectRootId: subjectRootId, + subjectRootName: subjectRootName.convertCodes(), + authorId: authorId, + authorName: authorName.convertCodes(), + handlerId: handlerId, + handlerName: handlerName.convertCodes(), + createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)), + processedAt: processedAt != 0 ? Date(timeIntervalSince1970: TimeInterval(processedAt)) : nil + ), + comments: try parseComments(commentsRaw) + ) + } + + // MARK: - Ticket Comments + + private static func parseComments(_ commentsRaw: [[Any]]) throws(ParsingError) -> [Ticket.Comment] { + var comments: [Ticket.Comment] = [] + for comment in commentsRaw { + guard let id = comment[safe: 0] as? Int, + let content = comment[safe: 4] as? String, + let authorId = comment[safe: 2] as? Int, + let authorName = comment[safe: 3] as? String, + let createdAt = comment[safe: 1] as? Int else { + throw ParsingError.failedToCastFields + } + + comments.append(.init( + id: id, + content: content, + authorId: authorId, + authorName: authorName.convertCodes(), + createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)) + )) + } + return comments + } + + // MARK: - Tickets List + + public static func parseTicketsList(from string: String) throws(ParsingError) -> TicketsList { + 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 availableCount = array[safe: 2] as? Int, + let ticketsRaw = array[safe: 3] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return TicketsList( + tickets: try parseTicketsInfo(ticketsRaw), + availableCount: availableCount + ) + } + + // MARK: - Tickets Info + + private static func parseTicketsInfo(_ infoRaw: [[Any]]) throws(ParsingError) -> [TicketsList.TicketSimplified] { + var ticketsInfo: [TicketsList.TicketSimplified] = [] + for info in infoRaw { + guard let id = info[safe: 0] as? Int, + let title = info[safe: 2] as? String, + let subjectId = info[safe: 12] as? Int, + let subjectElementId = info[safe: 13] as? Int, + let subjectRootId = info[safe: 3] as? Int, + let subjectRootName = info[safe: 4] as? String, + let createdAt = info[safe: 5] as? Int, + let processedAt = info[safe: 6] as? Int, + let authorId = info[safe: 7] as? Int, + let authorName = info[safe: 8] as? String, + let handlerId = info[safe: 9] as? Int, + let handlerName = info[safe: 10] as? String, + let statusRaw = info[safe: 1] as? Int else { + throw ParsingError.failedToCastFields + } + + ticketsInfo.append(.init( + id: id, + info: TicketInfo( + title: title.convertCodes(), + status: TicketStatus(rawValue: statusRaw)!, + subjectId: subjectId, + subjectElementId: subjectElementId, + subjectRootId: subjectRootId, + subjectRootName: subjectRootName.convertCodes(), + authorId: authorId, + authorName: authorName.convertCodes(), + handlerId: handlerId, + handlerName: handlerName.convertCodes(), + createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)), + processedAt: processedAt != 0 ? Date(timeIntervalSince1970: TimeInterval(processedAt)) : nil + ) + )) + } + return ticketsInfo + } + + // MARK: - Ticket Status Change Response + + public static func parseChangeTicketStatus(from string: String) throws(ParsingError) -> TicketStatusChangeResponse { + 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 status = array[safe: 1] as? Int else { + throw ParsingError.failedToCastFields + } + + switch status { + case 0: + return .success + + case 4: + guard let handlerId = array[safe: 1] as? Int, + let handlerName = array[safe: 2] as? String else { + throw ParsingError.failedToCastFields + } + return .failure(.handlerChanged(id: handlerId, name: handlerName.convertCodes())) + + default: + return .failure(.other) + } + } + + // MARK: - Ticket Status History Response + + public static func parseTicketStatusHistory(from string: String) throws(ParsingError) -> [TicketStatusHistory] { + 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 contentRaw = array[safe: 2] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return try! contentRaw.map { statusRaw in + guard let status = statusRaw[safe: 0] as? Int, + let handlerId = statusRaw[safe: 2] as? Int, + let handlerName = statusRaw[safe: 3] as? String, + let changedAt = statusRaw[safe: 1] as? Int else { + throw ParsingError.failedToCastFields + } + + return TicketStatusHistory( + status: TicketStatus(rawValue: status)!, + handlerId: handlerId, + handlerName: handlerName.convertCodes(), + changedAt: Date(timeIntervalSince1970: TimeInterval(changedAt)) + ) + } + } +} diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 35c559c2..656db811 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -35,6 +35,7 @@ public struct ParsingClient: Sendable { public var parseForumJump: @Sendable (_ response: String) async throws -> ForumJump public var parseForum: @Sendable (_ response: String) async throws -> Forum public var parseForumStat: @Sendable (_ response: String) async throws -> ForumStat + public var parseForumEventLog: @Sendable (_ response: String) async throws -> [ForumEventLog] public var parseTopic: @Sendable (_ response: String) async throws -> Topic public var parseTopicViewers: @Sendable (_ response: String) async throws -> TopicViewers public var parseAnnouncement: @Sendable (_ response: String) async throws -> Announcement @@ -66,6 +67,12 @@ public struct ParsingClient: Sendable { public var parseDeviceBrands: @Sendable (_ response: String) async throws -> DeviceVendorsList public var parseDeviceVendor: @Sendable (_ response: String) async throws -> DeviceVendor public var parseDeviceSpecifications: @Sendable (_ response: String) async throws -> DeviceSpecifications + + // Ticket + public var parseTicketsList: @Sendable (_ response: String) async throws -> TicketsList + public var parseTicket: @Sendable (_ response: String) async throws -> Ticket + public var parseChangeTicketStatus: @Sendable (_ response: String) async throws -> TicketStatusChangeResponse + public var parseTicketStatusHistory: @Sendable (_ response: String) async throws -> [TicketStatusHistory] } // MARK: - Dependency Key @@ -114,6 +121,9 @@ extension ParsingClient: DependencyKey { parseForumStat: { response in return try ForumParser.parseForumStat(from: response) }, + parseForumEventLog: { response in + return try ForumParser.parseForumEventLog(from: response) + }, parseTopic: { response in return try TopicParser.parse(from: response) }, @@ -176,6 +186,18 @@ extension ParsingClient: DependencyKey { }, parseDeviceSpecifications: { response in return try DevDBParser.parse(from: response) + }, + parseTicketsList: { response in + return try TicketParser.parseTicketsList(from: response) + }, + parseTicket: { response in + return try TicketParser.parse(from: response) + }, + parseChangeTicketStatus: { response in + return try TicketParser.parseChangeTicketStatus(from: response) + }, + parseTicketStatusHistory: { response in + return try TicketParser.parseTicketStatusHistory(from: response) } ) } diff --git a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift index e6705a38..75dde944 100644 --- a/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift +++ b/Modules/Sources/ProfileFeature/Analytics/ProfileFeature+Analytics.swift @@ -42,7 +42,7 @@ extension ProfileFeature { switch action { case .edit: analyticsClient.log(ProfileEvent.editTapped) - case .addNotice: + case .addNotice, .changeReputation: // MARK: Moderator tools are skip analytics break } diff --git a/Modules/Sources/ProfileFeature/Models/ProfileContextMenuAction.swift b/Modules/Sources/ProfileFeature/Models/ProfileContextMenuAction.swift index efaf851c..5e765274 100644 --- a/Modules/Sources/ProfileFeature/Models/ProfileContextMenuAction.swift +++ b/Modules/Sources/ProfileFeature/Models/ProfileContextMenuAction.swift @@ -8,4 +8,5 @@ public enum ProfileContextMenuAction { case edit case addNotice + case changeReputation } diff --git a/Modules/Sources/ProfileFeature/ProfileFeature.swift b/Modules/Sources/ProfileFeature/ProfileFeature.swift index a3f998c8..d1cca106 100644 --- a/Modules/Sources/ProfileFeature/ProfileFeature.swift +++ b/Modules/Sources/ProfileFeature/ProfileFeature.swift @@ -14,6 +14,7 @@ import AnalyticsClient import ToastClient import NotificationsClient import FormFeature +import ReputationChangeFeature @Reducer public struct ProfileFeature: Reducer, Sendable { @@ -34,6 +35,7 @@ public struct ProfileFeature: Reducer, Sendable { public enum Destination { case note(FormFeature) case editProfile(EditFeature) + case changeReputation(ReputationChangeFeature) } // MARK: - State @@ -183,12 +185,18 @@ public struct ProfileFeature: Reducer, Sendable { 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 + + case .changeReputation: + state.destination = .changeReputation(ReputationChangeFeature.State( + userId: user.id, + username: user.nickname, + content: .profile + )) } + return .none case .view(.deeplinkTapped(let url, _)): return .send(.delegate(.handleUrl(url))) diff --git a/Modules/Sources/ProfileFeature/ProfileScreen.swift b/Modules/Sources/ProfileFeature/ProfileScreen.swift index 6e7de5e6..197125f8 100644 --- a/Modules/Sources/ProfileFeature/ProfileScreen.swift +++ b/Modules/Sources/ProfileFeature/ProfileScreen.swift @@ -16,6 +16,7 @@ import RichTextKit import ParsingClient import BBBuilder import FormFeature +import ReputationChangeFeature @ViewAction(for: ProfileFeature.self) public struct ProfileScreen: View { @@ -85,6 +86,12 @@ public struct ProfileScreen: View { FormScreen(store: store) } } + .fittedSheet( + item: $store.scope(state: \.$destination, action: \.destination).changeReputation, + embedIntoNavStack: true + ) { store in + ReputationChangeView(store: store) + } .toolbar { if store.shouldShowToolbarButtons || store.isUserSessionHasModerationGroup { ToolbarItem { @@ -102,32 +109,38 @@ public struct ProfileScreen: View { @ViewBuilder private func OptionsMenu() -> some View { - Menu { - let canEditProfile = store.userSessionGroup == .admin + WithPerceptionTracking { + Menu { + let canEditProfile = store.userSessionGroup == .admin || store.userSessionGroup == .supermoderator || store.userSessionGroup == .moderator - if store.shouldShowToolbarButtons || canEditProfile { - ContextButton( - text: LocalizedStringResource("Edit profile", bundle: .module), - symbol: .squareAndPencil - ) { - send(.contextMenu(.edit)) + if store.shouldShowToolbarButtons || canEditProfile { + ContextButton( + text: LocalizedStringResource("Edit profile", bundle: .module), + symbol: .squareAndPencil + ) { + 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)) + + if store.isUserSessionHasModerationGroup, !store.shouldShowToolbarButtons { + ContextButton( + text: LocalizedStringResource("Add notice", bundle: .module), + symbol: .noteTextBadgePlus + ) { + send(.contextMenu(.addNotice)) + } + + ContextButton( + text: LocalizedStringResource("Change reputation", bundle: .module), + symbol: .arrowUpArrowDown + ) { + send(.contextMenu(.changeReputation)) + } } + } label: { + Image(systemSymbol: .ellipsisCircle) } - } label: { - Image(systemSymbol: .ellipsisCircle) } } diff --git a/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings b/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings index 07592953..5ab0d88e 100644 --- a/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ProfileFeature/Resources/Localizable.xcstrings @@ -131,6 +131,16 @@ } } }, + "Change reputation" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить репутацию" + } + } + } + }, "City" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/QMSFeature/QMSFeature.swift b/Modules/Sources/QMSFeature/QMSFeature.swift index b7af35f3..89362872 100644 --- a/Modules/Sources/QMSFeature/QMSFeature.swift +++ b/Modules/Sources/QMSFeature/QMSFeature.swift @@ -36,6 +36,7 @@ public struct QMSFeature: Reducer, Sendable { public struct State: Equatable { @Presents var alert: AlertState? + @Shared(.appSettings) var appSettings: AppSettings @Shared(.userSession) var userSession: UserSession? public let chatId: Int @@ -165,9 +166,14 @@ public struct QMSFeature: Reducer, Sendable { return .send(.delegate(.handleUrl(url))) } - return .run { send in + return .run { [topicShowAllPosts = state.appSettings.topicShowAllPostsFilter] send in @Dependency(\.apiClient) var api - let request = JumpForumRequest(postId: pid, topicId: 0, allPosts: true, type: .post) + let request = JumpForumRequest( + postId: pid, + topicId: 0, + postsFilter: topicShowAllPosts ? .all : .exceptDeleted, + type: .post + ) let response = try await api.jumpForum(request: request) let url = URL(string: "https://4pda.to/forum/index.php?showtopic=\(response.id)&view=findpost&p=\(response.postId)")! await send(.delegate(.handleUrl(url))) diff --git a/Modules/Sources/ReputationFeature/Analytics/ReputationFeature+Analytics.swift b/Modules/Sources/ReputationFeature/Analytics/ReputationFeature+Analytics.swift index 279e3576..0da8464c 100644 --- a/Modules/Sources/ReputationFeature/Analytics/ReputationFeature+Analytics.swift +++ b/Modules/Sources/ReputationFeature/Analytics/ReputationFeature+Analytics.swift @@ -37,13 +37,21 @@ extension ReputationFeature { case let .view(.profileTapped(profileId)): analytics.log(ReputationEvent.profileTapped(profileId)) - case let .view(.complainButtonTapped(voteId)): - analytics.log(ReputationEvent.complainTapped(voteId)) + case let .view(.contextVoteMenu(action)): + switch action { + case .report(let voteId): + analytics.log(ReputationEvent.voteMenuComplainTapped(voteId)) + case .goToAuthor(let profileId): + analytics.log(ReputationEvent.voteMenuGoToAuthorTapped(profileId)) + case .modify: + // MARK: Moderator tools are skip analytics + break + } case let .view(.sourceTapped(vote)): switch vote.createdIn { case .profile: - analytics.log(ReputationEvent.sourceProfileTapped(vote.authorId)) + analytics.log(ReputationEvent.profileTapped(vote.authorId)) case let .topic(id: topicId, topicName: _, postId: _): analytics.log(ReputationEvent.sourceTopicTapped(topicId)) case let .site(id: articleId, _, _): diff --git a/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift b/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift new file mode 100644 index 00000000..0c01ae5e --- /dev/null +++ b/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift @@ -0,0 +1,14 @@ +// +// ReputationVoteContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 14.05.26. +// + +import Models + +public enum ReputationVoteContextMenuAction { + case report(Int) + case modify(Int, ReputationModifyActionType) + case goToAuthor(Int) +} diff --git a/Modules/Sources/ReputationFeature/ReputationFeature.swift b/Modules/Sources/ReputationFeature/ReputationFeature.swift index f1b761f3..c5010fe1 100644 --- a/Modules/Sources/ReputationFeature/ReputationFeature.swift +++ b/Modules/Sources/ReputationFeature/ReputationFeature.swift @@ -12,6 +12,7 @@ import APIClient import Models import FormFeature import ToastClient +import CacheClient @Reducer public struct ReputationFeature: Reducer, Sendable { @@ -22,6 +23,8 @@ public struct ReputationFeature: Reducer, Sendable { public enum Localization { static let reportSent = LocalizedStringResource("Report sent", bundle: .module) + static let reputationDeleted = LocalizedStringResource("Reputation deleted", bundle: .module) + static let reputationRestored = LocalizedStringResource("Reputation restored", bundle: .module) } // MARK: - Destinations @@ -38,7 +41,11 @@ public struct ReputationFeature: Reducer, Sendable { case report(FormFeature.Action) } - public enum Alert { case ok } + @CasePathable + public enum Alert: Equatable { + case ok + case modifyVote(Int, ReputationModifyActionType) + } } // MARK: - Picker Section @@ -53,7 +60,8 @@ public struct ReputationFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { @Presents public var destination: Destination.State? - @Shared(.userSession) private var userSession: UserSession? + @Shared(.userSession) var userSession: UserSession? + var userSessionInfo: User? public let userId: Int public var isLoading = true @@ -67,6 +75,18 @@ public struct ReputationFeature: Reducer, Sendable { return userSession?.userId == userId } + public var isUserAuthorized: Bool { + return userSession != nil + } + + var isUserSessionHasModerationGroup: Bool { + return userSessionInfo?.group == .admin + || userSessionInfo?.group == .supermoderator + || userSessionInfo?.group == .moderator + || userSessionInfo?.group == .moderatorHelper + || userSessionInfo?.group == .moderatorSchool + } + public init(userId: Int) { self.userId = userId } @@ -84,14 +104,18 @@ public struct ReputationFeature: Reducer, Sendable { case loadMore case refresh case profileTapped(Int) - case complainButtonTapped(Int) case sourceTapped(ReputationVote) + + case contextVoteMenu(ReputationVoteContextMenuAction) } case `internal`(Internal) public enum Internal { case loadData case historyResponse(Result) + case modifyResponse(Result<(Int, ReputationModifyActionType, Bool), any Error>) + + case initUserSessionInfo(User) } case delegate(Delegate) @@ -112,6 +136,7 @@ public struct ReputationFeature: Reducer, Sendable { @Dependency(\.apiClient) private var apiClient @Dependency(\.analyticsClient) private var analyticsClient + @Dependency(\.cacheClient) private var cacheClient @Dependency(\.toastClient) private var toastClient // MARK: - body @@ -133,8 +158,21 @@ public struct ReputationFeature: Reducer, Sendable { await toastClient.showToast(ToastMessage(text: Localization.reportSent, haptic: .success)) } + case let .destination(.presented(.alert(.modifyVote(voteId, type)))): + return .run { send in + let status = try await apiClient.modifyReputation(voteId, type) + await send(.internal(.modifyResponse(.success((voteId, type, status))))) + } catch: { error, send in + await send(.internal(.modifyResponse(.failure(error)))) + } + case .view(.onAppear): - return .send(.internal(.loadData)) + return .run { [session = state.userSession] send in + if let session, let user = cacheClient.getUser(session.userId) { + await send(.internal(.initUserSessionInfo(user))) + } + await send(.internal(.loadData)) + } case .view(.loadMore): guard !state.isLoading else { return .none } @@ -148,13 +186,6 @@ public struct ReputationFeature: Reducer, Sendable { case let .view(.profileTapped(profileId)): return .send(.delegate(.openProfile(profileId: profileId))) - case let .view(.complainButtonTapped(voteId)): - let feature = FormFeature.State( - type: .report(id: voteId, type: .reputation) - ) - state.destination = .report(feature) - return .none - case let .view(.sourceTapped(vote)): switch vote.createdIn { case .profile: @@ -167,6 +198,22 @@ public struct ReputationFeature: Reducer, Sendable { return .send(.delegate(.openArticle(articleId: articleId))) } + case let .view(.contextVoteMenu(action)): + switch action { + case .report(let voteId): + let feature = FormFeature.State( + type: .report(id: voteId, type: .reputation) + ) + state.destination = .report(feature) + + case .modify(let voteId, let type): + state.destination = .alert(.modifyVoteConfirmation(voteId: voteId, type: type)) + + case .goToAuthor(let profileId): + return .send(.delegate(.openProfile(profileId: profileId))) + } + return .none + case .internal(.loadData): let isHistory = state.pickerSection == .history return .run { [userId = state.userId, offset = state.offset, amount = state.loadAmount] send in @@ -200,6 +247,33 @@ public struct ReputationFeature: Reducer, Sendable { analyticsClient.reportFullyDisplayed() return .none + case let .internal(.modifyResponse(.success((voteId, type, status)))): + if let userSession = state.userSessionInfo, status, + let voteIndex = state.historyData.firstIndex(where: { $0.id == voteId }) { + let modified = ReputationVote.VoteModified( + userId: userSession.id, + userName: userSession.nickname, + modifiedAt: Date.now, + isDenied: type == .delete + ) + state.historyData[voteIndex].modified = modified + } + return .run { _ in + let reputationToast = ToastMessage( + text: type == .delete ? Localization.reputationDeleted : Localization.reputationRestored, + haptic: .success + ) + await toastClient.showToast(status ? reputationToast : .whoopsSomethingWentWrong) + } + + case let .internal(.modifyResponse(.failure(error))): + print(error) + return .run { _ in await toastClient.showToast(.whoopsSomethingWentWrong) } + + case let .internal(.initUserSessionInfo(user)): + state.userSessionInfo = user + return .none + case .delegate, .binding, .destination: return .none } @@ -208,13 +282,33 @@ public struct ReputationFeature: Reducer, Sendable { Analytics() } - } +} extension ReputationFeature.Destination.State: Equatable {} // MARK: - Alert Extension extension AlertState where Action == ReputationFeature.Destination.Alert { + + nonisolated static func modifyVoteConfirmation(voteId: Int, type: ReputationModifyActionType) -> AlertState { + return AlertState( + title: { + switch type { + case .delete: TextState("Are you sure, that you want to delete this vote?", bundle: .module) + case .restore: TextState("Are you sure, that you want to restore this vote?", bundle: .module) + } + }, + actions: { + ButtonState(role: type == .delete ? .destructive : nil, action: .modifyVote(voteId, type)) { + TextState("Yes", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("No", bundle: .module) + } + } + ) + } + nonisolated(unsafe) static let error = Self { TextState("Whoops!", bundle: .module) } actions: { diff --git a/Modules/Sources/ReputationFeature/ReputationScreen.swift b/Modules/Sources/ReputationFeature/ReputationScreen.swift index 1db195d2..bd66c2cf 100644 --- a/Modules/Sources/ReputationFeature/ReputationScreen.swift +++ b/Modules/Sources/ReputationFeature/ReputationScreen.swift @@ -130,12 +130,12 @@ public struct ReputationScreen: View { Spacer() Text(LocalizedStringKey(vote.markLabel), bundle: .module) - .foregroundStyle(vote.flag == 1 ? tintColor : Color(.Labels.teritary)) + .foregroundStyle(!vote.isDown ? tintColor : Color(.Labels.teritary)) .font(.caption) .fontWeight(.medium) Image(systemSymbol: vote.arrowSymbol) - .foregroundStyle(vote.flag == 1 ? tintColor : Color(.Labels.teritary)) + .foregroundStyle(!vote.isDown ? tintColor : Color(.Labels.teritary)) .font(.body) } @@ -165,6 +165,16 @@ public struct ReputationScreen: View { .multilineTextAlignment(.leading) .padding(.vertical, 8) + if let modified = vote.modified { + Button { + send(.profileTapped(modified.userId)) + } label: { + ReputationModifiedBadge(modified) + } + .buttonStyle(.plain) + .padding(.bottom, 8) + } + HStack { Text(formatDate(vote.createdAt)) .foregroundStyle(Color(.Labels.teritary)) @@ -172,15 +182,17 @@ public struct ReputationScreen: View { Spacer() - Menu { - MenuButtons(voteId: vote.id, authorId: authorId) - } label: { - Image(systemSymbol: .ellipsis) - .foregroundStyle(Color(.Labels.teritary)) - .font(.body) + if store.isUserAuthorized { + Menu { + MenuButtons(voteId: vote.id, authorId: authorId, modified: vote.modified) + } label: { + Image(systemSymbol: .ellipsis) + .foregroundStyle(Color(.Labels.teritary)) + .font(.body) + } + .menuStyle(.button) + .buttonStyle(.plain) } - .menuStyle(.button) - .buttonStyle(.plain) } } .padding(.leading, 12) @@ -188,9 +200,36 @@ public struct ReputationScreen: View { .contentShape(Rectangle()) .background(Color(.Background.primary)) .contextMenu { - MenuButtons(voteId: vote.id, authorId: authorId) + if store.isUserAuthorized { + MenuButtons(voteId: vote.id, authorId: authorId, modified: vote.modified) + } + } + } + + // MARK: - Reputation Modified Badge + + @ViewBuilder + private func ReputationModifiedBadge(_ modified: ReputationVote.VoteModified) -> some View { + let text: LocalizedStringKey = modified.isDenied ? "Denied" : "Restored" + HStack(spacing: 4) { + Text(text, bundle: .module) + + HStack(spacing: 4) { + Text(formatDate(modified.modifiedAt)) + + Text(verbatim: "· \(modified.userName)") + } } + .font(.caption) + .foregroundStyle((modified.isDenied ? Color(.Main.yellow) : tintColor)) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + Color(modified.isDenied ? .Main.yellowAlpha : .Main.primaryAlpha) + .clipShape(RoundedRectangle(cornerRadius: isLiquidGlass ? 10 : 6)) + ) } + // MARK: - Empty Reputation @ViewBuilder @@ -231,20 +270,37 @@ public struct ReputationScreen: View { // MARK: - Menu Buttons @ViewBuilder - private func MenuButtons(voteId: Int, authorId: Int) -> some View { + private func MenuButtons(voteId: Int, authorId: Int, modified: ReputationVote.VoteModified?) -> some View { ContextButton( text: LocalizedStringResource("Profile", bundle: .module), symbol: .personCropCircle, - action: { send(.profileTapped(authorId)) } + action: { send(.contextVoteMenu(.goToAuthor(authorId))) } ) if store.pickerSection == .history { ContextButton( text: LocalizedStringResource("Complain", bundle: .module), symbol: .exclamationmarkTriangle, - action: { send(.complainButtonTapped(voteId)) } + action: { send(.contextVoteMenu(.report(voteId))) } ) } + + WithPerceptionTracking { + if store.isUserSessionHasModerationGroup { + Section { + let isDenied = if let modified = modified { modified.isDenied } else { false } + Button(role: isDenied ? .cancel : .destructive) { + send(.contextVoteMenu(.modify(voteId, isDenied ? .restore : .delete))) + } label: { + HStack { + Text(isDenied ? "Restore" : "Delete", bundle: .module) + Image(systemSymbol: isDenied ? .clockArrowCirclepath : .trash) + } + } + .tint(isDenied ? .primary : .red) + } + } + } } // MARK: - format Date diff --git a/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings b/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings index ce2789b0..9423dd98 100644 --- a/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings @@ -1,6 +1,26 @@ { "sourceLanguage" : "en", "strings" : { + "Are you sure, that you want to delete this vote?" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите отменить данный голос?" + } + } + } + }, + "Are you sure, that you want to restore this vote?" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите восстановить данный голос?" + } + } + } + }, "Change the reputation of users on the forum for their actions" : { "localizations" : { "ru" : { @@ -21,6 +41,26 @@ } } }, + "Delete" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменить" + } + } + } + }, + "Denied" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменено" + } + } + } + }, "Help other users on the forum and get reputation" : { "localizations" : { "ru" : { @@ -72,6 +112,16 @@ } } }, + "No" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет" + } + } + } + }, "No reputation history" : { "localizations" : { "ru" : { @@ -133,6 +183,46 @@ } } }, + "Reputation deleted" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Репутация отменена" + } + } + } + }, + "Reputation restored" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Репутация восстановлена" + } + } + } + }, + "Restore" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановить" + } + } + } + }, + "Restored" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановлено" + } + } + } + }, "Something went wrong while loading reputation :(" : { "localizations" : { "ru" : { @@ -162,6 +252,16 @@ } } } + }, + "Yes" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Да" + } + } + } } }, "version" : "1.0" diff --git a/Modules/Sources/SettingsFeature/Resources/Localizable.xcstrings b/Modules/Sources/SettingsFeature/Resources/Localizable.xcstrings index 95d75758..944e97c8 100644 --- a/Modules/Sources/SettingsFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/SettingsFeature/Resources/Localizable.xcstrings @@ -323,6 +323,16 @@ } } }, + "Show all posts in topic" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Показывать все посты" + } + } + } + }, "Sky" : { "localizations" : { "ru" : { @@ -373,6 +383,17 @@ } } }, + "Topic" : { + "extractionState" : "stale", + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема" + } + } + } + }, "Topic opening" : { "localizations" : { "ru" : { @@ -399,6 +420,16 @@ } } }, + "When you enter a topic, the 'All posts' filter will be selected" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "При заходе в тему будет выбран фильтр ‘Все посты'" + } + } + } + }, "Yellow" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/SettingsFeature/Subsettings/NavigationSettingsFeature.swift b/Modules/Sources/SettingsFeature/Subsettings/NavigationSettingsFeature.swift index ad307e38..132de251 100644 --- a/Modules/Sources/SettingsFeature/Subsettings/NavigationSettingsFeature.swift +++ b/Modules/Sources/SettingsFeature/Subsettings/NavigationSettingsFeature.swift @@ -9,6 +9,7 @@ import Foundation import ComposableArchitecture import PersistenceKeys import Models +import CacheClient @Reducer public struct NavigationSettingsFeature: Reducer, Sendable { @@ -20,17 +21,29 @@ public struct NavigationSettingsFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { @Shared(.appSettings) var appSettings: AppSettings + @Shared(.userSession) var userSession: UserSession? + var userSessionGroup: User.Group? public var topicOpening: TopicOpeningStrategy + public var topicShowAllPosts: Bool public var hideTabBarOnScroll: Bool public var floatingNavigation: Bool public var experimentalFloatingNavigation: Bool + + var isUserSessionHasModerationGroup: Bool { + return userSessionGroup == .admin + || userSessionGroup == .supermoderator + || userSessionGroup == .moderator + || userSessionGroup == .moderatorHelper + || userSessionGroup == .moderatorSchool + } public init() { self.topicOpening = _appSettings.topicOpeningStrategy.wrappedValue self.hideTabBarOnScroll = _appSettings.hideTabBarOnScroll.wrappedValue self.floatingNavigation = _appSettings.floatingNavigation.wrappedValue self.experimentalFloatingNavigation = _appSettings.experimentalFloatingNavigation.wrappedValue + self.topicShowAllPosts = _appSettings.topicShowAllPostsFilter.wrappedValue } } @@ -46,13 +59,13 @@ public struct NavigationSettingsFeature: Reducer, Sendable { case `internal`(Internal) public enum Internal { - + case initUserSessionGroup(User.Group) } } // MARK: - Dependency - + @Dependency(\.cacheClient) private var cacheClient // MARK: - Body @@ -62,11 +75,18 @@ public struct NavigationSettingsFeature: Reducer, Sendable { Reduce { state, action in switch action { case .view(.onAppear): - break + return .run { [session = state.userSession] send in + if let session, let user = cacheClient.getUser(session.userId) { + await send(.internal(.initUserSessionGroup(user.group))) + } + } case .binding(\.topicOpening): state.$appSettings.topicOpeningStrategy.withLock { $0 = state.topicOpening } + case .binding(\.topicShowAllPosts): + state.$appSettings.topicShowAllPostsFilter.withLock { $0 = state.topicShowAllPosts } + case .binding(\.hideTabBarOnScroll): state.$appSettings.hideTabBarOnScroll.withLock { $0 = state.hideTabBarOnScroll } @@ -83,6 +103,10 @@ public struct NavigationSettingsFeature: Reducer, Sendable { case .binding: break + + case let .internal(.initUserSessionGroup(group)): + state.userSessionGroup = group + return .none } return .none diff --git a/Modules/Sources/SettingsFeature/Subsettings/NavigationSettingsScreen.swift b/Modules/Sources/SettingsFeature/Subsettings/NavigationSettingsScreen.swift index 20710604..f29e31cc 100644 --- a/Modules/Sources/SettingsFeature/Subsettings/NavigationSettingsScreen.swift +++ b/Modules/Sources/SettingsFeature/Subsettings/NavigationSettingsScreen.swift @@ -46,6 +46,16 @@ public struct NavigationSettingsScreen: View { } } + if store.isUserSessionHasModerationGroup { + Row( + LocalizedStringResource("Show all posts in topic", bundle: .module), + description: LocalizedStringResource("When you enter a topic, the 'All posts' filter will be selected", bundle: .module) + ) { + Toggle(String(""), isOn: $store.topicShowAllPosts) + .labelsHidden() + } + } + if isLiquidGlass { Row(LocalizedStringResource("Hide tabbar on scroll", bundle: .module)) { Toggle(String(""), isOn: $store.hideTabBarOnScroll) @@ -84,16 +94,30 @@ public struct NavigationSettingsScreen: View { // MARK: - Row @ViewBuilder - private func Row(_ text: LocalizedStringResource, content: () -> Content) -> some View { + private func Row( + _ text: LocalizedStringResource, + description: LocalizedStringResource? = nil, + content: () -> Content + ) -> some View { HStack(spacing: 0) { - Text(text) - .font(.body) - .foregroundStyle(Color(.Labels.primary)) + VStack(alignment: .leading) { + Text(text) + .font(.body) + .foregroundStyle(Color(.Labels.primary)) + + if let description { + Text(description) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 12) Spacer(minLength: 8) content() } + .frame(maxWidth: .infinity) } } diff --git a/Modules/Sources/SharedUI/Post/PostRowView.swift b/Modules/Sources/SharedUI/Post/PostRowView.swift index c4e1ed21..a414f3df 100644 --- a/Modules/Sources/SharedUI/Post/PostRowView.swift +++ b/Modules/Sources/SharedUI/Post/PostRowView.swift @@ -343,6 +343,13 @@ public struct PostRowView: View { ) { toolsMenuAction(.move(state.post.id)) } + + ContextButton( + text: LocalizedStringResource("History", bundle: .module), + symbol: .clockArrowCirclepath + ) { + toolsMenuAction(.eventLog(state.post.id)) + } } label: { HStack { Text("Tools", bundle: .module) diff --git a/Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/yellow.colorset/Contents.json b/Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/yellow.colorset/Contents.json new file mode 100644 index 00000000..94efee64 --- /dev/null +++ b/Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/yellow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x9E", + "red" : "0xC1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x9E", + "red" : "0xD2" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/yellowAlpha.colorset/Contents.json b/Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/yellowAlpha.colorset/Contents.json new file mode 100644 index 00000000..1dd07beb --- /dev/null +++ b/Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/yellowAlpha.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.080", + "blue" : "0x00", + "green" : "0x9E", + "red" : "0xC1" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.080", + "blue" : "0x00", + "green" : "0x9E", + "red" : "0xC1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings index bedcb7b1..ab17d427 100644 --- a/Modules/Sources/SharedUI/Resources/Localizable.xcstrings +++ b/Modules/Sources/SharedUI/Resources/Localizable.xcstrings @@ -97,6 +97,16 @@ } } }, + "History" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История" + } + } + } + }, "IN DEVELOPMENT" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift b/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift new file mode 100644 index 00000000..540e8598 --- /dev/null +++ b/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift @@ -0,0 +1,41 @@ +// +// TicketsListRequest.swift +// ForPDA +// +// Created by Xialtal on 4.05.26. +// + +public struct TicketsListRequest: Sendable { + public let forId: Int + public let offset: Int + public let amount: Int + public let isSortByForums: Bool + public let isShowOnlyMine: Bool + + public init( + forId: Int, + offset: Int, + amount: Int, + isSortByForums: Bool, + isShowOnlyMine: Bool + ) { + self.forId = forId + self.offset = offset + self.amount = amount + self.isSortByForums = isSortByForums + self.isShowOnlyMine = isShowOnlyMine + } +} + +extension TicketsListRequest { + var transferSort: Int { + var type = 0 + if isShowOnlyMine { + type |= 1 + } + if isSortByForums { + type |= 4 + } + return type + } +} diff --git a/Modules/Sources/TicketClient/TicketClient.swift b/Modules/Sources/TicketClient/TicketClient.swift new file mode 100644 index 00000000..91607d2a --- /dev/null +++ b/Modules/Sources/TicketClient/TicketClient.swift @@ -0,0 +1,127 @@ +// +// TicketClient.swift +// ForPDA +// +// Created by Xialtal on 4.05.26. +// + +import APIClient +import Dependencies +import DependenciesMacros +import Foundation +import Models +import PDAPI +import ParsingClient + +@DependencyClient +public struct TicketClient: Sendable { + public var getTicketsList: @Sendable (_ data: TicketsListRequest) async throws -> TicketsList + public var getTicket: @Sendable (_ id: Int) async throws -> Ticket + public var getTicketStatusHistory: @Sendable (_ ticketId: Int) async throws -> [TicketStatusHistory] + public var changeTicketStatus: @Sendable (_ id: Int, _ handlerId: Int, _ status: TicketStatus) async throws -> TicketStatusChangeResponse + + public var modifyComment: @Sendable (_ id: Int, _ ticketId: Int, _ text: String) async throws -> Bool + public var deleteComment: @Sendable (_ id: Int, _ ticketId: Int) async throws -> Bool +} + +extension TicketClient: DependencyKey { + + private static var api: API { + return APIClient.api + } + + // MARK: - Live Value + + public static var liveValue: TicketClient { + @Dependency(\.parsingClient) var parser + + return TicketClient( + getTicketsList: { data in + let response = try await api.send(TicketCommand.list( + forId: data.forId, + sortType: data.transferSort, + offset: data.offset, + limit: data.amount + )) + return try await parser.parseTicketsList(response) + }, + getTicket: { id in + let response = try await api.send(TicketCommand.view(id: id)) + return try await parser.parseTicket(response) + }, + getTicketStatusHistory: { ticketId in + let response = try await api.send(TicketCommand.history(id: ticketId)) + return try await parser.parseTicketStatusHistory(response) + }, + changeTicketStatus: { ticketId, handlerId, status in + let response = try await api.send(TicketCommand.modify( + id: ticketId, + handlerId: handlerId, + statusCode: status.rawValue + )) + return try await parser.parseChangeTicketStatus(response) + }, + + modifyComment: { id, ticketId, text in + let response = try await api.send(TicketCommand.Comment.modify( + id: id, + ticketId: ticketId, + text: text + )) + let status = Int(response.getResponseStatus()) + return status == 0 + }, + deleteComment: { id, ticketId in + let response = try await api.send(TicketCommand.Comment.delete( + id: id, + ticketId: ticketId + )) + let status = Int(response.getResponseStatus()) + return status == 0 + } + ) + } + + // MARK: - Preview Value + + public static var previewValue: TicketClient { + return TicketClient( + getTicketsList: { _ in + return .mock + }, + getTicket: { _ in + return .mock + }, + getTicketStatusHistory: { _ in + return [.mockNotProcessed, .mockProcessing, .mockProcessed] + }, + changeTicketStatus: { _, _, _ in + return .success + }, + modifyComment: { _, _, _ in + return true + }, + deleteComment: { _, _ in + return true + } + ) + } +} + +// MARK: - Extensions + +extension DependencyValues { + public var ticketClient: TicketClient { + get { self[TicketClient.self] } + set { self[TicketClient.self] = newValue } + } +} + +extension String { + func getResponseStatus() -> String { + return self + .replacingOccurrences(of: "[", with: "") + .replacingOccurrences(of: "]", with: "") + .components(separatedBy: ",")[1] + } +} diff --git a/Modules/Sources/TicketFeature/Models/TicketCommentContextMenuAction.swift b/Modules/Sources/TicketFeature/Models/TicketCommentContextMenuAction.swift new file mode 100644 index 00000000..2923e0be --- /dev/null +++ b/Modules/Sources/TicketFeature/Models/TicketCommentContextMenuAction.swift @@ -0,0 +1,11 @@ +// +// TicketCommentContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 13.05.26. +// + +public enum TicketCommentContextMenuAction { + case edit(Int) + case delete(Int) +} diff --git a/Modules/Sources/TicketFeature/Models/TicketContextMenuAction.swift b/Modules/Sources/TicketFeature/Models/TicketContextMenuAction.swift new file mode 100644 index 00000000..50d157ab --- /dev/null +++ b/Modules/Sources/TicketFeature/Models/TicketContextMenuAction.swift @@ -0,0 +1,14 @@ +// +// TicketContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 8.05.26. +// + +import Models + +public enum TicketContextMenuAction { + case statusHistory + case openAuthor + case copyLink +} diff --git a/Modules/Sources/TicketFeature/Models/UITicket.swift b/Modules/Sources/TicketFeature/Models/UITicket.swift new file mode 100644 index 00000000..c4f29f23 --- /dev/null +++ b/Modules/Sources/TicketFeature/Models/UITicket.swift @@ -0,0 +1,36 @@ +// +// UITicket.swift +// ForPDA +// +// Created by Xialtal on 16.05.26. +// + +import Models +import SharedUI + +struct UITicket: Sendable, Equatable { + public var info: TicketInfo + public let comments: [HybridComment] + + struct HybridComment: Sendable, Equatable, Identifiable { + public let comment: Ticket.Comment + public let uiContent: [UITopicType] + + public var id: Int { + return comment.id + } + + public init( + comment: Ticket.Comment, + uiContent: [UITopicType] + ) { + self.comment = comment + self.uiContent = uiContent + } + } + + public init(info: TicketInfo, comments: [HybridComment]) { + self.info = info + self.comments = comments + } +} diff --git a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..74fdb7cc --- /dev/null +++ b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings @@ -0,0 +1,306 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Add the first one for other moderators" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавьте первый для других модераторов" + } + } + } + }, + "Add ticket comment" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментировать тикет" + } + } + } + }, + "Are you sure, that you want to delete this comment?" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить этот комментарий?" + } + } + } + }, + "Cancel" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "Change status" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изм. статус" + } + } + } + }, + "Change ticket comment" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить комментарий тикета" + } + } + } + }, + "Comment" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментировать" + } + } + } + }, + "Comment added" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментарий добавлен" + } + } + } + }, + "Comment deleted" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментарий удален" + } + } + } + }, + "Comment edited" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментарий отредактирован" + } + } + } + }, + "Comments" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментарии" + } + } + } + }, + "Copy Link" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать ссылку" + } + } + } + }, + "Delete" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + } + } + }, + "Edit" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить" + } + } + } + }, + "Go to Author" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти к автору" + } + } + } + }, + "Input comment..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите комментарий…" + } + } + } + }, + "Link copied" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ссылка скопирована" + } + } + } + }, + "Loading..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка…" + } + } + } + }, + "New" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новый" + } + } + } + }, + "No" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет" + } + } + } + }, + "No Comments" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет комментариев" + } + } + } + }, + "Processed · " : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработан · " + } + } + } + }, + "Processing · " : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В работе · " + } + } + } + }, + "Send" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить" + } + } + } + }, + "Status History" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История статусов" + } + } + } + }, + "The ticket's handler has changed, please try again" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "У тикета изменился ответственный, попробуйте еще раз" + } + } + } + }, + "Ticket %@" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тикет %@" + } + } + } + }, + "Ticket status changed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус изменен" + } + } + } + }, + "Unable to change ticket status" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно сменить статус тикета" + } + } + } + }, + "Yes" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Да" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift new file mode 100644 index 00000000..c193c2da --- /dev/null +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -0,0 +1,349 @@ +// +// TicketFeature.swift +// ForPDA +// +// Created by Xialtal on 5.05.26. +// + +import Foundation +import ComposableArchitecture +import TicketClient +import Models +import PasteboardClient +import PersistenceKeys +import ToastClient +import CacheClient +import TicketStatusHistoryFeature +import TopicBuilder + +@Reducer +public struct TicketFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Localizations + + private enum Localization { + static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) + static let commentAdded = LocalizedStringResource("Comment added", bundle: .module) + static let commentEdited = LocalizedStringResource("Comment edited", bundle: .module) + static let commentDeleted = LocalizedStringResource("Comment deleted", bundle: .module) + static let handlerChanged = LocalizedStringResource("The ticket's handler has changed, please try again", bundle: .module) + static let unableChangeStatus = LocalizedStringResource("Unable to change ticket status", bundle: .module) + static let statusChanged = LocalizedStringResource("Ticket status changed", bundle: .module) + } + + // MARK: - Destinations + + @Reducer + public enum Destination { + @ReducerCaseIgnored + case alert(AlertState) + case statusHistory(TicketStatusHistoryFeature) + + case addComment + @ReducerCaseIgnored + case editComment(Int) + + @CasePathable + public enum Action { + case alert(Alert) + case statusHistory(TicketStatusHistoryFeature.Action) + } + + @CasePathable + public enum Alert: Equatable { + case deleteComment(Int) + } + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Shared(.userSession) var userSession: UserSession? + var userSessionNickname: String? + + @Presents public var destination: Destination.State? + + public let id: Int + + var ticket: UITicket? + var isLoading = false + var isRefreshing = false + + var alertInput = "" + + public init( + id: Int + ) { + self.id = id + } + } + + // MARK: - Action + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case destination(PresentationAction) + + case view(View) + public enum View { + case onAppear + case onRefresh + + case commentButtonTapped(Int, isAdd: Bool) + case changeStatusButtonTapped(TicketStatus) + case showAddCommentAlertButtonTapped + + case urlTapped(URL) + case commentAuthorButtonTapped(Int) + + case contextMenu(TicketContextMenuAction) + case contextCommentMenu(TicketCommentContextMenuAction) + } + + case `internal`(Internal) + public enum Internal { + case refresh + case loadTicket + case ticketResponse(Result) + case changeTicketStatusResponse(Result<(TicketStatus, TicketStatusChangeResponse), any Error>) + case commentTicketResponse(Result<(Bool, Bool), any Error>) + + case initUserSessionNickname(String) + } + + case delegate(Delegate) + public enum Delegate { + case handleUrl(URL) + case openUser(Int) + } + } + + // MARK: - Dependencies + + @Dependency(\.pasteboardClient) private var pasteboardClient + @Dependency(\.ticketClient) private var ticketClient + @Dependency(\.toastClient) private var toastClient + @Dependency(\.cacheClient) private var cacheClient + @Dependency(\.openURL) private var openURL + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case let .destination(.presented(.statusHistory(.delegate(.openUser(id))))): + state.destination = nil + return .send(.delegate(.openUser(id))) + + case let .destination(.presented(.alert(.deleteComment(id)))): + return .run { [ticketId = state.id] send in + let status = try await ticketClient.deleteComment(id, ticketId) + let postDeletedToast = ToastMessage( + text: Localization.commentDeleted, + haptic: .success + ) + await toastClient.showToast(status ? postDeletedToast : .whoopsSomethingWentWrong) + await send(.internal(.refresh)) + } + + case .delegate, .destination, .binding: + return .none + + case .view(.onAppear): + return .run { [session = state.userSession] send in + if let session, let user = cacheClient.getUser(session.userId) { + await send(.internal(.initUserSessionNickname(user.nickname))) + } + await send(.internal(.loadTicket)) + } + + case .view(.onRefresh): + return .send(.internal(.refresh)) + + case let .view(.urlTapped(url)): + return .send(.delegate(.handleUrl(url))) + + case let .view(.commentAuthorButtonTapped(id)): + return .send(.delegate(.openUser(id))) + + case let .view(.contextMenu(action)): + guard let ticket = state.ticket else { return .none } + switch action { + case .statusHistory: + state.destination = .statusHistory(TicketStatusHistoryFeature.State( + ticketId: state.id + )) + + case .openAuthor: + return .send(.delegate(.openUser(ticket.info.authorId))) + + case .copyLink: + pasteboardClient.copy("https://4pda.to/forum/index.php?act=ticket&s=thread&t_id=\(state.id)") + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.linkCopied, haptic: .success)) + } + } + return .none + + case let .view(.contextCommentMenu(action)): + switch action { + case .edit(let commentId): + if let comment = state.ticket?.comments.first(where: { $0.id == commentId }) { + if let range = comment.comment.content.range(of: "[na]") { + state.alertInput = String(comment.comment.content[range.upperBound...]) + } else { + state.alertInput = comment.comment.content + } + } + state.destination = .editComment(commentId) + + case .delete(let commentId): + state.destination = .alert(.deleteCommentConfirmation(commentId: commentId)) + } + return .none + + case .view(.showAddCommentAlertButtonTapped): + state.destination = .addComment + return .none + + case let .view(.commentButtonTapped(commentId, isAdd)): + return .run { [ticketId = state.id, text = state.alertInput] send in + let status = try await ticketClient.modifyComment(commentId, ticketId, text) + await send(.internal(.commentTicketResponse(.success((isAdd, status))))) + } catch: { error, send in + await send(.internal(.commentTicketResponse(.failure(error)))) + } + + case let .view(.changeStatusButtonTapped(status)): + return .run { [ticketId = state.id, handlerId = state.ticket?.info.handlerId] send in + let response = try await ticketClient.changeTicketStatus(ticketId, handlerId ?? 0, status) + await send(.internal(.changeTicketStatusResponse(.success((status, response))))) + } catch: { error, send in + await send(.internal(.changeTicketStatusResponse(.failure(error)))) + } + + case .internal(.refresh): + state.isRefreshing = true + return .send(.internal(.loadTicket)) + + case let .internal(.changeTicketStatusResponse(.success((status, .success)))): + if let session = state.userSession, let handlerName = state.userSessionNickname { + let info: (Int, String, Date?) = switch status { + case .processed: (session.userId, handlerName, Date.now) + case .processing: (session.userId, handlerName, nil) + case .notProcessed: (0, "", nil) + } + state.ticket?.info.status = status + state.ticket?.info.handlerId = info.0 + state.ticket?.info.handlerName = info.1 + state.ticket?.info.processedAt = info.2 + } + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.statusChanged, haptic: .success)) + } + + case let .internal(.changeTicketStatusResponse(.success((_, .failure(reason))))): + switch reason { + case .handlerChanged(let id, let name): + state.ticket?.info.handlerId = id + state.ticket?.info.handlerName = name + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.handlerChanged, isError: true, haptic: .error)) + } + + case .other: + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.unableChangeStatus, isError: true, haptic: .error)) + } + } + + case let .internal(.changeTicketStatusResponse(.failure(error))): + print(error) + return .run { _ in + await toastClient.showToast(.whoopsSomethingWentWrong) + } + + case let .internal(.commentTicketResponse(.success((isAdd, status)))): + state.alertInput = "" + return .run { send in + let commentToast = ToastMessage( + text: isAdd ? Localization.commentAdded : Localization.commentEdited, + haptic: .success + ) + await toastClient.showToast(status ? commentToast : .whoopsSomethingWentWrong) + await send(.internal(.refresh)) + } + + case let .internal(.commentTicketResponse(.failure(error))): + print(error) + state.alertInput = "" + return .run { _ in + await toastClient.showToast(.whoopsSomethingWentWrong) + } + + case .internal(.loadTicket): + if !state.isRefreshing { + state.isLoading = true + } + return .run { [id = state.id] send in + let response = try await ticketClient.getTicket(id) + await send(.internal(.ticketResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.ticketResponse(.failure(error)))) + } + + case let .internal(.ticketResponse(.success(response))): + let comments = response.comments.map { comment in + let uiContent = TopicNodeBuilder( + text: comment.content.replacingOccurrences(of: "[na]", with: ""), + attachments: [] + ).build() + return UITicket.HybridComment(comment: comment, uiContent: uiContent) + } + state.ticket = UITicket(info: response.info, comments: comments) + state.isLoading = false + state.isRefreshing = false + return .none + + case let .internal(.ticketResponse(.failure(error))): + print(error) + state.isLoading = false + state.isRefreshing = false + return .none + + case let .internal(.initUserSessionNickname(name)): + state.userSessionNickname = name + return .none + } + } + .ifLet(\.$destination, action: \.destination) + } +} + +extension TicketFeature.Destination.State: Equatable {} + +// MARK: - Alert Extension + +extension AlertState where Action == TicketFeature.Destination.Alert { + + nonisolated static func deleteCommentConfirmation(commentId: Int) -> AlertState { + return AlertState( + title: { + TextState("Are you sure, that you want to delete this comment?", bundle: .module) + }, + actions: { + ButtonState(role: .destructive, action: .deleteComment(commentId)) { + TextState("Yes", bundle: .module) + } + ButtonState(role: .cancel) { + TextState("No", bundle: .module) + } + } + ) + } +} diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift new file mode 100644 index 00000000..c9d58a53 --- /dev/null +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -0,0 +1,426 @@ +// +// TicketScreen.swift +// ForPDA +// +// Created by Xialtal on 5.05.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI +import SFSafeSymbols +import RichTextKit +import TicketStatusHistoryFeature +import TopicBuilder + +@ViewAction(for: TicketFeature.self) +public struct TicketScreen: 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() + + ScrollView { + if let ticket = store.ticket { + VStack(alignment: .leading, spacing: 12) { + VStack(spacing: 8) { + Divider() + + TicketHeader(ticket.info) + + Divider() + } + + if let content = ticket.comments.first { + AttributedContent(content) + } + + Section { + VStack(alignment: .leading, spacing: 8) { + if ticket.comments.count > 1 { + Divider() + + ForEach(ticket.comments.suffix(from: 1)) { comment in + Comment(comment) + + Divider() + } + } else { + NoComments() + .padding(.top, 84) + } + } + } header: { + Text("Comments", bundle: .module) + .font(.body) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.primary)) + .padding(.top, 28) + } + } + } + } + .padding(.horizontal, 16) + } + .navigationTitle(Text(store.ticket != nil ? "Ticket \(String(store.id))" : "Loading...", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .alert($store.scope(state: \.$destination, action: \.destination).alert) + .sheet(item: $store.scope(state: \.$destination, action: \.destination).statusHistory) { store in + NavigationStack { + TicketStatusHistoryView(store: store) + } + } + .alert( + item: $store.destination.editComment, + title: { _ in Text("Change ticket comment", bundle: .module) } + ) { commentId in + AlertInput({ + send(.commentButtonTapped(commentId, isAdd: false)) + }) + } + .alert( + item: $store.destination.addComment, + title: { _ in Text("Add ticket comment", bundle: .module) } + ) { + AlertInput({ + send(.commentButtonTapped(0, isAdd: true)) + }) + } + ._safeAreaBar(edge: .bottom) { + if store.ticket != nil { + ActionButtons() + } + } + .toolbar { + ToolbarItem { + OptionsMenu() + } + } + .refreshable { + await send(.onRefresh).finish() + } + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - Options Menu + + @ViewBuilder + private func OptionsMenu() -> some View { + Menu { + Section { + ContextButton(text: LocalizedStringResource("Status History", bundle: .module), symbol: .clockArrowCirclepath) { + send(.contextMenu(.statusHistory)) + } + } + + Section { + ContextButton(text: LocalizedStringResource("Go to Author", bundle: .module), symbol: .personCropCircle) { + send(.contextMenu(.openAuthor)) + } + } + + ContextButton(text: LocalizedStringResource("Copy Link", bundle: .module), symbol: .docOnDoc) { + send(.contextMenu(.copyLink)) + } + } label: { + Image(systemSymbol: .ellipsisCircle) + } + } + + // MARK: - Action Buttons + + @ViewBuilder + private func ActionButtons() -> some View { + HStack { + WithPerceptionTracking { + Menu { + let status = store.ticket!.info.status + Picker(String(), selection: Binding( + get: { status }, + set: { newValue in + send(.changeStatusButtonTapped(newValue)) + } + )) { + ForEach(TicketStatus.allCases) { status in + Text(status.title) + .tag(status) + } + } + } label: { + Text("Change status", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + } + .tint(tintColor) + .buttonStyle(.bordered) + .frame(height: 48) + } + + Button { + send(.showAddCommentAlertButtonTapped) + } label: { + Text("Comment", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + } + .buttonStyle(.borderedProminent) + .frame(height: 48) + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + } + + // MARK: - Comment + + @ViewBuilder + private func Comment(_ comment: UITicket.HybridComment) -> some View { + HStack(alignment: .top) { + Image(systemSymbol: .bubbleLeft) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { + Button { + send(.commentAuthorButtonTapped(comment.comment.authorId)) + } label: { + Text(verbatim: comment.comment.authorName) + .foregroundStyle(tintColor) + .underline() + } + .buttonStyle(.plain) + + AttributedContent(comment) + } + .font(.subheadline) + + HStack { + Text(verbatim: comment.comment.createdAt.formatted()) + .font(.caption) + .foregroundStyle(Color(.Labels.quaternary)) + + Spacer() + + WithPerceptionTracking { + if let session = store.userSession, session.userId == comment.comment.authorId { + CommentContextMenu(id: comment.id) + } + } + } + } + } + } + + // MARK: - Comment Context Menu + + @ViewBuilder + private func CommentContextMenu(id: Int) -> some View { + Menu { + ContextButton(text: LocalizedStringResource("Edit", bundle: .module), symbol: .squareAndPencil) { + send(.contextCommentMenu(.edit(id))) + } + + Button(role: .destructive) { + send(.contextCommentMenu(.delete(id))) + } label: { + HStack { + Text("Delete", bundle: .module) + Image(systemSymbol: .trash) + } + } + .tint(.red) + } label: { + Image(systemSymbol: .ellipsis) + .font(.body) + .foregroundStyle(Color(.Labels.teritary)) + .padding(.horizontal, 8) // Padding for tap area + .padding(.vertical, 16) + } + .onTapGesture {} // DO NOT DELETE, FIX FOR IOS 17 + .frame(width: 18, height: 22) + } + + // MARK: - Attributed Content + + @ViewBuilder + private func AttributedContent(_ comment: UITicket.HybridComment) -> some View { + if !comment.uiContent.isEmpty { + ForEach(comment.uiContent, id: \.self) { type in + WithPerceptionTracking { + TopicView(type: type, attachments: []) { url in + send(.urlTapped(url)) + } + } + } + } else { + Text(verbatim: comment.comment.content) + .font(.subheadline) + } + } + + // MARK: - Ticket Header + + @ViewBuilder + private func TicketHeader(_ ticket: TicketInfo) -> some View { + VStack(alignment: .leading, spacing: 8) { + TicketStatusBadge(info: ticket) + + Text(verbatim: ticket.title) + .font(.subheadline) + .foregroundStyle(Color(.Labels.primary)) + + HStack(spacing: 6) { + Image(systemSymbol: .textBubble) + + Text(verbatim: ticket.subjectRootName) + } + .font(.caption) + .foregroundStyle(Color(.Labels.teritary)) + + HStack { + HStack(spacing: 0) { + let date = if ticket.status == .processed { + ticket.processedAt ?? Date.unknown + } else { + ticket.createdAt + } + Text(verbatim: "\(date.formatted()) · ") + + HStack(spacing: 4) { + Image(systemSymbol: .personCropCircle) + + Text(verbatim: ticket.authorName) + } + } + + Spacer() + } + .font(.caption) + .foregroundStyle(Color(.Labels.quaternary)) + } + } + + // MARK: - Ticket Status Badge + + @ViewBuilder + private func TicketStatusBadge(info: TicketInfo) -> some View { + let text: LocalizedStringKey = switch info.status { + case .notProcessed: "New" + case .processing: "Processing · " + case .processed: "Processed · " + } + HStack(spacing: 0) { + Text(text, bundle: .module) + + if info.handlerId > 0 { + HStack(spacing: 4) { + Image(systemSymbol: .personCropCircle) + + Text(verbatim: info.handlerName) + } + } + } + .font(.caption) + .foregroundStyle(info.status.textColor) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + info.status.maskColor + .clipShape(RoundedRectangle(cornerRadius: isLiquidGlass ? 10 : 6)) + ) + } + + // MARK: - Alert Input + + @ViewBuilder + private func AlertInput(_ action: @escaping () -> Void) -> some View { + WithPerceptionTracking { + TextField(String(localized: "Input comment...", bundle: .module), text: $store.alertInput) + + Button(LocalizedStringResource("Cancel", bundle: .module)) { } + + Button(LocalizedStringResource("Send", bundle: .module)) { + action() + } + .disabled(store.alertInput.isEmpty) + } + } + + // MARK: - No Comments + + private func NoComments() -> some View { + VStack(spacing: 0) { + Image(systemSymbol: .bubbleLeft) + .font(.title) + .foregroundStyle(tintColor) + .frame(width: 48, height: 48) + .padding(.bottom, 8) + + Text("No Comments", bundle: .module) + .font(.title3) + .bold() + .foregroundStyle(Color(.Labels.primary)) + .padding(.bottom, 6) + + Text("Add the first one for other moderators", bundle: .module) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(Color(.Labels.teritary)) + .frame(maxWidth: UIScreen.main.bounds.width * 0.7) + .padding(.horizontal, 55) + } + } +} + +// MARK: - Extensions + +extension TicketStatus { + var maskColor: Color { + switch self { + case .notProcessed: Color(.Main.redAlpha) + case .processing: Color(.Main.yellowAlpha) + case .processed: Color(.Background.teritary) + } + } + + var textColor: Color { + switch self { + case .notProcessed: Color(.Main.red) + case .processing: Color(.Main.yellow) + case .processed: Color(.Labels.teritary) + } + } +} + + +// MARK: - Previews + +#Preview("Ticket") { + NavigationStack { + TicketScreen( + store: Store( + initialState: TicketFeature.State(id: 0) + ) { + TicketFeature() + } + ) + } +} diff --git a/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..8624259d --- /dev/null +++ b/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings @@ -0,0 +1,36 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Loading Error" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка загрузки" + } + } + } + }, + "Okay" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Понятно" + } + } + } + }, + "Status History" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История статусов" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryFeature.swift b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryFeature.swift new file mode 100644 index 00000000..12e896f3 --- /dev/null +++ b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryFeature.swift @@ -0,0 +1,102 @@ +// +// TicketStatusHistoryFeature.swift +// ForPDA +// +// Created by Xialtal on 10.05.26. +// + +import Foundation +import ComposableArchitecture +import TicketClient +import Models + +@Reducer +public struct TicketStatusHistoryFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public let ticketId: Int + + var history: [TicketStatusHistory] = [] + + var isLoading = false + + public init( + ticketId: Int + ) { + self.ticketId = ticketId + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + + case closeButtonTapped + + case handlerButtonTapped(Int) + } + + case `internal`(Internal) + public enum Internal { + case loadHistory + case historyResponse(Result<[TicketStatusHistory], any Error>) + } + + case delegate(Delegate) + public enum Delegate { + case openUser(Int) + } + } + + // MARK: - Dependencies + + @Dependency(\.ticketClient) private var ticketClient + @Dependency(\.dismiss) private var dismiss + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .send(.internal(.loadHistory)) + + case .view(.closeButtonTapped): + return .run { _ in await dismiss() } + + case let .view(.handlerButtonTapped(id)): + return .send(.delegate(.openUser(id))) + + case .internal(.loadHistory): + state.isLoading = true + return .run { [id = state.ticketId] send in + let response = try await ticketClient.getTicketStatusHistory(ticketId: id) + await send(.internal(.historyResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.historyResponse(.failure(error)))) + } + + case let .internal(.historyResponse(.success(response))): + state.history = response + state.isLoading = false + return .none + + case let .internal(.historyResponse(.failure(error))): + print(error) + state.isLoading = false + return .none + + case .delegate: + return .none + } + } + } +} diff --git a/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift new file mode 100644 index 00000000..c731ec56 --- /dev/null +++ b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift @@ -0,0 +1,179 @@ +// +// TicketStatusHistoryView.swift +// ForPDA +// +// Created by Xialtal on 10.05.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI +import SFSafeSymbols + +@ViewAction(for: TicketStatusHistoryFeature.self) +public struct TicketStatusHistoryView: 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 { + List { + ForEach(store.history) { status in + Status(status) + } + } + .listStyle(.plain) + ._toolbarTitleDisplayMode(.inline) + .navigationTitle(Text("Status History", bundle: .module)) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + send(.closeButtonTapped) + } 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()) + ) + } + } + } + } + .safeAreaInset(edge: .bottom) { + CloseButton() + } + .overlay { + if store.isLoading { + PDALoader() + .frame(width: 24, height: 24) + } else if store.history.isEmpty { + LoadingError() + } + } + .background(Color(.Background.primary)) + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - Status + + @ViewBuilder + private func Status(_ status: TicketStatusHistory) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(status.status.title) + .font(.subheadline) + + Spacer() + + if status.handlerId > 0 { + Button { + send(.handlerButtonTapped(status.handlerId)) + } label: { + HandlerBadge(name: status.handlerName) + } + .buttonStyle(.plain) + } + } + + Text(verbatim: status.changedAt.formatted()) + .font(.caption) + .foregroundStyle(Color(.Labels.quaternary)) + } + .listRowBackground(Color.clear) + } + + // MARK: - Handler Badge + + private func HandlerBadge(name: String) -> some View { + HStack(spacing: 4) { + Image(systemSymbol: .personCropCircle) + + Text(verbatim: name) + } + .font(.caption) + .foregroundStyle(Color(.Labels.teritary)) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + Color(.Background.teritary) + .clipShape(RoundedRectangle(cornerRadius: isLiquidGlass ? 10 : 6)) + ) + } + + // MARK: - Close Button + + @ViewBuilder + private func CloseButton() -> some View { + Button { + send(.closeButtonTapped) + } label: { + Text("Okay", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + + } + .buttonStyle(.borderedProminent) + .tint(tintColor) + .frame(height: 48) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color(.Background.primary)) + } + + // MARK: - Loading Error + + private func LoadingError() -> some View { + VStack(spacing: 0) { + Image(systemSymbol: .clockArrowCirclepath) + .font(.title) + .foregroundStyle(tintColor) + .frame(width: 48, height: 48) + .padding(.bottom, 8) + + Text("Loading Error", bundle: .module) + .font(.title3) + .bold() + .foregroundStyle(Color(.Labels.primary)) + .padding(.bottom, 6) + } + } +} + +// MARK: - Previews + +#Preview { + NavigationStack { + TicketStatusHistoryView( + store: Store( + initialState: TicketStatusHistoryFeature.State( + ticketId: 0 + ) + ) { + TicketStatusHistoryFeature() + } + ) + } +} diff --git a/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift b/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift new file mode 100644 index 00000000..46935a86 --- /dev/null +++ b/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift @@ -0,0 +1,15 @@ +// +// TicketContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 8.05.26. +// + +import Models + +public enum TicketContextMenuAction { + case changeStatus(TicketStatus) + case statusHistory + case openAuthor(Int) + case copyLink +} diff --git a/Modules/Sources/TicketsListFeature/Models/TicketsListContextMenuAction.swift b/Modules/Sources/TicketsListFeature/Models/TicketsListContextMenuAction.swift new file mode 100644 index 00000000..f8b04cd9 --- /dev/null +++ b/Modules/Sources/TicketsListFeature/Models/TicketsListContextMenuAction.swift @@ -0,0 +1,11 @@ +// +// TicketsListContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 9.05.26. +// + +public enum TicketsListContextMenuAction { + case copyLink + // TODO: case toBookmarks +} diff --git a/Modules/Sources/TicketsListFeature/Models/TicketsListType.swift b/Modules/Sources/TicketsListFeature/Models/TicketsListType.swift new file mode 100644 index 00000000..5f04531c --- /dev/null +++ b/Modules/Sources/TicketsListFeature/Models/TicketsListType.swift @@ -0,0 +1,11 @@ +// +// TicketsListType.swift +// ForPDA +// +// Created by Xialtal on 8.05.26. +// + +public enum TicketsListType: Sendable, Equatable { + case list + case topic(Int) +} diff --git a/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..13530f7e --- /dev/null +++ b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings @@ -0,0 +1,186 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Change Status" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить статус" + } + } + } + }, + "Copy Link" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопировать ссылку" + } + } + } + }, + "Go to Author" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Перейти к автору" + } + } + } + }, + "Link copied" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ссылка скопирована" + } + } + } + }, + "New" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Новый" + } + } + } + }, + "No Tickets" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет тикетов" + } + } + } + }, + "Only My" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только мои" + } + } + } + }, + "Processed · " : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработан · " + } + } + } + }, + "Processing · " : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В работе · " + } + } + } + }, + "Sort" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сортировка" + } + } + } + }, + "Sort by Forums" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "По форумам" + } + } + } + }, + "Status History" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История статусов" + } + } + } + }, + "The ticket's handler has changed, please try again" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "У тикета изменился ответственный, попробуйте еще раз" + } + } + } + }, + "Ticket status changed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Статус изменен" + } + } + } + }, + "Tickets" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тикеты" + } + } + } + }, + "Topic Tickets" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тикеты темы" + } + } + } + }, + "Unable to change ticket status" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно сменить статус тикета" + } + } + } + }, + "When requests come in, they will appear here" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Когда придут обращения, они появятся тут" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift new file mode 100644 index 00000000..be624123 --- /dev/null +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -0,0 +1,301 @@ +// +// TicketsListFeature.swift +// ForPDA +// +// Created by Xialtal on 8.05.26. +// + +import Foundation +import ComposableArchitecture +import TicketClient +import Models +import PersistenceKeys +import PageNavigationFeature +import ToastClient +import PasteboardClient +import CacheClient +import TicketStatusHistoryFeature + +@Reducer +public struct TicketsListFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Localizations + + private enum Localization { + static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) + static let handlerChanged = LocalizedStringResource("The ticket's handler has changed, please try again", bundle: .module) + static let unableChangeStatus = LocalizedStringResource("Unable to change ticket status", bundle: .module) + static let statusChanged = LocalizedStringResource("Ticket status changed", bundle: .module) + } + + // MARK: - Destinations + + @Reducer + public enum Destination { + case statusHistory(TicketStatusHistoryFeature) + + @CasePathable + public enum Action { + case statusHistory(TicketStatusHistoryFeature.Action) + } + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Shared(.appSettings) var appSettings: AppSettings + @Shared(.userSession) var userSession: UserSession? + + @Presents public var destination: Destination.State? + + var userSessionNickname: String? + public var pageNavigation = PageNavigationFeature.State(type: .tickets) + + public let type: TicketsListType + public let initialOffset: Int + + var tickets: IdentifiedArrayOf = [] + + var isLoading = false + var isRefreshing = false + var isFirstAppear = false + + public init( + type: TicketsListType, + initialOffset: Int = 0 + ) { + self.type = type + self.initialOffset = initialOffset + } + } + + // MARK: - Action + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case destination(PresentationAction) + case pageNavigation(PageNavigationFeature.Action) + + case view(View) + public enum View { + case onFirstAppear + case onNextAppear + case onRefresh + + case ticketButtonTapped(Int) + + case contextMenu(TicketsListContextMenuAction) + case contextTicketMenu(TicketContextMenuAction, Int) + } + + case `internal`(Internal) + public enum Internal { + case refresh + case initUserSessionNickname(String) + case loadTickets(offset: Int) + case ticketsResponse(Result) + case changeTicketStatusResponse(Result<(Int, TicketStatus, TicketStatusChangeResponse), any Error>) + } + + case delegate(Delegate) + public enum Delegate { + case openUser(Int) + case openTicket(Int) + } + } + + // MARK: - Dependencies + + @Dependency(\.pasteboardClient) private var pasteboardClient + @Dependency(\.ticketClient) private var ticketClient + @Dependency(\.toastClient) private var toastClient + @Dependency(\.cacheClient) private var cacheClient + @Dependency(\.openURL) private var openURL + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Scope(state: \.pageNavigation, action: \.pageNavigation) { + PageNavigationFeature() + } + + Reduce { state, action in + switch action { + case .binding(\.appSettings.tickets.isSortByForums), + .binding(\.appSettings.tickets.isShowOnlyMine): + return .send(.internal(.refresh)) + + case let .pageNavigation(.offsetChanged(to: newOffset)): + state.isRefreshing = false + return .send(.internal(.loadTickets(offset: newOffset))) + + case let .destination(.presented(.statusHistory(.delegate(.openUser(id))))): + state.destination = nil + return .send(.delegate(.openUser(id))) + + case .pageNavigation, .binding, .delegate, .destination: + return .none + + case .view(.onFirstAppear): + state.isFirstAppear = true + return .run { [initialOffset = state.initialOffset, session = state.userSession] send in + if let session, let user = cacheClient.getUser(session.userId) { + await send(.internal(.initUserSessionNickname(user.nickname))) + } + await send(.internal(.loadTickets(offset: initialOffset))) + } + + case .view(.onNextAppear): + return .send(.internal(.refresh)) + + case .view(.onRefresh): + guard !state.isLoading else { return .none } + return .send(.internal(.refresh)) + + case let .view(.ticketButtonTapped(id)): + return .send(.delegate(.openTicket(id))) + + case let .view(.contextMenu(action)): + switch action { + case .copyLink: + let type = switch state.type { + case .list: "" + case .topic(let id): "&only-topic=\(id)" + } + let offset = state.pageNavigation.offset > 0 ? "&st=\(state.pageNavigation.offset)" : "" + pasteboardClient.copy("https://4pda.to/forum/index.php?act=ticket\(offset)\(type)") + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.linkCopied, haptic: .success)) + } + } + + case let .view(.contextTicketMenu(action, ticketId)): + switch action { + case .changeStatus(let status): + return .run { [handlerId = state.tickets[ticketId].info.handlerId] send in + let response = try await ticketClient.changeTicketStatus( + id: ticketId, + handlerId: handlerId, + status: status + ) + await send(.internal(.changeTicketStatusResponse(.success((ticketId, status, response))))) + } catch: { error, send in + await send(.internal(.changeTicketStatusResponse(.failure(error)))) + } + + case .statusHistory: + state.destination = .statusHistory(TicketStatusHistoryFeature.State(ticketId: ticketId)) + + case .openAuthor(let authorId): + return .send(.delegate(.openUser(authorId))) + + case .copyLink: + pasteboardClient.copy("https://4pda.to/forum/index.php?act=ticket&s=thread&t_id=\(ticketId)") + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.linkCopied, haptic: .success)) + } + } + return .none + + case let .internal(.initUserSessionNickname(name)): + state.userSessionNickname = name + return .none + + case .internal(.refresh): + state.isRefreshing = true + return .send(.internal(.loadTickets(offset: state.pageNavigation.offset))) + + case let .internal(.loadTickets(offset)): + if !state.isRefreshing { + state.isLoading = true + } + let forId = switch state.type { + case .list: 0 + case .topic(let id): id + } + return .run { [ + amount = state.appSettings.ticketsPerPage, + ticketsSettings = state.appSettings.tickets + ] send in + let request = TicketsListRequest( + forId: forId, + offset: offset, + amount: amount, + isSortByForums: ticketsSettings.isSortByForums, + isShowOnlyMine: ticketsSettings.isShowOnlyMine + ) + let respone = try await ticketClient.getTicketsList(request) + await send(.internal(.ticketsResponse(.success(respone)))) + } catch: { error, send in + await send(.internal(.ticketsResponse(.failure(error)))) + } + + case let .internal(.ticketsResponse(.success(response))): + state.tickets = .init(uniqueElements: response.tickets) + state.pageNavigation.count = response.availableCount + if state.isFirstAppear { + state.isFirstAppear = false + state.isLoading = false + state.isRefreshing = false + return .send(.pageNavigation(.update(count: response.availableCount, offset: state.initialOffset))) + } + state.isLoading = false + state.isRefreshing = false + return .none + + case let .internal(.ticketsResponse(.failure(error))): + print(error) + state.isLoading = false + state.isRefreshing = false + return .run { _ in + await toastClient.showToast(.whoopsSomethingWentWrong) + } + + case let .internal(.changeTicketStatusResponse(.success((ticketId, status, .success)))): + if let session = state.userSession, let handlerName = state.userSessionNickname { + let info: (Int, String, Date?) = switch status { + case .processed: (session.userId, handlerName, Date.now) + case .processing: (session.userId, handlerName, nil) + case .notProcessed: (0, "", nil) + } + state.tickets[ticketId].info.status = status + state.tickets[ticketId].info.handlerId = info.0 + state.tickets[ticketId].info.handlerName = info.1 + state.tickets[ticketId].info.processedAt = info.2 + } + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.statusChanged, haptic: .success)) + } + + case let .internal(.changeTicketStatusResponse(.success((ticketId, _, .failure(reason))))): + switch reason { + case .handlerChanged(let id, let name): + state.tickets[ticketId].info.handlerId = id + state.tickets[ticketId].info.handlerName = name + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.handlerChanged, haptic: .success)) + } + + case .other: + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.unableChangeStatus, isError: true, haptic: .error)) + } + } + + case let .internal(.changeTicketStatusResponse(.failure(error))): + print(error) + return .run { _ in + await toastClient.showToast(.whoopsSomethingWentWrong) + } + } + } + .ifLet(\.$destination, action: \.destination) + } +} + +extension TicketsListFeature.Destination.State: Equatable {} diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift new file mode 100644 index 00000000..892942d2 --- /dev/null +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -0,0 +1,375 @@ +// +// TicketsListScreen.swift +// ForPDA +// +// Created by Xialtal on 8.05.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI +import PageNavigationFeature +import SFSafeSymbols +import TicketStatusHistoryFeature + +@ViewAction(for: TicketsListFeature.self) +public struct TicketsListScreen: View { + + // MARK: - Properties + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + @State private var navigationMinimized = false + + private var shouldShowInlineNavigation: Bool { + let isAnyFloatingNavigationEnabled = store.appSettings.floatingNavigation || store.appSettings.experimentalFloatingNavigation + return store.pageNavigation.shouldShow && (!isLiquidGlass || !isAnyFloatingNavigationEnabled) + } + + private var shouldShowFloatingNavigation: Bool { + return isLiquidGlass && store.appSettings.floatingNavigation && !store.appSettings.experimentalFloatingNavigation + } + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + WithPerceptionTracking { + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + if !store.isLoading { + if !store.tickets.isEmpty { + List { + if shouldShowInlineNavigation { + Navigation() + } + + Content() + + if shouldShowInlineNavigation { + Navigation() + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + ._inScrollContentDetector(isEnabled: shouldShowFloatingNavigation, state: $navigationMinimized) + } else { + NothingFound() + } + } + } + .overlay { + if store.isLoading { + PDALoader() + .frame(width: 24, height: 24) + } + } + .navigationTitle(Text(navigationTitleText(), bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .background(Color(.Background.primary)) + .toolbar { + ToolbarItem { + OptionsMenu() + } + } + .safeAreaInset(edge: .bottom) { + if shouldShowFloatingNavigation { + PageNavigation( + store: store.scope(state: \.pageNavigation, action: \.pageNavigation), + minimized: $navigationMinimized + ) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + } + .sheet(item: $store.scope(state: \.$destination, action: \.destination).statusHistory) { store in + NavigationStack { + TicketStatusHistoryView(store: store) + } + } + .refreshable { + await send(.onRefresh).finish() + } + .onFirstAppear { + send(.onFirstAppear) + } onNextAppear: { + send(.onNextAppear) + } + } + } + + // MARK: - Options Menu + + @ViewBuilder + private func OptionsMenu() -> some View { + WithPerceptionTracking { + Menu { + Section { + Toggle( + LocalizedStringResource("Only My", bundle: .module), + isOn: Binding(store.$appSettings.tickets.isShowOnlyMine) + ) + + if case .list = store.type { + Toggle( + LocalizedStringResource("Sort by Forums", bundle: .module), + isOn: Binding(store.$appSettings.tickets.isSortByForums) + ) + } + } header: { + Text("Sort", bundle: .module) + } + + ContextButton(text: LocalizedStringResource("Copy Link", bundle: .module), symbol: .docOnDoc) { + send(.contextMenu(.copyLink)) + } + } label: { + Image(systemSymbol: .ellipsisCircle) + } + } + } + + // MARK: - Ticket Context Menu + + @ViewBuilder + private func TicketContextMenu(id: Int, _ ticket: TicketInfo) -> some View { + Menu { + Section { + Menu { + TicketStatusPicker(id: id) + } label: { + HStack { + Text("Change Status", bundle: .module) + Image(systemSymbol: .checklist) + } + } + + ContextButton(text: LocalizedStringResource("Status History", bundle: .module), symbol: .clockArrowCirclepath) { + send(.contextTicketMenu(.statusHistory, id)) + } + } + + Section { + ContextButton(text: LocalizedStringResource("Go to Author", bundle: .module), symbol: .personCropCircle) { + send(.contextTicketMenu(.openAuthor(ticket.authorId), id)) + } + } + + Section { + ContextButton(text: LocalizedStringResource("Copy Link", bundle: .module), symbol: .docOnDoc) { + send(.contextTicketMenu(.copyLink, id)) + } + } + } label: { + Image(systemSymbol: .ellipsis) + .font(.body) + .foregroundStyle(Color(.Labels.teritary)) + .padding(.horizontal, 8) // Padding for tap area + .padding(.vertical, 16) + } + .onTapGesture {} // DO NOT DELETE, FIX FOR IOS 17 + .frame(width: 8, height: 22) + } + + // MARK: - Content + + @ViewBuilder + private func Content() -> some View { + WithPerceptionTracking { + ForEach(store.tickets) { ticket in + Button { + send(.ticketButtonTapped(ticket.id)) + } label: { + TicketRow(ticket) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) + } + } + } + + // MARK: - Ticket Row + + @ViewBuilder + private func TicketRow(_ ticket: TicketsList.TicketSimplified) -> some View { + VStack(alignment: .leading, spacing: 8) { + Menu { + TicketStatusPicker(id: ticket.id) + } label: { + TicketStatusBadge(info: ticket.info) + } + + Text(verbatim: ticket.info.title) + .font(.subheadline) + .foregroundStyle(Color(.Labels.primary)) + + HStack(spacing: 6) { + Image(systemSymbol: .textBubble) + + Text(verbatim: ticket.info.subjectRootName) + } + .font(.caption) + .foregroundStyle(Color(.Labels.teritary)) + + HStack { + HStack(spacing: 0) { + let date = if ticket.info.status == .processed { + ticket.info.processedAt ?? Date.unknown + } else { + ticket.info.createdAt + } + Text(verbatim: "\(date.formatted()) · ") + + HStack(spacing: 4) { + Image(systemSymbol: .personCropCircle) + + Text(verbatim: ticket.info.authorName) + } + } + + Spacer() + + TicketContextMenu(id: ticket.id, ticket.info) + } + .font(.caption) + .foregroundStyle(Color(.Labels.quaternary)) + } + } + + // MARK: - Ticket Status Badge + + @ViewBuilder + private func TicketStatusBadge(info: TicketInfo) -> some View { + let text: LocalizedStringKey = switch info.status { + case .notProcessed: "New" + case .processing: "Processing · " + case .processed: "Processed · " + } + HStack(spacing: 0) { + Text(text, bundle: .module) + + if info.handlerId > 0 { + HStack(spacing: 4) { + Image(systemSymbol: .personCropCircle) + + Text(verbatim: info.handlerName) + } + } + } + .font(.caption) + .foregroundStyle(info.status.textColor) + .padding(.vertical, 2) + .padding(.horizontal, 6) + .background( + info.status.maskColor + .clipShape(RoundedRectangle(cornerRadius: isLiquidGlass ? 10 : 6)) + ) + } + + // MARK: - Ticket Status Picker + + private func TicketStatusPicker(id: Int) -> some View { + WithPerceptionTracking { + let status = store.tickets.first(where: { $0.id == id })!.info.status + Picker(String(), selection: Binding( + get: { status }, + set: { newValue in + send(.contextTicketMenu(.changeStatus(newValue), id)) + } + )) { + ForEach(TicketStatus.allCases) { status in + Text(status.title) + .tag(status) + } + } + } + } + + // MARK: - Navigation + + @ViewBuilder + private func Navigation() -> some View { + PageNavigation(store: store.scope(state: \.pageNavigation, action: \.pageNavigation)) + .listRowBackground(Color(.Background.primary)) + } + + // MARK: - Nothing Found + + private func NothingFound() -> some View { + VStack(spacing: 0) { + Image(systemSymbol: .exclamationmarkBubble) + .font(.title) + .foregroundStyle(tintColor) + .frame(width: 48, height: 48) + .padding(.bottom, 8) + + Text("No Tickets", bundle: .module) + .font(.title3) + .bold() + .foregroundStyle(Color(.Labels.primary)) + .padding(.bottom, 6) + + Text("When requests come in, they will appear here", bundle: .module) + .font(.footnote) + .multilineTextAlignment(.center) + .foregroundStyle(Color(.Labels.teritary)) + .frame(maxWidth: UIScreen.main.bounds.width * 0.7) + .padding(.horizontal, 55) + } + } + + // MARK: - Helpers + + private func navigationTitleText() -> LocalizedStringKey { + return switch store.type { + case .list: "Tickets" + case .topic: "Topic Tickets" + } + } +} + +// MARK: - Extensions + +extension TicketStatus { + var maskColor: Color { + switch self { + case .notProcessed: Color(.Main.redAlpha) + case .processing: Color(.Main.yellowAlpha) + case .processed: Color(.Background.teritary) + } + } + + var textColor: Color { + switch self { + case .notProcessed: Color(.Main.red) + case .processing: Color(.Main.yellow) + case .processed: Color(.Labels.teritary) + } + } +} + +// MARK: - Previews + +#Preview { + NavigationStack { + TicketsListScreen( + store: Store( + initialState: TicketsListFeature.State( + type: .list + ) + ) { + TicketsListFeature() + } + ) + } + .tint(Color(.Theme.primary)) +} diff --git a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift index 5f4ff1c4..c5bec416 100644 --- a/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift +++ b/Modules/Sources/TopicFeature/Models/TopicToolsContextMenuAction.swift @@ -9,5 +9,6 @@ import Models public enum TopicToolsContextMenuAction { case move + case tickets case modify(TopicModifyAction, Bool) } diff --git a/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryView.swift b/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryView.swift index 4a00d3aa..3fd5e2ea 100644 --- a/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryView.swift +++ b/Modules/Sources/TopicFeature/PostKarmaHistory/PostKarmaHistoryView.swift @@ -114,6 +114,7 @@ public struct PostKarmaHistoryView: View { .font(.caption) .foregroundStyle(Color(.Labels.quaternary)) } + .listRowBackground(Color.clear) } } diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 10fda9df..10b3ccee 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -311,6 +311,16 @@ } } }, + "Posts moved" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Посты перемещены" + } + } + } + }, "Remove from favorites" : { "localizations" : { "ru" : { @@ -421,6 +431,16 @@ } } }, + "Topic Tickets" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тикеты темы" + } + } + } + }, "Unable to delete topic" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 660fa267..ce2162f8 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -37,6 +37,7 @@ public struct TopicFeature: Reducer, Sendable { private enum Localization { static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) static let reportSent = LocalizedStringResource("Report sent", bundle: .module) + static let postsMoved = LocalizedStringResource("Posts moved", bundle: .module) static let topicEdited = LocalizedStringResource("The topic has been edited", bundle: .module) static let favoriteAdded = LocalizedStringResource("Added to favorites", bundle: .module) static let favoriteRemoved = LocalizedStringResource("Removed from favorites", bundle: .module) @@ -102,7 +103,7 @@ public struct TopicFeature: Reducer, Sendable { public var goTo: GoTo var posts: [UIPost] = [] - var postsFilter: TopicPostsFilter = .exceptDeleted + var postsFilter: TopicPostsFilter var isLoadingTopic = true var isRefreshing = false @@ -123,6 +124,7 @@ public struct TopicFeature: Reducer, Sendable { topicName: String? = nil, initialOffset: Int = 0, // TODO: Not needed anymore? goTo: GoTo = .first, + postsFilter: TopicPostsFilter? = nil, destination: Destination.State? = nil ) { self.topicId = topicId @@ -130,6 +132,7 @@ public struct TopicFeature: Reducer, Sendable { self.goTo = goTo self.destination = destination self.floatingNavigation = _appSettings.floatingNavigation.wrappedValue + self.postsFilter = postsFilter ?? (_appSettings.topicShowAllPostsFilter.wrappedValue ? .all : .exceptDeleted) // If we open this screen with Go To End usage then we can get offset like 99 // which means that we need to lower it to 80 (if topicPerPage is 20) with remainder @@ -172,7 +175,7 @@ public struct TopicFeature: Reducer, Sendable { public enum Internal { case load case refresh - case goToPost(postId: Int, offset: Int, forceRefresh: Bool) + case goToPost(postId: Int, offset: Int, filter: TopicPostsFilter, forceRefresh: Bool) case changeKarma(postId: Int, isUp: Bool) case jumpToPostAfterKarma(postId: Int) case voteInPoll(selections: [[Int]]) @@ -187,6 +190,9 @@ public struct TopicFeature: Reducer, Sendable { public enum Delegate { case handleUrl(URL) case openUser(id: Int) + case openTopic(Int) + case openTickets(Int) + case openEventLog(Int, ForumEventLogType) case openSearch(SearchOn, ForumInfo?) case openSearchResult(SearchResult) case openedLastPage @@ -246,9 +252,18 @@ public struct TopicFeature: Reducer, Sendable { await toastClient.showToast(ToastMessage(text: Localization.topicEdited, haptic: .success)) } + case let .destination(.presented(.move(.delegate(.openTopic(id))))): + return .run { send in + await toastClient.showToast(ToastMessage(text: Localization.postsMoved, haptic: .success)) + await send(.delegate(.openTopic(id))) + } + case let .destination(.presented(.stat(.delegate(.userTapped(id))))): return .send(.delegate(.openUser(id: id))) + case .destination(.presented(.stat(.delegate(.topicHistoryTapped)))): + return .send(.delegate(.openEventLog(state.topicId, .topic))) + case let .destination(.presented(.karmaHistory(.delegate(.openUser(id))))): return .send(.delegate(.openUser(id: id))) @@ -407,6 +422,9 @@ public struct TopicFeature: Reducer, Sendable { state.destination = .move(ForumMoveFeature.State(type: .topic(topic.id))) return .none + case .tickets: + return .send(.delegate(.openTickets(topic.id))) + case .modify(let action, let isUndo): switch action { case .hide, .close: @@ -503,6 +521,9 @@ public struct TopicFeature: Reducer, Sendable { state.destination = .move(ForumMoveFeature.State(type: .posts([postId]))) return .none + case .eventLog(let postId): + return .send(.delegate(.openEventLog(postId, .post))) + case .modify(let action, let postId, let isUndo): switch action { case .pin, .hide, .protect: @@ -709,8 +730,9 @@ public struct TopicFeature: Reducer, Sendable { await toastClient.showToast(.whoopsSomethingWentWrong) } - case let .internal(.goToPost(postId: postId, offset: offset, forceRefresh)): + case let .internal(.goToPost(postId, offset, filter, forceRefresh)): state.postId = postId + state.postsFilter = filter if !forceRefresh && offset == state.pageNavigation.offset && state.topic != nil { // If we have this post on the same page without force refresh, don't reload return .none @@ -768,16 +790,19 @@ public struct TopicFeature: Reducer, Sendable { return .send(.pageNavigation(.goToPage(newPage: page))) } - return .run { [topicId = state.topicId, topicPerPage = state.appSettings.topicPerPage] send in - let request = JumpForumRequest(postId: jump.postId, topicId: topicId, allPosts: true, type: jump.type) + return .run { [topicId = state.topicId, filter = state.postsFilter, topicPerPage = state.appSettings.topicPerPage] send in + let request = JumpForumRequest(postId: jump.postId, topicId: topicId, postsFilter: filter, type: jump.type) let response = try await apiClient.jumpForum(request) if response.id != topicId { // Handling case where post is in another topic - let url = URL(string: "https://4pda.to/forum/index.php?showtopic=\(response.id)&view=findpost&p=\(response.postId)")! + let modfilter = if let modfilter = response.postsFilter.modfilter { + "&modfilter=\(modfilter)" + } else { "" } + let url = URL(string: "https://4pda.to/forum/index.php?showtopic=\(response.id)&view=findpost&p=\(response.postId)\(modfilter)")! return await send(.delegate(.handleUrl(url))) } let offset = response.offset - (response.offset % topicPerPage) - await send(.internal(.goToPost(postId: response.postId, offset: offset, forceRefresh: forceRefresh))) + await send(.internal(.goToPost(postId: response.postId, offset: offset, filter: response.postsFilter, forceRefresh: forceRefresh))) if jump.type == .post && jump.postId != response.postId { await toastClient.showToast(ToastMessage(text: Localization.showingNearestPost)) diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 67369246..9a706d4e 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -224,6 +224,10 @@ public struct TopicScreen: View { Image(systemSymbol: .line3HorizontalDecrease) } } + + ContextButton(text: LocalizedStringResource("Topic Tickets", bundle: .module), symbol: .exclamationmarkBubble) { + send(.contextToolsMenu(.tickets)) + } } } } @@ -441,6 +445,8 @@ public struct TopicScreen: View { switch action { case .move(let postId): send(.contextPostToolsMenu(.move(postId))) + case .eventLog(let postId): + send(.contextPostToolsMenu(.eventLog(postId))) case .modify(let action, let postId, let isUndo): send(.contextPostToolsMenu(.modify(action, postId, isUndo))) } diff --git a/Project.swift b/Project.swift index b990cf8f..c77182f6 100644 --- a/Project.swift +++ b/Project.swift @@ -45,6 +45,7 @@ let project = Project( .Internal.DeviceTypeFeature, .Internal.FavoritesFeature, .Internal.FavoritesRootFeature, + .Internal.ForumEventLogFeature, .Internal.ForumFeature, .Internal.ForumsListFeature, .Internal.HistoryFeature, @@ -65,6 +66,8 @@ let project = Project( .Internal.SettingsFeature, .Internal.SharedUI, .Internal.TCAExtensions, + .Internal.TicketFeature, + .Internal.TicketsListFeature, .Internal.ToastClient, .Internal.TopicFeature, .SPM.AlertToast, @@ -264,6 +267,21 @@ let project = Project( .SPM.TCA, ] ), + + .feature( + name: "ForumEventLogFeature", + dependencies: [ + .Internal.APIClient, + .Internal.BBBuilder, + .Internal.Models, + .Internal.PasteboardClient, + .Internal.SharedUI, + .Internal.ToastClient, + .SPM.RichTextKit, + .SPM.SFSafeSymbols, + .SPM.TCA, + ] + ), .feature( name: "ForumFeature", @@ -409,6 +427,7 @@ let project = Project( .Internal.NotificationsClient, .Internal.ParsingClient, .Internal.PersistenceKeys, + .Internal.ReputationChangeFeature, .Internal.SharedUI, .Internal.ToastClient, .Internal.FormFeature, @@ -435,6 +454,7 @@ let project = Project( name: "QMSListFeature", hasResources: false, dependencies: [ + .Internal.AnalyticsClient, .Internal.CacheClient, .Internal.Models, .Internal.QMSClient, @@ -483,6 +503,7 @@ let project = Project( dependencies: [ .Internal.AnalyticsClient, .Internal.APIClient, + .Internal.CacheClient, .Internal.Models, .Internal.SharedUI, .Internal.FormFeature, @@ -504,6 +525,7 @@ let project = Project( .feature( name: "SearchResultFeature", dependencies: [ + .Internal.AnalyticsClient, .Internal.APIClient, .Internal.BBBuilder, .Internal.Models, @@ -530,6 +552,52 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "TicketFeature", + dependencies: [ + .Internal.CacheClient, + .Internal.Models, + .Internal.PasteboardClient, + .Internal.PersistenceKeys, + .Internal.SharedUI, + .Internal.TicketClient, + .Internal.TicketStatusHistoryFeature, + .Internal.ToastClient, + .Internal.TopicBuilder, + .SPM.RichTextKit, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), + + .feature( + name: "TicketsListFeature", + dependencies: [ + .Internal.CacheClient, + .Internal.Models, + .Internal.PageNavigationFeature, + .Internal.PasteboardClient, + .Internal.PersistenceKeys, + .Internal.SharedUI, + .Internal.TicketClient, + .Internal.TicketStatusHistoryFeature, + .Internal.ToastClient, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), + + .feature( + name: "TicketStatusHistoryFeature", + dependencies: [ + .Internal.Models, + .Internal.SharedUI, + .Internal.TicketClient, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), .feature( name: "TopicBuilder", @@ -647,6 +715,17 @@ let project = Project( .SPM.ZMarkupParser, ] ), + + .feature( + name: "TicketClient", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.ParsingClient, + .SPM.PDAPI, + .SPM.TCA + ] + ), .feature( name: "ToastClient", @@ -1106,6 +1185,7 @@ extension TargetDependency.Internal { static let FavoritesFeature = TargetDependency.target(name: "FavoritesFeature") static let FavoritesRootFeature = TargetDependency.target(name: "FavoritesRootFeature") static let FormFeature = TargetDependency.target(name: "FormFeature") + static let ForumEventLogFeature = TargetDependency.target(name: "ForumEventLogFeature") static let ForumFeature = TargetDependency.target(name: "ForumFeature") static let ForumsListFeature = TargetDependency.target(name: "ForumsListFeature") static let ForumMoveFeature = TargetDependency.target(name: "ForumMoveFeature") @@ -1124,6 +1204,9 @@ extension TargetDependency.Internal { static let SearchFeature = TargetDependency.target(name: "SearchFeature") static let SearchResultFeature = TargetDependency.target(name: "SearchResultFeature") static let SettingsFeature = TargetDependency.target(name: "SettingsFeature") + static let TicketFeature = TargetDependency.target(name: "TicketFeature") + static let TicketsListFeature = TargetDependency.target(name: "TicketsListFeature") + static let TicketStatusHistoryFeature = TargetDependency.target(name: "TicketStatusHistoryFeature") static let TopicBuilder = TargetDependency.target(name: "TopicBuilder") static let TopicEditFeature = TargetDependency.target(name: "TopicEditFeature") static let TopicFeature = TargetDependency.target(name: "TopicFeature") @@ -1139,6 +1222,7 @@ extension TargetDependency.Internal { static let ParsingClient = TargetDependency.target(name: "ParsingClient") static let PasteboardClient = TargetDependency.target(name: "PasteboardClient") static let QMSClient = TargetDependency.target(name: "QMSClient") + static let TicketClient = TargetDependency.target(name: "TicketClient") static let ToastClient = TargetDependency.target(name: "ToastClient") // Shared diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index a0ef0d27..0ff51adc 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fb8b5563a09730e1b427a82c15a7013e695b2305d2f502815e3d028a09c43032", + "originHash" : "f7dc84744279a1fac722da64e3a355db6d2204ce21d836f34a5c4b57f36e0fd8", "pins" : [ { "identity" : "activityindicatorview", @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SubvertDev/PDAPI_SPM.git", "state" : { - "revision" : "c1d783c1fab5a54a994d5fa68f02c631405573a9", - "version" : "0.8.0" + "revision" : "3fc0b24decfe9f550eebc0cb011260d654d21337", + "version" : "0.8.3" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 295c3f4c..b4926664 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -96,7 +96,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.8.0"), + .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.8.3"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), ] )