539 lines
21 KiB
Swift
539 lines
21 KiB
Swift
import UIKit
|
|
|
|
@MainActor
|
|
final class HomeViewController: RefreshingTableViewController {
|
|
private enum Timeline: Int {
|
|
case user
|
|
case allUsers
|
|
case notifications
|
|
}
|
|
|
|
private let context: AppContext
|
|
private var page: HomePage?
|
|
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
|
|
super.init()
|
|
title = "Home"
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
|
|
|
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
|
|
guard let self else { return }
|
|
self.navigationController?.pushViewController(
|
|
ServersViewController(context: self.context),
|
|
animated: true
|
|
)
|
|
}
|
|
)
|
|
servers.accessibilityLabel = "Servers"
|
|
navigationItem.leftBarButtonItem = servers
|
|
let settings = UIBarButtonItem(
|
|
image: context.symbol("gearshape"),
|
|
primaryAction: UIAction { [weak self] _ in
|
|
guard let self else { return }
|
|
self.navigationController?.pushViewController(
|
|
SettingsViewController(context: self.context),
|
|
animated: true
|
|
)
|
|
}
|
|
)
|
|
settings.accessibilityLabel = "Settings"
|
|
navigationItem.rightBarButtonItem = settings
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
if page == nil {
|
|
loadContent(refreshing: false)
|
|
} else if timeline == .notifications {
|
|
loadNotifications(page: 1, refreshing: false)
|
|
}
|
|
}
|
|
|
|
override func loadContent(refreshing: Bool) {
|
|
guard context.core.activeServerIndex() != nil else {
|
|
tableView.backgroundView = EmptyBackgroundView(
|
|
title: "No server selected",
|
|
detail: "Use the server button to select a server or add your first one."
|
|
)
|
|
refreshControl?.endRefreshing()
|
|
return
|
|
}
|
|
switch timeline {
|
|
case .user:
|
|
loadUserPage(1, refreshing: refreshing)
|
|
case .allUsers:
|
|
loadServerActivityPage(1, refreshing: refreshing)
|
|
case .notifications:
|
|
loadNotifications(page: 1, refreshing: refreshing)
|
|
}
|
|
}
|
|
|
|
override func loadMoreContent() {
|
|
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 loadUserPage(_ requestedPage: UInt32, refreshing: Bool) {
|
|
if requestedPage == 1 {
|
|
resetPagination()
|
|
beginLoading(refreshing: refreshing)
|
|
}
|
|
loadingTask?.cancel()
|
|
loadingTask = Task {
|
|
do {
|
|
let result = try await context.core.home(
|
|
page: requestedPage,
|
|
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
|
|
}
|
|
userNextPage = result.nextPage
|
|
finishPagination(hasMore: result.nextPage != nil)
|
|
setPanelTitle(self, "Home", server: page?.serverName)
|
|
if requestedPage == 1 { refreshPrimaryDestinations() }
|
|
updateRows()
|
|
} catch {
|
|
if !Task.isCancelled, timeline == .user {
|
|
show(error: error)
|
|
failPagination()
|
|
}
|
|
}
|
|
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 {
|
|
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]
|
|
configureTextCell(
|
|
cell,
|
|
title: row.title,
|
|
detail: "\(row.detail)\n\(row.meta)",
|
|
image: context.symbol(activitySymbolName(for: row.icon))
|
|
)
|
|
cell.accessoryType = row.target == .none ? .none : .disclosureIndicator
|
|
return cell
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
|
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)
|
|
if timeline == .notifications {
|
|
openNotification(notificationRows[indexPath.row])
|
|
} else {
|
|
context.route(activities[indexPath.row])
|
|
}
|
|
}
|
|
|
|
private func updateRows() {
|
|
tableView.reloadData()
|
|
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)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
func activitySymbolName(for icon: ActivityIcon) -> String {
|
|
switch icon {
|
|
case .pullRequest: return "arrow.triangle.pull"
|
|
case .issue: return "exclamationmark.circle"
|
|
case .branch: return "arrow.triangle.branch"
|
|
case .tag: return "tag"
|
|
case .push: return "arrow.up.circle"
|
|
case .release: return "shippingbox"
|
|
case .repository: return "books.vertical"
|
|
}
|
|
}
|
|
|
|
final class HeatmapView: UIView {
|
|
private let cells: [HeatCell]
|
|
private let calendar = Calendar(identifier: .gregorian)
|
|
private let controlsScroller = UIScrollView()
|
|
|
|
init(
|
|
page: HomePage,
|
|
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))
|
|
backgroundColor = .systemBackground
|
|
let title = UILabel(frame: CGRect(x: 16, y: 12, width: 300, height: 22))
|
|
title.text = "Activity · last 9 months"
|
|
title.font = .preferredFont(forTextStyle: .subheadline)
|
|
title.textColor = .secondaryLabel
|
|
addSubview(title)
|
|
let total = UILabel(frame: CGRect(x: 16, y: 108, width: 300, height: 18))
|
|
total.text = "\(page.contributionCount) contributions"
|
|
total.font = .preferredFont(forTextStyle: .caption1)
|
|
total.textColor = .tertiaryLabel
|
|
addSubview(total)
|
|
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 timelineItems.enumerated() {
|
|
let button = UIButton(type: .custom, primaryAction: UIAction { action in
|
|
guard
|
|
let button = action.sender as? UIButton,
|
|
let stack = button.superview as? UIStackView
|
|
else { return }
|
|
for case let item as UIButton in stack.arrangedSubviews {
|
|
item.isSelected = item === button
|
|
item.tintColor = item.isSelected ? .tintColor : .secondaryLabel
|
|
item.accessibilityTraits = item.isSelected ? [.button, .selected] : .button
|
|
}
|
|
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 == 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
|
|
timelinesView.addArrangedSubview(button)
|
|
}
|
|
|
|
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([
|
|
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,
|
|
let last = cells.last
|
|
else { return }
|
|
let firstDate = Date(timeIntervalSince1970: TimeInterval(first.timestamp))
|
|
let lastDate = Date(timeIntervalSince1970: TimeInterval(last.timestamp))
|
|
guard let firstWeek = calendar.dateInterval(of: .weekOfYear, for: firstDate)?.start else {
|
|
return
|
|
}
|
|
let monthFormatter = DateFormatter()
|
|
monthFormatter.dateFormat = "MMM"
|
|
let months = cells.reduce(into: [Date]()) { result, cell in
|
|
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
|
|
let month = calendar.date(
|
|
from: calendar.dateComponents([.year, .month], from: date)
|
|
) ?? date
|
|
if result.last != month { result.append(month) }
|
|
}
|
|
let weekCount = CGFloat(
|
|
(calendar.dateComponents([.day], from: firstWeek, to: lastDate).day ?? 0) / 7 + 1
|
|
)
|
|
let monthGap: CGFloat = 4
|
|
let monthGaps = CGFloat(max(months.count - 1, 0)) * monthGap
|
|
let width = max(4, min(6, (bounds.width - 32 - monthGaps) / weekCount - 1))
|
|
let gap = width + 1
|
|
let graphWidth = (weekCount - 1) * gap + width + monthGaps
|
|
let graphOrigin = max(0, (bounds.width - graphWidth) / 2)
|
|
let labelAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: UIFont.preferredFont(forTextStyle: .caption2),
|
|
.foregroundColor: UIColor.secondaryLabel,
|
|
]
|
|
for (index, month) in months.enumerated() {
|
|
let days = calendar.dateComponents([.day], from: firstWeek, to: month).day ?? 0
|
|
monthFormatter.string(from: month).draw(
|
|
at: CGPoint(
|
|
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(index) * monthGap,
|
|
y: 34
|
|
),
|
|
withAttributes: labelAttributes
|
|
)
|
|
}
|
|
for cell in cells {
|
|
let date = Date(timeIntervalSince1970: TimeInterval(cell.timestamp))
|
|
let days = calendar.dateComponents([.day], from: firstWeek, to: date).day ?? 0
|
|
let month = calendar.date(
|
|
from: calendar.dateComponents([.year, .month], from: date)
|
|
) ?? date
|
|
let monthIndex = months.firstIndex(of: month) ?? 0
|
|
let colors: [UIColor] = [
|
|
.systemGray5,
|
|
UIColor(red: 0.72, green: 0.85, blue: 0.96, alpha: 1),
|
|
UIColor(red: 0.45, green: 0.71, blue: 0.91, alpha: 1),
|
|
UIColor(red: 0.15, green: 0.55, blue: 0.83, alpha: 1),
|
|
UIColor(red: 0.04, green: 0.41, blue: 0.72, alpha: 1),
|
|
]
|
|
colors[Int(min(cell.level, 4))].setFill()
|
|
UIBezierPath(
|
|
roundedRect: CGRect(
|
|
x: graphOrigin + CGFloat(days / 7) * gap + CGFloat(monthIndex) * monthGap,
|
|
y: 48 + CGFloat(calendar.component(.weekday, from: date) - 1) * gap,
|
|
width: width,
|
|
height: width
|
|
),
|
|
cornerRadius: 1
|
|
).fill()
|
|
}
|
|
}
|
|
}
|