Add issue comment editing
This commit is contained in:
10
TESTING.md
10
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
|
||||
|
||||
|
||||
@@ -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<i64>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ pub struct HomeData {
|
||||
pub struct IssueDetails {
|
||||
pub issue: models::Issue,
|
||||
pub comments: Vec<models::Comment>,
|
||||
pub viewer_id: Option<i64>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
|
||||
@@ -353,6 +353,25 @@ impl GotchaCore {
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn save_issue_comment(
|
||||
&self,
|
||||
owner: String,
|
||||
repository: String,
|
||||
number: i64,
|
||||
comment_id: Option<i64>,
|
||||
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,
|
||||
|
||||
@@ -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<LabelRow> {
|
||||
})
|
||||
}
|
||||
|
||||
fn comment_row(comment: &models::Comment) -> CommentRow {
|
||||
fn comment_row(comment: &models::Comment, viewer_id: Option<i64>) -> 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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "<group>"; };
|
||||
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
|
||||
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = "<group>"; };
|
||||
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = "<group>"; };
|
||||
CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = "<group>"; };
|
||||
@@ -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 */,
|
||||
|
||||
201
ios/Sources/CommentEditorViewController.swift
Normal file
201
ios/Sources/CommentEditorViewController.swift
Normal file
@@ -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<Void, Never>?
|
||||
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) }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user