import UIKit final class NotificationCell: UITableViewCell { private let icon = UIImageView() private let titleLabel = UILabel() private let detailLabel = UILabel() private let metaLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) icon.preferredSymbolConfiguration = UIImage.SymbolConfiguration(textStyle: .headline) icon.setContentHuggingPriority(.required, for: .horizontal) titleLabel.font = .preferredFont(forTextStyle: .headline) titleLabel.numberOfLines = 2 detailLabel.font = .preferredFont(forTextStyle: .subheadline) detailLabel.textColor = .secondaryLabel detailLabel.numberOfLines = 2 metaLabel.font = .preferredFont(forTextStyle: .caption1) metaLabel.textColor = .tertiaryLabel [titleLabel, detailLabel, metaLabel].forEach { $0.adjustsFontForContentSizeCategory = true } let labels = UIStackView(arrangedSubviews: [titleLabel, detailLabel, metaLabel]) labels.axis = .vertical labels.spacing = 4 let stack = UIStackView(arrangedSubviews: [icon, labels]) stack.alignment = .top stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10), stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -10), icon.widthAnchor.constraint(equalToConstant: 24), ]) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } func configure(_ row: NotificationRow) { icon.image = UIImage(systemName: symbolName(for: row.target)) icon.tintColor = row.unread ? .tintColor : .secondaryLabel titleLabel.text = row.title detailLabel.text = row.detail metaLabel.text = row.meta accessoryType = row.target == .none ? .none : .disclosureIndicator selectionStyle = row.target == .none ? .none : .default accessibilityValue = row.unread ? "Open" : "Closed" } private func symbolName(for target: ActivityTargetKind) -> String { switch target { case .repository: return "books.vertical" case .issue: return "exclamationmark.circle" case .pullRequest: return "arrow.triangle.pull" case .commit: return "point.topleft.down.to.point.bottomright.curvepath" case .none: return "bell" } } } @MainActor final class NotificationsViewController: RefreshingTableViewController { private let context: AppContext private let statusControl = UISegmentedControl(items: ["Open", "Closed"]) private var rows: [NotificationRow] = [] private var currentPage: UInt32 = 0 init(context: AppContext) { self.context = context super.init() title = "Notifications" } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } override func viewDidLoad() { super.viewDidLoad() tableView.register(NotificationCell.self, forCellReuseIdentifier: "notification") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 92 statusControl.selectedSegmentIndex = 0 statusControl.addTarget(self, action: #selector(statusChanged), for: .valueChanged) statusControl.accessibilityLabel = "Notification status" navigationItem.rightBarButtonItem = UIBarButtonItem(customView: statusControl) loadContent(refreshing: false) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) guard currentPage > 0 else { return } loadNotifications(page: 1, refreshing: false) } override func loadContent(refreshing: Bool) { loadNotifications(page: 1, refreshing: refreshing) } override func loadMoreContent() { loadNotifications(page: currentPage + 1, refreshing: false) } private func loadNotifications(page: UInt32, refreshing: Bool) { if page == 1 { resetPagination() beginLoading(refreshing: refreshing) } loadingTask?.cancel() loadingTask = Task { do { let result = try await context.core.notifications( status: statusControl.selectedSegmentIndex == 0 ? .open : .closed, page: page ) if page == 1 { rows = result.rows } else { rows.append(contentsOf: result.rows) } currentPage = page finishPagination(hasMore: result.hasMore) tableView.reloadData() let status = statusControl.selectedSegmentIndex == 0 ? "open" : "closed" tableView.backgroundView = rows.isEmpty ? EmptyBackgroundView( title: "No \(status) notifications", detail: "This server has no \(status) notifications." ) : nil } catch { if !Task.isCancelled { show(error: error) failPagination() } } if page == 1 { endLoading() } } } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = tableView.dequeueReusableCell( withIdentifier: "notification", for: indexPath ) as! NotificationCell cell.configure(rows[indexPath.row]) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let row = rows[indexPath.row] 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) } } } } @objc private func statusChanged() { loadNotifications(page: 1, refreshing: false) } }