Use one Markdown presentation for free-form content. - issue and pull-request descriptions and comments - milestone and commit descriptions
388 lines
13 KiB
Swift
388 lines
13 KiB
Swift
import MarkdownUI
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct MarkdownContent: View {
|
|
let source: String
|
|
|
|
var body: some View {
|
|
Markdown(source)
|
|
.markdownTheme(.gitHub)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func markdownView(_ source: String) -> UIView {
|
|
UIHostingConfiguration {
|
|
MarkdownContent(source: source)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.margins(.all, 0)
|
|
.makeContentView()
|
|
}
|
|
|
|
func localDate(forUTCTimestamp timestamp: Int64) -> Date {
|
|
var utc = Calendar(identifier: .gregorian)
|
|
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
|
let components = utc.dateComponents(
|
|
[.year, .month, .day],
|
|
from: Date(timeIntervalSince1970: TimeInterval(timestamp))
|
|
)
|
|
return Calendar.current.date(from: components) ?? Date()
|
|
}
|
|
|
|
func utcTimestamp(forLocalDate date: Date) -> Int64 {
|
|
let components = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
|
var utc = Calendar(identifier: .gregorian)
|
|
utc.timeZone = TimeZone(secondsFromGMT: 0)!
|
|
return Int64((utc.date(from: components) ?? date).timeIntervalSince1970)
|
|
}
|
|
|
|
func errorAlert(_ message: String) -> UIAlertController {
|
|
let alert = UIAlertController(title: "Something went wrong", message: message, preferredStyle: .alert)
|
|
alert.addAction(UIAlertAction(title: "OK", style: .default))
|
|
return alert
|
|
}
|
|
|
|
extension UIViewController {
|
|
func show(error: Error) {
|
|
present(errorAlert(error.localizedDescription), animated: true)
|
|
}
|
|
|
|
func beginNavigationLoading(_ spinner: UIActivityIndicatorView) {
|
|
guard navigationItem.rightBarButtonItem == nil else { return }
|
|
spinner.startAnimating()
|
|
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner)
|
|
}
|
|
|
|
func endNavigationLoading(_ spinner: UIActivityIndicatorView) {
|
|
spinner.stopAnimating()
|
|
if navigationItem.rightBarButtonItem?.customView === spinner {
|
|
navigationItem.rightBarButtonItem = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
class RefreshingTableViewController: UITableViewController {
|
|
private let spinner = UIActivityIndicatorView(style: .medium)
|
|
var loadingTask: Task<Void, Never>?
|
|
private lazy var moreButton = UIButton(
|
|
configuration: .plain(),
|
|
primaryAction: UIAction { [weak self] _ in self?.requestMoreContent() }
|
|
)
|
|
private var hasMoreContent = false
|
|
private var loadingMore = false
|
|
|
|
init() {
|
|
super.init(style: .plain)
|
|
tableView.backgroundColor = .systemGroupedBackground
|
|
tableView.separatorInset = .zero
|
|
tableView.refreshControl = UIRefreshControl()
|
|
tableView.refreshControl?.addTarget(self, action: #selector(refreshRequested), for: .valueChanged)
|
|
navigationItem.largeTitleDisplayMode = .never
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
deinit { loadingTask?.cancel() }
|
|
|
|
func loadContent(refreshing: Bool) {}
|
|
|
|
func loadMoreContent() {}
|
|
|
|
func beginLoading(refreshing: Bool) {
|
|
if !refreshing { beginNavigationLoading(spinner) }
|
|
}
|
|
|
|
func endLoading() {
|
|
endNavigationLoading(spinner)
|
|
refreshControl?.endRefreshing()
|
|
}
|
|
|
|
func resetPagination() {
|
|
hasMoreContent = false
|
|
loadingMore = false
|
|
tableView.tableFooterView = nil
|
|
}
|
|
|
|
func finishPagination(hasMore: Bool) {
|
|
hasMoreContent = hasMore
|
|
loadingMore = false
|
|
var configuration = moreButton.configuration
|
|
configuration?.title = "Pull up or tap to load more"
|
|
configuration?.showsActivityIndicator = false
|
|
moreButton.configuration = configuration
|
|
moreButton.accessibilityLabel = "Load more results"
|
|
tableView.tableFooterView = hasMore ? paginationFooter() : nil
|
|
}
|
|
|
|
func failPagination() {
|
|
loadingMore = false
|
|
finishPagination(hasMore: hasMoreContent)
|
|
}
|
|
|
|
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
guard scrollView.isDragging, hasMoreContent, !loadingMore else { return }
|
|
let bottom = max(
|
|
-scrollView.adjustedContentInset.top,
|
|
scrollView.contentSize.height
|
|
+ scrollView.adjustedContentInset.bottom
|
|
- scrollView.bounds.height
|
|
)
|
|
if scrollView.contentOffset.y > bottom + 60 { requestMoreContent() }
|
|
}
|
|
|
|
private func requestMoreContent() {
|
|
guard hasMoreContent, !loadingMore else { return }
|
|
loadingMore = true
|
|
var configuration = moreButton.configuration
|
|
configuration?.title = "Loading more…"
|
|
configuration?.showsActivityIndicator = true
|
|
moreButton.configuration = configuration
|
|
moreButton.accessibilityLabel = "Loading more results"
|
|
loadMoreContent()
|
|
}
|
|
|
|
private func paginationFooter() -> UIView {
|
|
let footer = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 56))
|
|
moreButton.frame = footer.bounds
|
|
moreButton.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
footer.addSubview(moreButton)
|
|
return footer
|
|
}
|
|
|
|
@objc private func refreshRequested() {
|
|
loadContent(refreshing: true)
|
|
}
|
|
}
|
|
|
|
final class EmptyBackgroundView: UIView {
|
|
init(title: String, detail: String) {
|
|
super.init(frame: .zero)
|
|
let titleLabel = UILabel()
|
|
titleLabel.text = title
|
|
titleLabel.font = .preferredFont(forTextStyle: .title2)
|
|
titleLabel.textAlignment = .center
|
|
let detailLabel = UILabel()
|
|
detailLabel.text = detail
|
|
detailLabel.font = .preferredFont(forTextStyle: .body)
|
|
detailLabel.textColor = .secondaryLabel
|
|
detailLabel.textAlignment = .center
|
|
detailLabel.numberOfLines = 0
|
|
let stack = UIStackView(arrangedSubviews: [titleLabel, detailLabel])
|
|
stack.axis = .vertical
|
|
stack.spacing = 8
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(stack)
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
|
|
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
|
|
stack.centerYAnchor.constraint(equalTo: centerYAnchor, constant: -40),
|
|
])
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
}
|
|
|
|
extension UIColor {
|
|
convenience init?(hex: String) {
|
|
let value = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
|
guard value.count == 6, let rgb = Int(value, radix: 16) else { return nil }
|
|
self.init(
|
|
red: CGFloat((rgb >> 16) & 0xff) / 255,
|
|
green: CGFloat((rgb >> 8) & 0xff) / 255,
|
|
blue: CGFloat(rgb & 0xff) / 255,
|
|
alpha: 1
|
|
)
|
|
}
|
|
}
|
|
|
|
func configureTextCell(_ cell: UITableViewCell, title: String, detail: String, image: UIImage? = nil) {
|
|
var content = cell.defaultContentConfiguration()
|
|
content.text = title
|
|
content.secondaryText = detail
|
|
content.secondaryTextProperties.numberOfLines = 2
|
|
content.image = image
|
|
content.imageProperties.tintColor = .tintColor
|
|
cell.contentConfiguration = content
|
|
cell.backgroundColor = .systemBackground
|
|
}
|
|
|
|
func filterMenuImage(_ symbol: String, active: Bool) -> UIImage? {
|
|
let image = UIImage(systemName: symbol)
|
|
return active
|
|
? image?.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
|
|
: image
|
|
}
|
|
|
|
func configureRepositoryContentCell(_ cell: UITableViewCell, row: RepositoryContentRow) {
|
|
let directory = row.kind == "dir"
|
|
let detail = directory
|
|
? "Folder"
|
|
: ByteCountFormatter.string(fromByteCount: row.size, countStyle: .file)
|
|
configureTextCell(
|
|
cell,
|
|
title: row.name,
|
|
detail: detail,
|
|
image: UIImage(systemName: directory ? "folder.fill" : "doc")
|
|
)
|
|
cell.accessoryType = .disclosureIndicator
|
|
}
|
|
|
|
@MainActor
|
|
func showRepositoryContent(
|
|
_ row: RepositoryContentRow,
|
|
context: AppContext,
|
|
owner: String,
|
|
repository: String,
|
|
navigationController: UINavigationController?
|
|
) {
|
|
let destination: UIViewController = row.kind == "dir"
|
|
? RepositoryDirectoryViewController(
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
path: row.path,
|
|
name: row.name
|
|
)
|
|
: RepositoryFileViewController(
|
|
context: context,
|
|
owner: owner,
|
|
repository: repository,
|
|
path: row.path,
|
|
name: row.name
|
|
)
|
|
navigationController?.pushViewController(destination, animated: true)
|
|
}
|
|
|
|
func symbolText(_ symbol: String, text: String, font: UIFont, color: UIColor) -> NSAttributedString {
|
|
let output = NSMutableAttributedString()
|
|
if let image = UIImage(systemName: symbol)?.withTintColor(color, renderingMode: .alwaysOriginal) {
|
|
let attachment = NSTextAttachment(image: image)
|
|
attachment.bounds = CGRect(x: 0, y: -2, width: font.pointSize, height: font.pointSize)
|
|
output.append(NSAttributedString(attachment: attachment))
|
|
output.append(NSAttributedString(string: " "))
|
|
}
|
|
output.append(NSAttributedString(string: text, attributes: [
|
|
.font: font,
|
|
.foregroundColor: color,
|
|
]))
|
|
return output
|
|
}
|
|
|
|
func configureIssueStateIcon(
|
|
_ icon: UIImageView,
|
|
state: String,
|
|
textStyle: UIFont.TextStyle
|
|
) {
|
|
configureOpenClosedStateIcon(icon, state: state, subject: "issue", textStyle: textStyle)
|
|
}
|
|
|
|
func configureOpenClosedStateIcon(
|
|
_ icon: UIImageView,
|
|
state: String,
|
|
subject: String,
|
|
textStyle: UIFont.TextStyle
|
|
) {
|
|
let symbol: String
|
|
let color: UIColor
|
|
let accessibilityLabel: String
|
|
switch state {
|
|
case "open":
|
|
(symbol, color, accessibilityLabel) = (
|
|
"exclamationmark.circle.fill", .systemGreen, "Open \(subject)"
|
|
)
|
|
case "closed":
|
|
(symbol, color, accessibilityLabel) = (
|
|
"checkmark.circle.fill", .systemPurple, "Closed \(subject)"
|
|
)
|
|
default:
|
|
(symbol, color, accessibilityLabel) = (
|
|
"questionmark.circle.fill", .systemGray, "Unknown \(subject) state"
|
|
)
|
|
}
|
|
icon.image = UIImage(systemName: symbol)
|
|
icon.tintColor = color
|
|
icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: textStyle)
|
|
icon.setContentHuggingPriority(.required, for: .horizontal)
|
|
icon.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
icon.isAccessibilityElement = true
|
|
icon.accessibilityLabel = accessibilityLabel
|
|
}
|
|
|
|
func issueLabelView(_ label: LabelRow) -> UILabel {
|
|
let view = UILabel()
|
|
view.text = " \(label.name) "
|
|
view.font = .preferredFont(forTextStyle: .caption2)
|
|
view.adjustsFontForContentSizeCategory = true
|
|
view.textColor = label.light ? .black : .white
|
|
view.backgroundColor = UIColor(hex: label.color)
|
|
view.layer.cornerRadius = 9
|
|
view.clipsToBounds = true
|
|
view.accessibilityLabel = "Label \(label.name)"
|
|
return view
|
|
}
|
|
|
|
final class IssueLabelsView: UIView {
|
|
private let labels: [UILabel]
|
|
private var contentHeight: CGFloat
|
|
private let spacing: CGFloat = 6
|
|
|
|
init(_ rows: [LabelRow]) {
|
|
labels = rows.map(issueLabelView)
|
|
contentHeight = labels.first?.intrinsicContentSize.height ?? 0
|
|
super.init(frame: .zero)
|
|
labels.forEach(addSubview)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(contentSizeCategoryDidChange),
|
|
name: UIContentSizeCategory.didChangeNotification,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
CGSize(width: UIView.noIntrinsicMetric, height: contentHeight)
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
guard bounds.width > 0 else { return }
|
|
var origin = CGPoint.zero
|
|
var rowHeight: CGFloat = 0
|
|
for label in labels {
|
|
let size = label.sizeThatFits(
|
|
CGSize(width: bounds.width, height: .greatestFiniteMagnitude)
|
|
)
|
|
if origin.x > 0, origin.x + size.width > bounds.width {
|
|
origin.x = 0
|
|
origin.y += rowHeight + spacing
|
|
rowHeight = 0
|
|
}
|
|
label.frame = CGRect(origin: origin, size: size)
|
|
origin.x += size.width + spacing
|
|
rowHeight = max(rowHeight, size.height)
|
|
}
|
|
let height = origin.y + rowHeight
|
|
if contentHeight != height {
|
|
contentHeight = height
|
|
invalidateIntrinsicContentSize()
|
|
}
|
|
}
|
|
|
|
@objc private func contentSizeCategoryDidChange() {
|
|
labels.forEach { $0.font = .preferredFont(forTextStyle: .caption2) }
|
|
contentHeight = labels.first?.intrinsicContentSize.height ?? 0
|
|
invalidateIntrinsicContentSize()
|
|
setNeedsLayout()
|
|
}
|
|
}
|