mirror of
https://github.com/zhigang1992/GitHawk.git
synced 2026-03-29 22:39:31 +08:00
619 lines
22 KiB
Swift
619 lines
22 KiB
Swift
//
|
|
// IssuesViewController.swift
|
|
// Freetime
|
|
//
|
|
// Created by Ryan Nystrom on 5/17/17.
|
|
// Copyright © 2017 Ryan Nystrom. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import IGListKit
|
|
import TUSafariActivity
|
|
import SafariServices
|
|
import SnapKit
|
|
import FlatCache
|
|
import MessageViewController
|
|
import Squawk
|
|
import ContextMenu
|
|
import GitHubAPI
|
|
|
|
extension ListDiffable {
|
|
var needsSpacer: Bool {
|
|
return self is IssueCommentModel || self is IssueReviewModel
|
|
}
|
|
}
|
|
|
|
final class IssuesViewController:
|
|
MessageViewController,
|
|
ListAdapterDataSource,
|
|
FeedDelegate,
|
|
AddCommentListener,
|
|
FeedSelectionProviding,
|
|
IssueNeckLoadSectionControllerDelegate,
|
|
FlatCacheListener,
|
|
IssueCommentSectionControllerDelegate,
|
|
IssueTextActionsViewSendDelegate,
|
|
MessageTextViewListener {
|
|
|
|
private let client: GithubClient
|
|
private let model: IssueDetailsModel
|
|
private let addCommentClient: AddCommentClient
|
|
private let textActionsController = TextActionsController()
|
|
private var bookmarkNavController: BookmarkNavigationController? = nil
|
|
private var autocompleteController: AutocompleteController!
|
|
private let manageController: IssueManagingContextController
|
|
private let threadInset = UIEdgeInsets(
|
|
top: Styles.Sizes.rowSpacing / 2,
|
|
left: Styles.Sizes.gutter,
|
|
bottom: 2 * Styles.Sizes.rowSpacing + Styles.Sizes.tableCellHeight,
|
|
right: Styles.Sizes.gutter
|
|
)
|
|
|
|
private var needsScrollToBottom = false
|
|
private var lastTimelineElement: ListDiffable?
|
|
private var actions: IssueTextActionsView?
|
|
|
|
// must fetch collaborator info from API before showing editing controls
|
|
private var viewerIsCollaborator = false
|
|
|
|
lazy private var feed: Feed = {
|
|
let f = Feed(viewController: self, delegate: self, managesLayout: false)
|
|
f.collectionView.contentInset = threadInset
|
|
f.collectionView.backgroundColor = .white
|
|
return f
|
|
}()
|
|
|
|
private var resultID: String? = nil {
|
|
didSet {
|
|
let hidden: Bool
|
|
if let id = resultID,
|
|
let result = self.client.cache.get(id: id) as IssueResult? {
|
|
hidden = result.labels.locked && !viewerIsCollaborator
|
|
|
|
let bookmark = Bookmark(
|
|
type: result.pullRequest ? .pullRequest : .issue,
|
|
name: self.model.repo,
|
|
owner: self.model.owner,
|
|
number: self.model.number,
|
|
title: result.title.string.allText,
|
|
defaultBranch: result.defaultBranch
|
|
)
|
|
self.bookmarkNavController = BookmarkNavigationController(store: client.bookmarksStore, model: bookmark)
|
|
self.configureNavigationItems()
|
|
} else {
|
|
hidden = true
|
|
}
|
|
self.setMessageView(hidden: hidden, animated: trueUnlessReduceMotionEnabled)
|
|
|
|
self.manageController.resultID = resultID
|
|
}
|
|
}
|
|
|
|
var result: IssueResult? {
|
|
guard let id = resultID else { return nil }
|
|
return client.cache.get(id: id) as IssueResult?
|
|
}
|
|
|
|
var moreOptionsItem: UIBarButtonItem {
|
|
let rightItem = UIBarButtonItem(
|
|
barButtonSystemItem: .action,
|
|
target: self,
|
|
action: #selector(IssuesViewController.onMore(sender:))
|
|
)
|
|
rightItem.accessibilityLabel = NSLocalizedString("Share", comment: "")
|
|
return rightItem
|
|
}
|
|
|
|
init(
|
|
client: GithubClient,
|
|
model: IssueDetailsModel,
|
|
scrollToBottom: Bool = false
|
|
) {
|
|
self.client = client
|
|
self.model = model
|
|
self.addCommentClient = AddCommentClient(client: client)
|
|
self.needsScrollToBottom = scrollToBottom
|
|
self.manageController = IssueManagingContextController(model: model, client: client)
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
self.autocompleteController = AutocompleteController(
|
|
messageAutocompleteController: messageAutocompleteController,
|
|
autocomplete: IssueCommentAutocomplete(autocompletes: [
|
|
EmojiAutocomplete(),
|
|
IssueAutocomplete(client: client.client, owner: model.owner, repo: model.repo)
|
|
])
|
|
)
|
|
|
|
self.hidesBottomBarWhenPushed = true
|
|
self.addCommentClient.addListener(listener: self)
|
|
|
|
cacheKey = "issue.\(model.owner).\(model.repo).\(model.number)"
|
|
|
|
manageController.viewController = self
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
makeBackBarItemEmpty()
|
|
|
|
let labelFormat = NSLocalizedString("#%d in repository %@ by %@", comment: "Accessibility label for an issue/pull request navigation item")
|
|
let labelString = String(format: labelFormat, arguments: [model.number, model.repo, model.owner])
|
|
|
|
let navigationTitle = NavigationTitleDropdownView()
|
|
navigationTitle.addTarget(self, action: #selector(onNavigationTitle(sender:)), for: .touchUpInside)
|
|
navigationTitle.configure(
|
|
title: "#\(model.number)",
|
|
subtitle: "\(model.owner)/\(model.repo)",
|
|
accessibilityLabel: labelString,
|
|
accessibilityHint: NSLocalizedString(
|
|
"Gives the option to view the repository's overview or owner",
|
|
comment: "The hint for tapping the navigationBar's repository information.")
|
|
)
|
|
navigationItem.titleView = navigationTitle
|
|
|
|
feed.viewDidLoad()
|
|
feed.adapter.dataSource = self
|
|
|
|
// setup after feed is lazy loaded
|
|
setup(scrollView: feed.collectionView)
|
|
setMessageView(hidden: true, animated: false)
|
|
|
|
// override Feed bg color setting
|
|
view.backgroundColor = Styles.Colors.background
|
|
|
|
// setup message view properties
|
|
configure()
|
|
|
|
let getMarkdownBlock = { [weak self] () -> (String) in
|
|
return self?.messageView.text ?? ""
|
|
}
|
|
let actions = IssueTextActionsView.forMarkdown(
|
|
viewController: self,
|
|
getMarkdownBlock: getMarkdownBlock,
|
|
repo: model.repo,
|
|
owner: model.owner,
|
|
addBorder: false,
|
|
supportsImageUpload: true,
|
|
showSendButton: true
|
|
)
|
|
// text input bar uses UIVisualEffectView, don't try to match it
|
|
actions.backgroundColor = .clear
|
|
actions.sendDelegate = self
|
|
self.actions = actions
|
|
|
|
actions.sendButtonEnabled = !messageView.textView.text.isEmpty
|
|
messageView.textView.add(listener: self)
|
|
|
|
textActionsController.configure(client: client, textView: messageView.textView, actions: actions)
|
|
textActionsController.viewController = self
|
|
|
|
actions.frame = CGRect(x: 0, y: 0, width: 0, height: 32)
|
|
messageView.add(contentView: actions)
|
|
|
|
//show disabled bookmark button until issue has finished loading
|
|
navigationItem.rightBarButtonItems = [ moreOptionsItem, BookmarkNavigationController.disabledNavigationItem ]
|
|
|
|
// insert below so button doesn't appear above autocomplete
|
|
view.insertSubview(manageController.manageButton, belowSubview: messageView)
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
let informator = HandoffInformator(
|
|
activityName: "viewIssue",
|
|
activityTitle: "\(model.owner)/\(model.repo)#\(model.number)",
|
|
url: externalURL
|
|
)
|
|
setupUserActivity(with: informator)
|
|
}
|
|
|
|
override func viewDidDisappear(_ animated: Bool) {
|
|
super.viewDidDisappear(animated)
|
|
invalidateUserActivity()
|
|
}
|
|
|
|
override func viewWillLayoutSubviews() {
|
|
super.viewWillLayoutSubviews()
|
|
feed.viewWillLayoutSubviews(view: view)
|
|
}
|
|
|
|
override func viewSafeAreaInsetsDidChange() {
|
|
super.viewSafeAreaInsetsDidChange()
|
|
feed.collectionView.updateSafeInset(container: view, base: threadInset)
|
|
}
|
|
|
|
override func didLayout() {
|
|
let manageButtonSize = manageController.manageButton.bounds.size
|
|
manageController.manageButton.frame = CGRect(
|
|
origin: CGPoint(
|
|
x: view.bounds.width - manageButtonSize.width - Styles.Sizes.gutter - view.safeAreaInsets.right,
|
|
y: messageView.frame.minY - manageButtonSize.height - Styles.Sizes.gutter),
|
|
size: manageButtonSize
|
|
)
|
|
}
|
|
|
|
// MARK: Private API
|
|
|
|
var externalURL: URL {
|
|
return URL(string: "https://github.com/\(model.owner)/\(model.repo)/issues/\(model.number)")!
|
|
}
|
|
|
|
var bookmark: Bookmark? {
|
|
guard let result = result else { return nil }
|
|
return Bookmark(
|
|
type: result.pullRequest ? .pullRequest : .issue,
|
|
name: model.repo,
|
|
owner: model.owner,
|
|
number: model.number,
|
|
title: result.title.string.allText,
|
|
defaultBranch: result.defaultBranch
|
|
)
|
|
}
|
|
|
|
func configureNavigationItems() {
|
|
guard let rightbarButtonItems = navigationItem.rightBarButtonItems else { return }
|
|
guard let bookmarkItem = rightbarButtonItems.last else { return }
|
|
bookmarkNavController?.configureNavigationItem(bookmarkItem)
|
|
}
|
|
|
|
func viewRepoAction() -> UIAlertAction? {
|
|
guard let result = result else { return nil }
|
|
return action(
|
|
owner: model.owner,
|
|
repo: model.repo,
|
|
branch: result.defaultBranch,
|
|
issuesEnabled: result.hasIssuesEnabled,
|
|
client: client
|
|
)
|
|
}
|
|
|
|
@objc func onMore(sender: UIBarButtonItem) {
|
|
let activityController = UIActivityViewController(
|
|
activityItems: [externalURL],
|
|
applicationActivities: [TUSafariActivity()]
|
|
)
|
|
activityController.popoverPresentationController?.barButtonItem = sender
|
|
present(activityController, animated: trueUnlessReduceMotionEnabled)
|
|
}
|
|
|
|
func fetch(previous: Bool) {
|
|
if !previous {
|
|
client.fetchViewerCollaborator(
|
|
owner: model.owner,
|
|
repo: model.repo
|
|
) { [weak self] (result) in
|
|
switch result {
|
|
case .success(let permission):
|
|
let collab: Bool
|
|
switch permission {
|
|
case .admin, .write: collab = true
|
|
case .read, .none: collab = false
|
|
}
|
|
self?.viewerIsCollaborator = collab
|
|
if collab {
|
|
self?.manageController.permissions = .collaborator
|
|
}
|
|
// avoid finishLoading() so empty view doesn't appear
|
|
self?.feed.adapter.performUpdates(animated: trueUnlessReduceMotionEnabled)
|
|
case .error:
|
|
Squawk.showGenericError()
|
|
}
|
|
}
|
|
}
|
|
|
|
// assumptions here, but the collectionview may not have been laid out or content size found
|
|
// assume the collectionview is pinned to the view's bounds
|
|
let contentInset = feed.collectionView.contentInset
|
|
let width = view.bounds.width - contentInset.left - contentInset.right
|
|
|
|
client.fetch(
|
|
owner: model.owner,
|
|
repo: model.repo,
|
|
number: model.number,
|
|
width: width,
|
|
prependResult: previous ? result : nil
|
|
) { [weak self] resultType in
|
|
guard let strongSelf = self else { return }
|
|
|
|
let isFirstUpdate = strongSelf.resultID == nil
|
|
|
|
switch resultType {
|
|
case .success(let result, let mentionableUsers):
|
|
strongSelf.autocompleteController.autocomplete.add(
|
|
UserAutocomplete(mentionableUsers: mentionableUsers)
|
|
)
|
|
strongSelf.client.cache.add(listener: strongSelf, value: result)
|
|
strongSelf.resultID = result.id
|
|
default: break
|
|
}
|
|
|
|
// subsequent updates are handled by the FlatCacheListener
|
|
if isFirstUpdate {
|
|
strongSelf.updateAndScrollIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateAndScrollIfNeeded(dismissRefresh: Bool = true) {
|
|
feed.finishLoading(dismissRefresh: dismissRefresh) { [weak self] in
|
|
if self?.needsScrollToBottom == true {
|
|
self?.needsScrollToBottom = false
|
|
self?.scrollToLastContentElement()
|
|
}
|
|
}
|
|
}
|
|
|
|
func scrollToLastContentElement() {
|
|
guard let lastTimeline = lastTimelineElement else { return }
|
|
|
|
// assuming the last element is the "actions" when collaborator
|
|
feed.adapter.scroll(to: lastTimeline, padding: Styles.Sizes.rowSpacing)
|
|
}
|
|
|
|
func onPreview() {
|
|
let controller = IssuePreviewViewController(
|
|
markdown: messageView.text,
|
|
owner: model.owner,
|
|
repo: model.repo
|
|
)
|
|
showDetailViewController(controller, sender: nil)
|
|
}
|
|
|
|
@objc func onNavigationTitle(sender: UIView) {
|
|
let alert = UIAlertController.configured(preferredStyle: .actionSheet)
|
|
alert.addActions([
|
|
action(owner: model.owner),
|
|
viewRepoAction(),
|
|
AlertAction.cancel()
|
|
])
|
|
alert.popoverPresentationController?.setSourceView(sender)
|
|
present(alert, animated: trueUnlessReduceMotionEnabled)
|
|
}
|
|
|
|
// MARK: ListAdapterDataSource
|
|
|
|
func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
|
|
guard let current = self.result else { return [] }
|
|
|
|
var objects = [ListDiffable]()
|
|
|
|
// BEGIN collect metadata that lives between title and root comment
|
|
var metadata = [ListDiffable]()
|
|
metadata.append(current.labels)
|
|
if let milestone = current.milestone {
|
|
metadata.append(milestone)
|
|
}
|
|
if current.assignee.users.count > 0 {
|
|
metadata.append(current.assignee)
|
|
}
|
|
if let reviewers = current.reviewers {
|
|
metadata.append(reviewers)
|
|
}
|
|
if let changes = current.fileChanges {
|
|
metadata.append(IssueFileChangesModel(changes: changes))
|
|
}
|
|
// END metadata collection
|
|
|
|
objects.append(IssueTitleModel(string: current.title))
|
|
objects += metadata
|
|
|
|
if let targetBranch = current.targetBranch {
|
|
objects.append(targetBranch)
|
|
}
|
|
|
|
if let rootComment = current.rootComment {
|
|
objects.append(rootComment)
|
|
objects.append(SpacerModel(position: objects.count))
|
|
}
|
|
|
|
if current.hasPreviousPage {
|
|
objects.append(IssueNeckLoadModel())
|
|
}
|
|
|
|
let timelineViewModels = current.timelineViewModels
|
|
for (i, model) in timelineViewModels.enumerated() {
|
|
let needsSpacer = model.needsSpacer
|
|
|
|
// append a spacer if the previous timeline element wasn't a comment
|
|
if needsSpacer, i > 0
|
|
&& !(timelineViewModels[i-1].needsSpacer || timelineViewModels[i-1].needsSpacer) {
|
|
objects.append(SpacerModel(position: objects.count))
|
|
}
|
|
|
|
objects.append(model)
|
|
|
|
// always append a spacer unless its the last item in the timeline
|
|
if needsSpacer, i < timelineViewModels.count - 1 {
|
|
objects.append(SpacerModel(position: objects.count))
|
|
}
|
|
}
|
|
|
|
// side effect so to jump to the last element when auto scrolling
|
|
lastTimelineElement = objects.last
|
|
|
|
if viewerIsCollaborator,
|
|
current.labels.status.status == .open,
|
|
let merge = current.mergeModel {
|
|
objects.append(merge)
|
|
}
|
|
|
|
return objects
|
|
}
|
|
|
|
func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
|
|
switch object {
|
|
// header and metadata
|
|
case is IssueTitleModel: return IssueTitleSectionController()
|
|
case is IssueLabelsModel: return IssueLabelsSectionController(issue: model)
|
|
case is IssueAssigneesModel: return IssueAssigneesSectionController()
|
|
case is Milestone: return IssueMilestoneSectionController(issueModel: model)
|
|
case is IssueFileChangesModel: return IssueViewFilesSectionController(issueModel: model, client: client)
|
|
case is IssueTargetBranchModel: return IssueTargetBranchSectionController()
|
|
|
|
// timeline
|
|
case is IssueCommentModel:
|
|
return IssueCommentSectionController(
|
|
model: model,
|
|
client: client,
|
|
autocomplete: autocompleteController.autocomplete.copy,
|
|
issueCommentDelegate: self
|
|
)
|
|
case is IssueLabeledModel: return IssueLabeledSectionController(issueModel: model)
|
|
case is IssueStatusEventModel: return IssueStatusEventSectionController(issueModel: model)
|
|
case is IssueReferencedModel: return IssueReferencedSectionController(client: client)
|
|
case is IssueReferencedCommitModel: return IssueReferencedCommitSectionController()
|
|
case is IssueRenamedModel: return IssueRenamedSectionController()
|
|
case is IssueRequestModel: return IssueRequestSectionController()
|
|
case is IssueMilestoneEventModel: return IssueMilestoneEventSectionController()
|
|
case is IssueCommitModel: return IssueCommitSectionController(issueModel: model)
|
|
case is SpacerModel: return SpacerSectionController()
|
|
|
|
// controls
|
|
case is IssueNeckLoadModel: return IssueNeckLoadSectionController(delegate: self)
|
|
case is IssueMergeModel: return IssueMergeSectionController(model: model, client: client, resultID: resultID)
|
|
|
|
// deprecated
|
|
case is IssueDiffHunkModel: return IssueDiffHunkSectionController()
|
|
case is IssueReviewModel: return IssueReviewSectionController(
|
|
model: model,
|
|
client: client,
|
|
autocomplete: autocompleteController.autocomplete.copy
|
|
)
|
|
|
|
default: fatalError("Unhandled object: \(object)")
|
|
}
|
|
}
|
|
|
|
func emptyView(for listAdapter: ListAdapter) -> UIView? {
|
|
switch feed.status {
|
|
case .idle:
|
|
let emptyView = EmptyView()
|
|
emptyView.label.text = NSLocalizedString("Issue cannot be found", comment: "")
|
|
return emptyView
|
|
case .loading, .loadingNext:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: FeedDelegate
|
|
|
|
func loadFromNetwork(feed: Feed) {
|
|
fetch(previous: false)
|
|
}
|
|
|
|
func loadNextPage(feed: Feed) -> Bool {
|
|
return false
|
|
}
|
|
|
|
// MARK: AddCommentListener
|
|
|
|
func didSendComment(
|
|
client: AddCommentClient,
|
|
id: String,
|
|
commentFields: CommentFields,
|
|
reactionFields: ReactionFields,
|
|
viewerCanUpdate: Bool,
|
|
viewerCanDelete: Bool
|
|
) {
|
|
guard let previous = result,
|
|
let comment = createCommentModel(
|
|
id: id,
|
|
commentFields: commentFields,
|
|
reactionFields: reactionFields,
|
|
contentSizeCategory: UIContentSizeCategory.preferred,
|
|
width: view.bounds.width,
|
|
owner: model.owner,
|
|
repo: model.repo,
|
|
threadState: .single,
|
|
viewerCanUpdate: viewerCanUpdate,
|
|
viewerCanDelete: viewerCanDelete,
|
|
isRoot: false
|
|
)
|
|
else { return }
|
|
|
|
needsScrollToBottom = true
|
|
|
|
let newResult = previous.updated(
|
|
timelinePages: previous.timelinePages(appending: [comment])
|
|
)
|
|
self.client.cache.set(value: newResult)
|
|
}
|
|
|
|
func didFailSendingComment(client: AddCommentClient, subjectId: String, body: String) {
|
|
messageView.text = body
|
|
}
|
|
|
|
// MARK: FeedSelectionProviding
|
|
|
|
var feedContainsSelection: Bool {
|
|
return feed.collectionView.indexPathsForSelectedItems?.count != 0
|
|
}
|
|
|
|
// MARK: IssueNeckLoadSectionControllerDelegate
|
|
|
|
func didSelect(sectionController: IssueNeckLoadSectionController) {
|
|
fetch(previous: true)
|
|
}
|
|
|
|
// MARK: FlatCacheListener
|
|
|
|
func flatCacheDidUpdate(cache: FlatCache, update: FlatCache.Update) {
|
|
switch update {
|
|
case .item(let item):
|
|
guard item is IssueResult else { break }
|
|
updateAndScrollIfNeeded()
|
|
case .list: break
|
|
}
|
|
}
|
|
|
|
// MARK: IssueCommentSectionControllerDelegate
|
|
|
|
func didSelectReply(to sectionController: IssueCommentSectionController, commentModel: IssueCommentModel) {
|
|
setMessageView(hidden: false, animated: true)
|
|
messageView.textView.becomeFirstResponder()
|
|
let quote = getCommentUntilNewLine(from: commentModel.rawMarkdown)
|
|
messageView.text = ">\(quote)\n\n@\(commentModel.details.login) "
|
|
|
|
feed.adapter.scroll(to: commentModel, padding: Styles.Sizes.rowSpacing)
|
|
}
|
|
|
|
private func getCommentUntilNewLine(from string: String) -> String {
|
|
let substring = string.components(separatedBy: .newlines)[0]
|
|
if string == substring {
|
|
return string
|
|
}
|
|
return substring + " ..."
|
|
}
|
|
|
|
// MARK: IssueTextActionsViewSendDelegate
|
|
|
|
func didSend(for actionsView: IssueTextActionsView) {
|
|
// get text before calling super b/c it will clear it
|
|
let text = messageView.text
|
|
messageView.text = ""
|
|
|
|
if let id = resultID {
|
|
addCommentClient.addComment(
|
|
subjectId: id,
|
|
body: text
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: MessageTextViewListener
|
|
|
|
func didChange(textView: MessageTextView) {
|
|
actions?.sendButtonEnabled = !textView.text.isEmpty
|
|
}
|
|
|
|
func didChangeSelection(textView: MessageTextView) {}
|
|
func willChangeRange(textView: MessageTextView, to range: NSRange) {}
|
|
|
|
}
|