Files
GitHawk/Classes/Issues/IssuesViewController.swift
2018-08-11 14:51:11 -04:00

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