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