Add issue comment editing

This commit is contained in:
Georg Bauer
2026-07-31 15:17:09 +02:00
parent 33f9eb809c
commit da0a3aecb4
10 changed files with 451 additions and 12 deletions

View File

@@ -174,6 +174,16 @@ xcrun simctl launch booted de.rfc1437.gotcha
appears beside the title and VoiceOver announces the state; author appears beside the title and VoiceOver announces the state; author
metadata, Markdown body, and comments remain correct. Links and metadata, Markdown body, and comments remain correct. Links and
selectable text use normal iOS interaction. 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 ## Repositories and commits

View File

@@ -466,14 +466,16 @@ pub async fn load_issue(
let client = let client =
Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?;
let configuration = client.configuration(); 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), apis::issue_api::issue_get_issue(&configuration, owner, repository, number),
load_issue_comments(&configuration, owner, repository, number, page), load_issue_comments(&configuration, owner, repository, number, page),
client.current_user(),
); );
Ok(IssueDetails { Ok(IssueDetails {
issue: issue.map_err(|error| error.to_string())?, issue: issue.map_err(|error| error.to_string())?,
has_more: false, has_more: false,
comments: comments?, 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()) .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( pub async fn load_issue_editor(
server: &Server, server: &Server,
owner: &str, owner: &str,
@@ -1041,4 +1104,15 @@ mod tests {
assert_eq!(edit.milestone, Some(0)); assert_eq!(edit.milestone, Some(0));
assert_eq!(edit.unset_due_date, Some(true)); 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));
}
} }

View File

@@ -154,6 +154,7 @@ pub struct HomeData {
pub struct IssueDetails { pub struct IssueDetails {
pub issue: models::Issue, pub issue: models::Issue,
pub comments: Vec<models::Comment>, pub comments: Vec<models::Comment>,
pub viewer_id: Option<i64>,
pub has_more: bool, pub has_more: bool,
} }

View File

@@ -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( pub async fn issue_editor(
&self, &self,
owner: String, owner: String,

View File

@@ -64,9 +64,11 @@ pub struct PullRow {
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct CommentRow { pub struct CommentRow {
pub id: i64,
pub author: String, pub author: String,
pub body: String, pub body: String,
pub meta: String, pub meta: String,
pub can_edit: bool,
} }
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
@@ -514,7 +516,11 @@ pub fn issue_page(details: IssueDetails) -> IssuePage {
.body .body
.filter(|body| !body.is_empty()) .filter(|body| !body.is_empty())
.unwrap_or_else(|| "No description provided.".into()), .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, has_more: details.has_more,
} }
} }
@@ -627,7 +633,11 @@ pub fn pull_page(details: PullDetails) -> PullPage {
.unwrap_or_else(|| "No description provided.".into()), .unwrap_or_else(|| "No description provided.".into()),
files_ref: pull_files_ref(pull), files_ref: pull_files_ref(pull),
files: details.files.iter().filter_map(file_row).collect(), 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, 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 { CommentRow {
id: comment.id.unwrap_or_default(),
author: comment author: comment
.user .user
.as_ref() .as_ref()
@@ -1005,6 +1016,14 @@ fn comment_row(comment: &models::Comment) -> CommentRow {
.as_deref() .as_deref()
.or(comment.created_at.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] #[test]
fn issue_page_exposes_state() { fn issue_page_exposes_state_and_owned_comment_editing() {
let page = issue_page(IssueDetails { let page = issue_page(IssueDetails {
issue: models::Issue { issue: models::Issue {
state: Some("closed".into()), state: Some("closed".into()),
..Default::default() ..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, has_more: false,
}); });
assert_eq!(page.state, "closed"); 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] #[test]

View File

@@ -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 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 selectServer(index: UInt32) throws
func servers() -> [ServerRow] 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) { open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_select_server( 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 struct CommentRow: Equatable, Hashable {
public var id: Int64
public var author: String public var author: String
public var body: String public var body: String
public var meta: String public var meta: String
public var canEdit: Bool
// Default memberwise initializers are never public by default, so we // Default memberwise initializers are never public by default, so we
// declare one manually. // 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.author = author
self.body = body self.body = body
self.meta = meta 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 { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> CommentRow {
return return
try CommentRow( try CommentRow(
id: FfiConverterInt64.read(from: &buf),
author: FfiConverterString.read(from: &buf), author: FfiConverterString.read(from: &buf),
body: 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]) { 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.author, into: &buf)
FfiConverterString.write(value.body, into: &buf) FfiConverterString.write(value.body, into: &buf)
FfiConverterString.write(value.meta, 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) { if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue() != 55053) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }

View File

@@ -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 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 #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 #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
#define 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 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 #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER

View File

@@ -10,6 +10,7 @@
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; }; 1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; };
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; }; 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; };
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.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 */; }; 7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; };
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; }; 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; };
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.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; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = "<group>"; };
@@ -53,6 +55,7 @@
children = ( children = (
F61515849F6AACD721FE915C /* AppContext.swift */, F61515849F6AACD721FE915C /* AppContext.swift */,
9C0921C76676A14A024BA417 /* AppDelegate.swift */, 9C0921C76676A14A024BA417 /* AppDelegate.swift */,
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */,
CE5E10787062D1175125BB4C /* ContentScreens.swift */, CE5E10787062D1175125BB4C /* ContentScreens.swift */,
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */, 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
722405F999984C7CBE838711 /* IssueEditorViewController.swift */, 722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
@@ -193,6 +196,7 @@
files = ( files = (
EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */, EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */,
950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */, 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */,
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */,
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */, 1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */,
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */, 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */,
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */, D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */,

View 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) }
}

View File

@@ -146,15 +146,41 @@ final class IssueViewController: MarkdownPageViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.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", title: "Edit",
style: .plain, style: .plain,
target: self, target: self,
action: #selector(editIssue) action: #selector(editIssue)
) )
navigationItem.rightBarButtonItems = [addComment, editIssue]
loadContent(refreshing: false) 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() { @objc private func editIssue() {
let editor = IssueEditorViewController( let editor = IssueEditorViewController(
context: context, context: context,
@@ -204,6 +230,7 @@ final class IssueViewController: MarkdownPageViewController {
meta: page.meta, meta: page.meta,
body: page.body, body: page.body,
comments: page.comments, comments: page.comments,
editComment: { [weak self] comment in self?.editComment(comment) },
milestone: page.milestone milestone: page.milestone
)) ))
finishPagination(hasMore: page.hasMore) finishPagination(hasMore: page.hasMore)
@@ -791,6 +818,7 @@ private func detailViews(
meta: String, meta: String,
body: String, body: String,
comments: [CommentRow], comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil,
milestone: String = "" milestone: String = ""
) -> [UIView] { ) -> [UIView] {
let titleLabel = UILabel() let titleLabel = UILabel()
@@ -815,7 +843,7 @@ private func detailViews(
milestoneLabel.accessibilityLabel = "Milestone \(milestone)" milestoneLabel.accessibilityLabel = "Milestone \(milestone)"
views.append(milestoneLabel) 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 { private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView {
@@ -843,18 +871,37 @@ private func issueTitle(_ titleLabel: UILabel, state: String) -> UIView {
return stack return stack
} }
private func commentViews(_ comments: [CommentRow]) -> [UIView] { private func commentViews(
_ comments: [CommentRow],
editComment: ((CommentRow) -> Void)? = nil
) -> [UIView] {
guard !comments.isEmpty else { return [] } guard !comments.isEmpty else { return [] }
var views: [UIView] = [sectionLabel("Comments")] var views: [UIView] = [sectionLabel("Comments")]
for comment in comments { for comment in comments {
let author = UILabel() let author = UILabel()
author.text = comment.author author.text = comment.author
author.font = .preferredFont(forTextStyle: .headline) 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() let date = UILabel()
date.text = comment.meta date.text = comment.meta
date.font = .preferredFont(forTextStyle: .caption1) date.font = .preferredFont(forTextStyle: .caption1)
date.textColor = .tertiaryLabel 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.axis = .vertical
stack.spacing = 6 stack.spacing = 6
views.append(stack) views.append(stack)