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? 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() } }