Add first-class Actions management (#63)

This commit is contained in:
Georg Bauer
2026-08-15 20:06:52 +02:00
parent f84ab774e6
commit 59bcddfadf
32 changed files with 4039 additions and 275 deletions

View File

@@ -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,