From da0a3aecb49d80c874a71c8f63601709566a7edc Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 15:17:09 +0200 Subject: [PATCH] Add issue comment editing --- TESTING.md | 10 + crates/app/src/api.rs | 76 ++++++- crates/app/src/domain.rs | 1 + crates/app/src/lib.rs | 19 ++ crates/app/src/presentation.rs | 53 ++++- ios/Generated/gotcha_core.swift | 33 ++- ios/Generated/gotcha_coreFFI.h | 11 + ios/Gotcha.xcodeproj/project.pbxproj | 4 + ios/Sources/CommentEditorViewController.swift | 201 ++++++++++++++++++ ios/Sources/DetailScreens.swift | 55 ++++- 10 files changed, 451 insertions(+), 12 deletions(-) create mode 100644 ios/Sources/CommentEditorViewController.swift diff --git a/TESTING.md b/TESTING.md index 776c1a8..cc6ab6a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -174,6 +174,16 @@ xcrun simctl launch booted de.rfc1437.gotcha appears beside the title and VoiceOver announces the state; author metadata, Markdown body, and comments remain correct. Links and selectable text use normal iOS interaction. +- [ ] Tap the **Add comment** icon on an issue. Enter headings, emphasis, a + list, link, and fenced code block; **Write** syntax-highlights the + Markdown, **Preview** renders it, and switching modes preserves the exact + source. Save and verify the rendered comment appears after the detail + refresh; Cancel leaves the issue unchanged. +- [ ] Each comment authored by the signed-in account has its own **Edit** + button and comments by other accounts do not. Edit an owned comment in + Write and Preview modes, save it, refresh and relaunch, and verify the + Markdown change persists. Check Add/Edit/Cancel/Save with Dynamic Type + and VoiceOver, including the empty-comment Save state. ## Repositories and commits diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 2f39825..a8ec6f1 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -466,14 +466,16 @@ pub async fn load_issue( let client = Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; let configuration = client.configuration(); - let (issue, comments) = tokio::join!( + let (issue, comments, viewer) = tokio::join!( apis::issue_api::issue_get_issue(&configuration, owner, repository, number), load_issue_comments(&configuration, owner, repository, number, page), + client.current_user(), ); Ok(IssueDetails { issue: issue.map_err(|error| error.to_string())?, has_more: false, comments: comments?, + viewer_id: viewer.map_err(|error| error.to_string())?.id, }) } @@ -492,6 +494,67 @@ async fn load_issue_comments( .map_err(|error| error.to_string()) } +pub async fn save_issue_comment( + server: &Server, + owner: &str, + repository: &str, + number: i64, + comment_id: Option, + body: String, +) -> Result<(), String> { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let configuration = client.configuration(); + match comment_id { + Some(id) => { + let (comment, viewer) = tokio::join!( + apis::issue_api::issue_get_comment(&configuration, owner, repository, id), + client.current_user(), + ); + let comment = comment.map_err(|error| error.to_string())?; + let viewer = viewer.map_err(|error| error.to_string())?; + let owned = matches!( + (comment.user.as_ref().and_then(|user| user.id), viewer.id), + (Some(author), Some(viewer)) if author == viewer + ); + if !owned || !comment_belongs_to_issue(&comment, number) { + return Err("You can only edit your own comments.".into()); + } + apis::issue_api::issue_edit_comment( + &configuration, + owner, + repository, + id, + Some(models::EditIssueCommentOption::new(body)), + ) + .await + .map_err(|error| error.to_string())?; + } + None => { + apis::issue_api::issue_create_comment( + &configuration, + owner, + repository, + number, + Some(models::CreateIssueCommentOption::new(body)), + ) + .await + .map_err(|error| error.to_string())?; + } + } + Ok(()) +} + +fn comment_belongs_to_issue(comment: &models::Comment, number: i64) -> bool { + comment + .issue_url + .as_deref() + .map(|url| url.trim_end_matches('/')) + .and_then(|url| url.rsplit('/').next()) + .and_then(|index| index.parse().ok()) + == Some(number) +} + pub async fn load_issue_editor( server: &Server, owner: &str, @@ -1041,4 +1104,15 @@ mod tests { assert_eq!(edit.milestone, Some(0)); assert_eq!(edit.unset_due_date, Some(true)); } + + #[test] + fn scopes_comment_edits_to_the_selected_issue() { + let comment = models::Comment { + issue_url: Some("https://gitea.example/api/v1/repos/o/r/issues/4/".into()), + ..Default::default() + }; + assert!(comment_belongs_to_issue(&comment, 4)); + assert!(!comment_belongs_to_issue(&comment, 5)); + assert!(!comment_belongs_to_issue(&models::Comment::default(), 4)); + } } diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 15164e3..8f184e7 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -154,6 +154,7 @@ pub struct HomeData { pub struct IssueDetails { pub issue: models::Issue, pub comments: Vec, + pub viewer_id: Option, pub has_more: bool, } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 7400dee..5cdeb97 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -353,6 +353,25 @@ impl GotchaCore { )) } + pub async fn save_issue_comment( + &self, + owner: String, + repository: String, + number: i64, + comment_id: Option, + body: String, + ) -> Result<(), GotchaError> { + let (owner, repository) = validate_repository(&owner, &repository)?; + if number <= 0 || comment_id.is_some_and(|id| id <= 0) { + return Err("Invalid issue comment selection.".into()); + } + if body.trim().is_empty() { + return Err("Enter a comment.".into()); + } + save_issue_comment(&self.server()?, owner, repository, number, comment_id, body).await?; + Ok(()) + } + pub async fn issue_editor( &self, owner: String, diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 0c78901..169019e 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -64,9 +64,11 @@ pub struct PullRow { #[derive(Clone, uniffi::Record)] pub struct CommentRow { + pub id: i64, pub author: String, pub body: String, pub meta: String, + pub can_edit: bool, } #[derive(Clone, uniffi::Record)] @@ -514,7 +516,11 @@ pub fn issue_page(details: IssueDetails) -> IssuePage { .body .filter(|body| !body.is_empty()) .unwrap_or_else(|| "No description provided.".into()), - comments: details.comments.iter().map(comment_row).collect(), + comments: details + .comments + .iter() + .map(|comment| comment_row(comment, details.viewer_id)) + .collect(), has_more: details.has_more, } } @@ -627,7 +633,11 @@ pub fn pull_page(details: PullDetails) -> PullPage { .unwrap_or_else(|| "No description provided.".into()), files_ref: pull_files_ref(pull), files: details.files.iter().filter_map(file_row).collect(), - comments: details.comments.iter().map(comment_row).collect(), + comments: details + .comments + .iter() + .map(|comment| comment_row(comment, None)) + .collect(), has_more: details.has_more, } } @@ -985,8 +995,9 @@ fn label_row(label: &models::Label) -> Option { }) } -fn comment_row(comment: &models::Comment) -> CommentRow { +fn comment_row(comment: &models::Comment, viewer_id: Option) -> CommentRow { CommentRow { + id: comment.id.unwrap_or_default(), author: comment .user .as_ref() @@ -1005,6 +1016,14 @@ fn comment_row(comment: &models::Comment) -> CommentRow { .as_deref() .or(comment.created_at.as_deref()), ), + can_edit: matches!( + ( + comment.id, + comment.user.as_ref().and_then(|user| user.id), + viewer_id, + ), + (Some(_), Some(author), Some(viewer)) if author == viewer + ), } } @@ -1264,17 +1283,41 @@ mod tests { } #[test] - fn issue_page_exposes_state() { + fn issue_page_exposes_state_and_owned_comment_editing() { let page = issue_page(IssueDetails { issue: models::Issue { state: Some("closed".into()), ..Default::default() }, - comments: Vec::new(), + comments: vec![ + models::Comment { + id: Some(7), + user: Some(Box::new(models::User { + id: Some(3), + login: Some("viewer".into()), + ..Default::default() + })), + body: Some("Comment".into()), + ..Default::default() + }, + models::Comment { + id: Some(8), + user: Some(Box::new(models::User { + id: Some(4), + login: Some("someone-else".into()), + ..Default::default() + })), + ..Default::default() + }, + ], + viewer_id: Some(3), has_more: false, }); assert_eq!(page.state, "closed"); + assert_eq!(page.comments[0].id, 7); + assert!(page.comments[0].can_edit); + assert!(!page.comments[1].can_edit); } #[test] diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 364dff4..1a76507 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -649,6 +649,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?) async throws -> Int64 + func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws + func selectServer(index: UInt32) throws func servers() -> [ServerRow] @@ -1073,6 +1075,22 @@ open func saveIssue(owner: String, repository: String, number: Int64?, title: St ) } +open func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterOptionInt64.lower(commentId),FfiConverterString.lower(body) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_select_server( @@ -1299,16 +1317,20 @@ public func FfiConverterTypeActivityRow_lower(_ value: ActivityRow) -> RustBuffe public struct CommentRow: Equatable, Hashable { + public var id: Int64 public var author: String public var body: String public var meta: String + public var canEdit: Bool // Default memberwise initializers are never public by default, so we // declare one manually. - public init(author: String, body: String, meta: String) { + public init(id: Int64, author: String, body: String, meta: String, canEdit: Bool) { + self.id = id self.author = author self.body = body self.meta = meta + self.canEdit = canEdit } @@ -1327,16 +1349,20 @@ public struct FfiConverterTypeCommentRow: FfiConverterRustBuffer { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CommentRow { return try CommentRow( + id: FfiConverterInt64.read(from: &buf), author: FfiConverterString.read(from: &buf), body: FfiConverterString.read(from: &buf), - meta: FfiConverterString.read(from: &buf) + meta: FfiConverterString.read(from: &buf), + canEdit: FfiConverterBool.read(from: &buf) ) } public static func write(_ value: CommentRow, into buf: inout [UInt8]) { + FfiConverterInt64.write(value.id, into: &buf) FfiConverterString.write(value.author, into: &buf) FfiConverterString.write(value.body, into: &buf) FfiConverterString.write(value.meta, into: &buf) + FfiConverterBool.write(value.canEdit, into: &buf) } } @@ -3882,6 +3908,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue() != 55053) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index dc1a48c..88b8466 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -374,6 +374,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, R uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number, RustBuffer title, RustBuffer body, RustBuffer label_ids, RustBuffer milestone_id, RustBuffer due_date ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT +uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer comment_id, RustBuffer body +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status @@ -820,6 +825,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER diff --git a/ios/Gotcha.xcodeproj/project.pbxproj b/ios/Gotcha.xcodeproj/project.pbxproj index a9e5b65..7e1b063 100644 --- a/ios/Gotcha.xcodeproj/project.pbxproj +++ b/ios/Gotcha.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; }; 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; }; 374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.swift */; }; + 381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; }; 7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; }; 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; }; 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; }; @@ -26,6 +27,7 @@ 5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; }; 722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = ""; }; 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; + 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = ""; }; 9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = ""; }; CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = ""; }; @@ -53,6 +55,7 @@ children = ( F61515849F6AACD721FE915C /* AppContext.swift */, 9C0921C76676A14A024BA417 /* AppDelegate.swift */, + 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */, CE5E10787062D1175125BB4C /* ContentScreens.swift */, 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */, 722405F999984C7CBE838711 /* IssueEditorViewController.swift */, @@ -193,6 +196,7 @@ files = ( EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */, 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */, + 381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */, 1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */, 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */, D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */, diff --git a/ios/Sources/CommentEditorViewController.swift b/ios/Sources/CommentEditorViewController.swift new file mode 100644 index 0000000..7ca9588 --- /dev/null +++ b/ios/Sources/CommentEditorViewController.swift @@ -0,0 +1,201 @@ +import Highlighter +import SwiftUI +import UIKit + +@MainActor +final class CommentEditorViewController: UIViewController, UITextViewDelegate { + private let context: AppContext + private let owner: String + private let repository: String + private let number: Int64 + private let comment: CommentRow? + private let saved: () -> Void + private let scrollView = UIScrollView() + private let modeControl = UISegmentedControl(items: ["Write", "Preview"]) + private let bodyView = UITextView() + private let previewView = UIView() + private let spinner = UIActivityIndicatorView(style: .medium) + private var task: Task? + private var previewController: UIViewController? + private var editorFont: UIFont { + UIFontMetrics(forTextStyle: .body).scaledFont( + for: .monospacedSystemFont(ofSize: 15, weight: .regular) + ) + } + + init( + context: AppContext, + owner: String, + repository: String, + number: Int64, + comment: CommentRow? = nil, + saved: @escaping () -> Void + ) { + self.context = context + self.owner = owner + self.repository = repository + self.number = number + self.comment = comment + self.saved = saved + super.init(nibName: nil, bundle: nil) + title = comment == nil ? "New Comment" : "Edit Comment" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + deinit { task?.cancel() } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemGroupedBackground + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancel) + ) + restoreSaveButton() + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.keyboardDismissMode = .interactive + view.addSubview(scrollView) + let stack = UIStackView(arrangedSubviews: [modeControl, bodyView, previewView]) + stack.axis = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stack) + + modeControl.selectedSegmentIndex = 0 + modeControl.accessibilityLabel = "Comment editor mode" + modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged) + bodyView.delegate = self + bodyView.text = comment?.body ?? "" + bodyView.font = editorFont + bodyView.adjustsFontForContentSizeCategory = true + bodyView.backgroundColor = .secondarySystemGroupedBackground + bodyView.layer.cornerRadius = 10 + bodyView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8) + bodyView.autocapitalizationType = .sentences + bodyView.accessibilityLabel = "Comment in Markdown" + previewView.backgroundColor = .secondarySystemGroupedBackground + previewView.layer.cornerRadius = 10 + previewView.clipsToBounds = true + previewView.accessibilityLabel = "Comment preview" + previewView.isHidden = true + highlightBody() + updateSaveButton() + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), + stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16), + stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16), + stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32), + bodyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280), + previewView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280), + ]) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if comment == nil { bodyView.becomeFirstResponder() } + } + + func textViewDidChange(_ textView: UITextView) { + highlightBody() + updateSaveButton() + } + + private func highlightBody() { + let source = bodyView.text ?? "" + let selection = bodyView.selectedRange + let highlighter = Highlighter() + highlighter?.setTheme(traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light") + highlighter?.theme.setCodeFont(editorFont) + bodyView.attributedText = highlighter?.highlight(source, as: "markdown") + ?? NSAttributedString(string: source, attributes: [ + .font: editorFont, + .foregroundColor: UIColor.label, + ]) + bodyView.selectedRange = selection + bodyView.typingAttributes = [ + .font: editorFont, + .foregroundColor: UIColor.label, + ] + } + + @objc private func modeChanged() { + let preview = modeControl.selectedSegmentIndex == 1 + if preview { + previewController?.willMove(toParent: nil) + previewController?.view.removeFromSuperview() + previewController?.removeFromParent() + let controller = UIHostingController( + rootView: RepositoryMarkdownPreview(source: bodyView.text ?? "") + ) + addChild(controller) + controller.view.translatesAutoresizingMaskIntoConstraints = false + previewView.addSubview(controller.view) + NSLayoutConstraint.activate([ + controller.view.leadingAnchor.constraint(equalTo: previewView.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: previewView.trailingAnchor), + controller.view.topAnchor.constraint(equalTo: previewView.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: previewView.bottomAnchor), + ]) + controller.didMove(toParent: self) + previewController = controller + } + bodyView.isHidden = preview + previewView.isHidden = !preview + view.endEditing(true) + } + + @objc private func save() { + view.endEditing(true) + navigationItem.leftBarButtonItem?.isEnabled = false + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) + spinner.startAnimating() + task?.cancel() + task = Task { + do { + try await context.core.saveIssueComment( + owner: owner, + repository: repository, + number: number, + commentId: comment?.id, + body: bodyView.text ?? "" + ) + guard !Task.isCancelled else { return } + saved() + } catch { + if !Task.isCancelled { + show(error: error) + restoreSaveButton() + } + } + } + } + + private func restoreSaveButton() { + spinner.stopAnimating() + navigationItem.leftBarButtonItem?.isEnabled = true + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Save", + style: .done, + target: self, + action: #selector(save) + ) + updateSaveButton() + } + + private func updateSaveButton() { + navigationItem.rightBarButtonItem?.isEnabled = !(bodyView.text ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @objc private func cancel() { dismiss(animated: true) } +} diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index f544528..c5b227b 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -146,15 +146,41 @@ final class IssueViewController: MarkdownPageViewController { override func viewDidLoad() { super.viewDidLoad() - navigationItem.rightBarButtonItem = UIBarButtonItem( + let addComment = UIBarButtonItem( + image: context.symbol("plus.bubble"), + style: .plain, + target: self, + action: #selector(addComment) + ) + addComment.accessibilityLabel = "Add comment" + let editIssue = UIBarButtonItem( title: "Edit", style: .plain, target: self, action: #selector(editIssue) ) + navigationItem.rightBarButtonItems = [addComment, editIssue] loadContent(refreshing: false) } + @objc private func addComment() { presentCommentEditor() } + + private func editComment(_ comment: CommentRow) { presentCommentEditor(comment) } + + private func presentCommentEditor(_ comment: CommentRow? = nil) { + let editor = CommentEditorViewController( + context: context, + owner: owner, + repository: repository, + number: number, + comment: comment + ) { [weak self] in + guard let self else { return } + self.dismiss(animated: true) { self.loadContent(refreshing: false) } + } + present(UINavigationController(rootViewController: editor), animated: true) + } + @objc private func editIssue() { let editor = IssueEditorViewController( context: context, @@ -204,6 +230,7 @@ final class IssueViewController: MarkdownPageViewController { meta: page.meta, body: page.body, comments: page.comments, + editComment: { [weak self] comment in self?.editComment(comment) }, milestone: page.milestone )) finishPagination(hasMore: page.hasMore) @@ -791,6 +818,7 @@ private func detailViews( meta: String, body: String, comments: [CommentRow], + editComment: ((CommentRow) -> Void)? = nil, milestone: String = "" ) -> [UIView] { let titleLabel = UILabel() @@ -815,7 +843,7 @@ private func detailViews( milestoneLabel.accessibilityLabel = "Milestone \(milestone)" views.append(milestoneLabel) } - return views + [separator(), bodyView] + commentViews(comments) + return views + [separator(), bodyView] + commentViews(comments, editComment: editComment) } private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView { @@ -843,18 +871,37 @@ private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView { return stack } -private func commentViews(_ comments: [CommentRow]) -> [UIView] { +private func commentViews( + _ comments: [CommentRow], + editComment: ((CommentRow) -> Void)? = nil +) -> [UIView] { guard !comments.isEmpty else { return [] } var views: [UIView] = [sectionLabel("Comments")] for comment in comments { let author = UILabel() author.text = comment.author author.font = .preferredFont(forTextStyle: .headline) + var headerViews: [UIView] = [author, UIView()] + if comment.canEdit, let editComment { + var configuration = UIButton.Configuration.plain() + configuration.title = "Edit" + configuration.image = UIImage(systemName: "pencil") + configuration.imagePadding = 5 + configuration.contentInsets = .zero + let button = UIButton( + configuration: configuration, + primaryAction: UIAction { _ in editComment(comment) } + ) + button.accessibilityLabel = "Edit comment by \(comment.author)" + headerViews.append(button) + } + let header = UIStackView(arrangedSubviews: headerViews) + header.alignment = .center let date = UILabel() date.text = comment.meta date.font = .preferredFont(forTextStyle: .caption1) date.textColor = .tertiaryLabel - let stack = UIStackView(arrangedSubviews: [author, markdownView(comment.body), date, separator()]) + let stack = UIStackView(arrangedSubviews: [header, markdownView(comment.body), date, separator()]) stack.axis = .vertical stack.spacing = 6 views.append(stack)