From d54dd5f83face4e05d7c69bf9e4b7d067e245364 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 4 May 2026 10:25:57 +0300 Subject: [PATCH 001/112] Bump API version --- Tuist/Package.resolved | 6 +++--- Tuist/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index ce8f700d..5d0caeb6 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "535f4e2bce52ea18cfea287c9fea9fadee9e199ae263148fd5578ddc96b652c0", + "originHash" : "4bdfe875163519003e17ff6b5fa18b164341c7d0c94971ebb1763ccd1d31a7b5", "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" : "e1c5ddfaf6c98d9b60960ab0d984b98fc61f1c7f", + "version" : "0.8.1" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 46b7b2c4..ddd001c7 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.1"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), ] ) From 84d18305b5065a781e26df499f3bd727c8cb6024 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 4 May 2026 10:30:45 +0300 Subject: [PATCH 002/112] [WIP] TicketClient --- .../Sources/Models/Ticket/TicketInfo.swift | 60 +++++++++++++++++ .../Sources/Models/Ticket/TicketStatus.swift | 12 ++++ .../Sources/Models/Ticket/TicketsList.swift | 23 +++++++ .../ParsingClient/Parsers/TicketParser.swift | 65 +++++++++++++++++++ .../Sources/ParsingClient/ParsingClient.swift | 6 ++ .../TicketClient/Models/TicketsListSort.swift | 17 +++++ .../Requests/TicketsListRequest.swift | 25 +++++++ .../Sources/TicketClient/TicketClient.swift | 61 +++++++++++++++++ Project.swift | 11 ++++ 9 files changed, 280 insertions(+) create mode 100644 Modules/Sources/Models/Ticket/TicketInfo.swift create mode 100644 Modules/Sources/Models/Ticket/TicketStatus.swift create mode 100644 Modules/Sources/Models/Ticket/TicketsList.swift create mode 100644 Modules/Sources/ParsingClient/Parsers/TicketParser.swift create mode 100644 Modules/Sources/TicketClient/Models/TicketsListSort.swift create mode 100644 Modules/Sources/TicketClient/Requests/TicketsListRequest.swift create mode 100644 Modules/Sources/TicketClient/TicketClient.swift diff --git a/Modules/Sources/Models/Ticket/TicketInfo.swift b/Modules/Sources/Models/Ticket/TicketInfo.swift new file mode 100644 index 00000000..3976ced5 --- /dev/null +++ b/Modules/Sources/Models/Ticket/TicketInfo.swift @@ -0,0 +1,60 @@ +// +// TicketInfo.swift +// ForPDA +// +// Created by Xialtal on 3.05.26. +// + +import Foundation + +public struct TicketInfo: Sendable { + public let id: Int + public let title: String + public let status: TicketStatus + public let subjectId: Int + public let subjectName: String + public let authorId: Int + public let authorName: String + public let handlerId: Int + public let handlerName: String + public let createdAt: Date + + public init( + id: Int, + title: String, + status: TicketStatus, + subjectId: Int, + subjectName: String, + authorId: Int, + authorName: String, + handlerId: Int, + handlerName: String, + createdAt: Date + ) { + self.id = id + self.title = title + self.status = status + self.subjectId = subjectId + self.subjectName = subjectName + self.authorId = authorId + self.authorName = authorName + self.handlerId = handlerId + self.handlerName = handlerName + self.createdAt = createdAt + } +} + +public extension TicketInfo { + static let mock = TicketInfo( + id: 0, + title: "New topic: ForPDA [iOS]", + status: .processing, + subjectId: 12, + subjectName: "ForPDA [iOS]", + authorId: 6176341, + authorName: "AirFlare", + handlerId: 3640948, + handlerName: "subvertd", + createdAt: Date.now + ) +} diff --git a/Modules/Sources/Models/Ticket/TicketStatus.swift b/Modules/Sources/Models/Ticket/TicketStatus.swift new file mode 100644 index 00000000..77607ba2 --- /dev/null +++ b/Modules/Sources/Models/Ticket/TicketStatus.swift @@ -0,0 +1,12 @@ +// +// TicketStatus.swift +// ForPDA +// +// Created by Xialtal on 3.05.26. +// + +public enum TicketStatus: Int, Sendable { + case notProcessed = 0 + case processing = 1 + case processed = 2 +} diff --git a/Modules/Sources/Models/Ticket/TicketsList.swift b/Modules/Sources/Models/Ticket/TicketsList.swift new file mode 100644 index 00000000..202da2bd --- /dev/null +++ b/Modules/Sources/Models/Ticket/TicketsList.swift @@ -0,0 +1,23 @@ +// +// TicketsList.swift +// ForPDA +// +// Created by Xialtal on 3.05.26. +// + +public struct TicketsList: Sendable { + public let tickets: [TicketInfo] + public let availableCount: Int + + public init(tickets: [TicketInfo], availableCount: Int) { + self.tickets = tickets + self.availableCount = availableCount + } +} + +public extension TicketsList { + static let mock = TicketsList( + tickets: [.mock], + availableCount: 1 + ) +} diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift new file mode 100644 index 00000000..501732c3 --- /dev/null +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -0,0 +1,65 @@ +// +// TicketParser.swift +// ForPDA +// +// Created by Xialtal on 3.05.26. +// + +import Foundation +import Models + +public struct TicketParser { + + // 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) -> [TicketInfo] { + var ticketsInfo: [TicketInfo] = [] + for info in infoRaw { + guard let id = info[safe: 0] as? Int, + let title = info[safe: 2] as? String, + let subjectId = info[safe: 3] as? Int, + let subjectName = info[safe: 4] as? String, + let createdAt = info[safe: 5] 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: 14] as? Int else { + throw ParsingError.failedToCastFields + } + + ticketsInfo.append(TicketInfo( + id: id, + title: title.convertCodes(), + status: TicketStatus(rawValue: statusRaw)!, + subjectId: subjectId, + subjectName: subjectName.convertCodes(), + authorId: authorId, + authorName: authorName.convertCodes(), + handlerId: handlerId, + handlerName: handlerName.convertCodes(), + createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)) + )) + } + return ticketsInfo + } +} diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 35c559c2..c082bd95 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -66,6 +66,9 @@ 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 } // MARK: - Dependency Key @@ -176,6 +179,9 @@ extension ParsingClient: DependencyKey { }, parseDeviceSpecifications: { response in return try DevDBParser.parse(from: response) + }, + parseTicketsList: { response in + return try TicketParser.parseTicketsList(from: response) } ) } diff --git a/Modules/Sources/TicketClient/Models/TicketsListSort.swift b/Modules/Sources/TicketClient/Models/TicketsListSort.swift new file mode 100644 index 00000000..065cf34b --- /dev/null +++ b/Modules/Sources/TicketClient/Models/TicketsListSort.swift @@ -0,0 +1,17 @@ +// +// TicketsListSort.swift +// ForPDA +// +// Created by Xialtal on 4.05.26. +// + +public struct TicketsListSort: OptionSet, Sendable { + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let onlyMy = TicketsListSort(rawValue: 1 << 0) + public static let byForums = TicketsListSort(rawValue: 1 << 2) +} diff --git a/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift b/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift new file mode 100644 index 00000000..e9daf3e3 --- /dev/null +++ b/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift @@ -0,0 +1,25 @@ +// +// TicketsListRequest.swift +// ForPDA +// +// Created by Xialtal on 4.05.26. +// + +public struct TicketsListRequest: Sendable { + public let forId: Int + public let sort: TicketsListSort + public let offset: Int + public let amount: Int + + public init( + forId: Int, + sort: TicketsListSort, + offset: Int, + amount: Int + ) { + self.forId = forId + self.sort = sort + self.offset = offset + self.amount = amount + } +} diff --git a/Modules/Sources/TicketClient/TicketClient.swift b/Modules/Sources/TicketClient/TicketClient.swift new file mode 100644 index 00000000..b28dfdd1 --- /dev/null +++ b/Modules/Sources/TicketClient/TicketClient.swift @@ -0,0 +1,61 @@ +// +// 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 +} + +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.sort.rawValue, + offset: data.offset, + limit: data.amount + )) + return try await parser.parseTicketsList(response) + } + ) + } + + // MARK: - Preview Value + + public static var previewValue: TicketClient { + return TicketClient( + getTicketsList: { _ in + return .mock + } + ) + } +} + +extension DependencyValues { + public var ticketClient: TicketClient { + get { self[TicketClient.self] } + set { self[TicketClient.self] = newValue } + } +} diff --git a/Project.swift b/Project.swift index cdfc46d4..36250e48 100644 --- a/Project.swift +++ b/Project.swift @@ -615,6 +615,17 @@ let project = Project( .SPM.ZMarkupParser, ] ), + + .feature( + name: "TicketClient", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.ParsingClient, + .SPM.PDAPI, + .SPM.TCA + ] + ), .feature( name: "ToastClient", From a60228dd2ce5c58343b2ec5f576cdd04044dc054 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 4 May 2026 10:56:31 +0300 Subject: [PATCH 003/112] Improve TicketInfo model --- Modules/Sources/Models/Ticket/TicketInfo.swift | 18 +++++++++++++----- .../ParsingClient/Parsers/TicketParser.swift | 10 +++++++--- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/Models/Ticket/TicketInfo.swift b/Modules/Sources/Models/Ticket/TicketInfo.swift index 3976ced5..6d89332a 100644 --- a/Modules/Sources/Models/Ticket/TicketInfo.swift +++ b/Modules/Sources/Models/Ticket/TicketInfo.swift @@ -12,7 +12,9 @@ public struct TicketInfo: Sendable { public let title: String public let status: TicketStatus public let subjectId: Int - public let subjectName: String + public let subjectElementId: Int + public let subjectRootId: Int + public let subjectRootName: String public let authorId: Int public let authorName: String public let handlerId: Int @@ -24,7 +26,9 @@ public struct TicketInfo: Sendable { title: String, status: TicketStatus, subjectId: Int, - subjectName: String, + subjectElementId: Int, + subjectRootId: Int, + subjectRootName: String, authorId: Int, authorName: String, handlerId: Int, @@ -35,7 +39,9 @@ public struct TicketInfo: Sendable { self.title = title self.status = status self.subjectId = subjectId - self.subjectName = subjectName + self.subjectElementId = subjectElementId + self.subjectRootId = subjectRootId + self.subjectRootName = subjectRootName self.authorId = authorId self.authorName = authorName self.handlerId = handlerId @@ -49,8 +55,10 @@ public extension TicketInfo { id: 0, title: "New topic: ForPDA [iOS]", status: .processing, - subjectId: 12, - subjectName: "ForPDA [iOS]", + subjectId: 1104159, + subjectElementId: 136063497, + subjectRootId: 140, + subjectRootName: "iOS - Programs", authorId: 6176341, authorName: "AirFlare", handlerId: 3640948, diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index 501732c3..8eec9400 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -36,8 +36,10 @@ public struct TicketParser { for info in infoRaw { guard let id = info[safe: 0] as? Int, let title = info[safe: 2] as? String, - let subjectId = info[safe: 3] as? Int, - let subjectName = info[safe: 4] 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 authorId = info[safe: 7] as? Int, let authorName = info[safe: 8] as? String, @@ -52,7 +54,9 @@ public struct TicketParser { title: title.convertCodes(), status: TicketStatus(rawValue: statusRaw)!, subjectId: subjectId, - subjectName: subjectName.convertCodes(), + subjectElementId: subjectElementId, + subjectRootId: subjectRootId, + subjectRootName: subjectRootName.convertCodes(), authorId: authorId, authorName: authorName.convertCodes(), handlerId: handlerId, From 9b39ba663037bb2c200156457ca8f1321d47df6a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 4 May 2026 11:36:45 +0300 Subject: [PATCH 004/112] Improve TicketsList model --- Modules/Sources/Models/Ticket/TicketInfo.swift | 4 ---- .../Sources/Models/Ticket/TicketsList.swift | 18 +++++++++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/Models/Ticket/TicketInfo.swift b/Modules/Sources/Models/Ticket/TicketInfo.swift index 6d89332a..4d64ceb9 100644 --- a/Modules/Sources/Models/Ticket/TicketInfo.swift +++ b/Modules/Sources/Models/Ticket/TicketInfo.swift @@ -8,7 +8,6 @@ import Foundation public struct TicketInfo: Sendable { - public let id: Int public let title: String public let status: TicketStatus public let subjectId: Int @@ -22,7 +21,6 @@ public struct TicketInfo: Sendable { public let createdAt: Date public init( - id: Int, title: String, status: TicketStatus, subjectId: Int, @@ -35,7 +33,6 @@ public struct TicketInfo: Sendable { handlerName: String, createdAt: Date ) { - self.id = id self.title = title self.status = status self.subjectId = subjectId @@ -52,7 +49,6 @@ public struct TicketInfo: Sendable { public extension TicketInfo { static let mock = TicketInfo( - id: 0, title: "New topic: ForPDA [iOS]", status: .processing, subjectId: 1104159, diff --git a/Modules/Sources/Models/Ticket/TicketsList.swift b/Modules/Sources/Models/Ticket/TicketsList.swift index 202da2bd..33b445a7 100644 --- a/Modules/Sources/Models/Ticket/TicketsList.swift +++ b/Modules/Sources/Models/Ticket/TicketsList.swift @@ -6,10 +6,20 @@ // public struct TicketsList: Sendable { - public let tickets: [TicketInfo] + public let tickets: [TicketSimplified] public let availableCount: Int - public init(tickets: [TicketInfo], availableCount: Int) { + public struct TicketSimplified: Sendable, Identifiable { + public let id: Int + public let 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 } @@ -17,7 +27,9 @@ public struct TicketsList: Sendable { public extension TicketsList { static let mock = TicketsList( - tickets: [.mock], + tickets: [ + .init(id: 0, info: .mock) + ], availableCount: 1 ) } From 750d59f05a66985666cbeb8a283776c4c0245346 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 4 May 2026 11:37:29 +0300 Subject: [PATCH 005/112] Add ticket endpoint to TicketClient --- Modules/Sources/Models/Ticket/Ticket.swift | 65 ++++++++++++++++++ .../ParsingClient/Parsers/TicketParser.swift | 66 +++++++++++++++++++ .../Sources/ParsingClient/ParsingClient.swift | 4 ++ .../Sources/TicketClient/TicketClient.swift | 8 +++ 4 files changed, 143 insertions(+) create mode 100644 Modules/Sources/Models/Ticket/Ticket.swift diff --git a/Modules/Sources/Models/Ticket/Ticket.swift b/Modules/Sources/Models/Ticket/Ticket.swift new file mode 100644 index 00000000..345b01af --- /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 { + public let info: TicketInfo + public let comments: [Comment] + + public struct Comment: Sendable { + 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/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index 8eec9400..2639ca0b 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -10,6 +10,72 @@ 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 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: 16] as? Int else { + throw ParsingError.failedToCastFields + } + + return Ticket( + title: title, + status: TicketStatus(rawValue: statusRaw)!, + subjectId: subjectId, + subjectElementId: subjectElementId, + subjectRootId: subjectRootId, + subjectRootName: subjectRootName.convertCodes(), + authorId: authorId, + authorName: authorName.convertCodes(), + handlerId: handlerId, + handlerName: handlerName.convertCodes(), + comments: try parseComments(commentsRaw), + createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)) + ) + } + + // 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[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 { diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index c082bd95..880173df 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -69,6 +69,7 @@ public struct ParsingClient: Sendable { // Ticket public var parseTicketsList: @Sendable (_ response: String) async throws -> TicketsList + public var parseTicket: @Sendable (_ response: String) async throws -> Ticket } // MARK: - Dependency Key @@ -182,6 +183,9 @@ extension ParsingClient: DependencyKey { }, parseTicketsList: { response in return try TicketParser.parseTicketsList(from: response) + }, + parseTicket: { response in + return try TicketParser.parse(from: response) } ) } diff --git a/Modules/Sources/TicketClient/TicketClient.swift b/Modules/Sources/TicketClient/TicketClient.swift index b28dfdd1..6daa12f3 100644 --- a/Modules/Sources/TicketClient/TicketClient.swift +++ b/Modules/Sources/TicketClient/TicketClient.swift @@ -16,6 +16,7 @@ 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 } extension TicketClient: DependencyKey { @@ -38,6 +39,10 @@ extension TicketClient: DependencyKey { 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) } ) } @@ -48,6 +53,9 @@ extension TicketClient: DependencyKey { return TicketClient( getTicketsList: { _ in return .mock + }, + getTicket: { _ in + return .mock } ) } From 07518476c9dc6dacd47d3f8cc946bda2efbcc9b4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 4 May 2026 14:32:27 +0300 Subject: [PATCH 006/112] Fix TicketParser --- .../ParsingClient/Parsers/TicketParser.swift | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index 2639ca0b..ffe0a4bd 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -37,18 +37,20 @@ public struct TicketParser { } return Ticket( - title: title, - status: TicketStatus(rawValue: statusRaw)!, - subjectId: subjectId, - subjectElementId: subjectElementId, - subjectRootId: subjectRootId, - subjectRootName: subjectRootName.convertCodes(), - authorId: authorId, - authorName: authorName.convertCodes(), - handlerId: handlerId, - handlerName: handlerName.convertCodes(), - comments: try parseComments(commentsRaw), - createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)) + info: TicketInfo( + title: title, + 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)) + ), + comments: try parseComments(commentsRaw) ) } @@ -92,13 +94,16 @@ public struct TicketParser { throw ParsingError.failedToCastFields } - return TicketsList(tickets: try parseTicketsInfo(ticketsRaw), availableCount: availableCount) + return TicketsList( + tickets: try parseTicketsInfo(ticketsRaw), + availableCount: availableCount + ) } // MARK: - Tickets Info - private static func parseTicketsInfo(_ infoRaw: [[Any]]) throws(ParsingError) -> [TicketInfo] { - var ticketsInfo: [TicketInfo] = [] + 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, @@ -115,19 +120,21 @@ public struct TicketParser { throw ParsingError.failedToCastFields } - ticketsInfo.append(TicketInfo( + ticketsInfo.append(.init( id: id, - 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)) + 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)) + ) )) } return ticketsInfo From 7698fd85b52004ccd0964d6a28761ab76cf04569 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 5 May 2026 17:04:16 +0300 Subject: [PATCH 007/112] Fix ticket status parsing --- Modules/Sources/ParsingClient/Parsers/TicketParser.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index ffe0a4bd..9c768cf4 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -32,7 +32,7 @@ public struct TicketParser { 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: 16] as? Int else { + let statusRaw = array[safe: 0] as? Int else { throw ParsingError.failedToCastFields } @@ -63,7 +63,7 @@ public struct TicketParser { let content = comment[safe: 4] as? String, let authorId = comment[safe: 2] as? Int, let authorName = comment[safe: 3] as? String, - let createdAt = comment[1] as? Int else { + let createdAt = comment[safe: 1] as? Int else { throw ParsingError.failedToCastFields } @@ -116,7 +116,7 @@ public struct TicketParser { 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: 14] as? Int else { + let statusRaw = info[safe: 1] as? Int else { throw ParsingError.failedToCastFields } From b507496961030b2c2855a1ed1c49df5b84e7f6cc Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 5 May 2026 21:38:06 +0300 Subject: [PATCH 008/112] [WIP] TicketFeature --- .../TicketFeature/Models/TicketType.swift | 11 ++++ .../Resources/Localizable.xcstrings | 7 ++ .../Sources/TicketFeature/TicketFeature.swift | 55 ++++++++++++++++ .../Sources/TicketFeature/TicketScreen.swift | 64 +++++++++++++++++++ Project.swift | 13 ++++ 5 files changed, 150 insertions(+) create mode 100644 Modules/Sources/TicketFeature/Models/TicketType.swift create mode 100644 Modules/Sources/TicketFeature/Resources/Localizable.xcstrings create mode 100644 Modules/Sources/TicketFeature/TicketFeature.swift create mode 100644 Modules/Sources/TicketFeature/TicketScreen.swift diff --git a/Modules/Sources/TicketFeature/Models/TicketType.swift b/Modules/Sources/TicketFeature/Models/TicketType.swift new file mode 100644 index 00000000..024c8adf --- /dev/null +++ b/Modules/Sources/TicketFeature/Models/TicketType.swift @@ -0,0 +1,11 @@ +// +// TicketType.swift +// ForPDA +// +// Created by Xialtal on 5.05.26. +// + +public enum TicketType: Equatable { + case single(id: Int) + case list(forId: Int) +} diff --git a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..900453da --- /dev/null +++ b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings @@ -0,0 +1,7 @@ +{ + "sourceLanguage" : "en", + "strings" : { + + }, + "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..cf049ca4 --- /dev/null +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -0,0 +1,55 @@ +// +// TicketFeature.swift +// ForPDA +// +// Created by Xialtal on 5.05.26. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct TicketFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public let type: TicketType + + public init( + type: TicketType + ) { + self.type = type + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.openURL) var openURL + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .none + } + } + } +} diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift new file mode 100644 index 00000000..703a9a10 --- /dev/null +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -0,0 +1,64 @@ +// +// TicketScreen.swift +// ForPDA +// +// Created by Xialtal on 5.05.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI + +@ViewAction(for: TicketFeature.self) +public struct TicketScreen: View { + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + ScrollView { + Text("Ticket") + } + .background(Color(.Background.primary)) + .onAppear { + send(.onAppear) + } + } + } +} + +// MARK: - Previews + +#Preview("Ticket Single") { + NavigationStack { + TicketScreen( + store: Store( + initialState: TicketFeature.State( + type: .single(id: 0) + ) + ) { + TicketFeature() + } + ) + } +} + +#Preview("Tickets List") { + NavigationStack { + TicketScreen( + store: Store( + initialState: TicketFeature.State( + type: .list(forId: 0) + ) + ) { + TicketFeature() + } + ) + } +} diff --git a/Project.swift b/Project.swift index 36250e48..e13b5a91 100644 --- a/Project.swift +++ b/Project.swift @@ -510,6 +510,17 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "TicketFeature", + dependencies: [ + .Internal.Models, + .Internal.SharedUI, + .Internal.TicketClient, + .Internal.ToastClient, + .SPM.TCA + ] + ), .feature( name: "TopicBuilder", @@ -1102,6 +1113,7 @@ 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 TopicBuilder = TargetDependency.target(name: "TopicBuilder") static let TopicFeature = TargetDependency.target(name: "TopicFeature") static let UploadBoxFeature = TargetDependency.target(name: "UploadBoxFeature") @@ -1116,6 +1128,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 From 62cdfeb60cbd6c5f8d1d15eeeff455283c5609e0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 8 May 2026 14:07:14 +0300 Subject: [PATCH 009/112] [WIP] Tickets List --- .../Resources/Localizable.xcstrings | 7 +++ .../TicketsListFeature.swift | 55 ++++++++++++++++++ .../TicketsListScreen.swift | 56 +++++++++++++++++++ Project.swift | 13 +++++ 4 files changed, 131 insertions(+) create mode 100644 Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings create mode 100644 Modules/Sources/TicketsListFeature/TicketsListFeature.swift create mode 100644 Modules/Sources/TicketsListFeature/TicketsListScreen.swift diff --git a/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..900453da --- /dev/null +++ b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings @@ -0,0 +1,7 @@ +{ + "sourceLanguage" : "en", + "strings" : { + + }, + "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..df22378f --- /dev/null +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -0,0 +1,55 @@ +// +// TicketsListFeature.swift +// ForPDA +// +// Created by Xialtal on 8.05.26. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct TicketsListFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public let forId: Int + + public init( + forId: Int + ) { + self.forId = forId + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + @Dependency(\.openURL) var openURL + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .none + } + } + } +} diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift new file mode 100644 index 00000000..4b936e83 --- /dev/null +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -0,0 +1,56 @@ +// +// TicketsListScreen.swift +// ForPDA +// +// Created by Xialtal on 8.05.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI + +@ViewAction(for: TicketsListFeature.self) +public struct TicketsListScreen: View { + + // MARK: - Properties + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + // MARK: - Init + + public init(store: StoreOf) { + self.store = store + } + + // MARK: - Body + + public var body: some View { + WithPerceptionTracking { + ScrollView { + Text("Tickets List") + } + .background(Color(.Background.primary)) + .onAppear { + send(.onAppear) + } + } + } +} + +// MARK: - Previews + +#Preview { + NavigationStack { + TicketsListScreen( + store: Store( + initialState: TicketsListFeature.State( + forId: 0 + ) + ) { + TicketsListFeature() + } + ) + } +} diff --git a/Project.swift b/Project.swift index e13b5a91..991ee291 100644 --- a/Project.swift +++ b/Project.swift @@ -521,6 +521,18 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "TicketsListFeature", + dependencies: [ + .Internal.Models, + .Internal.SharedUI, + .Internal.TicketClient, + .Internal.ToastClient, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), .feature( name: "TopicBuilder", @@ -1114,6 +1126,7 @@ extension TargetDependency.Internal { 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 TopicBuilder = TargetDependency.target(name: "TopicBuilder") static let TopicFeature = TargetDependency.target(name: "TopicFeature") static let UploadBoxFeature = TargetDependency.target(name: "UploadBoxFeature") From ee58da0905d17881bf743e47fb09bd98c038d361 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 8 May 2026 14:52:17 +0300 Subject: [PATCH 010/112] Add yellow color to SharedUI --- .../Colors/Main/yellow.colorset/Contents.json | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/yellow.colorset/Contents.json 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 + } +} From afb7532a1aefd6620688d8736cb5a5d77b0c94f7 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 8 May 2026 14:53:43 +0300 Subject: [PATCH 011/112] Add yellowAlpha color to SharedUI --- .../Main/yellowAlpha.colorset/Contents.json | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 Modules/Sources/SharedUI/Resources/Assets.xcassets/Colors/Main/yellowAlpha.colorset/Contents.json 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 + } +} From ffbe1117a360f4f7ea7b4d87186f43fc2a2f47c2 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 8 May 2026 14:54:35 +0300 Subject: [PATCH 012/112] Add tickets pagination settings --- Modules/Sources/Models/Settings/AppSettings.swift | 5 +++++ .../PageNavigationFeature/PageNavigationFeature.swift | 2 ++ 2 files changed, 7 insertions(+) diff --git a/Modules/Sources/Models/Settings/AppSettings.swift b/Modules/Sources/Models/Settings/AppSettings.swift index 1c2078ec..99bad711 100644 --- a/Modules/Sources/Models/Settings/AppSettings.swift +++ b/Modules/Sources/Models/Settings/AppSettings.swift @@ -33,6 +33,7 @@ public struct AppSettings: Sendable, Equatable, Codable { 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 @@ -56,6 +57,7 @@ public struct AppSettings: Sendable, Equatable, Codable { topicPerPage: Int, historyPerPage: Int, mentionsPerPage: Int, + ticketsPerPage: Int, hideTabBarOnScroll: Bool, floatingNavigation: Bool, experimentalFloatingNavigation: Bool, @@ -78,6 +80,7 @@ public struct AppSettings: Sendable, Equatable, Codable { self.topicPerPage = topicPerPage self.historyPerPage = historyPerPage self.mentionsPerPage = mentionsPerPage + self.ticketsPerPage = ticketsPerPage self.hideTabBarOnScroll = hideTabBarOnScroll self.floatingNavigation = floatingNavigation self.experimentalFloatingNavigation = experimentalFloatingNavigation @@ -103,6 +106,7 @@ public struct AppSettings: Sendable, Equatable, Codable { 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 @@ -152,6 +156,7 @@ public extension AppSettings { topicPerPage: 20, historyPerPage: 20, mentionsPerPage: 20, + ticketsPerPage: 20, hideTabBarOnScroll: true, floatingNavigation: true, experimentalFloatingNavigation: false, 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 } } } From 9aa16e9c4fe5b07d7b957cb66a8e5877cd160301 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 8 May 2026 15:56:11 +0300 Subject: [PATCH 013/112] [WIP] Tickets List --- .../Sources/Models/Ticket/TicketInfo.swift | 2 +- .../Sources/Models/Ticket/TicketsList.swift | 4 +- .../Models/TicketsListType.swift | 11 + .../TicketsListFeature.swift | 83 +++++++- .../TicketsListScreen.swift | 191 +++++++++++++++++- Project.swift | 2 + 6 files changed, 277 insertions(+), 16 deletions(-) create mode 100644 Modules/Sources/TicketsListFeature/Models/TicketsListType.swift diff --git a/Modules/Sources/Models/Ticket/TicketInfo.swift b/Modules/Sources/Models/Ticket/TicketInfo.swift index 4d64ceb9..76476f84 100644 --- a/Modules/Sources/Models/Ticket/TicketInfo.swift +++ b/Modules/Sources/Models/Ticket/TicketInfo.swift @@ -7,7 +7,7 @@ import Foundation -public struct TicketInfo: Sendable { +public struct TicketInfo: Sendable, Equatable { public let title: String public let status: TicketStatus public let subjectId: Int diff --git a/Modules/Sources/Models/Ticket/TicketsList.swift b/Modules/Sources/Models/Ticket/TicketsList.swift index 33b445a7..eb64aaff 100644 --- a/Modules/Sources/Models/Ticket/TicketsList.swift +++ b/Modules/Sources/Models/Ticket/TicketsList.swift @@ -5,11 +5,11 @@ // Created by Xialtal on 3.05.26. // -public struct TicketsList: Sendable { +public struct TicketsList: Sendable, Equatable { public let tickets: [TicketSimplified] public let availableCount: Int - public struct TicketSimplified: Sendable, Identifiable { + public struct TicketSimplified: Sendable, Identifiable, Equatable { public let id: Int public let info: TicketInfo diff --git a/Modules/Sources/TicketsListFeature/Models/TicketsListType.swift b/Modules/Sources/TicketsListFeature/Models/TicketsListType.swift new file mode 100644 index 00000000..b125a858 --- /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 only(forId: Int) +} diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index df22378f..7413f6b1 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -7,8 +7,11 @@ import Foundation import ComposableArchitecture -import APIClient +import TicketClient import Models +import PersistenceKeys +import PageNavigationFeature +import ToastClient @Reducer public struct TicketsListFeature: Reducer, Sendable { @@ -19,36 +22,100 @@ public struct TicketsListFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { - public let forId: Int + @Shared(.appSettings) var appSettings: AppSettings + public var pageNavigation = PageNavigationFeature.State(type: .tickets) + + public let type: TicketsListType + + var tickets: [TicketsList.TicketSimplified] = [] + var sort: TicketsListSort = [] + + var isLoading = false public init( - forId: Int + type: TicketsListType ) { - self.forId = forId + self.type = type } } // MARK: - Action public enum Action: ViewAction { + case pageNavigation(PageNavigationFeature.Action) + case view(View) public enum View { - case onAppear + case onFirstAppear + case onRefresh + } + + case `internal`(Internal) + public enum Internal { + case loadTickets(offset: Int) + case ticketsResponse(Result) } } // MARK: - Dependencies - @Dependency(\.apiClient) private var apiClient - @Dependency(\.openURL) var openURL + @Dependency(\.ticketClient) private var ticketClient + @Dependency(\.openURL) private var openURL + @Dependency(\.toastClient) private var toastClient // MARK: - Body public var body: some Reducer { + Scope(state: \.pageNavigation, action: \.pageNavigation) { + PageNavigationFeature() + } + Reduce { state, action in switch action { - case .view(.onAppear): + case let .pageNavigation(.offsetChanged(to: newOffset)): + return .send(.internal(.loadTickets(offset: newOffset))) + + case .pageNavigation: + return .none + + case .view(.onFirstAppear): + return .send(.internal(.loadTickets(offset: 0))) + + case .view(.onRefresh): + guard !state.isLoading else { return .none } + return .send(.internal(.loadTickets(offset: state.pageNavigation.offset))) + + case let .internal(.loadTickets(offset)): + state.isLoading = true + let forId = switch state.type { + case .list: 0 + case .only(let forId): forId + } + return .run { [sort = state.sort, amount = state.appSettings.ticketsPerPage] send in + let request = TicketsListRequest( + forId: forId, + sort: sort, + offset: offset, + amount: amount + ) + 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 = response.tickets + state.pageNavigation.count = response.availableCount + state.isLoading = false return .none + + case let .internal(.ticketsResponse(.failure(error))): + print(error) + state.isLoading = false + return .run { _ in + await toastClient.showToast(.whoopsSomethingWentWrong) + } } } } diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index 4b936e83..30e2e662 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -9,6 +9,8 @@ import SwiftUI import ComposableArchitecture import Models import SharedUI +import PageNavigationFeature +import SFSafeSymbols @ViewAction(for: TicketsListFeature.self) public struct TicketsListScreen: View { @@ -18,6 +20,17 @@ public struct TicketsListScreen: View { @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) { @@ -28,14 +41,181 @@ public struct TicketsListScreen: View { public var body: some View { WithPerceptionTracking { - ScrollView { - Text("Tickets List") + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + if !store.isLoading { + if !store.tickets.isEmpty { + List { + if shouldShowInlineNavigation { + Navigation() + } + + ContentSection() + + 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("Tickets", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) .background(Color(.Background.primary)) - .onAppear { - send(.onAppear) + .safeAreaInset(edge: .bottom) { + if shouldShowFloatingNavigation { + PageNavigation( + store: store.scope(state: \.pageNavigation, action: \.pageNavigation), + minimized: $navigationMinimized + ) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + } + .refreshable { + await send(.onRefresh).finish() + } + .onFirstAppear { + send(.onFirstAppear) + } + } + } + + // MARK: - Content Section + + @ViewBuilder + private func ContentSection() -> some View { + Section { + ForEach(store.tickets) { ticket in + VStack(alignment: .leading, spacing: 8) { + 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) { + Text(verbatim: "\(ticket.info.createdAt.formatted()) · ") + + HStack(spacing: 4) { + Image(systemSymbol: .personCropCircle) + + Text(verbatim: ticket.info.authorName) + } + } + + Spacer() + + + } + .font(.caption) + .foregroundStyle(Color(.Labels.quaternary)) + } + } + } + } + + @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: - 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: - 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) + } } } @@ -46,11 +226,12 @@ public struct TicketsListScreen: View { TicketsListScreen( store: Store( initialState: TicketsListFeature.State( - forId: 0 + type: .list ) ) { TicketsListFeature() } ) } + .tint(Color(.Theme.primary)) } diff --git a/Project.swift b/Project.swift index 991ee291..08156e4d 100644 --- a/Project.swift +++ b/Project.swift @@ -526,6 +526,8 @@ let project = Project( name: "TicketsListFeature", dependencies: [ .Internal.Models, + .Internal.PageNavigationFeature, + .Internal.PersistenceKeys, .Internal.SharedUI, .Internal.TicketClient, .Internal.ToastClient, From 3f5be8a19c4d0e985389719d5a4358be8e2ee7f7 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 8 May 2026 16:57:33 +0300 Subject: [PATCH 014/112] Add tickets to app navigation --- .../Sources/AppFeature/Navigation/Path.swift | 24 +++++++++++++++++++ .../AppFeature/Navigation/StackTab.swift | 13 ++++++++++ Project.swift | 2 ++ 3 files changed, 39 insertions(+) diff --git a/Modules/Sources/AppFeature/Navigation/Path.swift b/Modules/Sources/AppFeature/Navigation/Path.swift index aff13965..ac5dfc2b 100644 --- a/Modules/Sources/AppFeature/Navigation/Path.swift +++ b/Modules/Sources/AppFeature/Navigation/Path.swift @@ -27,6 +27,8 @@ import ReputationFeature import SearchFeature import SearchResultFeature import SettingsFeature +import TicketFeature +import TicketsListFeature import TopicFeature import AuthFeature @@ -36,6 +38,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 profile(Profile.Body = Profile.body) case settings(Settings.Body = Settings.body) case search(Search.Body = Search.body) @@ -70,6 +73,12 @@ public enum Path { case topic(TopicFeature) } + @Reducer + public enum Tickets { + case ticketsList(TicketsListFeature) + case ticket(TicketFeature) + } + @Reducer public enum Settings { case settings(SettingsFeature) @@ -96,6 +105,7 @@ extension Path.Articles.State: Equatable {} extension Path.DevDB.State: Equatable {} extension Path.Profile.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 {} @@ -120,6 +130,9 @@ extension Path { case let .forum(path): ForumViews(path) + case let .tickets(path): + TicketsViews(path) + case let .settings(path): SettingsViews(path) @@ -203,6 +216,17 @@ extension Path { } } + @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 c6fde403..b74b48b2 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -147,6 +147,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 .profile(action): return handleProfilePathNavigation(action: action, state: &state) @@ -291,6 +294,16 @@ public struct StackTab: Reducer, Sendable { return .none } + // MARK: - Tickets + + private func handleTicketsPathNavigation(action: Path.Tickets.Action, state: inout State) -> Effect { + switch action { + default: + break + } + return .none + } + // MARK: - Profile private func handleProfilePathNavigation(action: Path.Profile.Action, state: inout State) -> Effect { diff --git a/Project.swift b/Project.swift index 08156e4d..6ccea48a 100644 --- a/Project.swift +++ b/Project.swift @@ -64,6 +64,8 @@ let project = Project( .Internal.SettingsFeature, .Internal.SharedUI, .Internal.TCAExtensions, + .Internal.TicketFeature, + .Internal.TicketsListFeature, .Internal.ToastClient, .Internal.TopicFeature, .SPM.AlertToast, From 5908c7552b909f5ee237478f3ba323a519e82100 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 8 May 2026 17:17:33 +0300 Subject: [PATCH 015/112] Add tickets option to topic context menu --- Modules/Sources/AppFeature/Navigation/StackTab.swift | 4 ++++ .../Models/TopicToolsContextMenuAction.swift | 1 + .../TopicFeature/Resources/Localizable.xcstrings | 10 ++++++++++ Modules/Sources/TopicFeature/TopicFeature.swift | 4 ++++ Modules/Sources/TopicFeature/TopicScreen.swift | 4 ++++ 5 files changed, 23 insertions(+) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index b74b48b2..b9ab27ea 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -34,6 +34,7 @@ import SearchFeature import SearchResultFeature import DeviceSpecificationsFeature import DeviceTypeFeature +import TicketsListFeature @Reducer public struct StackTab: Reducer, Sendable { @@ -272,6 +273,9 @@ public struct StackTab: Reducer, Sendable { case let .topic(.delegate(.openUser(id: id))): state.path.append(.profile(.profile(ProfileFeature.State(userId: id)))) + case let .topic(.delegate(.openTickets(id))): + state.path.append(.tickets(.ticketsList(TicketsListFeature.State(type: .only(forId: id))))) + case let .topic(.delegate(.openSearch(on, navigation))): state.path.append(.search(.search(SearchFeature.State(on: on, navigation: navigation)))) 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/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index c536abf3..f1e6f87b 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -401,6 +401,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 75cd553e..5810e718 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -184,6 +184,7 @@ public struct TopicFeature: Reducer, Sendable { public enum Delegate { case handleUrl(URL) case openUser(id: Int) + case openTickets(Int) case openSearch(SearchOn, ForumInfo?) case openSearchResult(SearchResult) case openedLastPage @@ -386,6 +387,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: diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 948b934d..be4e978e 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -217,6 +217,10 @@ public struct TopicScreen: View { Image(systemSymbol: .line3HorizontalDecrease) } } + + ContextButton(text: LocalizedStringResource("Topic Tickets", bundle: .module), symbol: .exclamationmarkBubble) { + send(.contextToolsMenu(.tickets)) + } } } } From a35cd0d539ec58c1631e007dced75e550948b043 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 8 May 2026 20:42:42 +0300 Subject: [PATCH 016/112] Add modify ticket comment endpoint --- .../Sources/TicketClient/TicketClient.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Modules/Sources/TicketClient/TicketClient.swift b/Modules/Sources/TicketClient/TicketClient.swift index 6daa12f3..7f37ff14 100644 --- a/Modules/Sources/TicketClient/TicketClient.swift +++ b/Modules/Sources/TicketClient/TicketClient.swift @@ -17,6 +17,8 @@ import ParsingClient public struct TicketClient: Sendable { public var getTicketsList: @Sendable (_ data: TicketsListRequest) async throws -> TicketsList public var getTicket: @Sendable (_ id: Int) async throws -> Ticket + + public var modifyComment: @Sendable (_ id: Int, _ ticketId: Int, _ text: String) async throws -> Bool } extension TicketClient: DependencyKey { @@ -43,6 +45,16 @@ extension TicketClient: DependencyKey { getTicket: { id in let response = try await api.send(TicketCommand.view(id: id)) return try await parser.parseTicket(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 } ) } @@ -56,14 +68,28 @@ extension TicketClient: DependencyKey { }, getTicket: { _ in return .mock + }, + modifyComment: { _, _, _ 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] + } +} From 56e11f7c1c8bec5b26864a1b0c5d4ea4e353297d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 8 May 2026 21:14:06 +0300 Subject: [PATCH 017/112] Add processedAt field to TicketInfo model --- Modules/Sources/Models/Ticket/TicketInfo.swift | 8 ++++++-- Modules/Sources/ParsingClient/Parsers/TicketParser.swift | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/Models/Ticket/TicketInfo.swift b/Modules/Sources/Models/Ticket/TicketInfo.swift index 76476f84..233ba05a 100644 --- a/Modules/Sources/Models/Ticket/TicketInfo.swift +++ b/Modules/Sources/Models/Ticket/TicketInfo.swift @@ -19,6 +19,7 @@ public struct TicketInfo: Sendable, Equatable { public let handlerId: Int public let handlerName: String public let createdAt: Date + public let processedAt: Date? public init( title: String, @@ -31,7 +32,8 @@ public struct TicketInfo: Sendable, Equatable { authorName: String, handlerId: Int, handlerName: String, - createdAt: Date + createdAt: Date, + processedAt: Date? ) { self.title = title self.status = status @@ -44,6 +46,7 @@ public struct TicketInfo: Sendable, Equatable { self.handlerId = handlerId self.handlerName = handlerName self.createdAt = createdAt + self.processedAt = processedAt } } @@ -59,6 +62,7 @@ public extension TicketInfo { authorName: "AirFlare", handlerId: 3640948, handlerName: "subvertd", - createdAt: Date.now + createdAt: Date.now, + processedAt: nil ) } diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index 9c768cf4..46b5085b 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -27,6 +27,7 @@ public struct TicketParser { 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, @@ -48,7 +49,8 @@ public struct TicketParser { authorName: authorName.convertCodes(), handlerId: handlerId, handlerName: handlerName.convertCodes(), - createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)) + createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)), + processedAt: processedAt != 0 ? Date(timeIntervalSince1970: TimeInterval(processedAt)) : nil ), comments: try parseComments(commentsRaw) ) @@ -112,6 +114,7 @@ public struct TicketParser { 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, @@ -133,7 +136,8 @@ public struct TicketParser { authorName: authorName.convertCodes(), handlerId: handlerId, handlerName: handlerName.convertCodes(), - createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)) + createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)), + processedAt: processedAt != 0 ? Date(timeIntervalSince1970: TimeInterval(processedAt)) : nil ) )) } From 5aca3ce51e00abb81b2f9b9de48f89afa07efc52 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 9 May 2026 21:13:57 +0300 Subject: [PATCH 018/112] Add change ticket status endpoint --- .../Ticket/TicketStatusChangeResponse.swift | 16 ++++++++++ .../ParsingClient/Parsers/TicketParser.swift | 31 +++++++++++++++++++ .../Sources/ParsingClient/ParsingClient.swift | 4 +++ .../Sources/TicketClient/TicketClient.swift | 12 +++++++ 4 files changed, 63 insertions(+) create mode 100644 Modules/Sources/Models/Ticket/TicketStatusChangeResponse.swift 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/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index 46b5085b..d9de200e 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -143,4 +143,35 @@ public struct TicketParser { } 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: 0] 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)) + + default: + return .failure(.other) + } + } } diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 880173df..ce17fa2a 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -70,6 +70,7 @@ public struct ParsingClient: Sendable { // 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 } // MARK: - Dependency Key @@ -186,6 +187,9 @@ extension ParsingClient: DependencyKey { }, parseTicket: { response in return try TicketParser.parse(from: response) + }, + parseChangeTicketStatus: { response in + return try TicketParser.parseChangeTicketStatus(from: response) } ) } diff --git a/Modules/Sources/TicketClient/TicketClient.swift b/Modules/Sources/TicketClient/TicketClient.swift index 7f37ff14..a9159b6e 100644 --- a/Modules/Sources/TicketClient/TicketClient.swift +++ b/Modules/Sources/TicketClient/TicketClient.swift @@ -17,6 +17,7 @@ import ParsingClient public struct TicketClient: Sendable { public var getTicketsList: @Sendable (_ data: TicketsListRequest) async throws -> TicketsList public var getTicket: @Sendable (_ id: Int) async throws -> Ticket + 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 } @@ -46,6 +47,14 @@ extension TicketClient: DependencyKey { let response = try await api.send(TicketCommand.view(id: id)) return try await parser.parseTicket(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( @@ -69,6 +78,9 @@ extension TicketClient: DependencyKey { getTicket: { _ in return .mock }, + changeTicketStatus: { _, _, _ in + return .success + }, modifyComment: { _, _, _ in return true } From 07f40244780f9eed6ed64de0dd1d938ffa2bcbf5 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 9 May 2026 21:15:00 +0300 Subject: [PATCH 019/112] Add PasteboardClient to TicketsListFeature dependencies --- Project.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Project.swift b/Project.swift index 6ccea48a..ac216a55 100644 --- a/Project.swift +++ b/Project.swift @@ -529,6 +529,7 @@ let project = Project( dependencies: [ .Internal.Models, .Internal.PageNavigationFeature, + .Internal.PasteboardClient, .Internal.PersistenceKeys, .Internal.SharedUI, .Internal.TicketClient, From 674ea8f2a4e652517bd5842fd8206023f3d6d600 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 18:44:50 +0300 Subject: [PATCH 020/112] Add CacheClient to TicketsListFeature dependencies --- Project.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Project.swift b/Project.swift index ac216a55..1e642b65 100644 --- a/Project.swift +++ b/Project.swift @@ -527,6 +527,7 @@ let project = Project( .feature( name: "TicketsListFeature", dependencies: [ + .Internal.CacheClient, .Internal.Models, .Internal.PageNavigationFeature, .Internal.PasteboardClient, From 7033a124529f8ee555e0eaa8d967b9426db3101f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 18:45:36 +0300 Subject: [PATCH 021/112] Improve tickets list endpoint --- .../TicketClient/Models/TicketsListSort.swift | 17 ------------- .../Requests/TicketsListRequest.swift | 24 +++++++++++++++---- .../Sources/TicketClient/TicketClient.swift | 2 +- 3 files changed, 21 insertions(+), 22 deletions(-) delete mode 100644 Modules/Sources/TicketClient/Models/TicketsListSort.swift diff --git a/Modules/Sources/TicketClient/Models/TicketsListSort.swift b/Modules/Sources/TicketClient/Models/TicketsListSort.swift deleted file mode 100644 index 065cf34b..00000000 --- a/Modules/Sources/TicketClient/Models/TicketsListSort.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// TicketsListSort.swift -// ForPDA -// -// Created by Xialtal on 4.05.26. -// - -public struct TicketsListSort: OptionSet, Sendable { - public var rawValue: Int - - public init(rawValue: Int) { - self.rawValue = rawValue - } - - public static let onlyMy = TicketsListSort(rawValue: 1 << 0) - public static let byForums = TicketsListSort(rawValue: 1 << 2) -} diff --git a/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift b/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift index e9daf3e3..540e8598 100644 --- a/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift +++ b/Modules/Sources/TicketClient/Requests/TicketsListRequest.swift @@ -7,19 +7,35 @@ public struct TicketsListRequest: Sendable { public let forId: Int - public let sort: TicketsListSort public let offset: Int public let amount: Int + public let isSortByForums: Bool + public let isShowOnlyMine: Bool public init( forId: Int, - sort: TicketsListSort, offset: Int, - amount: Int + amount: Int, + isSortByForums: Bool, + isShowOnlyMine: Bool ) { self.forId = forId - self.sort = sort 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 index a9159b6e..62bb63cd 100644 --- a/Modules/Sources/TicketClient/TicketClient.swift +++ b/Modules/Sources/TicketClient/TicketClient.swift @@ -37,7 +37,7 @@ extension TicketClient: DependencyKey { getTicketsList: { data in let response = try await api.send(TicketCommand.list( forId: data.forId, - sortType: data.sort.rawValue, + sortType: data.transferSort, offset: data.offset, limit: data.amount )) From 21a385c7e053e5b29c0ebe32e9ad0f774adba2de Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 18:46:19 +0300 Subject: [PATCH 022/112] Add tickets settings to AppSettings model --- .../Sources/Models/Settings/AppSettings.swift | 6 ++++ .../Models/Settings/TicketsSettings.swift | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 Modules/Sources/Models/Settings/TicketsSettings.swift diff --git a/Modules/Sources/Models/Settings/AppSettings.swift b/Modules/Sources/Models/Settings/AppSettings.swift index 99bad711..103fcdb1 100644 --- a/Modules/Sources/Models/Settings/AppSettings.swift +++ b/Modules/Sources/Models/Settings/AppSettings.swift @@ -27,6 +27,7 @@ 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 @@ -51,6 +52,7 @@ public struct AppSettings: Sendable, Equatable, Codable { notifications: NotificationsSettings, backgroundNotifications2: Bool, backupServer: Bool, + tickets: TicketsSettings, favorites: FavoritesSettings, searchSort: SearchSort, forumPerPage: Int, @@ -74,6 +76,7 @@ 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 @@ -100,6 +103,7 @@ 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 @@ -128,6 +132,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, @@ -150,6 +155,7 @@ public extension AppSettings { notifications: .default, backgroundNotifications2: true, backupServer: false, + tickets: .default, favorites: .default, searchSort: .relevance, forumPerPage: 30, 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 + ) +} From 2dca8c389491c7730c17b69f0b5b95333fe7dde0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 18:51:32 +0300 Subject: [PATCH 023/112] [WIP] Tickets List --- .../AppFeature/Navigation/StackTab.swift | 2 +- .../Sources/Models/Ticket/TicketInfo.swift | 8 +- .../Sources/Models/Ticket/TicketStatus.swift | 6 +- .../Sources/Models/Ticket/TicketsList.swift | 2 +- .../Models/TicketContextMenuAction.swift | 16 ++ .../Models/TicketsListContextMenuAction.swift | 11 + .../Models/TicketsListType.swift | 2 +- .../Resources/Localizable.xcstrings | 221 +++++++++++++++++- .../TicketsListFeature.swift | 151 +++++++++++- .../TicketsListScreen.swift | 115 ++++++++- 10 files changed, 510 insertions(+), 24 deletions(-) create mode 100644 Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift create mode 100644 Modules/Sources/TicketsListFeature/Models/TicketsListContextMenuAction.swift diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index b9ab27ea..2f76475a 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -274,7 +274,7 @@ public struct StackTab: Reducer, Sendable { state.path.append(.profile(.profile(ProfileFeature.State(userId: id)))) case let .topic(.delegate(.openTickets(id))): - state.path.append(.tickets(.ticketsList(TicketsListFeature.State(type: .only(forId: id))))) + state.path.append(.tickets(.ticketsList(TicketsListFeature.State(type: .topic(id))))) case let .topic(.delegate(.openSearch(on, navigation))): state.path.append(.search(.search(SearchFeature.State(on: on, navigation: navigation)))) diff --git a/Modules/Sources/Models/Ticket/TicketInfo.swift b/Modules/Sources/Models/Ticket/TicketInfo.swift index 233ba05a..f8d418d0 100644 --- a/Modules/Sources/Models/Ticket/TicketInfo.swift +++ b/Modules/Sources/Models/Ticket/TicketInfo.swift @@ -9,17 +9,17 @@ import Foundation public struct TicketInfo: Sendable, Equatable { public let title: String - public let status: TicketStatus + 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 let handlerId: Int - public let handlerName: String + public var handlerId: Int + public var handlerName: String public let createdAt: Date - public let processedAt: Date? + public var processedAt: Date? public init( title: String, diff --git a/Modules/Sources/Models/Ticket/TicketStatus.swift b/Modules/Sources/Models/Ticket/TicketStatus.swift index 77607ba2..b33978b3 100644 --- a/Modules/Sources/Models/Ticket/TicketStatus.swift +++ b/Modules/Sources/Models/Ticket/TicketStatus.swift @@ -5,8 +5,12 @@ // Created by Xialtal on 3.05.26. // -public enum TicketStatus: Int, Sendable { +public enum TicketStatus: Int, Sendable, CaseIterable, Identifiable { case notProcessed = 0 case processing = 1 case processed = 2 + + public var id: Int { + return self.rawValue + } } diff --git a/Modules/Sources/Models/Ticket/TicketsList.swift b/Modules/Sources/Models/Ticket/TicketsList.swift index eb64aaff..a026b89e 100644 --- a/Modules/Sources/Models/Ticket/TicketsList.swift +++ b/Modules/Sources/Models/Ticket/TicketsList.swift @@ -11,7 +11,7 @@ public struct TicketsList: Sendable, Equatable { public struct TicketSimplified: Sendable, Identifiable, Equatable { public let id: Int - public let info: TicketInfo + public var info: TicketInfo public init(id: Int, info: TicketInfo) { self.id = id diff --git a/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift b/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift new file mode 100644 index 00000000..ca597cee --- /dev/null +++ b/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift @@ -0,0 +1,16 @@ +// +// TicketContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 8.05.26. +// + +import Models + +public enum TicketContextMenuAction { + case changeStatus(TicketStatus) + case statusHistory + case sendComment + 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 index b125a858..5f04531c 100644 --- a/Modules/Sources/TicketsListFeature/Models/TicketsListType.swift +++ b/Modules/Sources/TicketsListFeature/Models/TicketsListType.swift @@ -7,5 +7,5 @@ public enum TicketsListType: Sendable, Equatable { case list - case only(forId: Int) + case topic(Int) } diff --git a/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings index 900453da..2b3fa2be 100644 --- a/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings @@ -1,7 +1,226 @@ { "sourceLanguage" : "en", "strings" : { - + "Change Status" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить статус" + } + } + } + }, + "Comment" : { + "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" : "Нет тикетов" + } + } + } + }, + "Not processed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не обработан" + } + } + } + }, + "Only My" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Только мои" + } + } + } + }, + "Processed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработан" + } + } + } + }, + "Processed · " : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработан · " + } + } + } + }, + "Processing" : { + "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 index 7413f6b1..c913b92b 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -12,23 +12,35 @@ import Models import PersistenceKeys import PageNavigationFeature import ToastClient +import PasteboardClient +import CacheClient @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: - State @ObservableState public struct State: Equatable { @Shared(.appSettings) var appSettings: AppSettings - public var pageNavigation = PageNavigationFeature.State(type: .tickets) + @Shared(.userSession) var userSession: UserSession? + var userSessionNickname: String? + var pageNavigation = PageNavigationFeature.State(type: .tickets) public let type: TicketsListType - var tickets: [TicketsList.TicketSimplified] = [] - var sort: TicketsListSort = [] + var tickets: IdentifiedArrayOf = [] var isLoading = false @@ -41,62 +53,140 @@ public struct TicketsListFeature: Reducer, Sendable { // MARK: - Action - public enum Action: ViewAction { + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) case pageNavigation(PageNavigationFeature.Action) case view(View) public enum View { case onFirstAppear case onRefresh + + 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(\.openURL) private var openURL @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)): return .send(.internal(.loadTickets(offset: newOffset))) - case .pageNavigation: + case .pageNavigation, .binding, .delegate: return .none case .view(.onFirstAppear): - return .send(.internal(.loadTickets(offset: 0))) + 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(.loadTickets(offset: 0))) + } case .view(.onRefresh): guard !state.isLoading else { return .none } + return .send(.internal(.refresh)) + + 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.userSession?.userId] send in + let response = try await ticketClient.changeTicketStatus( + id: ticketId, + handlerId: handlerId ?? 0, + status: status + ) + await send(.internal(.changeTicketStatusResponse(.success((ticketId, status, response))))) + } catch: { error, send in + await send(.internal(.changeTicketStatusResponse(.failure(error)))) + } + case .sendComment: + break + case .statusHistory: + break + 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): return .send(.internal(.loadTickets(offset: state.pageNavigation.offset))) case let .internal(.loadTickets(offset)): state.isLoading = true let forId = switch state.type { case .list: 0 - case .only(let forId): forId + case .topic(let id): id } - return .run { [sort = state.sort, amount = state.appSettings.ticketsPerPage] send in + return .run { [ + amount = state.appSettings.ticketsPerPage, + ticketsSettings = state.appSettings.tickets + ] send in let request = TicketsListRequest( forId: forId, - sort: sort, offset: offset, - amount: amount + amount: amount, + isSortByForums: ticketsSettings.isSortByForums, + isShowOnlyMine: ticketsSettings.isShowOnlyMine ) let respone = try await ticketClient.getTicketsList(request) await send(.internal(.ticketsResponse(.success(respone)))) @@ -105,7 +195,7 @@ public struct TicketsListFeature: Reducer, Sendable { } case let .internal(.ticketsResponse(.success(response))): - state.tickets = response.tickets + state.tickets = .init(uniqueElements: response.tickets) state.pageNavigation.count = response.availableCount state.isLoading = false return .none @@ -116,6 +206,43 @@ public struct TicketsListFeature: Reducer, Sendable { 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) + } } } } diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index 30e2e662..6d01d566 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -72,9 +72,14 @@ public struct TicketsListScreen: View { .frame(width: 24, height: 24) } } - .navigationTitle(Text("Tickets", bundle: .module)) + .navigationTitle(Text(navigationTitleText(), bundle: .module)) .navigationBarTitleDisplayMode(.inline) .background(Color(.Background.primary)) + .toolbar { + ToolbarItem { + OptionsMenu() + } + } .safeAreaInset(edge: .bottom) { if shouldShowFloatingNavigation { PageNavigation( @@ -94,6 +99,85 @@ public struct TicketsListScreen: View { } } + // MARK: - Options Menu + + @ViewBuilder + private func OptionsMenu() -> some View { + Menu { + Section { + Toggle("Only My", isOn: Binding(store.$appSettings.tickets.isShowOnlyMine)) + + if case .list = store.type { + Toggle("Sort by Forums", 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 { + Picker(String(), selection: Binding( + get: { store.tickets[id].info.status }, + set: { newValue in + send(.contextTicketMenu(.changeStatus(newValue), id)) + } + )) { + ForEach(TicketStatus.allCases) { status in + Text(status.title, bundle: .module) + .tag(status) + } + } + } label: { + HStack { + Text("Change Status", bundle: .module) + Image(systemSymbol: .checklist) + } + } + + ContextButton(text: LocalizedStringResource("Status History", bundle: .module), symbol: .clockArrowCirclepath) { + send(.contextTicketMenu(.statusHistory, id)) + } + + ContextButton(text: LocalizedStringResource("Comment", bundle: .module), symbol: .bubbleLeft) { + send(.contextTicketMenu(.sendComment, 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 Section @ViewBuilder @@ -117,7 +201,12 @@ public struct TicketsListScreen: View { HStack { HStack(spacing: 0) { - Text(verbatim: "\(ticket.info.createdAt.formatted()) · ") + 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) @@ -128,7 +217,7 @@ public struct TicketsListScreen: View { Spacer() - + TicketContextMenu(id: ticket.id, ticket.info) } .font(.caption) .foregroundStyle(Color(.Labels.quaternary)) @@ -197,6 +286,15 @@ public struct TicketsListScreen: View { .padding(.horizontal, 55) } } + + // MARK: - Helpers + + private func navigationTitleText() -> LocalizedStringKey { + return switch store.type { + case .list: "Tickets" + case .topic: "Topic Tickets" + } + } } // MARK: - Extensions @@ -217,6 +315,17 @@ extension TicketStatus { case .processed: Color(.Labels.teritary) } } + + var title: LocalizedStringKey { + switch self { + case .notProcessed: + return "Not processed" + case .processing: + return "Processing" + case .processed: + return "Processed" + } + } } // MARK: - Previews From bc7bcfd59e296a82a898fac0c30f03ba8d262e20 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 18:57:56 +0300 Subject: [PATCH 024/112] Add user & ticket delegate handling for TicketsListFeature --- Modules/Sources/AppFeature/Navigation/StackTab.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 2f76475a..a61f8f09 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -35,6 +35,7 @@ import SearchResultFeature import DeviceSpecificationsFeature import DeviceTypeFeature import TicketsListFeature +import TicketFeature @Reducer public struct StackTab: Reducer, Sendable { @@ -302,6 +303,12 @@ public struct StackTab: Reducer, Sendable { 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(.profile(.profile(ProfileFeature.State(userId: id)))) + default: break } From e6c607eb66061fcd20e370b605342f26b642b9d7 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 19:13:19 +0300 Subject: [PATCH 025/112] [WIP] Ticket Status History --- .../Resources/Localizable.xcstrings | 7 +++ .../TicketStatusHistoryFeature.swift | 54 +++++++++++++++++++ .../TicketStatusHistoryView.swift | 50 +++++++++++++++++ Project.swift | 13 +++++ 4 files changed, 124 insertions(+) create mode 100644 Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings create mode 100644 Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryFeature.swift create mode 100644 Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift diff --git a/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..900453da --- /dev/null +++ b/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings @@ -0,0 +1,7 @@ +{ + "sourceLanguage" : "en", + "strings" : { + + }, + "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..f93da04c --- /dev/null +++ b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryFeature.swift @@ -0,0 +1,54 @@ +// +// 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 + + public init( + ticketId: Int + ) { + self.ticketId = ticketId + } + } + + // MARK: - Action + + public enum Action: ViewAction { + case view(View) + public enum View { + case onAppear + } + } + + // MARK: - Dependencies + + @Dependency(\.ticketClient) private var ticketClient + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .none + } + } + } +} diff --git a/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift new file mode 100644 index 00000000..9f8a3de4 --- /dev/null +++ b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift @@ -0,0 +1,50 @@ +// +// TicketStatusHistoryView.swift +// ForPDA +// +// Created by Xialtal on 10.05.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI + +@ViewAction(for: TicketStatusHistoryFeature.self) +public struct TicketStatusHistoryView: View { + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + ScrollView { + Text("Ticket Status History") + } + .background(Color(.Background.primary)) + .onAppear { + send(.onAppear) + } + } + } +} + +// MARK: - Previews + +#Preview { + NavigationStack { + TicketStatusHistoryView( + store: Store( + initialState: TicketStatusHistoryFeature.State( + ticketId: 0 + ) + ) { + TicketStatusHistoryFeature() + } + ) + } +} diff --git a/Project.swift b/Project.swift index 1e642b65..e67e01ac 100644 --- a/Project.swift +++ b/Project.swift @@ -534,11 +534,23 @@ let project = Project( .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", @@ -1133,6 +1145,7 @@ extension TargetDependency.Internal { 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 TopicFeature = TargetDependency.target(name: "TopicFeature") static let UploadBoxFeature = TargetDependency.target(name: "UploadBoxFeature") From 217cc87822c006a918f1b504c1128fced30e0f6b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 19:40:16 +0300 Subject: [PATCH 026/112] Add ticket status history endpoint --- .../ParsingClient/Parsers/TicketParser.swift | 32 +++++++++++++++++++ .../Sources/ParsingClient/ParsingClient.swift | 4 +++ .../Sources/TicketClient/TicketClient.swift | 8 +++++ 3 files changed, 44 insertions(+) diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index d9de200e..25969743 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -174,4 +174,36 @@ public struct TicketParser { 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 statusRaw = array[safe: 0] as? [[Any]] else { + throw ParsingError.failedToCastFields + } + + return try! statusRaw.map { status in + guard let status = array[safe: 0] as? Int, + let handlerId = array[safe: 2] as? Int, + let handlerName = array[safe: 3] as? String, + let changedAt = array[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 ce17fa2a..74d92e6f 100644 --- a/Modules/Sources/ParsingClient/ParsingClient.swift +++ b/Modules/Sources/ParsingClient/ParsingClient.swift @@ -71,6 +71,7 @@ public struct ParsingClient: Sendable { 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 @@ -190,6 +191,9 @@ extension ParsingClient: DependencyKey { }, parseChangeTicketStatus: { response in return try TicketParser.parseChangeTicketStatus(from: response) + }, + parseTicketStatusHistory: { response in + return try TicketParser.parseTicketStatusHistory(from: response) } ) } diff --git a/Modules/Sources/TicketClient/TicketClient.swift b/Modules/Sources/TicketClient/TicketClient.swift index 62bb63cd..2858e736 100644 --- a/Modules/Sources/TicketClient/TicketClient.swift +++ b/Modules/Sources/TicketClient/TicketClient.swift @@ -17,6 +17,7 @@ import ParsingClient 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 @@ -47,6 +48,10 @@ extension TicketClient: DependencyKey { 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, @@ -78,6 +83,9 @@ extension TicketClient: DependencyKey { getTicket: { _ in return .mock }, + getTicketStatusHistory: { _ in + return [.mockNotProcessed, .mockProcessing, .mockProcessed] + }, changeTicketStatus: { _, _, _ in return .success }, From 40fbd1693d598ea380eb44426aebbaa78fb91732 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 19:40:58 +0300 Subject: [PATCH 027/112] Improve ticket status change response parser --- Modules/Sources/ParsingClient/Parsers/TicketParser.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index 25969743..e344b04a 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -168,7 +168,7 @@ public struct TicketParser { let handlerName = array[safe: 2] as? String else { throw ParsingError.failedToCastFields } - return .failure(.handlerChanged(id: handlerId, name: handlerName)) + return .failure(.handlerChanged(id: handlerId, name: handlerName.convertCodes())) default: return .failure(.other) From 90cabcd2a67123ec538494d619023628c6a82a5f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 19:43:29 +0300 Subject: [PATCH 028/112] Add TicketStatusHistory model --- .../Models/Ticket/TicketStatusHistory.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 Modules/Sources/Models/Ticket/TicketStatusHistory.swift diff --git a/Modules/Sources/Models/Ticket/TicketStatusHistory.swift b/Modules/Sources/Models/Ticket/TicketStatusHistory.swift new file mode 100644 index 00000000..3546269a --- /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 { + 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 + ) +} From 273bd1c5fc09d62143e294d9ece4165761b54018 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 20:38:26 +0300 Subject: [PATCH 029/112] Extract title for TicketStatus --- .../Sources/Models/Ticket/TicketStatus.swift | 13 ++++++++ .../Resources/Localizable.xcstrings | 30 ------------------- .../TicketsListScreen.swift | 11 ------- 3 files changed, 13 insertions(+), 41 deletions(-) diff --git a/Modules/Sources/Models/Ticket/TicketStatus.swift b/Modules/Sources/Models/Ticket/TicketStatus.swift index b33978b3..f4829f70 100644 --- a/Modules/Sources/Models/Ticket/TicketStatus.swift +++ b/Modules/Sources/Models/Ticket/TicketStatus.swift @@ -5,6 +5,8 @@ // Created by Xialtal on 3.05.26. // +import SwiftUI + public enum TicketStatus: Int, Sendable, CaseIterable, Identifiable { case notProcessed = 0 case processing = 1 @@ -13,4 +15,15 @@ public enum TicketStatus: Int, Sendable, CaseIterable, Identifiable { public var id: Int { return self.rawValue } + + public var title: LocalizedStringKey { + switch self { + case .notProcessed: + return "Not processed" + case .processing: + return "Processing" + case .processed: + return "Processed" + } + } } diff --git a/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings index 2b3fa2be..037e1511 100644 --- a/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings @@ -71,16 +71,6 @@ } } }, - "Not processed" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Не обработан" - } - } - } - }, "Only My" : { "localizations" : { "ru" : { @@ -91,16 +81,6 @@ } } }, - "Processed" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Обработан" - } - } - } - }, "Processed · " : { "localizations" : { "ru" : { @@ -111,16 +91,6 @@ } } }, - "Processing" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "В работе" - } - } - } - }, "Processing · " : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index 6d01d566..f55935b3 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -315,17 +315,6 @@ extension TicketStatus { case .processed: Color(.Labels.teritary) } } - - var title: LocalizedStringKey { - switch self { - case .notProcessed: - return "Not processed" - case .processing: - return "Processing" - case .processed: - return "Processed" - } - } } // MARK: - Previews From 53a4f1d8edd3cadbb788e1788baa3daff9cfd5f6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 20:39:09 +0300 Subject: [PATCH 030/112] [WIP] Ticket Status History --- .../Models/Resources/Localizable.xcstrings | 30 ++++ .../Models/Ticket/TicketStatusHistory.swift | 2 +- .../Resources/Localizable.xcstrings | 31 +++- .../TicketStatusHistoryFeature.swift | 48 +++++++ .../TicketStatusHistoryView.swift | 132 +++++++++++++++++- 5 files changed, 239 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/Models/Resources/Localizable.xcstrings b/Modules/Sources/Models/Resources/Localizable.xcstrings index df489419..d59db78e 100644 --- a/Modules/Sources/Models/Resources/Localizable.xcstrings +++ b/Modules/Sources/Models/Resources/Localizable.xcstrings @@ -91,6 +91,36 @@ } } }, + "Not processed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не обработан" + } + } + } + }, + "Processed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработан" + } + } + } + }, + "Processing" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "В работе" + } + } + } + }, "Profile" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/Models/Ticket/TicketStatusHistory.swift b/Modules/Sources/Models/Ticket/TicketStatusHistory.swift index 3546269a..f5682ec7 100644 --- a/Modules/Sources/Models/Ticket/TicketStatusHistory.swift +++ b/Modules/Sources/Models/Ticket/TicketStatusHistory.swift @@ -7,7 +7,7 @@ import Foundation -public struct TicketStatusHistory: Sendable, Identifiable { +public struct TicketStatusHistory: Sendable, Identifiable, Equatable { public let status: TicketStatus public let handlerId: Int public let handlerName: String diff --git a/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings index 900453da..8624259d 100644 --- a/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketStatusHistoryFeature/Resources/Localizable.xcstrings @@ -1,7 +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 index f93da04c..12e896f3 100644 --- a/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryFeature.swift +++ b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryFeature.swift @@ -21,6 +21,10 @@ public struct TicketStatusHistoryFeature: Reducer, Sendable { public struct State: Equatable { public let ticketId: Int + var history: [TicketStatusHistory] = [] + + var isLoading = false + public init( ticketId: Int ) { @@ -34,12 +38,28 @@ public struct TicketStatusHistoryFeature: Reducer, Sendable { 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 @@ -47,6 +67,34 @@ public struct TicketStatusHistoryFeature: Reducer, Sendable { 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 index 9f8a3de4..db639606 100644 --- a/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift +++ b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift @@ -9,21 +9,66 @@ 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 { - ScrollView { - Text("Ticket Status History") + 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 { @@ -31,6 +76,89 @@ public struct TicketStatusHistoryView: View { } } } + + // MARK: - Status + + @ViewBuilder + private func Status(_ status: TicketStatusHistory) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text(status.status.title, bundle: .module) + .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)) + } + } + + // 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 From b6ada78970980bfe13344a7cea650fd98a78d1ef Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 20:58:41 +0300 Subject: [PATCH 031/112] Add status history to ticket context menu --- .../TicketsListFeature.swift | 27 +++++++++++++++++-- .../TicketsListScreen.swift | 6 +++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index c913b92b..89df7b80 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -14,6 +14,7 @@ import PageNavigationFeature import ToastClient import PasteboardClient import CacheClient +import TicketStatusHistoryFeature @Reducer public struct TicketsListFeature: Reducer, Sendable { @@ -29,12 +30,27 @@ public struct TicketsListFeature: Reducer, Sendable { 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? var pageNavigation = PageNavigationFeature.State(type: .tickets) @@ -55,6 +71,7 @@ public struct TicketsListFeature: Reducer, Sendable { public enum Action: ViewAction, BindableAction { case binding(BindingAction) + case destination(PresentationAction) case pageNavigation(PageNavigationFeature.Action) case view(View) @@ -108,7 +125,10 @@ public struct TicketsListFeature: Reducer, Sendable { case let .pageNavigation(.offsetChanged(to: newOffset)): return .send(.internal(.loadTickets(offset: newOffset))) - case .pageNavigation, .binding, .delegate: + case let .destination(.presented(.statusHistory(.delegate(.openUser(id))))): + return .send(.delegate(.openUser(id))) + + case .pageNavigation, .binding, .delegate, .destination: return .none case .view(.onFirstAppear): @@ -153,7 +173,7 @@ public struct TicketsListFeature: Reducer, Sendable { case .sendComment: break case .statusHistory: - break + state.destination = .statusHistory(TicketStatusHistoryFeature.State(ticketId: ticketId)) case .openAuthor(let authorId): return .send(.delegate(.openUser(authorId))) case .copyLink: @@ -245,5 +265,8 @@ public struct TicketsListFeature: Reducer, Sendable { } } } + .ifLet(\.$destination, action: \.destination) } } + +extension TicketsListFeature.Destination.State: Equatable {} diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index f55935b3..f7914cfd 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -11,6 +11,7 @@ import Models import SharedUI import PageNavigationFeature import SFSafeSymbols +import TicketStatusHistoryFeature @ViewAction(for: TicketsListFeature.self) public struct TicketsListScreen: View { @@ -90,6 +91,11 @@ public struct TicketsListScreen: View { .padding(.bottom, 8) } } + .sheet(item: $store.scope(state: \.$destination, action: \.destination).statusHistory) { store in + NavigationStack { + TicketStatusHistoryView(store: store) + } + } .refreshable { await send(.onRefresh).finish() } From 9e3862eed4e73c8d85921024d419e0ad3a6520c8 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 21:13:34 +0300 Subject: [PATCH 032/112] Add picker to ticket status badge on tap --- .../TicketsListScreen.swift | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index f7914cfd..dcb49eb6 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -135,17 +135,7 @@ public struct TicketsListScreen: View { Menu { Section { Menu { - Picker(String(), selection: Binding( - get: { store.tickets[id].info.status }, - set: { newValue in - send(.contextTicketMenu(.changeStatus(newValue), id)) - } - )) { - ForEach(TicketStatus.allCases) { status in - Text(status.title, bundle: .module) - .tag(status) - } - } + TIcketStatusPicker(id: id) } label: { HStack { Text("Change Status", bundle: .module) @@ -191,7 +181,11 @@ public struct TicketsListScreen: View { Section { ForEach(store.tickets) { ticket in VStack(alignment: .leading, spacing: 8) { - TicketStatusBadge(info: ticket.info) + Menu { + TIcketStatusPicker(id: ticket.id) + } label: { + TicketStatusBadge(info: ticket.info) + } Text(verbatim: ticket.info.title) .font(.subheadline) @@ -232,6 +226,8 @@ public struct TicketsListScreen: View { } } + // MARK: - Ticket Status Badge + @ViewBuilder private func TicketStatusBadge(info: TicketInfo) -> some View { let text: LocalizedStringKey = switch info.status { @@ -260,6 +256,22 @@ public struct TicketsListScreen: View { ) } + // MARK: - Ticket Status Picker + + private func TIcketStatusPicker(id: Int) -> some View { + Picker(String(), selection: Binding( + get: { store.tickets[id].info.status }, + set: { newValue in + send(.contextTicketMenu(.changeStatus(newValue), id)) + } + )) { + ForEach(TicketStatus.allCases) { status in + Text(status.title, bundle: .module) + .tag(status) + } + } + } + // MARK: - Navigation @ViewBuilder From f9a1f85507a99b41461fd9894134c5de514bce32 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 21:47:43 +0300 Subject: [PATCH 033/112] Fix tickets list refreshing --- .../Sources/TicketsListFeature/TicketsListFeature.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index 89df7b80..7c8110f0 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -59,6 +59,7 @@ public struct TicketsListFeature: Reducer, Sendable { var tickets: IdentifiedArrayOf = [] var isLoading = false + var isRefreshing = false public init( type: TicketsListType @@ -189,10 +190,13 @@ public struct TicketsListFeature: Reducer, Sendable { return .none case .internal(.refresh): + state.isRefreshing = true return .send(.internal(.loadTickets(offset: state.pageNavigation.offset))) case let .internal(.loadTickets(offset)): - state.isLoading = true + if !state.isRefreshing { + state.isLoading = true + } let forId = switch state.type { case .list: 0 case .topic(let id): id @@ -218,11 +222,13 @@ public struct TicketsListFeature: Reducer, Sendable { state.tickets = .init(uniqueElements: response.tickets) state.pageNavigation.count = response.availableCount 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) } From 9f2e9038bbd14a16121299d3ccab551479003c5f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 21:49:44 +0300 Subject: [PATCH 034/112] Add ticket opening on tap --- .../TicketsListFeature.swift | 5 + .../TicketsListScreen.swift | 94 ++++++++++--------- 2 files changed, 57 insertions(+), 42 deletions(-) diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index 7c8110f0..f8681062 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -80,6 +80,8 @@ public struct TicketsListFeature: Reducer, Sendable { case onFirstAppear case onRefresh + case ticketButtonTapped(Int) + case contextMenu(TicketsListContextMenuAction) case contextTicketMenu(TicketContextMenuAction, Int) } @@ -144,6 +146,9 @@ public struct TicketsListFeature: Reducer, Sendable { 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: diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index dcb49eb6..3991246b 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -53,7 +53,7 @@ public struct TicketsListScreen: View { Navigation() } - ContentSection() + Content() if shouldShowInlineNavigation { Navigation() @@ -174,55 +174,65 @@ public struct TicketsListScreen: View { .frame(width: 8, height: 22) } - // MARK: - Content Section + // MARK: - Content @ViewBuilder - private func ContentSection() -> some View { - Section { - ForEach(store.tickets) { ticket in - 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) + private func Content() -> some View { + ForEach(store.tickets) { ticket in + Button { + send(.ticketButtonTapped(ticket.id)) + } label: { + TicketRow(ticket) + } + .buttonStyle(.plain) + } + } + + // 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 } - .font(.caption) - .foregroundStyle(Color(.Labels.teritary)) + Text(verbatim: "\(date.formatted()) · ") - 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) - } - } + HStack(spacing: 4) { + Image(systemSymbol: .personCropCircle) - Spacer() - - TicketContextMenu(id: ticket.id, ticket.info) + Text(verbatim: ticket.info.authorName) } - .font(.caption) - .foregroundStyle(Color(.Labels.quaternary)) } + + Spacer() + + TicketContextMenu(id: ticket.id, ticket.info) } + .font(.caption) + .foregroundStyle(Color(.Labels.quaternary)) } } From 45850445a4c01d7fc78d186df0e1e03277fcf048 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 10 May 2026 22:10:35 +0300 Subject: [PATCH 035/112] Remove send comment option from ticket context menu --- .../Models/TicketContextMenuAction.swift | 1 - .../TicketsListFeature/Resources/Localizable.xcstrings | 10 ---------- .../TicketsListFeature/TicketsListFeature.swift | 5 +++-- .../Sources/TicketsListFeature/TicketsListScreen.swift | 4 ---- 4 files changed, 3 insertions(+), 17 deletions(-) diff --git a/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift b/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift index ca597cee..46935a86 100644 --- a/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift +++ b/Modules/Sources/TicketsListFeature/Models/TicketContextMenuAction.swift @@ -10,7 +10,6 @@ import Models public enum TicketContextMenuAction { case changeStatus(TicketStatus) case statusHistory - case sendComment case openAuthor(Int) case copyLink } diff --git a/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings index 037e1511..13530f7e 100644 --- a/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketsListFeature/Resources/Localizable.xcstrings @@ -11,16 +11,6 @@ } } }, - "Comment" : { - "localizations" : { - "ru" : { - "stringUnit" : { - "state" : "translated", - "value" : "Комментировать" - } - } - } - }, "Copy Link" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index f8681062..56070620 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -176,12 +176,13 @@ public struct TicketsListFeature: Reducer, Sendable { } catch: { error, send in await send(.internal(.changeTicketStatusResponse(.failure(error)))) } - case .sendComment: - break + 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 diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index 3991246b..3d899ef7 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -146,10 +146,6 @@ public struct TicketsListScreen: View { ContextButton(text: LocalizedStringResource("Status History", bundle: .module), symbol: .clockArrowCirclepath) { send(.contextTicketMenu(.statusHistory, id)) } - - ContextButton(text: LocalizedStringResource("Comment", bundle: .module), symbol: .bubbleLeft) { - send(.contextTicketMenu(.sendComment, id)) - } } Section { From f80a0467cfcc79d62cd562f120d82ce4840b34a4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 11 May 2026 21:10:41 +0300 Subject: [PATCH 036/112] [WIP] Ticket --- Modules/Sources/Models/Ticket/Ticket.swift | 4 +- .../TicketFeature/Models/TicketType.swift | 11 - .../Resources/Localizable.xcstrings | 81 +++++- .../Sources/TicketFeature/TicketFeature.swift | 58 ++++- .../Sources/TicketFeature/TicketScreen.swift | 245 ++++++++++++++++-- Project.swift | 3 + 6 files changed, 365 insertions(+), 37 deletions(-) delete mode 100644 Modules/Sources/TicketFeature/Models/TicketType.swift diff --git a/Modules/Sources/Models/Ticket/Ticket.swift b/Modules/Sources/Models/Ticket/Ticket.swift index 345b01af..2491bcdf 100644 --- a/Modules/Sources/Models/Ticket/Ticket.swift +++ b/Modules/Sources/Models/Ticket/Ticket.swift @@ -7,11 +7,11 @@ import Foundation -public struct Ticket: Sendable { +public struct Ticket: Sendable, Equatable { public let info: TicketInfo public let comments: [Comment] - public struct Comment: Sendable { + public struct Comment: Sendable, Equatable, Identifiable { public let id: Int public let content: String public let authorId: Int diff --git a/Modules/Sources/TicketFeature/Models/TicketType.swift b/Modules/Sources/TicketFeature/Models/TicketType.swift deleted file mode 100644 index 024c8adf..00000000 --- a/Modules/Sources/TicketFeature/Models/TicketType.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// TicketType.swift -// ForPDA -// -// Created by Xialtal on 5.05.26. -// - -public enum TicketType: Equatable { - case single(id: Int) - case list(forId: Int) -} diff --git a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings index 900453da..8965fc09 100644 --- a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings @@ -1,7 +1,86 @@ { "sourceLanguage" : "en", "strings" : { - + "Add the first one for other moderators" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавьте первый для других модераторов" + } + } + } + }, + "Comments" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментарии" + } + } + } + }, + "Loading..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Загрузка…" + } + } + } + }, + "New" : { + "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" : "В работе · " + } + } + } + }, + "Ticket %lld" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тикет %lld" + } + } + } + } }, "version" : "1.1" } \ No newline at end of file diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index cf049ca4..f6791692 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -7,7 +7,7 @@ import Foundation import ComposableArchitecture -import APIClient +import TicketClient import Models @Reducer @@ -19,12 +19,15 @@ public struct TicketFeature: Reducer, Sendable { @ObservableState public struct State: Equatable { - public let type: TicketType + public let id: Int + + var ticket: Ticket? + var isLoading = false public init( - type: TicketType + id: Int ) { - self.type = type + self.id = id } } @@ -34,20 +37,63 @@ public struct TicketFeature: Reducer, Sendable { case view(View) public enum View { case onAppear + + case urlTapped(URL) + case commentAuthorButtonTapped(Int) + } + + case `internal`(Internal) + public enum Internal { + case loadTicket + case ticketResponse(Result) + } + + case delegate(Delegate) + public enum Delegate { + case handleUrl(URL) + case openUser(Int) } } // MARK: - Dependencies - @Dependency(\.apiClient) private var apiClient - @Dependency(\.openURL) var openURL + @Dependency(\.ticketClient) private var ticketClient + @Dependency(\.openURL) private var openURL // MARK: - Body public var body: some Reducer { Reduce { state, action in switch action { + case .delegate: + return .none + case .view(.onAppear): + return .send(.internal(.loadTicket)) + + case let .view(.urlTapped(url)): + return .send(.delegate(.handleUrl(url))) + + case let .view(.commentAuthorButtonTapped(id)): + return .send(.delegate(.openUser(id))) + + case .internal(.loadTicket): + state.isLoading = true + return .run { [id = state.id] send in + let response = try await ticketClient.getTicket(id: id) + await send(.internal(.ticketResponse(.success(response)))) + } catch: { error, send in + await send(.internal(.ticketResponse(.failure(error)))) + } + + case let .internal(.ticketResponse(.success(response))): + state.ticket = response + state.isLoading = false + return .none + + case let .internal(.ticketResponse(.failure(error))): + print(error) + state.isLoading = false return .none } } diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index 703a9a10..6dd866b0 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -9,53 +9,264 @@ import SwiftUI import ComposableArchitecture import Models import SharedUI +import SFSafeSymbols +import BBBuilder +import RichTextKit @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 { ScrollView { - Text("Ticket") + 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 \(store.id)" : "Loading...", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) .background(Color(.Background.primary)) + .toolbar { + + } .onAppear { send(.onAppear) } } } + + // MARK: - Comment + + @ViewBuilder + private func Comment(_ comment: Ticket.Comment) -> 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.authorId)) + } label: { + Text(verbatim: comment.authorName) + .foregroundStyle(tintColor) + .underline() + } + .buttonStyle(.plain) + + AttributedContent(comment) + } + .font(.subheadline) + + HStack { + Text(verbatim: comment.createdAt.formatted()) + .font(.caption) + .foregroundStyle(Color(.Labels.quaternary)) + + Spacer() + + // TODO: ContextMenu + } + } + } + } + + // MARK: - Attributed Content + + @ViewBuilder + private func AttributedContent(_ comment: Ticket.Comment) -> some View { + if let content = comment.contentAttributed { + RichText(text: content, isSelectable: false, onUrlTap: { url in + send(.urlTapped(url)) + }) + } else { + Text(verbatim: 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: - 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: - Previews +// MARK: - Extensions -#Preview("Ticket Single") { - NavigationStack { - TicketScreen( - store: Store( - initialState: TicketFeature.State( - type: .single(id: 0) - ) - ) { - TicketFeature() - } - ) +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) + } } } -#Preview("Tickets List") { +extension Ticket.Comment { + var contentAttributed: NSAttributedString? { + guard !content.isEmpty else { return nil } + return BBRenderer(baseAttributes: [.font: UIFont.preferredFont(forTextStyle: .subheadline)]) + .render(text: content) + } +} + +// MARK: - Previews + +#Preview("Ticket") { NavigationStack { TicketScreen( store: Store( - initialState: TicketFeature.State( - type: .list(forId: 0) - ) + initialState: TicketFeature.State(id: 0) ) { TicketFeature() } diff --git a/Project.swift b/Project.swift index b6d7056b..2791fabc 100644 --- a/Project.swift +++ b/Project.swift @@ -535,10 +535,13 @@ let project = Project( .feature( name: "TicketFeature", dependencies: [ + .Internal.BBBuilder, .Internal.Models, .Internal.SharedUI, .Internal.TicketClient, .Internal.ToastClient, + .SPM.RichTextKit, + .SPM.SFSafeSymbols, .SPM.TCA ] ), From bfbe3c15facb75a2ef4c7c5037eec1b6fe7abdaa Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 11 May 2026 22:37:24 +0300 Subject: [PATCH 037/112] Fix ticket row background in dark mode --- Modules/Sources/TicketsListFeature/TicketsListScreen.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index 3d899ef7..e8620f1b 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -181,6 +181,7 @@ public struct TicketsListScreen: View { TicketRow(ticket) } .buttonStyle(.plain) + .listRowBackground(Color.clear) } } From d8528152dd97ce0357c506bf05464f703a062443 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Mon, 11 May 2026 22:40:44 +0300 Subject: [PATCH 038/112] Add Tickets to More tab --- .../Sources/AppFeature/Navigation/StackTab.swift | 3 +++ .../MoreFeature/Resources/Localizable.xcstrings | 10 ++++++++++ .../Sources/Analytics/MoreFeature+Analytics.swift | 4 ++++ .../Sources/MoreFeature/Sources/MoreFeature.swift | 15 +++++++++++++++ .../Sources/MoreFeature/Sources/MoreScreen.swift | 4 ++++ 5 files changed, 36 insertions(+) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index f25b1a15..8d8817c6 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -333,6 +333,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()))) 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)) From 3fd51190a3779c4835041e77b8e202904f5e1df0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 18:48:29 +0300 Subject: [PATCH 039/112] Fix ticket status row background in dark mode --- .../TicketStatusHistoryFeature/TicketStatusHistoryView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift index db639606..0006c6de 100644 --- a/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift +++ b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift @@ -102,6 +102,7 @@ public struct TicketStatusHistoryView: View { .font(.caption) .foregroundStyle(Color(.Labels.quaternary)) } + .listRowBackground(Color.clear) } // MARK: - Handler Badge From b8f84dc2ab914909b180f35d4982487015ae0ee2 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 19:02:44 +0300 Subject: [PATCH 040/112] [WIP] Ticket --- Modules/Sources/Models/Ticket/Ticket.swift | 2 +- .../Models/TicketContextMenuAction.swift | 14 ++ .../Resources/Localizable.xcstrings | 90 +++++++++++ .../Sources/TicketFeature/TicketFeature.swift | 125 +++++++++++++- .../Sources/TicketFeature/TicketScreen.swift | 153 ++++++++++++++---- Project.swift | 4 + 6 files changed, 351 insertions(+), 37 deletions(-) create mode 100644 Modules/Sources/TicketFeature/Models/TicketContextMenuAction.swift diff --git a/Modules/Sources/Models/Ticket/Ticket.swift b/Modules/Sources/Models/Ticket/Ticket.swift index 2491bcdf..479fc932 100644 --- a/Modules/Sources/Models/Ticket/Ticket.swift +++ b/Modules/Sources/Models/Ticket/Ticket.swift @@ -8,7 +8,7 @@ import Foundation public struct Ticket: Sendable, Equatable { - public let info: TicketInfo + public var info: TicketInfo public let comments: [Comment] public struct Comment: Sendable, Equatable, Identifiable { 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/Resources/Localizable.xcstrings b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings index 8965fc09..b8498c6e 100644 --- a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings @@ -11,6 +11,26 @@ } } }, + "Change status" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изм. статус" + } + } + } + }, + "Comment" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментировать" + } + } + } + }, "Comments" : { "localizations" : { "ru" : { @@ -21,6 +41,36 @@ } } }, + "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" : "Ссылка скопирована" + } + } + } + }, "Loading..." : { "localizations" : { "ru" : { @@ -71,6 +121,26 @@ } } }, + "Status History" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "История статусов" + } + } + } + }, + "The ticket's handler has changed, please try again" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "У тикета изменился ответственный, попробуйте еще раз" + } + } + } + }, "Ticket %lld" : { "localizations" : { "ru" : { @@ -80,6 +150,26 @@ } } } + }, + "Ticket status changed" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно сменить статус тикета" + } + } + } + }, + "Unable to change ticket status" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Невозможно сменить статус тикета" + } + } + } } }, "version" : "1.1" diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index f6791692..573dc482 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -9,16 +9,47 @@ import Foundation import ComposableArchitecture import TicketClient import Models +import PasteboardClient +import PersistenceKeys +import ToastClient +import CacheClient +import TicketStatusHistoryFeature @Reducer public struct TicketFeature: 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(.userSession) var userSession: UserSession? + var userSessionNickname: String? + + @Presents public var destination: Destination.State? + public let id: Int var ticket: Ticket? @@ -34,18 +65,26 @@ public struct TicketFeature: Reducer, Sendable { // MARK: - Action public enum Action: ViewAction { + case destination(PresentationAction) + case view(View) public enum View { case onAppear + case commentButtonTapped + case changeStatusButtonTapped(TicketStatus) + case urlTapped(URL) case commentAuthorButtonTapped(Int) + + case contextMenu(TicketContextMenuAction) } case `internal`(Internal) public enum Internal { case loadTicket case ticketResponse(Result) + case changeTicketStatusResponse(Result<(TicketStatus, TicketStatusChangeResponse), any Error>) } case delegate(Delegate) @@ -57,7 +96,10 @@ public struct TicketFeature: Reducer, Sendable { // 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 @@ -65,7 +107,10 @@ public struct TicketFeature: Reducer, Sendable { public var body: some Reducer { Reduce { state, action in switch action { - case .delegate: + case let .destination(.presented(.statusHistory(.delegate(.openUser(id))))): + return .send(.delegate(.openUser(id))) + + case .delegate, .destination: return .none case .view(.onAppear): @@ -77,10 +122,83 @@ public struct TicketFeature: Reducer, Sendable { 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 .view(.commentButtonTapped): + return .run { [ticketId = state.id] send in + let response = try await ticketClient.modifyComment(id: 0, ticketId: ticketId, text: "Xx") + } + + case let .view(.changeStatusButtonTapped(status)): + return .run { [ticketId = state.id, handlerId = state.userSession?.userId] send in + let response = try await ticketClient.changeTicketStatus( + id: ticketId, + handlerId: handlerId ?? 0, + status: status + ) + await send(.internal(.changeTicketStatusResponse(.success((status, response))))) + } catch: { error, send in + await send(.internal(.changeTicketStatusResponse(.failure(error)))) + } + + 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, 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) + } + case .internal(.loadTicket): state.isLoading = true return .run { [id = state.id] send in - let response = try await ticketClient.getTicket(id: id) + let response = try await ticketClient.getTicket(id) await send(.internal(.ticketResponse(.success(response)))) } catch: { error, send in await send(.internal(.ticketResponse(.failure(error)))) @@ -97,5 +215,8 @@ public struct TicketFeature: Reducer, Sendable { return .none } } + .ifLet(\.$destination, action: \.destination) } } + +extension TicketFeature.Destination.State: Equatable {} diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index 6dd866b0..d9d7ccb3 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -12,6 +12,7 @@ import SharedUI import SFSafeSymbols import BBBuilder import RichTextKit +import TicketStatusHistoryFeature @ViewAction(for: TicketFeature.self) public struct TicketScreen: View { @@ -31,52 +32,68 @@ public struct TicketScreen: View { public var body: some View { WithPerceptionTracking { - ScrollView { - if let ticket = store.ticket { - VStack(alignment: .leading, spacing: 12) { - VStack(spacing: 8) { - Divider() + ZStack { + Color(.Background.primary) + .ignoresSafeArea() + + ScrollView { + if let ticket = store.ticket { + VStack(alignment: .leading, spacing: 12) { + VStack(spacing: 8) { + Divider() + + TicketHeader(ticket.info) + + Divider() + } - TicketHeader(ticket.info) + if let content = ticket.comments.first { + AttributedContent(content) + } - 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) - + 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) } - } else { - NoComments() - .padding(.top, 84) } + } header: { + Text("Comments", bundle: .module) + .font(.body) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.primary)) + .padding(.top, 28) } - } header: { - Text("Comments", bundle: .module) - .font(.body) - .fontWeight(.semibold) - .foregroundStyle(Color(.Labels.primary)) - .padding(.top, 28) } } } + .padding(.horizontal, 16) } - .padding(.horizontal, 16) .navigationTitle(Text(store.ticket != nil ? "Ticket \(store.id)" : "Loading...", bundle: .module)) .navigationBarTitleDisplayMode(.inline) - .background(Color(.Background.primary)) + .sheet(item: $store.scope(state: \.$destination, action: \.destination).statusHistory) { store in + NavigationStack { + TicketStatusHistoryView(store: store) + } + } + ._safeAreaBar(edge: .bottom) { + if store.ticket != nil { + ActionButtons() + } + } .toolbar { - + ToolbarItem { + OptionsMenu() + } } .onAppear { send(.onAppear) @@ -84,6 +101,72 @@ public struct TicketScreen: View { } } + // 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 { + Menu { + Picker(String(), selection: Binding( + get: { store.ticket!.info.status }, + set: { newValue in + send(.changeStatusButtonTapped(newValue)) + } + )) { + ForEach(TicketStatus.allCases) { status in + Text(status.title, bundle: .module) + .tag(status) + } + } + } label: { + Text("Change status", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + } + .tint(tintColor) + .buttonStyle(.bordered) + .frame(height: 48) + + Button { + send(.commentButtonTapped) + } label: { + Text("Comment", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + } + .buttonStyle(.borderedProminent) + .frame(height: 48) + .disabled(true) + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + } + // MARK: - Comment @ViewBuilder @@ -114,7 +197,9 @@ public struct TicketScreen: View { Spacer() - // TODO: ContextMenu + if let session = store.userSession, session.userId == comment.authorId { + // TODO: ContextMenu + } } } } diff --git a/Project.swift b/Project.swift index 5ed0898a..13fb8196 100644 --- a/Project.swift +++ b/Project.swift @@ -537,9 +537,13 @@ let project = Project( name: "TicketFeature", dependencies: [ .Internal.BBBuilder, + .Internal.CacheClient, .Internal.Models, + .Internal.PasteboardClient, + .Internal.PersistenceKeys, .Internal.SharedUI, .Internal.TicketClient, + .Internal.TicketStatusHistoryFeature, .Internal.ToastClient, .SPM.RichTextKit, .SPM.SFSafeSymbols, From 0ad6df58a8763ac6983b52428390b521bb711eb4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 19:03:56 +0300 Subject: [PATCH 041/112] Fix missing dependencies --- Project.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Project.swift b/Project.swift index 13fb8196..57ed7e16 100644 --- a/Project.swift +++ b/Project.swift @@ -437,6 +437,7 @@ let project = Project( name: "QMSListFeature", hasResources: false, dependencies: [ + .Internal.AnalyticsClient, .Internal.CacheClient, .Internal.Models, .Internal.QMSClient, @@ -506,6 +507,7 @@ let project = Project( .feature( name: "SearchResultFeature", dependencies: [ + .Internal.AnalyticsClient, .Internal.APIClient, .Internal.BBBuilder, .Internal.Models, From 388baf43770bbcedbff741fd479361e36891c13f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 19:06:33 +0300 Subject: [PATCH 042/112] Update Package.resolved --- Tuist/Package.resolved | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index a0ef0d27..095c928a 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fb8b5563a09730e1b427a82c15a7013e695b2305d2f502815e3d028a09c43032", + "originHash" : "95e8204f2cfe7f2089c0100e8891e11d5168254f8f404703e6a64dd79237ddab", "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" : "e1c5ddfaf6c98d9b60960ab0d984b98fc61f1c7f", + "version" : "0.8.1" } }, { From fe7b415c7496e2b681bc37bd85b7d9c4fc6df849 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 19:54:02 +0300 Subject: [PATCH 043/112] Fix sort type localization in tickets list --- .../Sources/TicketsListFeature/TicketsListScreen.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index e8620f1b..1881de91 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -111,10 +111,16 @@ public struct TicketsListScreen: View { private func OptionsMenu() -> some View { Menu { Section { - Toggle("Only My", isOn: Binding(store.$appSettings.tickets.isShowOnlyMine)) + Toggle( + LocalizedStringResource("Only My", bundle: .module), + isOn: Binding(store.$appSettings.tickets.isShowOnlyMine) + ) if case .list = store.type { - Toggle("Sort by Forums", isOn: Binding(store.$appSettings.tickets.isSortByForums)) + Toggle( + LocalizedStringResource("Sort by Forums", bundle: .module), + isOn: Binding(store.$appSettings.tickets.isSortByForums) + ) } } header: { Text("Sort", bundle: .module) From 97c872c32b60d4020e1b2332460a6b160cd80255 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 20:19:36 +0300 Subject: [PATCH 044/112] Fix ticket status crash in tickets list --- .../TicketsListScreen.swift | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index 1881de91..32ff70b9 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -141,7 +141,7 @@ public struct TicketsListScreen: View { Menu { Section { Menu { - TIcketStatusPicker(id: id) + TicketStatusPicker(id: id) } label: { HStack { Text("Change Status", bundle: .module) @@ -197,7 +197,7 @@ public struct TicketsListScreen: View { private func TicketRow(_ ticket: TicketsList.TicketSimplified) -> some View { VStack(alignment: .leading, spacing: 8) { Menu { - TIcketStatusPicker(id: ticket.id) + TicketStatusPicker(id: ticket.id) } label: { TicketStatusBadge(info: ticket.info) } @@ -271,16 +271,19 @@ public struct TicketsListScreen: View { // MARK: - Ticket Status Picker - private func TIcketStatusPicker(id: Int) -> some View { - Picker(String(), selection: Binding( - get: { store.tickets[id].info.status }, - set: { newValue in - send(.contextTicketMenu(.changeStatus(newValue), id)) - } - )) { - ForEach(TicketStatus.allCases) { status in - Text(status.title, bundle: .module) - .tag(status) + private func TicketStatusPicker(id: Int) -> some View { + WithPerceptionTracking { + let status = store.tickets[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, bundle: .module) + .tag(status) + } } } } From 224de0d098fafb6fdc8444d1cc0a3283a28fab04 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 21:02:15 +0300 Subject: [PATCH 045/112] Improve page changing on refreshing for tickets list --- Modules/Sources/TicketsListFeature/TicketsListFeature.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index 56070620..2f4c7e11 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -126,6 +126,7 @@ public struct TicketsListFeature: Reducer, Sendable { 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))))): From 9ac5227578f307047b0f4d25ea2b9f71005929eb Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 21:02:36 +0300 Subject: [PATCH 046/112] Temp fix for ticket status picker --- Modules/Sources/TicketsListFeature/TicketsListScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index 32ff70b9..976793cc 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -273,7 +273,7 @@ public struct TicketsListScreen: View { private func TicketStatusPicker(id: Int) -> some View { WithPerceptionTracking { - let status = store.tickets[id].info.status + let status = store.tickets.first(where: { $0.id == id })!.info.status Picker(String(), selection: Binding( get: { status }, set: { newValue in From 355b532516fc3330dd01af6bbb43ffd1436fa975 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 21:31:28 +0300 Subject: [PATCH 047/112] Fix ticket parser --- Modules/Sources/ParsingClient/Parsers/TicketParser.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index e344b04a..652402e8 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -33,7 +33,7 @@ public struct TicketParser { 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: 0] as? Int else { + let statusRaw = array[safe: 2] as? Int else { throw ParsingError.failedToCastFields } From b1cab1015929f0a813c415c08ea33837d265402a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 21:37:15 +0300 Subject: [PATCH 048/112] Fix session username init for TicketFeature --- Modules/Sources/TicketFeature/TicketFeature.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index 573dc482..39ed9219 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -85,6 +85,8 @@ public struct TicketFeature: Reducer, Sendable { case loadTicket case ticketResponse(Result) case changeTicketStatusResponse(Result<(TicketStatus, TicketStatusChangeResponse), any Error>) + + case initUserSessionNickname(String) } case delegate(Delegate) @@ -114,7 +116,12 @@ public struct TicketFeature: Reducer, Sendable { return .none case .view(.onAppear): - return .send(.internal(.loadTicket)) + 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 let .view(.urlTapped(url)): return .send(.delegate(.handleUrl(url))) @@ -213,6 +220,10 @@ public struct TicketFeature: Reducer, Sendable { print(error) state.isLoading = false return .none + + case let .internal(.initUserSessionNickname(name)): + state.userSessionNickname = name + return .none } } .ifLet(\.$destination, action: \.destination) From e4a72911fcfa7fde5e54612e211e2ef52ec32f2e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 21:39:26 +0300 Subject: [PATCH 049/112] Fix perception warnings --- .../TicketsListScreen.swift | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index 976793cc..77905006 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -109,28 +109,30 @@ public struct TicketsListScreen: View { @ViewBuilder private func OptionsMenu() -> some View { - Menu { - Section { - Toggle( - LocalizedStringResource("Only My", bundle: .module), - isOn: Binding(store.$appSettings.tickets.isShowOnlyMine) - ) - - if case .list = store.type { + WithPerceptionTracking { + Menu { + Section { Toggle( - LocalizedStringResource("Sort by Forums", bundle: .module), - isOn: Binding(store.$appSettings.tickets.isSortByForums) + 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) } - } header: { - Text("Sort", bundle: .module) - } - - ContextButton(text: LocalizedStringResource("Copy Link", bundle: .module), symbol: .docOnDoc) { - send(.contextMenu(.copyLink)) + + ContextButton(text: LocalizedStringResource("Copy Link", bundle: .module), symbol: .docOnDoc) { + send(.contextMenu(.copyLink)) + } + } label: { + Image(systemSymbol: .ellipsisCircle) } - } label: { - Image(systemSymbol: .ellipsisCircle) } } @@ -180,14 +182,16 @@ public struct TicketsListScreen: View { @ViewBuilder private func Content() -> some View { - ForEach(store.tickets) { ticket in - Button { - send(.ticketButtonTapped(ticket.id)) - } label: { - TicketRow(ticket) + WithPerceptionTracking { + ForEach(store.tickets) { ticket in + Button { + send(.ticketButtonTapped(ticket.id)) + } label: { + TicketRow(ticket) + } + .buttonStyle(.plain) + .listRowBackground(Color.clear) } - .buttonStyle(.plain) - .listRowBackground(Color.clear) } } From 33e44813852f574c4c0a567e6a421eac6906b8ff Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 21:42:02 +0300 Subject: [PATCH 050/112] Improve navigation title for TicketScreen --- Modules/Sources/TicketFeature/Resources/Localizable.xcstrings | 4 ++-- Modules/Sources/TicketFeature/TicketScreen.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings index b8498c6e..52eb1160 100644 --- a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings @@ -141,12 +141,12 @@ } } }, - "Ticket %lld" : { + "Ticket %@" : { "localizations" : { "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Тикет %lld" + "value" : "Тикет %@" } } } diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index d9d7ccb3..f6f483ce 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -78,7 +78,7 @@ public struct TicketScreen: View { } .padding(.horizontal, 16) } - .navigationTitle(Text(store.ticket != nil ? "Ticket \(store.id)" : "Loading...", bundle: .module)) + .navigationTitle(Text(store.ticket != nil ? "Ticket \(String(store.id))" : "Loading...", bundle: .module)) .navigationBarTitleDisplayMode(.inline) .sheet(item: $store.scope(state: \.$destination, action: \.destination).statusHistory) { store in NavigationStack { From b1201ca1cd79eb67799b550fd2be65e58dc331f6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 21:46:27 +0300 Subject: [PATCH 051/112] Fix delegates handling for ticket --- Modules/Sources/AppFeature/Navigation/StackTab.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 8d8817c6..3f45a9ae 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -308,6 +308,12 @@ public struct StackTab: Reducer, Sendable { 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 } From c46e500aceb73bc70cb9cc43565f50fabd0b7096 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 21:48:19 +0300 Subject: [PATCH 052/112] Fix ticket status history parser --- Modules/Sources/ParsingClient/Parsers/TicketParser.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index 652402e8..4071940a 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -186,7 +186,7 @@ public struct TicketParser { throw ParsingError.failedToCastDataToAny } - guard let statusRaw = array[safe: 0] as? [[Any]] else { + guard let statusRaw = array[safe: 2] as? [[Any]] else { throw ParsingError.failedToCastFields } From 0c85e3ce010fa91fdd3ada6650ba7141579c900e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Tue, 12 May 2026 22:02:21 +0300 Subject: [PATCH 053/112] Fix ticket status history parser --- .../Sources/ParsingClient/Parsers/TicketParser.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index 4071940a..f8d4d66e 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -186,15 +186,15 @@ public struct TicketParser { throw ParsingError.failedToCastDataToAny } - guard let statusRaw = array[safe: 2] as? [[Any]] else { + guard let contentRaw = array[safe: 2] as? [[Any]] else { throw ParsingError.failedToCastFields } - return try! statusRaw.map { status in - guard let status = array[safe: 0] as? Int, - let handlerId = array[safe: 2] as? Int, - let handlerName = array[safe: 3] as? String, - let changedAt = array[safe: 1] as? Int else { + 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 } From 138351b73d50f0090b5cdf50e80517e3b8a87896 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 13 May 2026 11:26:53 +0300 Subject: [PATCH 054/112] Fix experimental navigation in tickets list --- Modules/Sources/AppFeature/AppView.swift | 8 ++++++++ .../Sources/TicketsListFeature/TicketsListFeature.swift | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) 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/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index 2f4c7e11..637746e5 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -52,7 +52,7 @@ public struct TicketsListFeature: Reducer, Sendable { @Presents public var destination: Destination.State? var userSessionNickname: String? - var pageNavigation = PageNavigationFeature.State(type: .tickets) + public var pageNavigation = PageNavigationFeature.State(type: .tickets) public let type: TicketsListType From fb44afa28fcd2338ca408cd3cde9ae07bb6f583d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 13 May 2026 11:31:24 +0300 Subject: [PATCH 055/112] Close ticket status history sheet on handler tap --- Modules/Sources/TicketFeature/TicketFeature.swift | 1 + Modules/Sources/TicketsListFeature/TicketsListFeature.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index 39ed9219..0d3ec7b6 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -110,6 +110,7 @@ public struct TicketFeature: Reducer, Sendable { Reduce { state, action in switch action { case let .destination(.presented(.statusHistory(.delegate(.openUser(id))))): + state.destination = nil return .send(.delegate(.openUser(id))) case .delegate, .destination: diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index 637746e5..6629c129 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -130,6 +130,7 @@ public struct TicketsListFeature: Reducer, Sendable { 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: From 62dd330da3b0b59324955fce90970ae939c66680 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 13 May 2026 11:45:29 +0300 Subject: [PATCH 056/112] Fix ticket status localization --- Modules/Sources/Models/Ticket/TicketStatus.swift | 10 +++++----- Modules/Sources/TicketFeature/TicketScreen.swift | 2 +- .../TicketStatusHistoryView.swift | 2 +- .../Sources/TicketsListFeature/TicketsListScreen.swift | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/Models/Ticket/TicketStatus.swift b/Modules/Sources/Models/Ticket/TicketStatus.swift index f4829f70..b5b28f68 100644 --- a/Modules/Sources/Models/Ticket/TicketStatus.swift +++ b/Modules/Sources/Models/Ticket/TicketStatus.swift @@ -5,7 +5,7 @@ // Created by Xialtal on 3.05.26. // -import SwiftUI +import Foundation public enum TicketStatus: Int, Sendable, CaseIterable, Identifiable { case notProcessed = 0 @@ -16,14 +16,14 @@ public enum TicketStatus: Int, Sendable, CaseIterable, Identifiable { return self.rawValue } - public var title: LocalizedStringKey { + public var title: LocalizedStringResource { switch self { case .notProcessed: - return "Not processed" + return .init("Not processed", bundle: .module) case .processing: - return "Processing" + return .init("Processing", bundle: .module) case .processed: - return "Processed" + return .init("Processed", bundle: .module) } } } diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index f6f483ce..d981c395 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -139,7 +139,7 @@ public struct TicketScreen: View { } )) { ForEach(TicketStatus.allCases) { status in - Text(status.title, bundle: .module) + Text(status.title) .tag(status) } } diff --git a/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift index 0006c6de..c731ec56 100644 --- a/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift +++ b/Modules/Sources/TicketStatusHistoryFeature/TicketStatusHistoryView.swift @@ -83,7 +83,7 @@ public struct TicketStatusHistoryView: View { private func Status(_ status: TicketStatusHistory) -> some View { VStack(alignment: .leading, spacing: 16) { HStack { - Text(status.status.title, bundle: .module) + Text(status.status.title) .font(.subheadline) Spacer() diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index 77905006..ebf985c8 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -285,7 +285,7 @@ public struct TicketsListScreen: View { } )) { ForEach(TicketStatus.allCases) { status in - Text(status.title, bundle: .module) + Text(status.title) .tag(status) } } From a6d245f5a451eda6325a4064f961c7d3607c5ffc Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 13 May 2026 11:49:52 +0300 Subject: [PATCH 057/112] Fix perception warnings --- .../Sources/TicketFeature/TicketScreen.swift | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index d981c395..2d32c876 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -131,27 +131,30 @@ public struct TicketScreen: View { @ViewBuilder private func ActionButtons() -> some View { HStack { - Menu { - Picker(String(), selection: Binding( - get: { store.ticket!.info.status }, - set: { newValue in - send(.changeStatusButtonTapped(newValue)) - } - )) { - ForEach(TicketStatus.allCases) { status in - Text(status.title) - .tag(status) + 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) } - } label: { - Text("Change status", bundle: .module) - .frame(maxWidth: .infinity) - .padding(8) + .tint(tintColor) + .buttonStyle(.bordered) + .frame(height: 48) } - .tint(tintColor) - .buttonStyle(.bordered) - .frame(height: 48) - + Button { send(.commentButtonTapped) } label: { @@ -197,8 +200,10 @@ public struct TicketScreen: View { Spacer() - if let session = store.userSession, session.userId == comment.authorId { - // TODO: ContextMenu + WithPerceptionTracking { + if let session = store.userSession, session.userId == comment.authorId { + // TODO: ContextMenu + } } } } From f01ee966e5cfd285aa2819cca5f9008a5c74a3cf Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 13 May 2026 11:54:15 +0300 Subject: [PATCH 058/112] Add ticket comment delete endpoint --- Modules/Sources/TicketClient/TicketClient.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Modules/Sources/TicketClient/TicketClient.swift b/Modules/Sources/TicketClient/TicketClient.swift index 2858e736..91607d2a 100644 --- a/Modules/Sources/TicketClient/TicketClient.swift +++ b/Modules/Sources/TicketClient/TicketClient.swift @@ -21,6 +21,7 @@ public struct TicketClient: Sendable { 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 { @@ -69,6 +70,14 @@ extension TicketClient: DependencyKey { )) 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 } ) } @@ -91,6 +100,9 @@ extension TicketClient: DependencyKey { }, modifyComment: { _, _, _ in return true + }, + deleteComment: { _, _ in + return true } ) } From 5456a3c4089231ba8167aaecb21232156d0949b9 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Wed, 13 May 2026 12:09:07 +0300 Subject: [PATCH 059/112] Improve ticket toast --- Modules/Sources/TicketFeature/TicketFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index 0d3ec7b6..3f5b89d4 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -188,7 +188,7 @@ public struct TicketFeature: Reducer, Sendable { state.ticket?.info.handlerId = id state.ticket?.info.handlerName = name return .run { _ in - await toastClient.showToast(ToastMessage(text: Localization.handlerChanged, haptic: .success)) + await toastClient.showToast(ToastMessage(text: Localization.handlerChanged, isError: true, haptic: .error)) } case .other: From 25feaeedda29f455cfefacc618480dc0e355bad2 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 12:47:38 +0300 Subject: [PATCH 060/112] Add ticket comment delete option to context menu --- .../TicketCommentContextMenuAction.swift | 11 ++++ .../Resources/Localizable.xcstrings | 50 +++++++++++++++ .../Sources/TicketFeature/TicketFeature.swift | 64 ++++++++++++++++++- .../Sources/TicketFeature/TicketScreen.swift | 30 ++++++++- 4 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 Modules/Sources/TicketFeature/Models/TicketCommentContextMenuAction.swift 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/Resources/Localizable.xcstrings b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings index 52eb1160..d12602e1 100644 --- a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings @@ -11,6 +11,16 @@ } } }, + "Are you sure, that you want to delete this comment?" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы уверены, что хотите удалить этот комментарий?" + } + } + } + }, "Change status" : { "localizations" : { "ru" : { @@ -31,6 +41,16 @@ } } }, + "Comment deleted" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментарий удален" + } + } + } + }, "Comments" : { "localizations" : { "ru" : { @@ -51,6 +71,16 @@ } } }, + "Delete" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + } + } + }, "Go to Author" : { "localizations" : { "ru" : { @@ -91,6 +121,16 @@ } } }, + "No" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет" + } + } + } + }, "No Comments" : { "localizations" : { "ru" : { @@ -170,6 +210,16 @@ } } } + }, + "Yes" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Да" + } + } + } } }, "version" : "1.1" diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index 3f5b89d4..9c13a96c 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -24,6 +24,7 @@ public struct TicketFeature: Reducer, Sendable { private enum Localization { static let linkCopied = LocalizedStringResource("Link copied", 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) @@ -33,12 +34,20 @@ public struct TicketFeature: Reducer, Sendable { @Reducer public enum Destination { + @ReducerCaseIgnored + case alert(AlertState) case statusHistory(TicketStatusHistoryFeature) @CasePathable public enum Action { + case alert(Alert) case statusHistory(TicketStatusHistoryFeature.Action) } + + @CasePathable + public enum Alert: Equatable { + case deleteComment(Int) + } } // MARK: - State @@ -54,6 +63,7 @@ public struct TicketFeature: Reducer, Sendable { var ticket: Ticket? var isLoading = false + var isRefreshing = false public init( id: Int @@ -78,10 +88,12 @@ public struct TicketFeature: Reducer, Sendable { 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>) @@ -113,6 +125,17 @@ public struct TicketFeature: Reducer, Sendable { 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: return .none @@ -149,6 +172,16 @@ public struct TicketFeature: Reducer, Sendable { } return .none + case let .view(.contextCommentMenu(action)): + switch action { + case .edit(let commentId): + break + + case .delete(let commentId): + state.destination = .alert(.deleteCommentConfirmation(commentId: commentId)) + } + return .none + case .view(.commentButtonTapped): return .run { [ticketId = state.id] send in let response = try await ticketClient.modifyComment(id: 0, ticketId: ticketId, text: "Xx") @@ -166,6 +199,10 @@ public struct TicketFeature: Reducer, Sendable { 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 { @@ -204,7 +241,9 @@ public struct TicketFeature: Reducer, Sendable { } case .internal(.loadTicket): - state.isLoading = true + 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)))) @@ -215,11 +254,13 @@ public struct TicketFeature: Reducer, Sendable { case let .internal(.ticketResponse(.success(response))): state.ticket = response 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)): @@ -232,3 +273,24 @@ public struct TicketFeature: Reducer, Sendable { } 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 index 2d32c876..e3ecd7a6 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -80,6 +80,7 @@ public struct TicketScreen: View { } .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) @@ -201,8 +202,8 @@ public struct TicketScreen: View { Spacer() WithPerceptionTracking { - if let session = store.userSession, session.userId == comment.authorId { - // TODO: ContextMenu + if let session = store.userSession { + CommentContextMenu(id: comment.id) } } } @@ -210,6 +211,31 @@ public struct TicketScreen: View { } } + // MARK: - Comment Context Menu + + @ViewBuilder + private func CommentContextMenu(id: Int) -> some View { + Menu { + 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: 8, height: 22) + } + // MARK: - Attributed Content @ViewBuilder From f0e17f4c5e19f4b88dab05a6f5303a6d26768241 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 12:48:30 +0300 Subject: [PATCH 061/112] Add refresh support for ticket --- Modules/Sources/TicketFeature/TicketFeature.swift | 4 ++++ Modules/Sources/TicketFeature/TicketScreen.swift | 3 +++ 2 files changed, 7 insertions(+) diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index 9c13a96c..78b10bbc 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -80,6 +80,7 @@ public struct TicketFeature: Reducer, Sendable { case view(View) public enum View { case onAppear + case onRefresh case commentButtonTapped case changeStatusButtonTapped(TicketStatus) @@ -147,6 +148,9 @@ public struct TicketFeature: Reducer, Sendable { await send(.internal(.loadTicket)) } + case .view(.onRefresh): + return .send(.internal(.refresh)) + case let .view(.urlTapped(url)): return .send(.delegate(.handleUrl(url))) diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index e3ecd7a6..7079247b 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -96,6 +96,9 @@ public struct TicketScreen: View { OptionsMenu() } } + .refreshable { + await send(.onRefresh).finish() + } .onAppear { send(.onAppear) } From 891daeed78b171970ec4995e4cd1fc7e4d2ef2a4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 14:20:07 +0300 Subject: [PATCH 062/112] Add edit ticket comment option to context menu --- .../Resources/Localizable.xcstrings | 70 +++++++++++++++++++ .../Sources/TicketFeature/TicketFeature.swift | 39 +++++++++-- .../Sources/TicketFeature/TicketScreen.swift | 34 ++++++++- 3 files changed, 133 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings index d12602e1..c7b0c477 100644 --- a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings @@ -21,6 +21,16 @@ } } }, + "Cancel" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, "Change status" : { "localizations" : { "ru" : { @@ -31,6 +41,16 @@ } } }, + "Change ticket comment" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить комментарий тикета" + } + } + } + }, "Comment" : { "localizations" : { "ru" : { @@ -41,6 +61,16 @@ } } }, + "Comment added" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментарий добавлен" + } + } + } + }, "Comment deleted" : { "localizations" : { "ru" : { @@ -51,6 +81,16 @@ } } }, + "Comment edited" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментарий отредактирован" + } + } + } + }, "Comments" : { "localizations" : { "ru" : { @@ -81,6 +121,16 @@ } } }, + "Edit" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Изменить" + } + } + } + }, "Go to Author" : { "localizations" : { "ru" : { @@ -91,6 +141,16 @@ } } }, + "Input comment..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите комментарий…" + } + } + } + }, "Link copied" : { "localizations" : { "ru" : { @@ -161,6 +221,16 @@ } } }, + "Send" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить" + } + } + } + }, "Status History" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index 78b10bbc..2f068854 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -24,6 +24,8 @@ public struct TicketFeature: Reducer, Sendable { 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) @@ -38,6 +40,10 @@ public struct TicketFeature: Reducer, Sendable { case alert(AlertState) case statusHistory(TicketStatusHistoryFeature) + case addComment + @ReducerCaseIgnored + case editComment(Int) + @CasePathable public enum Action { case alert(Alert) @@ -65,6 +71,8 @@ public struct TicketFeature: Reducer, Sendable { var isLoading = false var isRefreshing = false + var alertInput = "" + public init( id: Int ) { @@ -74,7 +82,8 @@ public struct TicketFeature: Reducer, Sendable { // MARK: - Action - public enum Action: ViewAction { + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) case destination(PresentationAction) case view(View) @@ -82,8 +91,9 @@ public struct TicketFeature: Reducer, Sendable { case onAppear case onRefresh - case commentButtonTapped + case commentButtonTapped(Int, isAdd: Bool) case changeStatusButtonTapped(TicketStatus) + case showAddCommentAlertButtonTapped case urlTapped(URL) case commentAuthorButtonTapped(Int) @@ -120,6 +130,8 @@ public struct TicketFeature: Reducer, Sendable { // MARK: - Body public var body: some Reducer { + BindingReducer() + Reduce { state, action in switch action { case let .destination(.presented(.statusHistory(.delegate(.openUser(id))))): @@ -137,7 +149,7 @@ public struct TicketFeature: Reducer, Sendable { await send(.internal(.refresh)) } - case .delegate, .destination: + case .delegate, .destination, .binding: return .none case .view(.onAppear): @@ -179,16 +191,29 @@ public struct TicketFeature: Reducer, Sendable { case let .view(.contextCommentMenu(action)): switch action { case .edit(let commentId): - break + if let comment = state.ticket?.comments.first(where: { $0.id == commentId }) { + state.alertInput = comment.content + } + state.destination = .editComment(commentId) case .delete(let commentId): state.destination = .alert(.deleteCommentConfirmation(commentId: commentId)) } return .none - case .view(.commentButtonTapped): - return .run { [ticketId = state.id] send in - let response = try await ticketClient.modifyComment(id: 0, ticketId: ticketId, text: "Xx") + 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) + let commentToast = ToastMessage( + text: isAdd ? Localization.commentAdded : Localization.commentEdited, + haptic: .success + ) + await toastClient.showToast(status ? commentToast : .whoopsSomethingWentWrong) + await send(.internal(.refresh)) } case let .view(.changeStatusButtonTapped(status)): diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index 7079247b..caeb0805 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -86,6 +86,14 @@ public struct TicketScreen: View { TicketStatusHistoryView(store: store) } } + .alert( + item: $store.destination.editComment, + title: { _ in Text("Change ticket comment", bundle: .module) } + ) { commentId in + AlertInput({ + send(.commentButtonTapped(commentId, isAdd: false)) + }) + } ._safeAreaBar(edge: .bottom) { if store.ticket != nil { ActionButtons() @@ -160,7 +168,7 @@ public struct TicketScreen: View { } Button { - send(.commentButtonTapped) + send(.commentButtonTapped(0, isAdd: true)) } label: { Text("Comment", bundle: .module) .frame(maxWidth: .infinity) @@ -205,7 +213,7 @@ public struct TicketScreen: View { Spacer() WithPerceptionTracking { - if let session = store.userSession { + if let session = store.userSession, session.userId == comment.authorId { CommentContextMenu(id: comment.id) } } @@ -219,6 +227,10 @@ public struct TicketScreen: View { @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: { @@ -236,7 +248,7 @@ public struct TicketScreen: View { .padding(.vertical, 16) } .onTapGesture {} // DO NOT DELETE, FIX FOR IOS 17 - .frame(width: 8, height: 22) + .frame(width: 18, height: 22) } // MARK: - Attributed Content @@ -325,6 +337,22 @@ public struct TicketScreen: View { ) } + // MARK: - Alert Input + + @ViewBuilder + private func AlertInput(_ action: @escaping () -> Void) -> some View { + WithPerceptionTracking { + TextField(LocalizedStringKey("Input comment..."), 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 { From 7ec845c01bf038990b0d48b8cf465997149f37f2 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 14:25:04 +0300 Subject: [PATCH 063/112] Fix alert placeholder text localization --- Modules/Sources/TicketFeature/TicketScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index caeb0805..ce853e03 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -342,7 +342,7 @@ public struct TicketScreen: View { @ViewBuilder private func AlertInput(_ action: @escaping () -> Void) -> some View { WithPerceptionTracking { - TextField(LocalizedStringKey("Input comment..."), text: $store.alertInput) + TextField(String(localized: "Input comment...", bundle: .module), text: $store.alertInput) Button(LocalizedStringResource("Cancel", bundle: .module)) { } From c6d81a24defc0d4c05be8ff6b86fdcc9a8a6c115 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 14:25:59 +0300 Subject: [PATCH 064/112] Add comment ticket button --- .../TicketFeature/Resources/Localizable.xcstrings | 10 ++++++++++ Modules/Sources/TicketFeature/TicketScreen.swift | 11 +++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings index c7b0c477..43e27416 100644 --- a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings @@ -11,6 +11,16 @@ } } }, + "Add ticket comment" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Комментировать тикет" + } + } + } + }, "Are you sure, that you want to delete this comment?" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index ce853e03..9eea673b 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -94,6 +94,14 @@ public struct TicketScreen: View { 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() @@ -168,7 +176,7 @@ public struct TicketScreen: View { } Button { - send(.commentButtonTapped(0, isAdd: true)) + send(.showAddCommentAlertButtonTapped) } label: { Text("Comment", bundle: .module) .frame(maxWidth: .infinity) @@ -176,7 +184,6 @@ public struct TicketScreen: View { } .buttonStyle(.borderedProminent) .frame(height: 48) - .disabled(true) } .padding(.vertical, 8) .padding(.horizontal, 16) From 4cbf80c79b389da5cba23172b9fbb925f0386fff Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 14:30:58 +0300 Subject: [PATCH 065/112] TicketFeature improvements --- Modules/Sources/TicketFeature/TicketFeature.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index 2f068854..d15b7b25 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -218,11 +218,7 @@ public struct TicketFeature: Reducer, Sendable { case let .view(.changeStatusButtonTapped(status)): return .run { [ticketId = state.id, handlerId = state.userSession?.userId] send in - let response = try await ticketClient.changeTicketStatus( - id: ticketId, - handlerId: handlerId ?? 0, - status: status - ) + 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)))) From 75d76e2b2e88f0247f0f759d3139e13300282e9d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 14:31:39 +0300 Subject: [PATCH 066/112] Clear ticket comment input field on error or success --- .../Sources/TicketFeature/TicketFeature.swift | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index d15b7b25..3c2ff0f2 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -108,6 +108,7 @@ public struct TicketFeature: Reducer, Sendable { case loadTicket case ticketResponse(Result) case changeTicketStatusResponse(Result<(TicketStatus, TicketStatusChangeResponse), any Error>) + case commentTicketResponse(Result<(Bool, Bool), any Error>) case initUserSessionNickname(String) } @@ -208,12 +209,9 @@ public struct TicketFeature: Reducer, Sendable { 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) - let commentToast = ToastMessage( - text: isAdd ? Localization.commentAdded : Localization.commentEdited, - haptic: .success - ) - await toastClient.showToast(status ? commentToast : .whoopsSomethingWentWrong) - await send(.internal(.refresh)) + await send(.internal(.commentTicketResponse(.success((isAdd, status))))) + } catch: { error, send in + await send(.internal(.commentTicketResponse(.failure(error)))) } case let .view(.changeStatusButtonTapped(status)): @@ -265,6 +263,24 @@ public struct TicketFeature: Reducer, Sendable { 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 From d8e070b5fad0c75453e2006c81c5ce043cfeb5fc Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 15:08:29 +0300 Subject: [PATCH 067/112] Bump API version --- Tuist/Package.resolved | 6 +++--- Tuist/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index 095c928a..e2dcbc5a 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "95e8204f2cfe7f2089c0100e8891e11d5168254f8f404703e6a64dd79237ddab", + "originHash" : "e44a7bdc3addea29cbd54922a0caa19daac72cc2bfc6b032324bd764465fb6ca", "pins" : [ { "identity" : "activityindicatorview", @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SubvertDev/PDAPI_SPM.git", "state" : { - "revision" : "e1c5ddfaf6c98d9b60960ab0d984b98fc61f1c7f", - "version" : "0.8.1" + "revision" : "248d59880d875479956297e25a1e9e36dee22bab", + "version" : "0.8.2" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 0988ed4c..60c9972f 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.1"), + .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.8.2"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), ] ) From 68e16fd7121d7ff46931c651f830e9d806303f59 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 15:08:45 +0300 Subject: [PATCH 068/112] Fix post karma history endpoint --- Modules/Sources/APIClient/APIClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 7da2f856..29567808 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -504,7 +504,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) }, From a3a981a7df88cc42ba8110942342e88a42476e6e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 15:33:40 +0300 Subject: [PATCH 069/112] Add forum event log endpoint --- Modules/Sources/APIClient/APIClient.swift | 13 ++++ .../Sources/Models/Forum/ForumEventLog.swift | 66 +++++++++++++++++++ .../Models/Forum/ForumEventLogType.swift | 11 ++++ .../ParsingClient/Parsers/ForumParser.swift | 22 +++++++ .../Sources/ParsingClient/ParsingClient.swift | 4 ++ 5 files changed, 116 insertions(+) create mode 100644 Modules/Sources/Models/Forum/ForumEventLog.swift create mode 100644 Modules/Sources/Models/Forum/ForumEventLogType.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 29567808..181678c6 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -59,6 +59,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 @@ -332,6 +333,12 @@ 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, @@ -743,6 +750,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/Models/Forum/ForumEventLog.swift b/Modules/Sources/Models/Forum/ForumEventLog.swift new file mode 100644 index 00000000..7b750e9f --- /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 { + 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..ad08dfa6 --- /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 { + case post = 1 + case topic = 0 +} diff --git a/Modules/Sources/ParsingClient/Parsers/ForumParser.swift b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift index 49c568ba..7c933134 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( diff --git a/Modules/Sources/ParsingClient/ParsingClient.swift b/Modules/Sources/ParsingClient/ParsingClient.swift index 74d92e6f..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 @@ -120,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) }, From d59eb7260cd1fd258e302b328ea41b1d413ce70d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 21:53:09 +0300 Subject: [PATCH 070/112] Fix post karma history row background in dark mode --- .../TopicFeature/PostKarmaHistory/PostKarmaHistoryView.swift | 1 + 1 file changed, 1 insertion(+) 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) } } From b8920d35098db1015bcdf1e2d4cdac0e41b163e0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 22:07:30 +0300 Subject: [PATCH 071/112] Improve reputation change actions --- .../APIClient/Requests/ReputationChangeRequest.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift b/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift index 6dde1c7a..de4c07ee 100644 --- a/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift +++ b/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift @@ -23,16 +23,15 @@ public struct ReputationChangeRequest: Sendable { case up case down case delete - case recover + case restore } nonisolated var transferVoteType: MemberReputationRequest.ActionType { switch action { - case .up: .plus - case .down: .minus - - // TODO: Implement. - case .delete, .recover: .plus + case .up: .plus + case .down: .minus + case .delete: .delete + case .restore: .restore } } From c3299969c807998b7318585a8c871080a79acc6a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 22:25:03 +0300 Subject: [PATCH 072/112] Rework reputation vote context menu --- .../ReputationVoteContextMenuAction.swift | 13 +++++++++ .../ReputationFeature/ReputationFeature.swift | 28 +++++++++++++------ .../ReputationFeature/ReputationScreen.swift | 4 +-- 3 files changed, 35 insertions(+), 10 deletions(-) create mode 100644 Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift diff --git a/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift b/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift new file mode 100644 index 00000000..75706c2e --- /dev/null +++ b/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift @@ -0,0 +1,13 @@ +// +// ReputationVoteContextMenuAction.swift +// ForPDA +// +// Created by Xialtal on 14.05.26. +// + +public enum ReputationVoteContextMenuAction { + case report(Int) + case delete(Int) + case restore(Int) + case goToAuthor(Int) +} diff --git a/Modules/Sources/ReputationFeature/ReputationFeature.swift b/Modules/Sources/ReputationFeature/ReputationFeature.swift index f1b761f3..24a50934 100644 --- a/Modules/Sources/ReputationFeature/ReputationFeature.swift +++ b/Modules/Sources/ReputationFeature/ReputationFeature.swift @@ -84,8 +84,9 @@ public struct ReputationFeature: Reducer, Sendable { case loadMore case refresh case profileTapped(Int) - case complainButtonTapped(Int) case sourceTapped(ReputationVote) + + case contextVoteMenu(ReputationVoteContextMenuAction) } case `internal`(Internal) @@ -148,13 +149,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 +161,24 @@ 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 .delete(let voteId): + break + case .restore(let voteId): + break + + 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 diff --git a/Modules/Sources/ReputationFeature/ReputationScreen.swift b/Modules/Sources/ReputationFeature/ReputationScreen.swift index 1db195d2..77d77ab4 100644 --- a/Modules/Sources/ReputationFeature/ReputationScreen.swift +++ b/Modules/Sources/ReputationFeature/ReputationScreen.swift @@ -235,14 +235,14 @@ public struct ReputationScreen: 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))) } ) } } From d755b99cf828c1f1882cc939cc1c524900dce308 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 22:36:16 +0300 Subject: [PATCH 073/112] Fix analytics for reputation vote context menu --- .../AnalyticsClient/Events/ReputationEvent.swift | 8 +++++++- .../Analytics/ReputationFeature+Analytics.swift | 14 +++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) 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/ReputationFeature/Analytics/ReputationFeature+Analytics.swift b/Modules/Sources/ReputationFeature/Analytics/ReputationFeature+Analytics.swift index 279e3576..cd792d5c 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 .delete, .restore: + // 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, _, _): From c60f0004c24005ba4ed036f66864122c0b97c080 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 22:36:32 +0300 Subject: [PATCH 074/112] Add auth check for reputation vote context menu --- .../ReputationFeature/ReputationFeature.swift | 4 ++++ .../ReputationFeature/ReputationScreen.swift | 22 +++++++++++-------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Modules/Sources/ReputationFeature/ReputationFeature.swift b/Modules/Sources/ReputationFeature/ReputationFeature.swift index 24a50934..ef8a062e 100644 --- a/Modules/Sources/ReputationFeature/ReputationFeature.swift +++ b/Modules/Sources/ReputationFeature/ReputationFeature.swift @@ -67,6 +67,10 @@ public struct ReputationFeature: Reducer, Sendable { return userSession?.userId == userId } + public var isUserAuthorized: Bool { + return userSession != nil + } + public init(userId: Int) { self.userId = userId } diff --git a/Modules/Sources/ReputationFeature/ReputationScreen.swift b/Modules/Sources/ReputationFeature/ReputationScreen.swift index 77d77ab4..6d5bf7ad 100644 --- a/Modules/Sources/ReputationFeature/ReputationScreen.swift +++ b/Modules/Sources/ReputationFeature/ReputationScreen.swift @@ -172,15 +172,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) + } label: { + Image(systemSymbol: .ellipsis) + .foregroundStyle(Color(.Labels.teritary)) + .font(.body) + } + .menuStyle(.button) + .buttonStyle(.plain) } - .menuStyle(.button) - .buttonStyle(.plain) } } .padding(.leading, 12) @@ -188,7 +190,9 @@ 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) + } } } // MARK: - Empty Reputation From 464d714c855b89bc20f112d51c76d617d1187118 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 23:04:06 +0300 Subject: [PATCH 075/112] Fix ticket status change --- Modules/Sources/ParsingClient/Parsers/TicketParser.swift | 2 +- Modules/Sources/TicketFeature/TicketFeature.swift | 2 +- Modules/Sources/TicketsListFeature/TicketsListFeature.swift | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index f8d4d66e..aaf9c4a0 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -155,7 +155,7 @@ public struct TicketParser { throw ParsingError.failedToCastDataToAny } - guard let status = array[safe: 0] as? Int else { + guard let status = array[safe: 1] as? Int else { throw ParsingError.failedToCastFields } diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index 3c2ff0f2..bfe314f3 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -215,7 +215,7 @@ public struct TicketFeature: Reducer, Sendable { } case let .view(.changeStatusButtonTapped(status)): - return .run { [ticketId = state.id, handlerId = state.userSession?.userId] send in + 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 diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index 6629c129..221221b4 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -168,10 +168,10 @@ public struct TicketsListFeature: Reducer, Sendable { case let .view(.contextTicketMenu(action, ticketId)): switch action { case .changeStatus(let status): - return .run { [handlerId = state.userSession?.userId] send in + return .run { [handlerId = state.tickets[ticketId].info.handlerId] send in let response = try await ticketClient.changeTicketStatus( id: ticketId, - handlerId: handlerId ?? 0, + handlerId: handlerId, status: status ) await send(.internal(.changeTicketStatusResponse(.success((ticketId, status, response))))) From 03cb7f8899a215158d57cd6f450427fde391671d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 23:08:51 +0300 Subject: [PATCH 076/112] Add onNextAppear for tickets list --- Modules/Sources/TicketsListFeature/TicketsListFeature.swift | 4 ++++ Modules/Sources/TicketsListFeature/TicketsListScreen.swift | 2 ++ 2 files changed, 6 insertions(+) diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index 221221b4..e91fb0be 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -78,6 +78,7 @@ public struct TicketsListFeature: Reducer, Sendable { case view(View) public enum View { case onFirstAppear + case onNextAppear case onRefresh case ticketButtonTapped(Int) @@ -144,6 +145,9 @@ public struct TicketsListFeature: Reducer, Sendable { await send(.internal(.loadTickets(offset: 0))) } + case .view(.onNextAppear): + return .send(.internal(.refresh)) + case .view(.onRefresh): guard !state.isLoading else { return .none } return .send(.internal(.refresh)) diff --git a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift index ebf985c8..892942d2 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListScreen.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListScreen.swift @@ -101,6 +101,8 @@ public struct TicketsListScreen: View { } .onFirstAppear { send(.onFirstAppear) + } onNextAppear: { + send(.onNextAppear) } } } From 25347137c210845aa14ec5fb2c52bbfab6b3af8d Mon Sep 17 00:00:00 2001 From: Xialtal Date: Thu, 14 May 2026 23:19:29 +0300 Subject: [PATCH 077/112] Fix html symbols in ticket title --- Modules/Sources/ParsingClient/Parsers/TicketParser.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift index aaf9c4a0..81ebd1ba 100644 --- a/Modules/Sources/ParsingClient/Parsers/TicketParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/TicketParser.swift @@ -39,7 +39,7 @@ public struct TicketParser { return Ticket( info: TicketInfo( - title: title, + title: title.convertCodes(), status: TicketStatus(rawValue: statusRaw)!, subjectId: subjectId, subjectElementId: subjectElementId, From 860938a2f83a034f975689c4e86f38fd4508e438 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 11:16:34 +0300 Subject: [PATCH 078/112] Improve change ticket status toast for ru localization --- Modules/Sources/TicketFeature/Resources/Localizable.xcstrings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings index 43e27416..74fdb7cc 100644 --- a/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TicketFeature/Resources/Localizable.xcstrings @@ -276,7 +276,7 @@ "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Невозможно сменить статус тикета" + "value" : "Статус изменен" } } } From dc208f9453b847beebf5d4b08669be68b253e1bf Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 11:33:54 +0300 Subject: [PATCH 079/112] [WIP] Forum Event Log --- .../ForumEventLogFeature.swift | 58 ++++++++++++++++ .../ForumEventLogView.swift | 66 +++++++++++++++++++ .../Resources/Localizable.xcstrings | 9 +++ 3 files changed, 133 insertions(+) create mode 100644 Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift create mode 100644 Modules/Sources/ForumEventLogFeature/ForumEventLogView.swift create mode 100644 Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings diff --git a/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift new file mode 100644 index 00000000..7a75a2c2 --- /dev/null +++ b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift @@ -0,0 +1,58 @@ +// +// ForumEventLogFeature.swift +// ForPDA +// +// Created by Xialtal on 14.05.26. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct ForumEventLogFeature: Reducer, Sendable { + + public init() {} + + // MARK: - State + + @ObservableState + public struct State: Equatable { + public let id: Int + public let type: ForumEventLogType + + 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 + } + } + + // MARK: - Dependencies + + @Dependency(\.apiClient) private var apiClient + + // MARK: - Body + + public var body: some Reducer { + Reduce { state, action in + switch action { + case .view(.onAppear): + return .none + } + } + } +} + diff --git a/Modules/Sources/ForumEventLogFeature/ForumEventLogView.swift b/Modules/Sources/ForumEventLogFeature/ForumEventLogView.swift new file mode 100644 index 00000000..129b1384 --- /dev/null +++ b/Modules/Sources/ForumEventLogFeature/ForumEventLogView.swift @@ -0,0 +1,66 @@ +// +// ForumEventLogView.swift +// ForPDA +// +// Created by Xialtal on 14.05.26. +// + +import SwiftUI +import ComposableArchitecture +import Models +import SharedUI + +@ViewAction(for: ForumEventLogFeature.self) +public struct ForumEventLogView: View { + + @Perception.Bindable public var store: StoreOf + @Environment(\.tintColor) private var tintColor + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + ScrollView { + Text("Forum Event Log") + } + .background(Color(.Background.primary)) + .onAppear { + send(.onAppear) + } + } + } +} + +// MARK: - Previews + +#Preview("Post Events") { + NavigationStack { + ForumEventLogView( + store: Store( + initialState: ForumEventLogFeature.State( + id: 0, + type: .post + ) + ) { + ForumEventLogFeature() + } + ) + } +} + +#Preview("Topic Events") { + NavigationStack { + ForumEventLogView( + store: Store( + initialState: ForumEventLogFeature.State( + id: 0, + type: .topic + ) + ) { + ForumEventLogFeature() + } + ) + } +} diff --git a/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..69f18612 --- /dev/null +++ b/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings @@ -0,0 +1,9 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Forum Event Log" : { + + } + }, + "version" : "1.1" +} \ No newline at end of file From f32125d6b73150f2609f43ec88fe59f480ecdc0b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 17:03:31 +0300 Subject: [PATCH 080/112] [WIP] Forum Event Log --- .../Sources/AppFeature/Navigation/Path.swift | 5 + .../AppFeature/Navigation/StackTab.swift | 15 ++ .../ForumEventLogFeature.swift | 73 ++++++++ .../ForumEventLogScreen.swift | 169 ++++++++++++++++++ .../ForumEventLogView.swift | 66 ------- .../ForumEventLogContextMenuAction.swift | 12 ++ .../Resources/Localizable.xcstrings | 38 +++- Project.swift | 17 ++ 8 files changed, 328 insertions(+), 67 deletions(-) create mode 100644 Modules/Sources/ForumEventLogFeature/ForumEventLogScreen.swift delete mode 100644 Modules/Sources/ForumEventLogFeature/ForumEventLogView.swift create mode 100644 Modules/Sources/ForumEventLogFeature/Models/ForumEventLogContextMenuAction.swift diff --git a/Modules/Sources/AppFeature/Navigation/Path.swift b/Modules/Sources/AppFeature/Navigation/Path.swift index b5029156..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 @@ -73,6 +74,7 @@ public enum Path { case forum(ForumFeature) case announcement(AnnouncementFeature) case topic(TopicFeature) + case eventLog(ForumEventLogFeature) } @Reducer @@ -216,6 +218,9 @@ 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]) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 3f45a9ae..b2a4246e 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -36,6 +36,7 @@ import DeviceSpecificationsFeature import DeviceTypeFeature import TicketsListFeature import TicketFeature +import ForumEventLogFeature @Reducer public struct StackTab: Reducer, Sendable { @@ -287,6 +288,20 @@ 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(.openPost(id))): + state.path.append(.forum(.topic(TopicFeature.State(topicId: 0, goTo: .post(id: id))))) + + case let .eventLog(.delegate(.openTopic(id))): + state.path.append(.forum(.topic(TopicFeature.State(topicId: id, topicName: "", goTo: .first)))) + + case let .eventLog(.delegate(.handleUrl(url))): + return handleDeeplink(url: url, state: &state) + // Announcement case let .announcement(.delegate(.handleUrl(url))): diff --git a/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift index 7a75a2c2..31f77190 100644 --- a/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift +++ b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift @@ -9,6 +9,8 @@ import Foundation import ComposableArchitecture import APIClient import Models +import ToastClient +import PasteboardClient @Reducer public struct ForumEventLogFeature: Reducer, Sendable { @@ -22,6 +24,9 @@ public struct ForumEventLogFeature: Reducer, Sendable { public let id: Int public let type: ForumEventLogType + var eventLog: [ForumEventLog] = [] + var isLoading = false + public init( id: Int, type: ForumEventLogType @@ -37,12 +42,33 @@ public struct ForumEventLogFeature: Reducer, Sendable { 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 openPost(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 @@ -50,6 +76,53 @@ public struct ForumEventLogFeature: Reducer, Sendable { 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: + return .send(.delegate(.openPost(state.id))) + case .topic: + return .send(.delegate(.openTopic(state.id))) + } + + case .copyLink: + let type = state.type == .post ? "p" : "t" + pasteboardClient.copy("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/ForumEventLogView.swift b/Modules/Sources/ForumEventLogFeature/ForumEventLogView.swift deleted file mode 100644 index 129b1384..00000000 --- a/Modules/Sources/ForumEventLogFeature/ForumEventLogView.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// ForumEventLogView.swift -// ForPDA -// -// Created by Xialtal on 14.05.26. -// - -import SwiftUI -import ComposableArchitecture -import Models -import SharedUI - -@ViewAction(for: ForumEventLogFeature.self) -public struct ForumEventLogView: View { - - @Perception.Bindable public var store: StoreOf - @Environment(\.tintColor) private var tintColor - - public init(store: StoreOf) { - self.store = store - } - - public var body: some View { - WithPerceptionTracking { - ScrollView { - Text("Forum Event Log") - } - .background(Color(.Background.primary)) - .onAppear { - send(.onAppear) - } - } - } -} - -// MARK: - Previews - -#Preview("Post Events") { - NavigationStack { - ForumEventLogView( - store: Store( - initialState: ForumEventLogFeature.State( - id: 0, - type: .post - ) - ) { - ForumEventLogFeature() - } - ) - } -} - -#Preview("Topic Events") { - NavigationStack { - ForumEventLogView( - 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 index 69f18612..e6ffc8d4 100644 --- a/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings @@ -1,8 +1,44 @@ { "sourceLanguage" : "en", "strings" : { - "Forum Event Log" : { + "Copy Link" : { + }, + "Go to Post" : { + + }, + "Go to Topic" : { + + }, + "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" diff --git a/Project.swift b/Project.swift index 57ed7e16..8c7aef78 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, @@ -266,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", @@ -1167,6 +1183,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") From 8b29e8b8c45765ba94665d86e715bbc543fea9f6 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 17:04:52 +0300 Subject: [PATCH 081/112] Add topic history to topic stat --- .../AppFeature/Navigation/StackTab.swift | 3 ++ .../ForumStatFeature/ForumStatFeature.swift | 9 +++--- .../ForumStatFeature/ForumStatView.swift | 30 +++++++++---------- .../Resources/Localizable.xcstrings | 10 +++++++ .../Sources/Models/Forum/ForumEventLog.swift | 2 +- 5 files changed, 33 insertions(+), 21 deletions(-) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index b2a4246e..e2d73620 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -277,6 +277,9 @@ public struct StackTab: Reducer, Sendable { case let .topic(.delegate(.openTickets(id))): state.path.append(.tickets(.ticketsList(TicketsListFeature.State(type: .topic(id))))) + 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)))) diff --git a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift index aeb18e87..e65961c7 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,10 +144,8 @@ 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 .send(.delegate(.topicHistoryTapped)) case let .internal(.loadTopicStat(topic)): if state.isUserAuthorized { 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 index 7b750e9f..2e33966b 100644 --- a/Modules/Sources/Models/Forum/ForumEventLog.swift +++ b/Modules/Sources/Models/Forum/ForumEventLog.swift @@ -7,7 +7,7 @@ import Foundation -public struct ForumEventLog: Sendable { +public struct ForumEventLog: Sendable, Equatable, Hashable { public let userId: Int public let userName: String public let userGroup: User.Group From 53f6bb1ce05e430373746677373af3d466746d5f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 17:05:33 +0300 Subject: [PATCH 082/112] Add history option to post context menu --- Modules/Sources/Models/Post/PostToolsMenuAction.swift | 1 + Modules/Sources/SharedUI/Post/PostRowView.swift | 7 +++++++ .../Sources/SharedUI/Resources/Localizable.xcstrings | 10 ++++++++++ Modules/Sources/TopicFeature/TopicFeature.swift | 7 +++++++ Modules/Sources/TopicFeature/TopicScreen.swift | 2 ++ 5 files changed, 27 insertions(+) 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/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/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/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index ea056f73..bb4b4eef 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -188,6 +188,7 @@ public struct TopicFeature: Reducer, Sendable { case handleUrl(URL) case openUser(id: Int) case openTickets(Int) + case openEventLog(Int, ForumEventLogType) case openSearch(SearchOn, ForumInfo?) case openSearchResult(SearchResult) case openedLastPage @@ -250,6 +251,9 @@ public struct TopicFeature: Reducer, Sendable { 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))) @@ -507,6 +511,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: diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index be3f2da5..9a706d4e 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -445,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))) } From 3e8dbd986f18811a26e97cba3f5c6595c8278f05 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 17:26:44 +0300 Subject: [PATCH 083/112] Fix context menu localization for forum event log --- .../Resources/Localizable.xcstrings | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings index e6ffc8d4..66109351 100644 --- a/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumEventLogFeature/Resources/Localizable.xcstrings @@ -2,13 +2,34 @@ "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" : { From 9d03f66c33db9dc6eef983e4bd5dfed19c1ddd29 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 17:27:24 +0300 Subject: [PATCH 084/112] Fix crash on open post/topic history --- Modules/Sources/Models/Forum/ForumEventLogType.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/Models/Forum/ForumEventLogType.swift b/Modules/Sources/Models/Forum/ForumEventLogType.swift index ad08dfa6..aa2f1611 100644 --- a/Modules/Sources/Models/Forum/ForumEventLogType.swift +++ b/Modules/Sources/Models/Forum/ForumEventLogType.swift @@ -5,7 +5,7 @@ // Created by Xialtal on 14.05.26. // -public enum ForumEventLogType: Int, Sendable { +public enum ForumEventLogType: Int, Sendable, Hashable, Equatable { case post = 1 case topic = 0 } From f72ecbad6a85978a2d10f658cef47be5db53674a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 17:27:50 +0300 Subject: [PATCH 085/112] Fix copy link in forum event log context menu --- Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift index 31f77190..cf5fe80e 100644 --- a/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift +++ b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift @@ -96,7 +96,7 @@ public struct ForumEventLogFeature: Reducer, Sendable { case .copyLink: let type = state.type == .post ? "p" : "t" - pasteboardClient.copy("forum/index.php?act=mod&code=90&\(type)=\(state.id)") + 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) From a74eb01b7f6785efe4cc641b6ca4f3b2f3f5fc2c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 17:28:50 +0300 Subject: [PATCH 086/112] Fix post open from context menu in forum event log --- Modules/Sources/AppFeature/Navigation/StackTab.swift | 5 +---- .../Sources/ForumEventLogFeature/ForumEventLogFeature.swift | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index e2d73620..879c04c8 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -296,11 +296,8 @@ public struct StackTab: Reducer, Sendable { case let .eventLog(.delegate(.openUser(id))): state.path.append(.more(.profile(ProfileFeature.State(userId: id)))) - case let .eventLog(.delegate(.openPost(id))): - state.path.append(.forum(.topic(TopicFeature.State(topicId: 0, goTo: .post(id: id))))) - case let .eventLog(.delegate(.openTopic(id))): - state.path.append(.forum(.topic(TopicFeature.State(topicId: id, topicName: "", goTo: .first)))) + state.path.append(.forum(.topic(TopicFeature.State(topicId: id, goTo: .first)))) case let .eventLog(.delegate(.handleUrl(url))): return handleDeeplink(url: url, state: &state) diff --git a/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift index cf5fe80e..fb775baa 100644 --- a/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift +++ b/Modules/Sources/ForumEventLogFeature/ForumEventLogFeature.swift @@ -58,7 +58,6 @@ public struct ForumEventLogFeature: Reducer, Sendable { case delegate(Delegate) public enum Delegate { case openUser(Int) - case openPost(Int) case openTopic(Int) case handleUrl(URL) } @@ -89,7 +88,8 @@ public struct ForumEventLogFeature: Reducer, Sendable { case .goToSubject: switch state.type { case .post: - return .send(.delegate(.openPost(state.id))) + 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))) } From 269a9007ff13c3f17f59731cad7111f457dbca93 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 17:31:08 +0300 Subject: [PATCH 087/112] Close forum stat sheet on topic history tap --- Modules/Sources/ForumStatFeature/ForumStatFeature.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift index e65961c7..ed41b419 100644 --- a/Modules/Sources/ForumStatFeature/ForumStatFeature.swift +++ b/Modules/Sources/ForumStatFeature/ForumStatFeature.swift @@ -145,7 +145,10 @@ public struct ForumStatFeature: Reducer, Sendable { return .none case .view(.loadTopicHistoryButtonTapped): - return .send(.delegate(.topicHistoryTapped)) + return .run { send in + await send(.delegate(.topicHistoryTapped)) + await dismiss() + } case let .internal(.loadTopicStat(topic)): if state.isUserAuthorized { From a71b7ba29611efe65169d4c29dd52108c30a244f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 19:38:32 +0300 Subject: [PATCH 088/112] Add tickets deeplinks support --- Modules/Sources/AppFeature/AppFeature.swift | 6 ++++++ .../Sources/AppFeature/Navigation/StackTab.swift | 6 ++++++ .../Sources/DeeplinkHandler/DeeplinkHandler.swift | 15 +++++++++++++++ .../TicketsListFeature/TicketsListFeature.swift | 9 ++++++--- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 624cff7e..ca8a0a51 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -39,6 +39,8 @@ import CacheClient import DeviceSpecificationsFeature import DeviceTypeFeature import MoreFeature +import TicketsListFeature +import TicketFeature @Reducer public struct AppFeature: Reducer, Sendable { @@ -588,6 +590,10 @@ public struct AppFeature: Reducer, Sendable { case .device(let tag, let subTag): .devDB(.specifications(DeviceSpecificationsFeature.State(tag: tag, subTag: subTag))) } + 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): screen = .forum(.topic(TopicFeature.State(topicId: id!, goTo: goTo))) case let .forum(id, page): diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 879c04c8..7c96458d 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -538,6 +538,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..ecc9fba6 100644 --- a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift +++ b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift @@ -20,6 +20,8 @@ public enum Deeplink { case qms(id: Int) case search(SearchResult) case device(DeviceGoTo) + case ticketsList(offset: Int) + case ticket(Int) } public struct DeeplinkHandler { @@ -237,6 +239,19 @@ public struct DeeplinkHandler { 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 "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 diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index e91fb0be..d10cf808 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -55,6 +55,7 @@ public struct TicketsListFeature: Reducer, Sendable { public var pageNavigation = PageNavigationFeature.State(type: .tickets) public let type: TicketsListType + public let initialOffset: Int var tickets: IdentifiedArrayOf = [] @@ -62,9 +63,11 @@ public struct TicketsListFeature: Reducer, Sendable { var isRefreshing = false public init( - type: TicketsListType + type: TicketsListType, + initialOffset: Int = 0 ) { self.type = type + self.initialOffset = initialOffset } } @@ -138,11 +141,11 @@ public struct TicketsListFeature: Reducer, Sendable { return .none case .view(.onFirstAppear): - return .run { [session = state.userSession] send in + return .run { [offset = 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: 0))) + await send(.internal(.loadTickets(offset: offset))) } case .view(.onNextAppear): From 99b4b36de220a548f9cb8068084ed82cbd363307 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 20:09:33 +0300 Subject: [PATCH 089/112] Add forum event log deeplink support --- Modules/Sources/AppFeature/AppFeature.swift | 3 +++ .../AppFeature/Navigation/StackTab.swift | 3 +++ .../DeeplinkHandler/DeeplinkHandler.swift | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index ca8a0a51..44134105 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -41,6 +41,7 @@ import DeviceTypeFeature import MoreFeature import TicketsListFeature import TicketFeature +import ForumEventLogFeature @Reducer public struct AppFeature: Reducer, Sendable { @@ -598,6 +599,8 @@ public struct AppFeature: Reducer, Sendable { screen = .forum(.topic(TopicFeature.State(topicId: id!, goTo: goTo))) 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/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index 7c96458d..c08a0614 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -529,6 +529,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)))) diff --git a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift index ecc9fba6..2326f4ac 100644 --- a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift +++ b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift @@ -22,6 +22,7 @@ public enum Deeplink { case device(DeviceGoTo) case ticketsList(offset: Int) case ticket(Int) + case eventLog(Int, ForumEventLogType) } public struct DeeplinkHandler { @@ -252,6 +253,24 @@ public struct DeeplinkHandler { 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 From f261cc3ad910c6f8954b71d5579cb57b14440896 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 20:41:03 +0300 Subject: [PATCH 090/112] Improve site search deeplink --- Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift index 2326f4ac..34a09328 100644 --- a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift +++ b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift @@ -168,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 From 8b2772d97b89a0c0c96df76c32f9cdf4fe06fe8e Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 20:41:47 +0300 Subject: [PATCH 091/112] Fix initial offset in tickets list --- .../TicketsListFeature/TicketsListFeature.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift index d10cf808..be624123 100644 --- a/Modules/Sources/TicketsListFeature/TicketsListFeature.swift +++ b/Modules/Sources/TicketsListFeature/TicketsListFeature.swift @@ -61,6 +61,7 @@ public struct TicketsListFeature: Reducer, Sendable { var isLoading = false var isRefreshing = false + var isFirstAppear = false public init( type: TicketsListType, @@ -141,11 +142,12 @@ public struct TicketsListFeature: Reducer, Sendable { return .none case .view(.onFirstAppear): - return .run { [offset = state.initialOffset, session = state.userSession] send in + 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: offset))) + await send(.internal(.loadTickets(offset: initialOffset))) } case .view(.onNextAppear): @@ -236,6 +238,12 @@ public struct TicketsListFeature: Reducer, Sendable { 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 From 9309044990850a6af45abca706001aa8ca86402c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 21:12:37 +0300 Subject: [PATCH 092/112] Improve comment edit text init --- Modules/Sources/TicketFeature/TicketFeature.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/TicketFeature/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index bfe314f3..61b6fae0 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -193,7 +193,11 @@ public struct TicketFeature: Reducer, Sendable { switch action { case .edit(let commentId): if let comment = state.ticket?.comments.first(where: { $0.id == commentId }) { - state.alertInput = comment.content + if let range = comment.content.range(of: "[na]") { + state.alertInput = String(comment.content[range.upperBound...]) + } else { + state.alertInput = comment.content + } } state.destination = .editComment(commentId) From f8b0ab2c64b02456318ea250196f98fca5a6bf8a Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 21:25:06 +0300 Subject: [PATCH 093/112] Remove [na] tag from ticket comment content --- Modules/Sources/TicketFeature/TicketScreen.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index 9eea673b..e17d5cab 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -410,7 +410,7 @@ extension Ticket.Comment { var contentAttributed: NSAttributedString? { guard !content.isEmpty else { return nil } return BBRenderer(baseAttributes: [.font: UIFont.preferredFont(forTextStyle: .subheadline)]) - .render(text: content) + .render(text: content.replacingOccurrences(of: "[na]", with: "")) } } From e1d7048cc43d5afdbe568611e52b1d2d195324af Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 22:30:33 +0300 Subject: [PATCH 094/112] Add reputation vote modified badge --- .../Models/Profile/ReputationVote.swift | 27 ++++++++++++-- .../Models/Profile/ReputationVotes.swift | 4 +-- .../Parsers/ReputationParser.swift | 4 ++- .../ReputationFeature/ReputationScreen.swift | 35 +++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/Models/Profile/ReputationVote.swift b/Modules/Sources/Models/Profile/ReputationVote.swift index 7edb5655..e0b6d74d 100644 --- a/Modules/Sources/Models/Profile/ReputationVote.swift +++ b/Modules/Sources/Models/Profile/ReputationVote.swift @@ -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/ParsingClient/Parsers/ReputationParser.swift b/Modules/Sources/ParsingClient/Parsers/ReputationParser.swift index 22adbbc5..523bb2bd 100644 --- a/Modules/Sources/ParsingClient/Parsers/ReputationParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/ReputationParser.swift @@ -73,7 +73,8 @@ 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 } @@ -81,6 +82,7 @@ public struct ReputationParser { return ReputationVote.VoteModified( userId: userId, userName: userName, + modifiedAt: Date(timeIntervalSince1970: TimeInterval(modifiedAt)), isDenied: flag & 2 != 0 ) } diff --git a/Modules/Sources/ReputationFeature/ReputationScreen.swift b/Modules/Sources/ReputationFeature/ReputationScreen.swift index 6d5bf7ad..bd85d07c 100644 --- a/Modules/Sources/ReputationFeature/ReputationScreen.swift +++ b/Modules/Sources/ReputationFeature/ReputationScreen.swift @@ -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)) @@ -195,6 +205,31 @@ public struct ReputationScreen: View { } } } + + // 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 From 232c2fdf9ecbf8904a63b0918dc03a6a5bc9857f Mon Sep 17 00:00:00 2001 From: Xialtal Date: Fri, 15 May 2026 22:45:05 +0300 Subject: [PATCH 095/112] Improve reputation localizable --- .../Resources/Localizable.xcstrings | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings b/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings index ce2789b0..afd2409d 100644 --- a/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ReputationFeature/Resources/Localizable.xcstrings @@ -21,6 +21,16 @@ } } }, + "Denied" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменено" + } + } + } + }, "Help other users on the forum and get reputation" : { "localizations" : { "ru" : { @@ -133,6 +143,16 @@ } } }, + "Restored" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстановлено" + } + } + } + }, "Something went wrong while loading reputation :(" : { "localizations" : { "ru" : { From 9cbbf263bc82f96a7f132d258d560c9e74e4fdb0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 11:42:53 +0300 Subject: [PATCH 096/112] Add change reputation option to profile context menu --- .../Analytics/ProfileFeature+Analytics.swift | 2 +- .../Models/ProfileContextMenuAction.swift | 1 + .../ProfileFeature/ProfileFeature.swift | 12 +++- .../ProfileFeature/ProfileScreen.swift | 55 ++++++++++++------- .../Resources/Localizable.xcstrings | 10 ++++ Project.swift | 1 + 6 files changed, 57 insertions(+), 24 deletions(-) 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/Project.swift b/Project.swift index 8c7aef78..374cd48f 100644 --- a/Project.swift +++ b/Project.swift @@ -427,6 +427,7 @@ let project = Project( .Internal.NotificationsClient, .Internal.ParsingClient, .Internal.PersistenceKeys, + .Internal.ReputationChangeFeature, .Internal.SharedUI, .Internal.ToastClient, .Internal.FormFeature, From 8cef5261d503f73ab131143d97f969511f07d051 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 12:11:37 +0300 Subject: [PATCH 097/112] Fix vote label & symbol in ReputationVote model --- Modules/Sources/Models/Profile/ReputationVote.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/Models/Profile/ReputationVote.swift b/Modules/Sources/Models/Profile/ReputationVote.swift index e0b6d74d..292d8dd0 100644 --- a/Modules/Sources/Models/Profile/ReputationVote.swift +++ b/Modules/Sources/Models/Profile/ReputationVote.swift @@ -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 } } From 5b9d3ce5286e28f2a547bdfa8857cab52819c11b Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 12:57:28 +0300 Subject: [PATCH 098/112] Fix vote colors in reputation --- Modules/Sources/ReputationFeature/ReputationScreen.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/ReputationFeature/ReputationScreen.swift b/Modules/Sources/ReputationFeature/ReputationScreen.swift index bd85d07c..9d22ff76 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) } From 76935066128ab8b932278fe6c0646c24c8dfefc7 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 13:52:20 +0300 Subject: [PATCH 099/112] Add modify reputation endpoint --- Modules/Sources/APIClient/APIClient.swift | 15 +++++++++++++++ .../ReputationModifyActionType+Extension.swift | 18 ++++++++++++++++++ .../Profile/ReputationModifyActionType.swift | 11 +++++++++++ 3 files changed, 44 insertions(+) create mode 100644 Modules/Sources/APIClient/Models/Extensions/ReputationModifyActionType+Extension.swift create mode 100644 Modules/Sources/Models/Profile/ReputationModifyActionType.swift diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 181678c6..7c9b4b6e 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 @@ -260,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, @@ -727,6 +739,9 @@ extension APIClient: DependencyKey { getReputationVotes: { _ in return .mock }, + modifyReputation: { _, _ in + return true + }, changeReputation: { _ in return .success }, 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/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 +} From a47e6f82c221f90b0c9412e2933613396c354f6c Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 14:16:42 +0300 Subject: [PATCH 100/112] Add delete/restore option to reputation context menu --- .../Models/Profile/ReputationVote.swift | 2 +- .../ReputationFeature+Analytics.swift | 2 +- .../ReputationVoteContextMenuAction.swift | 5 +- .../ReputationFeature/ReputationFeature.swift | 94 +++++++++++++++++-- .../ReputationFeature/ReputationScreen.swift | 23 ++++- .../Resources/Localizable.xcstrings | 80 ++++++++++++++++ Project.swift | 1 + 7 files changed, 192 insertions(+), 15 deletions(-) diff --git a/Modules/Sources/Models/Profile/ReputationVote.swift b/Modules/Sources/Models/Profile/ReputationVote.swift index 292d8dd0..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 diff --git a/Modules/Sources/ReputationFeature/Analytics/ReputationFeature+Analytics.swift b/Modules/Sources/ReputationFeature/Analytics/ReputationFeature+Analytics.swift index cd792d5c..0da8464c 100644 --- a/Modules/Sources/ReputationFeature/Analytics/ReputationFeature+Analytics.swift +++ b/Modules/Sources/ReputationFeature/Analytics/ReputationFeature+Analytics.swift @@ -43,7 +43,7 @@ extension ReputationFeature { analytics.log(ReputationEvent.voteMenuComplainTapped(voteId)) case .goToAuthor(let profileId): analytics.log(ReputationEvent.voteMenuGoToAuthorTapped(profileId)) - case .delete, .restore: + case .modify: // MARK: Moderator tools are skip analytics break } diff --git a/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift b/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift index 75706c2e..0c01ae5e 100644 --- a/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift +++ b/Modules/Sources/ReputationFeature/Models/ReputationVoteContextMenuAction.swift @@ -5,9 +5,10 @@ // Created by Xialtal on 14.05.26. // +import Models + public enum ReputationVoteContextMenuAction { case report(Int) - case delete(Int) - case restore(Int) + case modify(Int, ReputationModifyActionType) case goToAuthor(Int) } diff --git a/Modules/Sources/ReputationFeature/ReputationFeature.swift b/Modules/Sources/ReputationFeature/ReputationFeature.swift index ef8a062e..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 @@ -71,6 +79,14 @@ public struct ReputationFeature: Reducer, Sendable { 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 } @@ -97,6 +113,9 @@ public struct ReputationFeature: Reducer, Sendable { public enum Internal { case loadData case historyResponse(Result) + case modifyResponse(Result<(Int, ReputationModifyActionType, Bool), any Error>) + + case initUserSessionInfo(User) } case delegate(Delegate) @@ -117,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 @@ -138,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 } @@ -173,10 +206,8 @@ public struct ReputationFeature: Reducer, Sendable { ) state.destination = .report(feature) - case .delete(let voteId): - break - case .restore(let voteId): - break + case .modify(let voteId, let type): + state.destination = .alert(.modifyVoteConfirmation(voteId: voteId, type: type)) case .goToAuthor(let profileId): return .send(.delegate(.openProfile(profileId: profileId))) @@ -216,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 } @@ -224,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 9d22ff76..bd66c2cf 100644 --- a/Modules/Sources/ReputationFeature/ReputationScreen.swift +++ b/Modules/Sources/ReputationFeature/ReputationScreen.swift @@ -184,7 +184,7 @@ public struct ReputationScreen: View { if store.isUserAuthorized { Menu { - MenuButtons(voteId: vote.id, authorId: authorId) + MenuButtons(voteId: vote.id, authorId: authorId, modified: vote.modified) } label: { Image(systemSymbol: .ellipsis) .foregroundStyle(Color(.Labels.teritary)) @@ -201,7 +201,7 @@ public struct ReputationScreen: View { .background(Color(.Background.primary)) .contextMenu { if store.isUserAuthorized { - MenuButtons(voteId: vote.id, authorId: authorId) + MenuButtons(voteId: vote.id, authorId: authorId, modified: vote.modified) } } } @@ -270,7 +270,7 @@ 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, @@ -284,6 +284,23 @@ public struct ReputationScreen: View { 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 afd2409d..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,16 @@ } } }, + "Delete" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отменить" + } + } + } + }, "Denied" : { "localizations" : { "ru" : { @@ -82,6 +112,16 @@ } } }, + "No" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет" + } + } + } + }, "No reputation history" : { "localizations" : { "ru" : { @@ -143,6 +183,36 @@ } } }, + "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" : { @@ -182,6 +252,16 @@ } } } + }, + "Yes" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Да" + } + } + } } }, "version" : "1.0" diff --git a/Project.swift b/Project.swift index 374cd48f..f8fb1ab8 100644 --- a/Project.swift +++ b/Project.swift @@ -503,6 +503,7 @@ let project = Project( dependencies: [ .Internal.AnalyticsClient, .Internal.APIClient, + .Internal.CacheClient, .Internal.Models, .Internal.SharedUI, .Internal.FormFeature, From 6d50eede12100d9eaeaf2ec6eef9675ee3ef4bf0 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 14:32:51 +0300 Subject: [PATCH 101/112] Improve ReputationChangeRequest model --- .../APIClient/Requests/ReputationChangeRequest.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift b/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift index de4c07ee..94430066 100644 --- a/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift +++ b/Modules/Sources/APIClient/Requests/ReputationChangeRequest.swift @@ -22,16 +22,12 @@ public struct ReputationChangeRequest: Sendable { public enum ChangeActionType: Sendable { case up case down - case delete - case restore } nonisolated var transferVoteType: MemberReputationRequest.ActionType { switch action { - case .up: .plus - case .down: .minus - case .delete: .delete - case .restore: .restore + case .up: .plus + case .down: .minus } } From cbefc672df92719139678e62f717af4059a99492 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 17:20:50 +0300 Subject: [PATCH 102/112] Rework delegate for ForumMoveFeature --- Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift index 0a3623e1..cc7a2f75 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) } } @@ -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))))) From 567cd88a41626ea1757ba67f8e6267ff687c1692 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 17:22:32 +0300 Subject: [PATCH 103/112] Add success toast after moving post --- Modules/Sources/AppFeature/Navigation/StackTab.swift | 3 +++ .../TopicFeature/Resources/Localizable.xcstrings | 10 ++++++++++ Modules/Sources/TopicFeature/TopicFeature.swift | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index c08a0614..aa0fc0b2 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -277,6 +277,9 @@ public struct StackTab: Reducer, Sendable { 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)))) diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index 9e97ec5e..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" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index bb4b4eef..091d0131 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) @@ -187,6 +188,7 @@ 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?) @@ -248,6 +250,12 @@ 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))) From 1a2024cf85d5525ee3d3e27f54608f623bb736ab Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 17:22:43 +0300 Subject: [PATCH 104/112] Add success toast after moving topic --- Modules/Sources/ForumFeature/ForumFeature.swift | 9 ++++++++- .../ForumFeature/Resources/Localizable.xcstrings | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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" : { From beed958c220f1f55289be1a32754dbe84b1e40b2 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sat, 16 May 2026 23:09:44 +0300 Subject: [PATCH 105/112] Move ticket comment content from BBBuilder to TopicBuilder --- Modules/Sources/Models/Ticket/Ticket.swift | 2 +- .../TicketFeature/Models/UITicket.swift | 36 +++++++++++++++++++ .../Sources/TicketFeature/TicketFeature.swift | 18 +++++++--- .../Sources/TicketFeature/TicketScreen.swift | 35 +++++++++--------- Project.swift | 2 +- 5 files changed, 67 insertions(+), 26 deletions(-) create mode 100644 Modules/Sources/TicketFeature/Models/UITicket.swift diff --git a/Modules/Sources/Models/Ticket/Ticket.swift b/Modules/Sources/Models/Ticket/Ticket.swift index 479fc932..2491bcdf 100644 --- a/Modules/Sources/Models/Ticket/Ticket.swift +++ b/Modules/Sources/Models/Ticket/Ticket.swift @@ -8,7 +8,7 @@ import Foundation public struct Ticket: Sendable, Equatable { - public var info: TicketInfo + public let info: TicketInfo public let comments: [Comment] public struct Comment: Sendable, Equatable, Identifiable { 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/TicketFeature.swift b/Modules/Sources/TicketFeature/TicketFeature.swift index 61b6fae0..c193c2da 100644 --- a/Modules/Sources/TicketFeature/TicketFeature.swift +++ b/Modules/Sources/TicketFeature/TicketFeature.swift @@ -14,6 +14,7 @@ import PersistenceKeys import ToastClient import CacheClient import TicketStatusHistoryFeature +import TopicBuilder @Reducer public struct TicketFeature: Reducer, Sendable { @@ -67,7 +68,7 @@ public struct TicketFeature: Reducer, Sendable { public let id: Int - var ticket: Ticket? + var ticket: UITicket? var isLoading = false var isRefreshing = false @@ -193,10 +194,10 @@ public struct TicketFeature: Reducer, Sendable { switch action { case .edit(let commentId): if let comment = state.ticket?.comments.first(where: { $0.id == commentId }) { - if let range = comment.content.range(of: "[na]") { - state.alertInput = String(comment.content[range.upperBound...]) + if let range = comment.comment.content.range(of: "[na]") { + state.alertInput = String(comment.comment.content[range.upperBound...]) } else { - state.alertInput = comment.content + state.alertInput = comment.comment.content } } state.destination = .editComment(commentId) @@ -297,7 +298,14 @@ public struct TicketFeature: Reducer, Sendable { } case let .internal(.ticketResponse(.success(response))): - state.ticket = 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 diff --git a/Modules/Sources/TicketFeature/TicketScreen.swift b/Modules/Sources/TicketFeature/TicketScreen.swift index e17d5cab..c9d58a53 100644 --- a/Modules/Sources/TicketFeature/TicketScreen.swift +++ b/Modules/Sources/TicketFeature/TicketScreen.swift @@ -10,9 +10,9 @@ import ComposableArchitecture import Models import SharedUI import SFSafeSymbols -import BBBuilder import RichTextKit import TicketStatusHistoryFeature +import TopicBuilder @ViewAction(for: TicketFeature.self) public struct TicketScreen: View { @@ -192,7 +192,7 @@ public struct TicketScreen: View { // MARK: - Comment @ViewBuilder - private func Comment(_ comment: Ticket.Comment) -> some View { + private func Comment(_ comment: UITicket.HybridComment) -> some View { HStack(alignment: .top) { Image(systemSymbol: .bubbleLeft) .frame(width: 32, height: 32) @@ -200,9 +200,9 @@ public struct TicketScreen: View { VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) { Button { - send(.commentAuthorButtonTapped(comment.authorId)) + send(.commentAuthorButtonTapped(comment.comment.authorId)) } label: { - Text(verbatim: comment.authorName) + Text(verbatim: comment.comment.authorName) .foregroundStyle(tintColor) .underline() } @@ -213,14 +213,14 @@ public struct TicketScreen: View { .font(.subheadline) HStack { - Text(verbatim: comment.createdAt.formatted()) + Text(verbatim: comment.comment.createdAt.formatted()) .font(.caption) .foregroundStyle(Color(.Labels.quaternary)) Spacer() WithPerceptionTracking { - if let session = store.userSession, session.userId == comment.authorId { + if let session = store.userSession, session.userId == comment.comment.authorId { CommentContextMenu(id: comment.id) } } @@ -261,13 +261,17 @@ public struct TicketScreen: View { // MARK: - Attributed Content @ViewBuilder - private func AttributedContent(_ comment: Ticket.Comment) -> some View { - if let content = comment.contentAttributed { - RichText(text: content, isSelectable: false, onUrlTap: { url in - send(.urlTapped(url)) - }) + 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.content) + Text(verbatim: comment.comment.content) .font(.subheadline) } } @@ -406,13 +410,6 @@ extension TicketStatus { } } -extension Ticket.Comment { - var contentAttributed: NSAttributedString? { - guard !content.isEmpty else { return nil } - return BBRenderer(baseAttributes: [.font: UIFont.preferredFont(forTextStyle: .subheadline)]) - .render(text: content.replacingOccurrences(of: "[na]", with: "")) - } -} // MARK: - Previews diff --git a/Project.swift b/Project.swift index f8fb1ab8..b9ef270d 100644 --- a/Project.swift +++ b/Project.swift @@ -556,7 +556,6 @@ let project = Project( .feature( name: "TicketFeature", dependencies: [ - .Internal.BBBuilder, .Internal.CacheClient, .Internal.Models, .Internal.PasteboardClient, @@ -565,6 +564,7 @@ let project = Project( .Internal.TicketClient, .Internal.TicketStatusHistoryFeature, .Internal.ToastClient, + .Internal.TopicBuilder, .SPM.RichTextKit, .SPM.SFSafeSymbols, .SPM.TCA From 4e35e2733eb23796a8d7d0b8f17bf04e77507abc Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 17 May 2026 12:07:21 +0300 Subject: [PATCH 106/112] Improve ForumJump model --- Modules/Sources/Models/Forum/ForumJump.swift | 8 ++++---- Modules/Sources/Models/Forum/TopicPostsFilter.swift | 2 +- Modules/Sources/ParsingClient/Parsers/ForumParser.swift | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) 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..1629d74c 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 diff --git a/Modules/Sources/ParsingClient/Parsers/ForumParser.swift b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift index 7c933134..7203e516 100644 --- a/Modules/Sources/ParsingClient/Parsers/ForumParser.swift +++ b/Modules/Sources/ParsingClient/Parsers/ForumParser.swift @@ -119,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) From bc3d7012ec514b7821ed4c5534c7d9f6777a31b3 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 17 May 2026 13:04:03 +0300 Subject: [PATCH 107/112] Add posts filter support for topic deeplinks --- Modules/Sources/AppFeature/AppFeature.swift | 4 +-- .../AppFeature/Navigation/StackTab.swift | 6 ++-- .../DeeplinkHandler/DeeplinkHandler.swift | 28 +++++++++++-------- .../ForumMoveFeature/ForumMoveFeature.swift | 2 +- .../Models/Forum/TopicPostsFilter.swift | 24 ++++++++++++++++ .../Sources/TopicFeature/TopicFeature.swift | 16 +++++++---- 6 files changed, 57 insertions(+), 23 deletions(-) diff --git a/Modules/Sources/AppFeature/AppFeature.swift b/Modules/Sources/AppFeature/AppFeature.swift index 44134105..c9a8ab67 100644 --- a/Modules/Sources/AppFeature/AppFeature.swift +++ b/Modules/Sources/AppFeature/AppFeature.swift @@ -595,8 +595,8 @@ public struct AppFeature: Reducer, Sendable { 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): - screen = .forum(.topic(TopicFeature.State(topicId: id!, goTo: goTo))) + 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): diff --git a/Modules/Sources/AppFeature/Navigation/StackTab.swift b/Modules/Sources/AppFeature/Navigation/StackTab.swift index aa0fc0b2..f3cecae7 100644 --- a/Modules/Sources/AppFeature/Navigation/StackTab.swift +++ b/Modules/Sources/AppFeature/Navigation/StackTab.swift @@ -492,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 { @@ -508,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 { @@ -518,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 diff --git a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift index 34a09328..d0282a98 100644 --- a/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift +++ b/Modules/Sources/DeeplinkHandler/DeeplinkHandler.swift @@ -14,7 +14,7 @@ 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) @@ -96,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": @@ -185,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 @@ -236,7 +240,7 @@ 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)) } @@ -379,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/ForumMoveFeature/ForumMoveFeature.swift b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift index cc7a2f75..5db912cc 100644 --- a/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift +++ b/Modules/Sources/ForumMoveFeature/ForumMoveFeature.swift @@ -117,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 diff --git a/Modules/Sources/Models/Forum/TopicPostsFilter.swift b/Modules/Sources/Models/Forum/TopicPostsFilter.swift index 1629d74c..ba2ab50f 100644 --- a/Modules/Sources/Models/Forum/TopicPostsFilter.swift +++ b/Modules/Sources/Models/Forum/TopicPostsFilter.swift @@ -15,4 +15,28 @@ public enum TopicPostsFilter: Int, Sendable, Codable, Hashable, Identifiable, Ca 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/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 091d0131..b2709f62 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -103,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 @@ -124,11 +124,13 @@ 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 self.topicName = topicName self.goTo = goTo + self.postsFilter = postsFilter ?? .exceptDeleted // TODO: Get from settings self.destination = destination self.floatingNavigation = _appSettings.floatingNavigation.wrappedValue @@ -173,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]]) @@ -728,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 @@ -792,11 +795,14 @@ public struct TopicFeature: Reducer, Sendable { 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)) From 2e6145db30983c6c5f985929cae8da38854200c2 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 17 May 2026 13:13:42 +0300 Subject: [PATCH 108/112] Add show all posts in topic by default to AppSettings --- Modules/Sources/Models/Settings/AppSettings.swift | 6 ++++++ Modules/Sources/TopicFeature/TopicFeature.swift | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/Models/Settings/AppSettings.swift b/Modules/Sources/Models/Settings/AppSettings.swift index 103fcdb1..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 @@ -45,6 +46,7 @@ public struct AppSettings: Sendable, Equatable, Codable { articlesListRowType: ArticleListRowType, bookmarksListRowType: ArticleListRowType, startPage: AppTab, + topicShowAllPostsFilter: Bool, topicOpeningStrategy: TopicOpeningStrategy, appColorScheme: AppColorScheme, backgroundTheme: BackgroundTheme, @@ -69,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 @@ -96,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 @@ -125,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, @@ -148,6 +153,7 @@ public extension AppSettings { articlesListRowType: .short, bookmarksListRowType: .short, startPage: .articles, + topicShowAllPostsFilter: false, topicOpeningStrategy: .first, appColorScheme: .system, backgroundTheme: .blue, diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index b2709f62..b2a39132 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -130,9 +130,9 @@ public struct TopicFeature: Reducer, Sendable { self.topicId = topicId self.topicName = topicName self.goTo = goTo - self.postsFilter = postsFilter ?? .exceptDeleted // TODO: Get from settings 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 From ef639c90819411b35c0de2b4f409ce91ecd0cf03 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 17 May 2026 13:34:15 +0300 Subject: [PATCH 109/112] Improve ReputationParser --- .../ParsingClient/Parsers/ReputationParser.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/ParsingClient/Parsers/ReputationParser.swift b/Modules/Sources/ParsingClient/Parsers/ReputationParser.swift index 523bb2bd..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), @@ -81,7 +81,7 @@ public struct ReputationParser { return ReputationVote.VoteModified( userId: userId, - userName: userName, + userName: userName.convertCodes(), modifiedAt: Date(timeIntervalSince1970: TimeInterval(modifiedAt)), isDenied: flag & 2 != 0 ) @@ -98,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)) } } } From 4c01f06e7a810fa63b2671c334a1a2307bbb8609 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 17 May 2026 17:28:13 +0300 Subject: [PATCH 110/112] Add show all posts in topic option to settings --- .../Resources/Localizable.xcstrings | 31 ++++++++++++++++++ .../NavigationSettingsFeature.swift | 30 +++++++++++++++-- .../NavigationSettingsScreen.swift | 32 ++++++++++++++++--- 3 files changed, 86 insertions(+), 7 deletions(-) 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) } } From c012dbdc70268d068aa492160e5054793513fb33 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 17 May 2026 20:42:48 +0300 Subject: [PATCH 111/112] Bump API version --- Tuist/Package.resolved | 6 +++--- Tuist/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tuist/Package.resolved b/Tuist/Package.resolved index e2dcbc5a..0ff51adc 100644 --- a/Tuist/Package.resolved +++ b/Tuist/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e44a7bdc3addea29cbd54922a0caa19daac72cc2bfc6b032324bd764465fb6ca", + "originHash" : "f7dc84744279a1fac722da64e3a355db6d2204ce21d836f34a5c4b57f36e0fd8", "pins" : [ { "identity" : "activityindicatorview", @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/SubvertDev/PDAPI_SPM.git", "state" : { - "revision" : "248d59880d875479956297e25a1e9e36dee22bab", - "version" : "0.8.2" + "revision" : "3fc0b24decfe9f550eebc0cb011260d654d21337", + "version" : "0.8.3" } }, { diff --git a/Tuist/Package.swift b/Tuist/Package.swift index 60c9972f..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.2"), + .package(url: "https://github.com/SubvertDev/PDAPI_SPM.git", exact: "0.8.3"), .package(url: "https://github.com/SubvertDev/RichTextKit.git", branch: "main"), ] ) From a06c5dfab3d524091a98e87a99a20bc0a3236eb4 Mon Sep 17 00:00:00 2001 From: Xialtal Date: Sun, 17 May 2026 20:46:14 +0300 Subject: [PATCH 112/112] Fix posts filter for jump forum endpoint --- Modules/Sources/APIClient/APIClient.swift | 2 +- .../Sources/APIClient/Requests/JumpForumRequest.swift | 7 ++++--- Modules/Sources/QMSFeature/QMSFeature.swift | 10 ++++++++-- Modules/Sources/TopicFeature/TopicFeature.swift | 4 ++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index 7c9b4b6e..5d908165 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -355,7 +355,7 @@ extension APIClient: DependencyKey { 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) 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/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/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index b2a39132..ce2162f8 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -790,8 +790,8 @@ 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