Add first-class Actions management (#63)
This commit is contained in:
@@ -2,25 +2,37 @@ import UIKit
|
||||
|
||||
@MainActor
|
||||
final class HomeViewController: RefreshingTableViewController {
|
||||
private enum ActivityFilter: Int {
|
||||
case all
|
||||
case issues
|
||||
case pulls
|
||||
|
||||
var coreValue: HomeActivityFilter {
|
||||
switch self {
|
||||
case .all: return .all
|
||||
case .issues: return .issues
|
||||
case .pulls: return .pullRequests
|
||||
}
|
||||
}
|
||||
private enum Timeline: Int {
|
||||
case user
|
||||
case allUsers
|
||||
case notifications
|
||||
}
|
||||
|
||||
private let context: AppContext
|
||||
private var page: HomePage?
|
||||
private var filter = ActivityFilter.all
|
||||
private var nextPage: UInt32?
|
||||
private var activities: [ActivityRow] { page?.activities ?? [] }
|
||||
private var timeline = Timeline.user
|
||||
private var userNextPage: UInt32?
|
||||
private var serverActivities: [ActivityRow] = []
|
||||
private var serverNextPage: UInt32?
|
||||
private let notificationStatusControl = UISegmentedControl(items: ["Open", "Closed"])
|
||||
private lazy var notificationHeaderView: UIView = {
|
||||
let header = UIView()
|
||||
notificationStatusControl.translatesAutoresizingMaskIntoConstraints = false
|
||||
header.addSubview(notificationStatusControl)
|
||||
NSLayoutConstraint.activate([
|
||||
notificationStatusControl.centerXAnchor.constraint(equalTo: header.centerXAnchor),
|
||||
notificationStatusControl.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
||||
])
|
||||
return header
|
||||
}()
|
||||
private var notificationStatus = NotificationStatus.open
|
||||
private var notificationRows: [NotificationRow] = []
|
||||
private var notificationPage: UInt32 = 0
|
||||
private var notificationsHaveMore = false
|
||||
|
||||
private var activities: [ActivityRow] {
|
||||
timeline == .allUsers ? serverActivities : page?.activities ?? []
|
||||
}
|
||||
|
||||
init(context: AppContext) {
|
||||
self.context = context
|
||||
@@ -33,6 +45,14 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification")
|
||||
notificationStatusControl.selectedSegmentIndex = 0
|
||||
notificationStatusControl.accessibilityLabel = "Notification status"
|
||||
notificationStatusControl.addTarget(
|
||||
self,
|
||||
action: #selector(notificationStatusChanged),
|
||||
for: .valueChanged
|
||||
)
|
||||
let servers = UIBarButtonItem(
|
||||
image: context.symbol("server.rack"),
|
||||
primaryAction: UIAction { [weak self] _ in
|
||||
@@ -61,8 +81,11 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
guard page == nil else { return }
|
||||
loadContent(refreshing: false)
|
||||
if page == nil {
|
||||
loadContent(refreshing: false)
|
||||
} else if timeline == .notifications {
|
||||
loadNotifications(page: 1, refreshing: false)
|
||||
}
|
||||
}
|
||||
|
||||
override func loadContent(refreshing: Bool) {
|
||||
@@ -74,15 +97,31 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
refreshControl?.endRefreshing()
|
||||
return
|
||||
}
|
||||
loadPage(1, refreshing: refreshing)
|
||||
switch timeline {
|
||||
case .user:
|
||||
loadUserPage(1, refreshing: refreshing)
|
||||
case .allUsers:
|
||||
loadServerActivityPage(1, refreshing: refreshing)
|
||||
case .notifications:
|
||||
loadNotifications(page: 1, refreshing: refreshing)
|
||||
}
|
||||
}
|
||||
|
||||
override func loadMoreContent() {
|
||||
guard let nextPage else { return }
|
||||
loadPage(nextPage, refreshing: false)
|
||||
switch timeline {
|
||||
case .user:
|
||||
guard let userNextPage else { return }
|
||||
loadUserPage(userNextPage, refreshing: false)
|
||||
case .allUsers:
|
||||
guard let serverNextPage else { return }
|
||||
loadServerActivityPage(serverNextPage, refreshing: false)
|
||||
case .notifications:
|
||||
guard notificationsHaveMore else { return }
|
||||
loadNotifications(page: notificationPage + 1, refreshing: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||
private func loadUserPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||
if requestedPage == 1 {
|
||||
resetPagination()
|
||||
beginLoading(refreshing: refreshing)
|
||||
@@ -92,62 +131,106 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
do {
|
||||
let result = try await context.core.home(
|
||||
page: requestedPage,
|
||||
filter: filter.coreValue
|
||||
filter: .all
|
||||
)
|
||||
guard !Task.isCancelled, timeline == .user else { return }
|
||||
if requestedPage == 1 {
|
||||
page = result
|
||||
} else {
|
||||
page?.activities.append(contentsOf: result.activities)
|
||||
page?.nextPage = result.nextPage
|
||||
}
|
||||
nextPage = result.nextPage
|
||||
userNextPage = result.nextPage
|
||||
finishPagination(hasMore: result.nextPage != nil)
|
||||
title = page?.serverName
|
||||
if requestedPage == 1 { tableView.tableHeaderView = page.map { page in
|
||||
HeatmapView(
|
||||
page: page,
|
||||
selectedFilter: filter.rawValue,
|
||||
onFilter: { [weak self] index in
|
||||
guard let self, let filter = ActivityFilter(rawValue: index) else { return }
|
||||
guard filter != self.filter else { return }
|
||||
self.filter = filter
|
||||
self.loadPage(1, refreshing: false)
|
||||
},
|
||||
onNotifications: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.navigationController?.pushViewController(
|
||||
NotificationsViewController(context: self.context),
|
||||
animated: true
|
||||
)
|
||||
},
|
||||
onServerActivity: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.navigationController?.pushViewController(
|
||||
ServerActivityViewController(context: self.context),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
)
|
||||
} }
|
||||
updateActivities()
|
||||
setPanelTitle(self, "Home", server: page?.serverName)
|
||||
if requestedPage == 1 { refreshPrimaryDestinations() }
|
||||
updateRows()
|
||||
} catch {
|
||||
if !Task.isCancelled {
|
||||
if !Task.isCancelled, timeline == .user {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
if requestedPage == 1 { endLoading() }
|
||||
if requestedPage == 1, timeline == .user { endLoading() }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadServerActivityPage(_ requestedPage: UInt32, refreshing: Bool) {
|
||||
if requestedPage == 1 {
|
||||
resetPagination()
|
||||
beginLoading(refreshing: refreshing)
|
||||
}
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
let result = try await context.core.serverActivity(page: requestedPage)
|
||||
guard !Task.isCancelled, timeline == .allUsers else { return }
|
||||
if requestedPage == 1 {
|
||||
serverActivities = result.rows
|
||||
} else {
|
||||
serverActivities.append(contentsOf: result.rows)
|
||||
}
|
||||
serverNextPage = result.hasMore ? requestedPage + 1 : nil
|
||||
finishPagination(hasMore: result.hasMore)
|
||||
updateRows()
|
||||
} catch {
|
||||
if !Task.isCancelled, timeline == .allUsers {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
if requestedPage == 1, timeline == .allUsers { endLoading() }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadNotifications(page requestedPage: UInt32, refreshing: Bool) {
|
||||
if requestedPage == 1 {
|
||||
resetPagination()
|
||||
beginLoading(refreshing: refreshing)
|
||||
}
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
let result = try await context.core.notifications(
|
||||
status: notificationStatus,
|
||||
page: requestedPage
|
||||
)
|
||||
guard !Task.isCancelled, timeline == .notifications else { return }
|
||||
if requestedPage == 1 {
|
||||
notificationRows = result.rows
|
||||
} else {
|
||||
notificationRows.append(contentsOf: result.rows)
|
||||
}
|
||||
notificationPage = requestedPage
|
||||
notificationsHaveMore = result.hasMore
|
||||
finishPagination(hasMore: result.hasMore)
|
||||
updateRows()
|
||||
} catch {
|
||||
if !Task.isCancelled, timeline == .notifications {
|
||||
show(error: error)
|
||||
failPagination()
|
||||
}
|
||||
}
|
||||
if requestedPage == 1, timeline == .notifications { endLoading() }
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
activities.count
|
||||
timeline == .notifications ? notificationRows.count : activities.count
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
cellForRowAt indexPath: IndexPath
|
||||
) -> UITableViewCell {
|
||||
if timeline == .notifications {
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: "notification",
|
||||
for: indexPath
|
||||
) as! NotificationCell
|
||||
cell.configure(notificationRows[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: "activity")
|
||||
?? UITableViewCell(style: .subtitle, reuseIdentifier: "activity")
|
||||
let row = activities[indexPath.row]
|
||||
@@ -162,22 +245,98 @@ final class HomeViewController: RefreshingTableViewController {
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
92
|
||||
timeline == .notifications ? UITableView.automaticDimension : 92
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
viewForHeaderInSection section: Int
|
||||
) -> UIView? {
|
||||
guard timeline == .notifications else { return nil }
|
||||
return notificationHeaderView
|
||||
}
|
||||
|
||||
override func tableView(
|
||||
_ tableView: UITableView,
|
||||
heightForHeaderInSection section: Int
|
||||
) -> CGFloat {
|
||||
timeline == .notifications ? 44 : .leastNormalMagnitude
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
context.route(activities[indexPath.row])
|
||||
if timeline == .notifications {
|
||||
openNotification(notificationRows[indexPath.row])
|
||||
} else {
|
||||
context.route(activities[indexPath.row])
|
||||
}
|
||||
}
|
||||
|
||||
private func updateActivities() {
|
||||
private func updateRows() {
|
||||
tableView.reloadData()
|
||||
tableView.backgroundView = activities.isEmpty
|
||||
? EmptyBackgroundView(
|
||||
title: "No matching activity",
|
||||
detail: "This server has no recent activity of the selected type."
|
||||
let isEmpty = timeline == .notifications ? notificationRows.isEmpty : activities.isEmpty
|
||||
guard isEmpty else {
|
||||
tableView.backgroundView = nil
|
||||
return
|
||||
}
|
||||
let message: (String, String) = switch timeline {
|
||||
case .user:
|
||||
("No activity", "This user has no recent activity.")
|
||||
case .allUsers:
|
||||
("No server activity", "No activity is visible to this server account.")
|
||||
case .notifications:
|
||||
notificationStatus == .open
|
||||
? ("No open notifications", "This server has no open notifications.")
|
||||
: ("No closed notifications", "This server has no closed notifications.")
|
||||
}
|
||||
tableView.backgroundView = EmptyBackgroundView(title: message.0, detail: message.1)
|
||||
}
|
||||
|
||||
private func selectTimeline(_ rawValue: Int) {
|
||||
guard let selected = Timeline(rawValue: rawValue), selected != timeline else { return }
|
||||
timeline = selected
|
||||
setPanelTitle(self, "Home", server: page?.serverName)
|
||||
refreshPrimaryDestinations()
|
||||
tableView.reloadData()
|
||||
loadContent(refreshing: false)
|
||||
}
|
||||
|
||||
@objc private func notificationStatusChanged() {
|
||||
notificationStatus = notificationStatusControl.selectedSegmentIndex == 0 ? .open : .closed
|
||||
loadNotifications(page: 1, refreshing: false)
|
||||
}
|
||||
|
||||
private func openNotification(_ row: NotificationRow) {
|
||||
guard row.target != .none else { return }
|
||||
guard row.unread else {
|
||||
context.route(row)
|
||||
return
|
||||
}
|
||||
loadingTask?.cancel()
|
||||
loadingTask = Task {
|
||||
do {
|
||||
try await context.core.markNotificationRead(serverId: row.serverId, id: row.id)
|
||||
guard !Task.isCancelled else { return }
|
||||
context.route(row)
|
||||
} catch {
|
||||
if !Task.isCancelled { show(error: error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshPrimaryDestinations() {
|
||||
tableView.tableHeaderView = page.map { page in
|
||||
HeatmapView(
|
||||
page: page,
|
||||
selectedTimeline: timeline.rawValue,
|
||||
destinations: context.secondaryDestinations.filter { $0 != .serverActivity },
|
||||
onTimeline: { [weak self] timeline in self?.selectTimeline(timeline) },
|
||||
onDestination: { [weak self] destination in
|
||||
guard let self else { return }
|
||||
self.context.show(destination, from: self.navigationController)
|
||||
}
|
||||
)
|
||||
: nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -197,13 +356,14 @@ func activitySymbolName(for icon: ActivityIcon) -> String {
|
||||
final class HeatmapView: UIView {
|
||||
private let cells: [HeatCell]
|
||||
private let calendar = Calendar(identifier: .gregorian)
|
||||
private let controlsScroller = UIScrollView()
|
||||
|
||||
init(
|
||||
page: HomePage,
|
||||
selectedFilter: Int,
|
||||
onFilter: @escaping (Int) -> Void,
|
||||
onNotifications: @escaping () -> Void,
|
||||
onServerActivity: @escaping () -> Void
|
||||
selectedTimeline: Int,
|
||||
destinations: [PrimaryDestination],
|
||||
onTimeline: @escaping (Int) -> Void,
|
||||
onDestination: @escaping (PrimaryDestination) -> Void
|
||||
) {
|
||||
cells = page.heatCells
|
||||
super.init(frame: CGRect(x: 0, y: 0, width: 0, height: 180))
|
||||
@@ -218,16 +378,14 @@ final class HeatmapView: UIView {
|
||||
total.font = .preferredFont(forTextStyle: .caption1)
|
||||
total.textColor = .tertiaryLabel
|
||||
addSubview(total)
|
||||
let filters = UIStackView()
|
||||
filters.axis = .horizontal
|
||||
filters.spacing = 4
|
||||
filters.translatesAutoresizingMaskIntoConstraints = false
|
||||
let filterItems = [
|
||||
("clock", "clock.fill", "All activity"),
|
||||
("exclamationmark.circle", "exclamationmark.circle.fill", "Issues"),
|
||||
("arrow.triangle.pull", "arrow.triangle.pull", "Pull requests"),
|
||||
let timelinesView = UIStackView()
|
||||
timelinesView.axis = .horizontal
|
||||
let timelineItems = [
|
||||
("person.crop.circle", "person.crop.circle.fill", "Your activity"),
|
||||
("person.3", "person.3.fill", "All users activity"),
|
||||
("bell", "bell.fill", "Notifications"),
|
||||
]
|
||||
for (index, item) in filterItems.enumerated() {
|
||||
for (index, item) in timelineItems.enumerated() {
|
||||
let button = UIButton(type: .custom, primaryAction: UIAction { action in
|
||||
guard
|
||||
let button = action.sender as? UIButton,
|
||||
@@ -238,47 +396,77 @@ final class HeatmapView: UIView {
|
||||
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
|
||||
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
|
||||
}
|
||||
onFilter(button.tag)
|
||||
onTimeline(button.tag)
|
||||
})
|
||||
button.tag = index
|
||||
button.setImage(UIImage(systemName: item.0), for: .normal)
|
||||
button.setImage(UIImage(systemName: item.1), for: .selected)
|
||||
button.isSelected = index == selectedFilter
|
||||
button.isSelected = index == selectedTimeline
|
||||
button.tintColor = button.isSelected ? .tintColor : .secondaryLabel
|
||||
button.accessibilityLabel = item.2
|
||||
button.accessibilityTraits = button.isSelected ? [.button, .selected] : .button
|
||||
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
filters.addArrangedSubview(button)
|
||||
timelinesView.addArrangedSubview(button)
|
||||
}
|
||||
let serverActivity = UIButton(
|
||||
type: .custom,
|
||||
primaryAction: UIAction { _ in onServerActivity() }
|
||||
)
|
||||
serverActivity.setImage(UIImage(systemName: "person.3"), for: .normal)
|
||||
serverActivity.tintColor = .secondaryLabel
|
||||
serverActivity.accessibilityLabel = "Server Activity"
|
||||
serverActivity.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
filters.addArrangedSubview(serverActivity)
|
||||
let notifications = UIButton(
|
||||
type: .custom,
|
||||
primaryAction: UIAction { _ in onNotifications() }
|
||||
)
|
||||
notifications.setImage(UIImage(systemName: "bell"), for: .normal)
|
||||
notifications.tintColor = .secondaryLabel
|
||||
notifications.accessibilityLabel = "Notifications"
|
||||
notifications.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
filters.addArrangedSubview(notifications)
|
||||
addSubview(filters)
|
||||
|
||||
let destinationsView = UIStackView()
|
||||
destinationsView.axis = .horizontal
|
||||
for destination in destinations {
|
||||
let button = UIButton(
|
||||
type: .custom,
|
||||
primaryAction: UIAction { _ in onDestination(destination) }
|
||||
)
|
||||
let symbol = destination == .milestones ? "flag.fill" : destination.symbolName
|
||||
button.setImage(UIImage(systemName: symbol), for: .normal)
|
||||
button.tintColor = .secondaryLabel
|
||||
button.accessibilityLabel = destination.title
|
||||
button.accessibilityHint = "Opens from Home"
|
||||
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
|
||||
destinationsView.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
let controls = UIStackView(arrangedSubviews: [timelinesView])
|
||||
controls.axis = .horizontal
|
||||
controls.alignment = .center
|
||||
controls.translatesAutoresizingMaskIntoConstraints = false
|
||||
if !destinations.isEmpty {
|
||||
controls.addArrangedSubview(destinationsView)
|
||||
}
|
||||
controlsScroller.showsHorizontalScrollIndicator = false
|
||||
controlsScroller.translatesAutoresizingMaskIntoConstraints = false
|
||||
controlsScroller.addSubview(controls)
|
||||
addSubview(controlsScroller)
|
||||
NSLayoutConstraint.activate([
|
||||
filters.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
filters.topAnchor.constraint(equalTo: topAnchor, constant: 132),
|
||||
filters.heightAnchor.constraint(equalToConstant: 44),
|
||||
controlsScroller.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
controlsScroller.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
controlsScroller.topAnchor.constraint(equalTo: topAnchor, constant: 132),
|
||||
controlsScroller.heightAnchor.constraint(equalToConstant: 44),
|
||||
controls.leadingAnchor.constraint(
|
||||
equalTo: controlsScroller.contentLayoutGuide.leadingAnchor,
|
||||
constant: 8
|
||||
),
|
||||
controls.trailingAnchor.constraint(
|
||||
equalTo: controlsScroller.contentLayoutGuide.trailingAnchor,
|
||||
constant: -8
|
||||
),
|
||||
controls.topAnchor.constraint(equalTo: controlsScroller.contentLayoutGuide.topAnchor),
|
||||
controls.bottomAnchor.constraint(
|
||||
equalTo: controlsScroller.contentLayoutGuide.bottomAnchor
|
||||
),
|
||||
controls.heightAnchor.constraint(equalTo: controlsScroller.frameLayoutGuide.heightAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let inset = max(0, (controlsScroller.bounds.width - controlsScroller.contentSize.width) / 2)
|
||||
controlsScroller.contentInset.left = inset
|
||||
controlsScroller.contentInset.right = inset
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard
|
||||
let first = cells.first,
|
||||
|
||||
Reference in New Issue
Block a user