diff --git a/Modules/Sources/APIClient/APIClient.swift b/Modules/Sources/APIClient/APIClient.swift index eae25624..0fc65597 100644 --- a/Modules/Sources/APIClient/APIClient.swift +++ b/Modules/Sources/APIClient/APIClient.swift @@ -65,6 +65,7 @@ public struct APIClient: Sendable { public var getTopic: @Sendable (_ id: Int, _ page: Int, _ perPage: Int, _ postsFilter: TopicPostsFilter) async throws -> Topic public var modifyForum: @Sendable (_ ids: [Int], _ type: ForumModifyType, _ isUndo: Bool) async throws -> Bool public var moveTopic: @Sendable (_ id: Int, _ toForumId: Int, _ saveLink: Bool) async throws -> Bool + public var editTopic: @Sendable (_ data: TopicEditRequest) async throws -> TopicEditResponse public var getTopicViewers: @Sendable (_ id: Int) async throws -> TopicViewers public var setTopicCurator: @Sendable (_ topicId: Int, _ userId: Int, _ reason: String) async throws -> Bool public var getTemplate: @Sendable (_ request: ForumTemplateRequest, _ isTopic: Bool) async throws -> [FormFieldType] @@ -384,6 +385,17 @@ extension APIClient: DependencyKey { let status = Int(response.getResponseStatus())! return status == 0 }, + editTopic: { data in + let request = PDAPI.TopicEditRequest( + id: data.id, + title: data.title, + description: data.description, + poll: data.poll + ) + let response = try await api.send(ForumCommand.Topic.edit(data: request)) + let status = Int(response.getResponseStatus())! + return TopicEditResponse(rawValue: status) + }, getTopicViewers: { topicId in let command = MemberCommand.sessions( pageType: .topic, @@ -742,6 +754,9 @@ extension APIClient: DependencyKey { moveTopic: { _, _, _ in return true }, + editTopic: { _ in + return .success + }, getTopicViewers: { _ in return .mock }, diff --git a/Modules/Sources/APIClient/Requests/TopicEditRequest.swift b/Modules/Sources/APIClient/Requests/TopicEditRequest.swift new file mode 100644 index 00000000..90eaccfc --- /dev/null +++ b/Modules/Sources/APIClient/Requests/TopicEditRequest.swift @@ -0,0 +1,27 @@ +// +// TopicEditRequest.swift +// ForPDA +// +// Created by Xialtal on 29.03.26. +// + +import Models + +public struct TopicEditRequest { + public let id: Int + public let title: String + public let description: String + public let poll: PDAPIDocument? + + public init( + id: Int, + title: String, + description: String, + poll: PDAPIDocument? + ) { + self.id = id + self.title = title + self.description = description + self.poll = poll + } +} diff --git a/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift b/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift index 24ec77f8..ab778974 100644 --- a/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/ForumEvent.swift @@ -21,6 +21,7 @@ public enum ForumEvent: Event { case menuOpen(Int) case menuGoToEnd(Int) + case menuEdit(Int) case menuStat(Int, Bool) case menuMarkRead(Int, Bool) @@ -60,6 +61,9 @@ public enum ForumEvent: Event { case let .menuGoToEnd(id): return ["id": String(id)] + case let .menuEdit(id): + return ["id": String(id)] + case let .menuStat(id, isForum): return ["id": String(id), "isForum": String(isForum)] diff --git a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift index b5956563..e4b23ad0 100644 --- a/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift +++ b/Modules/Sources/AnalyticsClient/Events/TopicEvent.swift @@ -21,6 +21,7 @@ public enum TopicEvent: Event { case menuGoToEnd case menuSetFavorite case menuAboutTopic + case menuEditTopic case menuWritePost case menuWritePostWithTemplate diff --git a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift index 6a8a38fc..44b53fe5 100644 --- a/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift +++ b/Modules/Sources/ForumFeature/Analytics/ForumFeature+Analytics.swift @@ -66,6 +66,8 @@ extension ForumFeature { analytics.log(ForumEvent.menuOpen(topic.id)) case .goToEnd: analytics.log(ForumEvent.menuGoToEnd(topic.id)) + case .edit: + analytics.log(ForumEvent.menuEdit(topic.id)) } case let .view(.contextCommonMenu(option, id, isForum)): diff --git a/Modules/Sources/ForumFeature/ForumFeature.swift b/Modules/Sources/ForumFeature/ForumFeature.swift index 7cdb6015..aec2bdf5 100644 --- a/Modules/Sources/ForumFeature/ForumFeature.swift +++ b/Modules/Sources/ForumFeature/ForumFeature.swift @@ -18,6 +18,7 @@ import ToastClient import FormFeature import ForumStatFeature import ForumMoveFeature +import TopicEditFeature @Reducer public struct ForumFeature: Reducer, Sendable { @@ -28,6 +29,7 @@ public struct ForumFeature: Reducer, Sendable { public enum Localization { static let linkCopied = LocalizedStringResource("Link copied", bundle: .module) + static let topicEdited = LocalizedStringResource("The topic has been edited", bundle: .module) static let markAsReadSuccess = LocalizedStringResource("Marked as read", bundle: .module) } @@ -59,6 +61,7 @@ public struct ForumFeature: Reducer, Sendable { case form(FormFeature) case move(ForumMoveFeature) case stat(ForumStatFeature) + case edit(TopicEditFeature) } // MARK: - State @@ -170,6 +173,11 @@ public struct ForumFeature: Reducer, Sendable { case let .destination(.presented(.stat(.delegate(.userTapped(id))))): return .send(.delegate(.openUser(id: id))) + case .destination(.presented(.edit(.delegate(.topicEdited)))): + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.topicEdited, haptic: .success)) + } + case .destination, .pageNavigation: return .none @@ -245,6 +253,16 @@ public struct ForumFeature: Reducer, Sendable { await send(.delegate(.openTopic(id: topic.id, name: topic.name, goTo: .unread))) await send(.internal(.refresh)) } + + case .edit: + state.destination = .edit(TopicEditFeature.State( + id: topic.id, + flag: topic.flag, + title: topic.name, + description: topic.description, + supportsPoll: false + )) + return .none } case let .view(.contextTopicToolsMenu(action)): diff --git a/Modules/Sources/ForumFeature/ForumScreen.swift b/Modules/Sources/ForumFeature/ForumScreen.swift index ae878eb8..454de33c 100644 --- a/Modules/Sources/ForumFeature/ForumScreen.swift +++ b/Modules/Sources/ForumFeature/ForumScreen.swift @@ -15,6 +15,7 @@ import BBBuilder import FormFeature import ForumStatFeature import ForumMoveFeature +import TopicEditFeature @ViewAction(for: ForumFeature.self) public struct ForumScreen: View { @@ -25,10 +26,6 @@ public struct ForumScreen: View { @Environment(\.tintColor) private var tintColor @State private var navigationMinimized = false - private var title: String { - return store.forumName ?? String(localized: "Loading...", bundle: .module) - } - private var shouldShowInlineNavigation: Bool { let isAnyFloatingNavigationEnabled = store.appSettings.floatingNavigation || store.appSettings.experimentalFloatingNavigation return store.pageNavigation.shouldShow && (!isLiquidGlass || !isAnyFloatingNavigationEnabled) @@ -87,13 +84,7 @@ public struct ForumScreen: View { } .animation(.default, value: store.forum) .animation(.default, value: store.sectionsExpandState) - .navigationTitle(Text(title)) - ._toolbarTitleDisplayMode(.large) - .fullScreenCover(item: $store.scope(state: \.$destination, action: \.destination).form) { store in - NavigationStack { - FormScreen(store: store) - } - } + .navigations(store: store) .safeAreaInset(edge: .bottom) { if shouldShowFloatingNavigation { PageNavigation( @@ -104,17 +95,6 @@ public struct ForumScreen: View { .padding(.bottom, 8) } } - .sheet(item: $store.scope(state: \.$destination, action: \.destination).stat) { store in - NavigationStack { - ForumStatView(store: store) - } - } - .fittedSheet( - item: $store.scope(state: \.$destination, action: \.destination).move, - embedIntoNavStack: true - ) { store in - ForumMoveView(store: store) - } .toolbar { ToolbarItem { Button { @@ -267,6 +247,12 @@ public struct ForumScreen: View { ContextButton(text: LocalizedStringResource("Go To End", bundle: .module), symbol: .chevronRight2) { send(.contextTopicMenu(.goToEnd, topic)) } + + if topic.canEdit { + ContextButton(text: LocalizedStringResource("Edit", bundle: .module), symbol: .squareAndPencil) { + send(.contextTopicMenu(.edit, topic)) + } + } } } } @@ -451,6 +437,83 @@ public struct ForumScreen: View { } } +// MARK: - Navigation Modifier + +struct NavigationModifier: ViewModifier { + + @Perception.Bindable private var store: StoreOf + @Environment(\.tintColor) private var tintColor + + private var title: String { + return store.forumName ?? String(localized: "Loading...", bundle: .module) + } + + init(store: StoreOf) { + self.store = store + } + + func body(content: Content) -> some View { + WithPerceptionTracking { + content + .navigationTitle(Text(title)) + ._toolbarTitleDisplayMode(.large) + .modifier(FullScreenCoverModifier(store: store)) + .modifier(SheetModifier(store: store)) + } + } + + struct FullScreenCoverModifier: ViewModifier { + @Perception.Bindable private var store: StoreOf + @Environment(\.tintColor) private var tintColor + + init(store: StoreOf) { + self.store = store + } + + func body(content: Content) -> some View { + WithPerceptionTracking { + content + .fullScreenCover(item: $store.scope(state: \.$destination, action: \.destination).form) { store in + NavigationStack { + FormScreen(store: store) + } + } + } + } + } + + struct SheetModifier: ViewModifier { + @Perception.Bindable private var store: StoreOf + @Environment(\.tintColor) private var tintColor + + init(store: StoreOf) { + self.store = store + } + + func body(content: Content) -> some View { + WithPerceptionTracking { + content + .sheet(item: $store.scope(state: \.$destination, action: \.destination).stat) { store in + NavigationStack { + ForumStatView(store: store) + } + } + .sheet(item: $store.scope(state: \.$destination, action: \.destination).edit) { store in + NavigationStack { + TopicEditView(store: store) + } + } + .fittedSheet( + item: $store.scope(state: \.$destination, action: \.destination).move, + embedIntoNavStack: true + ) { store in + ForumMoveView(store: store) + } + } + } + } +} + // MARK: - Extensions extension Bundle { @@ -459,6 +522,12 @@ extension Bundle { } } +extension View { + func navigations(store: StoreOf) -> some View { + self.modifier(NavigationModifier(store: store)) + } +} + extension Forum { var globalAnnouncementAttributed: NSAttributedString? { guard !globalAnnouncement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } diff --git a/Modules/Sources/ForumFeature/Models/ForumTopicContextMenuAction.swift b/Modules/Sources/ForumFeature/Models/ForumTopicContextMenuAction.swift index 9ee9a868..875d6cdf 100644 --- a/Modules/Sources/ForumFeature/Models/ForumTopicContextMenuAction.swift +++ b/Modules/Sources/ForumFeature/Models/ForumTopicContextMenuAction.swift @@ -8,4 +8,5 @@ public enum ForumTopicContextMenuAction { case open case goToEnd + case edit } diff --git a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings index 96711dda..2240dc6a 100644 --- a/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/ForumFeature/Resources/Localizable.xcstrings @@ -71,6 +71,16 @@ } } }, + "Edit" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактировать" + } + } + } + }, "Go To End" : { "localizations" : { "ru" : { @@ -211,6 +221,16 @@ } } }, + "The topic has been edited" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема отредактирована" + } + } + } + }, "Tools" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/Models/Forum/Topic.swift b/Modules/Sources/Models/Forum/Topic.swift index 702d5ab8..e8b800ae 100644 --- a/Modules/Sources/Models/Forum/Topic.swift +++ b/Modules/Sources/Models/Forum/Topic.swift @@ -23,6 +23,10 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { public let navigation: [ForumInfo] public let postTemplateName: String? + public var canEdit: Bool { + return flag.contains(.canEdit) + } + public var canPost: Bool { return flag.contains(.canPost) && !flag.contains(.marker) } @@ -50,10 +54,10 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { public var isFavorite: Bool public struct Poll: Sendable, Codable, Hashable { - public let name: String + public var name: String public let voted: Bool public let totalVotes: Int - public let options: [Option] + public var options: [Option] public init(name: String, voted: Bool, totalVotes: Int, options: [Option]) { self.name = name @@ -64,8 +68,8 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { public struct Choice: Sendable, Codable, Hashable, Identifiable { public let id: Int - public let votes: Int - public let name: String + public var votes: Int + public var name: String public init(id: Int, name: String, votes: Int) { self.id = id @@ -76,9 +80,9 @@ public struct Topic: Codable, Sendable, Identifiable, Hashable { public struct Option: Sendable, Codable, Hashable, Identifiable { public let id: Int - public let name: String - public let several: Bool - public let choices: [Choice] + public var name: String + public var several: Bool + public var choices: [Choice] public init(id: Int, name: String, several: Bool, choices: [Choice]) { self.id = id @@ -158,8 +162,8 @@ public extension Topic.Poll { name: "Select not several...", several: false, choices: [ - .init(id: 2, name: "First choice", votes: 2), - .init(id: 3, name: "Second choice", votes: 4) + .init(id: 0, name: "First choice", votes: 2), + .init(id: 1, name: "Second choice", votes: 4) ] ), .init( @@ -167,8 +171,8 @@ public extension Topic.Poll { name: "Select several...", several: true, choices: [ - .init(id: 4, name: "First choice", votes: 4), - .init(id: 5, name: "Second choice", votes: 2) + .init(id: 0, name: "First choice", votes: 4), + .init(id: 1, name: "Second choice", votes: 2) ] ), ] diff --git a/Modules/Sources/Models/Forum/TopicEditResponse.swift b/Modules/Sources/Models/Forum/TopicEditResponse.swift new file mode 100644 index 00000000..12f8bb27 --- /dev/null +++ b/Modules/Sources/Models/Forum/TopicEditResponse.swift @@ -0,0 +1,26 @@ +// +// TopicEditResponse.swift +// ForPDA +// +// Created by Xialtal on 29.03.26. +// + +public enum TopicEditResponse: Int, Sendable { + case success = 0 + case tooManyQuestionsInPoll = 4 + case tooManyAnswersInPoll = 5 + case inappropriateContent = 6 + case sentToPremod = 7 + case noAccess + + public init(rawValue: Int) { + switch rawValue { + case 0: self = .success + case 4: self = .tooManyQuestionsInPoll + case 5: self = .tooManyAnswersInPoll + case 6: self = .inappropriateContent + case 7: self = .sentToPremod + default: self = .noAccess + } + } +} diff --git a/Modules/Sources/Models/Forum/TopicInfo.swift b/Modules/Sources/Models/Forum/TopicInfo.swift index e9874020..23407b5d 100644 --- a/Modules/Sources/Models/Forum/TopicInfo.swift +++ b/Modules/Sources/Models/Forum/TopicInfo.swift @@ -36,6 +36,10 @@ public struct TopicInfo: Sendable, Hashable, Codable, Identifiable { return flag.contains(.favorite) } + public var canEdit: Bool { + return flag.contains(.canEdit) + } + public var canDelete: Bool { return flag.contains(.canDelete) } diff --git a/Modules/Sources/SharedUI/Field.swift b/Modules/Sources/SharedUI/Field.swift index 0ab10016..d3672d0d 100644 --- a/Modules/Sources/SharedUI/Field.swift +++ b/Modules/Sources/SharedUI/Field.swift @@ -48,7 +48,7 @@ public struct Field: View { // MARK: - Body public var body: some View { - VStack { + FieldContainer(focus: $focus, focusEqual: focusEqual) { SelectableTextView( content: content, selection: selection, @@ -62,11 +62,92 @@ public struct Field: View { bbPanel() } + .frame(minHeight: minHeight, alignment: .top) + } +} + +// MARK: - Single Line Field + +public struct SingleLineField: View { + + // MARK: - Properties + + @FocusState.Binding var focus: F? + + var content: Binding + let placeholder: LocalizedStringResource + let focusEqual: F + let keyboardType: UIKeyboardType + let characterLimit: Int? + + // MARK: - Init + + public init( + content: Binding, + placeholder: LocalizedStringResource, + focusEqual: F, + focus: FocusState.Binding, + keyboardType: UIKeyboardType, + characterLimit: Int? + ) { + self.content = content + self.placeholder = placeholder + self.focusEqual = focusEqual + self.keyboardType = keyboardType + self.characterLimit = characterLimit + + self._focus = focus + } + + // MARK: - Body + + public var body: some View { + FieldContainer(focus: $focus, focusEqual: focusEqual) { + TextField(text: content, axis: .horizontal) { + Text(placeholder) + .font(.body) + .foregroundStyle(Color(.Labels.quaternary)) + } + .onChange(of: content.wrappedValue) { newValue in + if let limit = characterLimit, newValue.count > limit { + content.wrappedValue = String(newValue.prefix(limit)) + } + } + .keyboardType(keyboardType) + .frame(minHeight: nil, alignment: .top) + } + } +} + +// MARK: - Field Container + +public struct FieldContainer: View { + + @Environment(\.tintColor) private var tintColor + @FocusState.Binding public var focus: F? + + let focusEqual: F + let content: () -> Content + + public init( + focus: FocusState.Binding, + focusEqual: F, + @ViewBuilder content: @escaping () -> Content + ) { + self.focusEqual = focusEqual + self.content = content + + self._focus = focus + } + + public var body: some View { + VStack { + content() + } .padding(.vertical, 15) .padding(.horizontal, 12) .focused($focus, equals: focusEqual) .foregroundStyle(Color(.Labels.primary)) - .frame(minHeight: minHeight, alignment: .top) .background { RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) .fill(Color(.Background.teritary)) @@ -75,6 +156,7 @@ public struct Field: View { RoundedRectangle(cornerRadius: isLiquidGlass ? 28 : 14) .stroke($focus.wrappedValue == focusEqual ? tintColor : Color(.Separator.primary), lineWidth: 1) } + .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture { focus = focusEqual } diff --git a/Modules/Sources/TopicEditFeature/Extensions/TopicEditRequest+Extension.swift b/Modules/Sources/TopicEditFeature/Extensions/TopicEditRequest+Extension.swift new file mode 100644 index 00000000..6702314a --- /dev/null +++ b/Modules/Sources/TopicEditFeature/Extensions/TopicEditRequest+Extension.swift @@ -0,0 +1,31 @@ +// +// TopicEditRequest+Extension.swift +// ForPDA +// +// Created by Xialtal on 1.05.26. +// + +import APIClient +import Models + +extension Topic.Poll { + var asDocument: PDAPIDocument { + let document = PDAPIDocument() + try! document.append(name) + + let options = PDAPIDocument() + for option in self.options { + let optionDocument = PDAPIDocument() + + try! optionDocument.append(option.name) + try! optionDocument.append(option.several) + try! optionDocument.append(option.choices.compactMap { $0.name }) + try! optionDocument.append(option.choices.compactMap { $0.votes }) + + try! options.append(optionDocument) + } + try! document.append(options) + + return document + } +} diff --git a/Modules/Sources/TopicEditFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicEditFeature/Resources/Localizable.xcstrings new file mode 100644 index 00000000..d88bae8b --- /dev/null +++ b/Modules/Sources/TopicEditFeature/Resources/Localizable.xcstrings @@ -0,0 +1,216 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Answer" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ответ" + } + } + } + }, + "Answers" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ответы" + } + } + } + }, + "Cancel" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "Enable multi-selection" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить мультивыбор" + } + } + } + }, + "Enable poll" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить опрос" + } + } + } + }, + "Inappropriate content" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Недопустимое содержание" + } + } + } + }, + "Input answer" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите ответ" + } + } + } + }, + "Input poll name" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите название опроса" + } + } + } + }, + "Input question" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите вопрос" + } + } + } + }, + "Input..." : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите…" + } + } + } + }, + "No access" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет доступа" + } + } + } + }, + "OK" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ОК" + } + } + } + }, + "Question" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вопрос" + } + } + } + }, + "Save" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сохранить" + } + } + } + }, + "Too many answers in poll" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слишком много ответов в опросе" + } + } + } + }, + "Too many questions in poll" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Слишком много вопросов в опросе" + } + } + } + }, + "Topic description" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Описание темы" + } + } + } + }, + "Topic Edit" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактирование темы" + } + } + } + }, + "Topic header" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Заголовок темы" + } + } + } + }, + "Topic is sent to premoderation" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема отправлена на премодерацию" + } + } + } + }, + "Unknown error" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Неизвестная ошибка" + } + } + } + } + }, + "version" : "1.1" +} \ No newline at end of file diff --git a/Modules/Sources/TopicEditFeature/TopicEditFeature.swift b/Modules/Sources/TopicEditFeature/TopicEditFeature.swift new file mode 100644 index 00000000..d2c5539b --- /dev/null +++ b/Modules/Sources/TopicEditFeature/TopicEditFeature.swift @@ -0,0 +1,319 @@ +// +// TopicEditFeature.swift +// ForPDA +// +// Created by Xialtal on 29.03.26. +// + +import Foundation +import ComposableArchitecture +import APIClient +import Models + +@Reducer +public struct TopicEditFeature: Reducer, Sendable { + + public init() {} + + // MARK: - Alert + + public enum Alert { + case dismiss, ok + } + + // MARK: - State + + @ObservableState + public struct State: Equatable { + @Presents public var alert: AlertState? + + public enum Field: Hashable { + case title + case description + case pollName + case pollQuestion(Int) + case pollAnswer(questionId: Int, Int) + case pollAnswerVote(questionId: Int, Int) + } + + public let id: Int + public let flag: ForumFlag + public let supportsPoll: Bool + + public var canModerate: Bool { + return flag.contains(.canModerate) + } + + public var title: String + public var description: String + public var poll: Topic.Poll? + + var draftPoll = Topic.Poll( + name: "", + voted: false, + totalVotes: 0, + options: [] + ) + + var focus: Field? + var isSending = false + var isPollEnabled = false + + var isSaveButtonDisabled: Bool { + if !canModerate && !supportsPoll { + return true + } + return title.isEmpty || (isPollEnabled && !isPollValid) + } + + var isPollValid: Bool { + guard !draftPoll.name.isEmpty, !draftPoll.options.isEmpty else { + return false + } + for option in draftPoll.options { + guard !option.name.isEmpty, !option.choices.isEmpty else { + return false + } + for choice in option.choices { + guard !choice.name.isEmpty else { + return false + } + } + } + return true + } + + public init( + id: Int, + flag: ForumFlag, + title: String, + description: String, + poll: Topic.Poll? = nil, + supportsPoll: Bool + ) { + self.id = id + self.flag = flag + self.title = title + self.description = description + self.poll = poll + self.supportsPoll = supportsPoll + } + } + + // MARK: - Action + + public enum Action: ViewAction, BindableAction { + case binding(BindingAction) + case alert(PresentationAction) + + case view(View) + public enum View { + case onAppear + + case saveButtonTapped + case cancelButtonTapped + + case updateQuestion(Int, Topic.Poll.Option) + case updateAnswerVotes(questionId: Int, answerId: Int, String) + + case addQuestionButtonTapped + case removeQuestionButtonTapped(Int) + + case addAnswerButtonTapped(questionId: Int) + case removeAnswerButtonTapped(questionId: Int, Int) + } + + case `internal`(Internal) + public enum Internal { + case editResponse(Result) + } + + case delegate(Delegate) + public enum Delegate { + case topicEdited + } + } + + // MARK: - Dependencies + + @Dependency(\.dismiss) private var dismiss + @Dependency(\.apiClient) private var apiClient + + // MARK: - Body + + public var body: some Reducer { + BindingReducer() + + Reduce { state, action in + switch action { + case .binding(\.isPollEnabled): + if state.isPollEnabled { + state.focus = .pollName + } + return .none + + case .alert(.dismiss), .delegate(.topicEdited): + return .run { _ in await dismiss() } + + case .binding, .alert, .delegate: + return .none + + case .view(.onAppear): + if let poll = state.poll { + state.draftPoll = poll + state.isPollEnabled = true + } + return .none + + case .view(.saveButtonTapped): + let poll: PDAPIDocument? = if state.supportsPoll { + state.isPollEnabled ? state.draftPoll.asDocument : PDAPIDocument() + } else { + nil + } + return .run { [ + id = state.id, + title = state.title, + description = state.description, + poll = poll + ] send in + let request = TopicEditRequest( + id: id, + title: title, + description: description, + poll: poll + ) + let result = try await apiClient.editTopic(request) + await send(.internal(.editResponse(.success(result)))) + } catch: { error, send in + await send(.internal(.editResponse(.failure(error)))) + } + + case let .view(.updateQuestion(id, option)): + guard let index = state.draftPoll.options.firstIndex(where: { $0.id == id }) else { + return .none + } + state.draftPoll.options[index] = option + return .none + + case let .view(.updateAnswerVotes(questionId, answerId, votes)): + if let questionIndex = state.draftPoll.options.firstIndex(where: { $0.id == questionId }), + let answerIndex = state.draftPoll.options[questionIndex].choices.firstIndex(where: { $0.id == answerId }) { + state.draftPoll.options[questionIndex].choices[answerIndex].votes = Int(votes) ?? 0 + } + return .none + + case .view(.cancelButtonTapped): + return .run { _ in await dismiss() } + + case .view(.addQuestionButtonTapped): + let id = Int(Date.now.timeIntervalSince1970) + state.draftPoll.options.append(.init( + id: id, + name: "", + several: false, + choices: [] + )) + state.focus = .pollQuestion(id) + return .none + + case let .view(.removeQuestionButtonTapped(id)): + state.draftPoll.options.removeAll(where: { $0.id == id }) + return .none + + case let .view(.addAnswerButtonTapped(questionId)): + let id = Int(Date.now.timeIntervalSince1970) + if let index = state.draftPoll.options.firstIndex(where: { $0.id == questionId }) { + state.draftPoll.options[index].choices.append( + .init(id: id, name: "", votes: 0) + ) + state.focus = .pollAnswer(questionId: questionId, id) + } + return .none + + case let .view(.removeAnswerButtonTapped(questionId, answerId)): + if let questionIndex = state.draftPoll.options.firstIndex(where: { $0.id == questionId }), + let answerIndex = state.draftPoll.options[questionIndex].choices.firstIndex(where: { $0.id == answerId }) { + state.draftPoll.options[questionIndex].choices.remove(at: answerIndex) + } + return .none + + case let .internal(.editResponse(.success(status))): + switch status { + case .success: + return .send(.delegate(.topicEdited)) + case .tooManyQuestionsInPoll: + state.alert = .tooManyQuestionsInPoll + case .tooManyAnswersInPoll: + state.alert = .tooManyAnswersInPoll + case .inappropriateContent: + state.alert = .inappropriateContent + case .sentToPremod: + state.alert = .topicIsSentToPremoderation + case .noAccess: + state.alert = .noAccess + } + return .none + + case let .internal(.editResponse(.failure(error))): + print(error) + state.alert = .unknownError + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } +} + +// MARK: - Alerts + +public extension AlertState where Action == TopicEditFeature.Alert { + + nonisolated(unsafe) static let topicIsSentToPremoderation = AlertState { + TextState("Topic is sent to premoderation") + } actions: { + ButtonState(action: .dismiss) { + TextState("OK") + } + } + + nonisolated(unsafe) static let tooManyQuestionsInPoll = AlertState { + TextState("Too many questions in poll", bundle: .module) + } actions: { + ButtonState(action: .ok) { + TextState("OK") + } + } + + nonisolated(unsafe) static let tooManyAnswersInPoll = AlertState { + TextState("Too many answers in poll", bundle: .module) + } actions: { + ButtonState(action: .ok) { + TextState("OK") + } + } + + nonisolated(unsafe) static let inappropriateContent = AlertState { + TextState("Inappropriate content", bundle: .module) + } actions: { + ButtonState(action: .ok) { + TextState("OK") + } + } + + nonisolated(unsafe) static let noAccess = AlertState { + TextState("No access", bundle: .module) + } actions: { + ButtonState(action: .ok) { + TextState("OK") + } + } + + nonisolated(unsafe) static let unknownError = AlertState { + TextState("Unknown error", bundle: .module) + } actions: { + ButtonState(action: .ok) { + TextState("OK") + } + } +} diff --git a/Modules/Sources/TopicEditFeature/TopicEditView.swift b/Modules/Sources/TopicEditFeature/TopicEditView.swift new file mode 100644 index 00000000..d5718a10 --- /dev/null +++ b/Modules/Sources/TopicEditFeature/TopicEditView.swift @@ -0,0 +1,372 @@ +// +// TopicEditView.swift +// ForPDA +// +// Created by Xialtal on 29.03.26. +// + +import SwiftUI +import ComposableArchitecture +import SharedUI +import Models +import SFSafeSymbols + +@ViewAction(for: TopicEditFeature.self) +public struct TopicEditView: View { + + @Perception.Bindable public var store: StoreOf + @FocusState private var focus: TopicEditFeature.State.Field? + @Environment(\.tintColor) private var tintColor + + public init(store: StoreOf) { + self.store = store + } + + public var body: some View { + WithPerceptionTracking { + ScrollView(.vertical) { + VStack(spacing: 28) { + Field( + title: "Topic header", + content: $store.title, + placeholder: LocalizedStringResource("Input...", bundle: .module), + focusEqual: .title, + characterLimit: 255 + ) + .disabled(!store.canModerate) + + Field( + title: "Topic description", + content: $store.description, + placeholder: LocalizedStringResource("Input...", bundle: .module), + focusEqual: .description, + characterLimit: 255 + ) + .disabled(!store.canModerate) + + if store.supportsPoll { + Poll() + } + } + .padding(.top, 16) + .padding(.horizontal, 16) + } + .scrollIndicators(.hidden) + .navigationTitle(Text("Topic Edit", bundle: .module)) + .navigationBarTitleDisplayMode(.inline) + .alert($store.scope(state: \.alert, action: \.alert)) + .safeAreaInset(edge: .bottom) { + SaveButton() + } + .onTapGesture { + focus = nil + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + send(.cancelButtonTapped) + } label: { + if isLiquidGlass { + Image(systemSymbol: .xmark) + } else { + Text("Cancel", bundle: .module) + } + } + .tint(tintColor) + .disabled(store.isSending) + } + } + .background(Color(.Background.primary)) + .disabled(store.isSending) + .animation(.default, value: store.isSending) + .bind($store.focus, to: $focus) + .onAppear { + send(.onAppear) + } + } + } + + // MARK: - Save Button + + @ViewBuilder + private func SaveButton() -> some View { + WithPerceptionTracking { + Button { + send(.saveButtonTapped) + } label: { + if store.isSending { + ProgressView() + .progressViewStyle(.circular) + .frame(maxWidth: .infinity) + .padding(8) + } else { + Text("Save", bundle: .module) + .frame(maxWidth: .infinity) + .padding(8) + } + } + .buttonStyle(.borderedProminent) + .tint(tintColor) + .disabled(store.isSaveButtonDisabled) + .frame(height: 48) + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color(.Background.primary)) + } + } + + // MARK: - Poll + + private func Poll() -> some View { + WithPerceptionTracking { + VStack { + HStack(spacing: 0) { + Text("Enable poll", bundle: .module) + .foregroundStyle(Color(.Labels.teritary)) + .font(.footnote) + .fontWeight(.semibold) + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle(String(""), isOn: $store.isPollEnabled) + .labelsHidden() + .tint(tintColor) + } + .padding(.horizontal, 2) + + if store.isPollEnabled { + VStack(spacing: 16) { + Field( + content: $store.draftPoll.name, + placeholder: LocalizedStringResource("Input poll name", bundle: .module), + focusEqual: .pollName + ) + + ForEach(store.draftPoll.options) { question in + WithPerceptionTracking { + // We use this solution with binding, because otherwise, on iOS 17+, + // when deleting a question, error "Index out of range" appears + // due to the fact that "multiselection Toggle" calling an already deleted question + let question = store.draftPoll.options.first { $0.id == question.id } ?? question + PollQuestion( + question: Binding( + get: { question }, + set: { newValue in + send(.updateQuestion(question.id, newValue)) + } + ) + ) + } + } + + AddPollElementButton(title: "Question") { + send(.addQuestionButtonTapped) + } + } + } + } + } + .animation(.default, value: store.isPollEnabled) + } + + // MARK: - Poll Question + + @ViewBuilder + private func PollQuestion(question: Binding) -> some View { + VStack(spacing: 10) { + Field( + title: "Question", + content: question.name, + placeholder: LocalizedStringResource("Input question", bundle: .module), + focusEqual: .pollQuestion(question.wrappedValue.id), + action: { + RemovePollElementButton { + send(.removeQuestionButtonTapped(question.wrappedValue.id)) + } + } + ) + + HStack(spacing: 0) { + Toggle(String(""), isOn: question.several) + .labelsHidden() + .tint(tintColor) + .padding(.trailing, 8) + + Text("Enable multi-selection", bundle: .module) + .foregroundStyle(Color(.Labels.teritary)) + .font(.footnote) + .fontWeight(.semibold) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.bottom, 12) + + PollAnswers(questionId: question.wrappedValue.id, question.choices) + + AddPollElementButton(title: "Answer") { + send(.addAnswerButtonTapped(questionId: question.wrappedValue.id)) + } + } + .padding(16) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(.Separator.secondary), lineWidth: 1) + ) + } + + // MARK: - Poll Answer + + @ViewBuilder + private func PollAnswers(questionId: Int, _ answers: Binding<[Topic.Poll.Choice]>) -> some View { + VStack(spacing: 8) { + Header(title: "Answers") + + ForEach(answers) { answer in + WithPerceptionTracking { + HStack(spacing: 8) { + Field( + type: .singleLine(numeric: false), + content: answer.name, + placeholder: LocalizedStringResource("Input answer", bundle: .module), + focusEqual: .pollAnswer(questionId: questionId, answer.wrappedValue.id), + action: { + if !store.canModerate { + RemovePollElementButton { + send(.removeAnswerButtonTapped(questionId: questionId, answer.wrappedValue.id)) + } + } + } + ) + + if store.canModerate { + Field( + type: .singleLine(numeric: true), + content: Binding(get: { String(answer.votes.wrappedValue) }, set: { newValue in + send(.updateAnswerVotes(questionId: questionId, answerId: answer.wrappedValue.id, newValue)) + }), + placeholder: LocalizedStringResource(stringLiteral: String(answer.votes.wrappedValue)), + focusEqual: .pollAnswerVote(questionId: questionId, answer.wrappedValue.id), + characterLimit: 7, + action: { + RemovePollElementButton { + send(.removeAnswerButtonTapped(questionId: questionId, answer.wrappedValue.id)) + } + } + ) + .frame(width: 120) + } + + } + } + } + } + } + + // MARK: - Add Poll Element Button + + @ViewBuilder + private func AddPollElementButton( + title: LocalizedStringKey, + action: @escaping () -> () + ) -> some View { + Button { + action() + } label: { + HStack { + Image(systemSymbol: .plus) + + Text(title, bundle: .module) + } + .padding(.vertical, 4) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .center) + } + .tint(tintColor) + .buttonStyle(.bordered) + } + + // MARK: - Remove Poll Element Button + + @ViewBuilder + private func RemovePollElementButton(action: @escaping () -> ()) -> some View { + Button { + action() + } label: { + Image(systemSymbol: .trash) + .foregroundStyle(.red) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + } + + // MARK: - Field + + enum FieldType { + case singleLine(numeric: Bool) + case full + } + + private func Field( + type: FieldType = .full, + title: LocalizedStringKey? = nil, + content: Binding, + placeholder: LocalizedStringResource, + focusEqual: TopicEditFeature.State.Field, + characterLimit: Int? = nil, + @ViewBuilder action: @escaping () -> Action = { EmptyView() } + ) -> some View { + VStack(spacing: 6) { + if let title = title { + Header(title: title) + } + + HStack { + switch type { + case .singleLine(let numeric): + SharedUI.SingleLineField( + content: content, + placeholder: placeholder, + focusEqual: focusEqual, + focus: $focus, + keyboardType: numeric ? .numberPad : .default, + characterLimit: characterLimit + ) + case .full: + SharedUI.Field( + content: content, + placeholder: placeholder, + focusEqual: focusEqual, + focus: $focus, + characterLimit: characterLimit + ) + } + + action() + } + } + } + + private func Header(title: LocalizedStringKey) -> some View { + Text(title, bundle: .module) + .font(.footnote) + .fontWeight(.semibold) + .foregroundStyle(Color(.Labels.teritary)) + .textCase(nil) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview { + NavigationStack { + TopicEditView(store: Store( + initialState: TopicEditFeature.State( + id: 0, + flag: .canModerate, + title: "Test Title", + description: "Description", + poll: .mock, + supportsPoll: true + ) + ) { + TopicEditFeature() + }) + } +} diff --git a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift index d82f80ba..cc086dff 100644 --- a/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift +++ b/Modules/Sources/TopicFeature/Analytics/TopicFeature+Analytics.swift @@ -90,6 +90,8 @@ extension TopicFeature { analytics.log(TopicEvent.menuSetFavorite) case .about: analytics.log(TopicEvent.menuAboutTopic) + case .edit: + analytics.log(TopicEvent.menuEditTopic) case .writePost: analytics.log(TopicEvent.menuWritePost) case .writePostWithTemplate: diff --git a/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift b/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift index a982e538..64d9b5e8 100644 --- a/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift +++ b/Modules/Sources/TopicFeature/Models/TopicContextMenuAction.swift @@ -13,4 +13,5 @@ public enum TopicContextMenuAction { case goToEnd case setFavorite case about + case edit } diff --git a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings index c536abf3..10fda9df 100644 --- a/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings +++ b/Modules/Sources/TopicFeature/Resources/Localizable.xcstrings @@ -121,6 +121,16 @@ } } }, + "Edit" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Редактировать" + } + } + } + }, "Except deleted" : { "localizations" : { "ru" : { @@ -361,6 +371,16 @@ } } }, + "The topic has been edited" : { + "localizations" : { + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тема отредактирована" + } + } + } + }, "Today, %@" : { "localizations" : { "ru" : { diff --git a/Modules/Sources/TopicFeature/TopicFeature.swift b/Modules/Sources/TopicFeature/TopicFeature.swift index 75cd553e..4d6f259a 100644 --- a/Modules/Sources/TopicFeature/TopicFeature.swift +++ b/Modules/Sources/TopicFeature/TopicFeature.swift @@ -25,6 +25,7 @@ import NotificationsClient import ForumStatFeature import ForumMoveFeature import GalleryFeature +import TopicEditFeature @Reducer public struct TopicFeature: Reducer, Sendable { @@ -36,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 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) static let topicDeleted = LocalizedStringResource("Topic deleted", bundle: .module) @@ -61,6 +63,7 @@ public struct TopicFeature: Reducer, Sendable { case form(FormFeature) case stat(ForumStatFeature) case move(ForumMoveFeature) + case edit(TopicEditFeature) case changeReputation(ReputationChangeFeature) @CasePathable @@ -70,6 +73,7 @@ public struct TopicFeature: Reducer, Sendable { case form(FormFeature.Action) case stat(ForumStatFeature.Action) case move(ForumMoveFeature.Action) + case edit(TopicEditFeature.Action) case changeReputation(ReputationChangeFeature.Action) } @@ -237,6 +241,11 @@ public struct TopicFeature: Reducer, Sendable { await toastClient.showToast(ToastMessage(text: Localization.reportSent, haptic: .success)) } + case .destination(.presented(.edit(.delegate(.topicEdited)))): + return .run { _ in + await toastClient.showToast(ToastMessage(text: Localization.topicEdited, haptic: .success)) + } + case let .destination(.presented(.stat(.delegate(.userTapped(id))))): return .send(.delegate(.openUser(id: id))) @@ -345,6 +354,18 @@ public struct TopicFeature: Reducer, Sendable { state.destination = .form(formState) return .none + case .edit: + let editState = TopicEditFeature.State( + id: topic.id, + flag: topic.flag, + title: topic.name, + description: topic.description, + poll: topic.poll, + supportsPoll: true + ) + state.destination = .edit(editState) + return .none + case .about: let statState = ForumStatFeature.State( type: .topic(topic) diff --git a/Modules/Sources/TopicFeature/TopicScreen.swift b/Modules/Sources/TopicFeature/TopicScreen.swift index 948b934d..bf1577c1 100644 --- a/Modules/Sources/TopicFeature/TopicScreen.swift +++ b/Modules/Sources/TopicFeature/TopicScreen.swift @@ -19,6 +19,7 @@ import TopicBuilder import GalleryFeature import ForumStatFeature import ForumMoveFeature +import TopicEditFeature @ViewAction(for: TopicFeature.self) public struct TopicScreen: View { @@ -198,6 +199,12 @@ public struct TopicScreen: View { ContextButton(text: LocalizedStringResource("About Topic", bundle: .module), symbol: .infoCircle) { send(.contextMenu(.about)) } + + if topic.canEdit { + ContextButton(text: LocalizedStringResource("Edit", bundle: .module), symbol: .squareAndPencil) { + send(.contextMenu(.edit)) + } + } } if topic.canModerate { @@ -506,7 +513,12 @@ struct NavigationModifier: ViewModifier { FormScreen(store: store) } } - .fullScreenCover(item: $store.scope(state: \.$destination, action: \.destination).gallery) { model in + .fullScreenCover(item: $store.scope(state: \.destination?.edit, action: \.destination.edit)) { store in + NavigationStack { + TopicEditView(store: store) + } + } + .fullScreenCover(item: $store.scope(state: \.destination, action: \.destination).gallery) { model in TabViewGallery(model: model) } } diff --git a/Project.swift b/Project.swift index cdfc46d4..915ac891 100644 --- a/Project.swift +++ b/Project.swift @@ -275,6 +275,7 @@ let project = Project( .Internal.SharedUI, .Internal.TCAExtensions, .Internal.ToastClient, + .Internal.TopicEditFeature, .Internal.FormFeature, .Internal.ForumMoveFeature, .Internal.ForumStatFeature, @@ -522,6 +523,17 @@ let project = Project( .SPM.TCA ] ), + + .feature( + name: "TopicEditFeature", + dependencies: [ + .Internal.APIClient, + .Internal.Models, + .Internal.SharedUI, + .SPM.SFSafeSymbols, + .SPM.TCA + ] + ), .feature( name: "TopicFeature", @@ -541,6 +553,7 @@ let project = Project( .Internal.TCAExtensions, .Internal.ToastClient, .Internal.TopicBuilder, + .Internal.TopicEditFeature, .Internal.FormFeature, .Internal.ForumMoveFeature, .Internal.ForumStatFeature, @@ -1092,6 +1105,7 @@ extension TargetDependency.Internal { static let SearchResultFeature = TargetDependency.target(name: "SearchResultFeature") static let SettingsFeature = TargetDependency.target(name: "SettingsFeature") static let TopicBuilder = TargetDependency.target(name: "TopicBuilder") + static let TopicEditFeature = TargetDependency.target(name: "TopicEditFeature") static let TopicFeature = TargetDependency.target(name: "TopicFeature") static let UploadBoxFeature = TargetDependency.target(name: "UploadBoxFeature")