diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift index 8bf69264e..71705f8af 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift @@ -25,6 +25,7 @@ import Images let showIcon: Bool // Make space even when icon is nil let read: Bool let starred: Bool + let thumbnailURL: URL? // Article thumbnail URL init(article: Article, showFeedName: TimelineShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool) { @@ -59,6 +60,13 @@ import Images self.read = article.status.read self.starred = article.status.starred + + // Prefer the article's imageURL; fall back to the first image in its content. + if let imageURL = article.imageURL { + self.thumbnailURL = imageURL + } else { + self.thumbnailURL = article.extractFirstImageURL() + } } init() { // Empty @@ -73,5 +81,6 @@ import Images self.read = true self.starred = false self.attributedTitle = NSAttributedString() + self.thumbnailURL = nil } } diff --git a/Mac/MainWindow/Timeline/Cell/TimelineModernCellLayout.swift b/Mac/MainWindow/Timeline/Cell/TimelineModernCellLayout.swift new file mode 100644 index 000000000..8d60a47d1 --- /dev/null +++ b/Mac/MainWindow/Timeline/Cell/TimelineModernCellLayout.swift @@ -0,0 +1,81 @@ +// +// TimelineModernCellLayout.swift +// NetNewsWire +// +// Copyright © 2026 Ranchero Software, LLC. All rights reserved. +// + +import AppKit +import RSCore + +// Rects for the modern timeline cell. Table-view cells in NetNewsWire use manual +// frame layout (not Auto Layout), so this computes every subview frame. +@MainActor +struct TimelineModernCellLayout { + + let feedIconRect: NSRect + let metadataRect: NSRect + let titleRect: NSRect + let summaryRect: NSRect + let thumbnailRect: NSRect + let separatorRect: NSRect + let height: CGFloat + + private static let horizontalPadding: CGFloat = 16.0 + private static let verticalPadding: CGFloat = 12.0 + private static let thumbnailSize: CGFloat = 72.0 + private static let leftRightGap: CGFloat = 12.0 + private static let iconHeight: CGFloat = 20.0 + private static let iconToTitleGap: CGFloat = 6.0 + private static let titleToSummaryGap: CGFloat = 4.0 + private static let iconToMetadataGap: CGFloat = 8.0 + + init(width: CGFloat, cellData: TimelineCellData) { + let titleFont = NSFont.systemFont(ofSize: 14, weight: .semibold) + let summaryFont = NSFont.systemFont(ofSize: 12, weight: .regular) + + let hasThumbnail = cellData.thumbnailURL != nil + let availableWidth = width - Self.horizontalPadding * 2 + let leftWidth = hasThumbnail ? (availableWidth - Self.thumbnailSize - Self.leftRightGap) : availableWidth + let leftWidthInt = max(1, Int(leftWidth)) + + // Coordinates run top-down (the cell view is flipped). + var y = Self.verticalPadding + + self.feedIconRect = NSRect(x: Self.horizontalPadding, y: y, width: Self.iconHeight, height: Self.iconHeight) + + let metadataX = self.feedIconRect.maxX + Self.iconToMetadataGap + self.metadataRect = NSRect(x: metadataX, y: y, width: width - metadataX - Self.horizontalPadding, height: Self.iconHeight) + + y = self.feedIconRect.maxY + Self.iconToTitleGap + + // Title (up to 2 lines); its actual line count drives the summary line count. + let titleInfo = MultilineTextFieldSizer.size(for: cellData.title, font: titleFont, numberOfLines: 2, width: leftWidthInt) + let titleLines = max(1, titleInfo.numberOfLinesUsed) + self.titleRect = NSRect(x: Self.horizontalPadding, y: y, width: leftWidth, height: titleInfo.size.height) + + y = self.titleRect.maxY + Self.titleToSummaryGap + + // Summary: 1 line when the title is 2 lines, otherwise 2 lines. + let summaryLines = titleLines >= 2 ? 1 : 2 + let summaryHeight = MultilineTextFieldSizer.size(for: cellData.text, font: summaryFont, numberOfLines: summaryLines, width: leftWidthInt).size.height + self.summaryRect = NSRect(x: Self.horizontalPadding, y: y, width: leftWidth, height: summaryHeight) + + y = self.summaryRect.maxY + Self.verticalPadding + self.height = y + + if hasThumbnail { + let thumbX = Self.horizontalPadding + leftWidth + Self.leftRightGap + let thumbY = (self.height - Self.thumbnailSize) / 2.0 + self.thumbnailRect = NSRect(x: thumbX, y: thumbY, width: Self.thumbnailSize, height: Self.thumbnailSize) + } else { + self.thumbnailRect = .zero + } + + self.separatorRect = NSRect(x: 0, y: self.height - 0.5, width: width, height: 0.5) + } + + static func height(for width: CGFloat, cellData: TimelineCellData) -> CGFloat { + return TimelineModernCellLayout(width: width, cellData: cellData).height + } +} diff --git a/Mac/MainWindow/Timeline/Cell/TimelineModernCellView.swift b/Mac/MainWindow/Timeline/Cell/TimelineModernCellView.swift new file mode 100644 index 000000000..94d976513 --- /dev/null +++ b/Mac/MainWindow/Timeline/Cell/TimelineModernCellView.swift @@ -0,0 +1,248 @@ +// +// TimelineModernCellView.swift +// NetNewsWire +// +// Copyright © 2026 Ranchero Software, LLC. All rights reserved. +// + +import AppKit +import RSCore + +// Modern horizontal-split timeline cell (left: text / right: thumbnail) for macOS. +// Uses manual frame layout per NetNewsWire guidelines (no Auto Layout in table cells). +final class TimelineModernCellView: NSView { + + // MARK: - UI Components + + private let feedIconView: NSImageView = { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.wantsLayer = true + imageView.layer?.cornerRadius = 10 + imageView.layer?.masksToBounds = true + imageView.layer?.backgroundColor = NSColor.systemRed.cgColor + imageView.autoresizingMask = [] + return imageView + }() + + private let metadataLabel: NSTextField = { + let label = NSTextField(labelWithString: "") + label.font = .systemFont(ofSize: 11, weight: .regular) + label.textColor = .secondaryLabelColor + label.lineBreakMode = .byTruncatingTail + label.cell?.truncatesLastVisibleLine = true + label.autoresizingMask = [] + return label + }() + + private let titleLabel: NSTextField = { + let label = NSTextField(labelWithString: "") + label.font = .systemFont(ofSize: 14, weight: .semibold) + label.textColor = .labelColor + label.maximumNumberOfLines = 2 + label.lineBreakMode = .byWordWrapping + label.cell?.wraps = true + label.cell?.truncatesLastVisibleLine = true + label.autoresizingMask = [] + return label + }() + + private let summaryLabel: NSTextField = { + let label = NSTextField(labelWithString: "") + label.font = .systemFont(ofSize: 12, weight: .regular) + label.textColor = .secondaryLabelColor + label.maximumNumberOfLines = 2 + label.lineBreakMode = .byWordWrapping + label.cell?.wraps = true + label.cell?.truncatesLastVisibleLine = true + label.autoresizingMask = [] + return label + }() + + private let thumbnailImageView: NSImageView = { + let imageView = NSImageView() + imageView.imageScaling = .scaleProportionallyUpOrDown + imageView.wantsLayer = true + imageView.layer?.masksToBounds = true + imageView.layer?.cornerRadius = 6 + imageView.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + imageView.imageAlignment = .alignCenter + imageView.autoresizingMask = [] + return imageView + }() + + private let separatorView: NSView = { + let view = NSView() + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.separatorColor.cgColor + view.autoresizingMask = [] + return view + }() + + // MARK: - Properties + + var cellData: TimelineCellData! { + didSet { + configureWithCellData() + needsLayout = true + } + } + + private var imageLoadTask: URLSessionDataTask? + + // Flipped so layout coordinates run top-to-bottom. + override var isFlipped: Bool { + return true + } + + // MARK: - Initialization + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setupViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + } + + deinit { + imageLoadTask?.cancel() + } + + // MARK: - Setup + + private func setupViews() { + addSubview(feedIconView) + addSubview(metadataLabel) + addSubview(titleLabel) + addSubview(summaryLabel) + addSubview(thumbnailImageView) + addSubview(separatorView) + } + + override func layout() { + super.layout() + + guard let cellData, bounds.width > 0 else { + return + } + + let layout = TimelineModernCellLayout(width: bounds.width, cellData: cellData) + feedIconView.frame = layout.feedIconRect + metadataLabel.frame = layout.metadataRect + titleLabel.frame = layout.titleRect + summaryLabel.frame = layout.summaryRect + thumbnailImageView.frame = layout.thumbnailRect + thumbnailImageView.isHidden = layout.thumbnailRect == .zero + separatorView.frame = layout.separatorRect + } + + // MARK: - Configuration + + private func configureWithCellData() { + guard let cellData else { + return + } + + // NSTableView reuse has no prepareForReuse callback, so cancel any in-flight + // load and clear the image to avoid flashing the previous article's thumbnail. + imageLoadTask?.cancel() + imageLoadTask = nil + thumbnailImageView.image = nil + + if let iconImage = cellData.iconImage { + feedIconView.image = iconImage.image + feedIconView.layer?.backgroundColor = NSColor.clear.cgColor + } else { + feedIconView.image = nil + feedIconView.layer?.backgroundColor = NSColor.systemRed.cgColor + } + + metadataLabel.stringValue = "\(cellData.feedName) • \(cellData.dateString)" + + titleLabel.stringValue = cellData.title + titleLabel.textColor = cellData.read ? .secondaryLabelColor : .labelColor + + summaryLabel.stringValue = cellData.text + + if let url = cellData.thumbnailURL { + loadImage(from: url) + } + } + + private func loadImage(from url: URL) { + imageLoadTask?.cancel() + + if let cachedImage = MacImageCache.shared.image(for: url) { + thumbnailImageView.image = cachedImage + return + } + + imageLoadTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + guard let self, + let data = data, + let image = NSImage(data: data), + error == nil else { + return + } + + // Cropping is CPU work; stay off the main thread. + let croppedImage = image.cropToSquare() + + // MacImageCache is MainActor-isolated. + DispatchQueue.main.async { [weak self] in + MacImageCache.shared.storeImage(croppedImage, for: url) + self?.thumbnailImageView.image = croppedImage + } + } + + imageLoadTask?.resume() + } +} + +// MARK: - Image Cache for macOS + +@MainActor +final class MacImageCache { + static let shared = MacImageCache() + + private let cache = NSCache() + + private init() { + cache.countLimit = 100 + cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB + } + + func image(for url: URL) -> NSImage? { + return cache.object(forKey: url as NSURL) + } + + func storeImage(_ image: NSImage, for url: URL) { + cache.setObject(image, forKey: url as NSURL) + } +} + +// MARK: - NSImage Extension for Square Cropping + +extension NSImage { + /// Crops the image to a centered square. + func cropToSquare() -> NSImage { + guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + return self + } + + let width = cgImage.width + let height = cgImage.height + let minDimension = min(width, height) + let x = (width - minDimension) / 2 + let y = (height - minDimension) / 2 + + if let croppedCGImage = cgImage.cropping(to: CGRect(x: x, y: y, width: minDimension, height: minDimension)) { + return NSImage(cgImage: croppedCGImage, size: NSSize(width: minDimension, height: minDimension)) + } + + return self + } +} diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 357565607..01d97d0d5 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -162,6 +162,12 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr var undoableCommands = [UndoableCommand]() + // MARK: - Layout Configuration + private var useModernLayout: Bool { + // Modern layout is on by default. + return true + } + private var fetchSerialNumber = 0 private let fetchRequestQueue = FetchRequestQueue() private var exceptionArticleFetcher: ArticleFetcher? @@ -888,6 +894,16 @@ extension TimelineViewController: NSTableViewDataSource { func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { // Keeping -[NSTableViewDelegate tableView:heightOfRow:] implemented fixes // an issue that the bottom inset of NSTableView disappears on macOS Monterey. + + // Modern layout: self-size the height from the actual content (title up to 2 lines + summary up to 2 lines), with symmetric vertical padding. + if useModernLayout { + guard let article = articles.articleAtRow(row) else { + return tableView.rowHeight + } + let cellData = configureTimelineCellData(for: article) + return TimelineModernCellLayout.height(for: tableView.bounds.width, cellData: cellData) + } + return tableView.rowHeight } } @@ -907,9 +923,15 @@ extension TimelineViewController: NSTableViewDelegate { } private static let timelineCellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "timelineCell") + private static let modernCellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "modernTimelineCell") func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + // Modern layout. + if useModernLayout { + return configureModernCell(tableView, row: row) + } + // Legacy layout, kept for compatibility. func configure(_ cell: TimelineTableCellView) { cell.cellAppearance = showIcons ? cellAppearanceWithIcon : cellAppearance if let article = articles.articleAtRow(row) { @@ -930,6 +952,36 @@ extension TimelineViewController: NSTableViewDelegate { return cell } + private func configureModernCell(_ tableView: NSTableView, row: Int) -> NSView? { + guard let article = articles.articleAtRow(row) else { + return nil + } + + let cellData = configureTimelineCellData(for: article) + + if let cell = tableView.makeView(withIdentifier: TimelineViewController.modernCellIdentifier, owner: nil) as? TimelineModernCellView { + cell.cellData = cellData + return cell + } + + let cell = TimelineModernCellView() + cell.identifier = TimelineViewController.modernCellIdentifier + cell.cellData = cellData + return cell + } + + private func configureTimelineCellData(for article: Article) -> TimelineCellData { + let iconImage = article.iconImage() + return TimelineCellData( + article: article, + showFeedName: showFeedNames, + feedName: article.feed?.nameForDisplay, + byline: article.byline(), + iconImage: iconImage, + showIcon: true + ) + } + func tableViewSelectionDidChange(_ notification: Notification) { if selectedArticles.isEmpty { selectionDidChange(nil) diff --git a/Shared/Extensions/ArticleImageExtractor.swift b/Shared/Extensions/ArticleImageExtractor.swift new file mode 100644 index 000000000..a62c47f5e --- /dev/null +++ b/Shared/Extensions/ArticleImageExtractor.swift @@ -0,0 +1,60 @@ +// +// ArticleImageExtractor.swift +// NetNewsWire +// +// Copyright © 2026 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import Articles + +@MainActor +extension Article { + + /// Returns the article's `imageURL`, or the first image found in its HTML or text content. + func extractFirstImageURL() -> URL? { + if let imageURL = self.imageURL { + return imageURL + } + if let html = contentHTML ?? summary { + return extractFirstImageFromHTML(html) + } + if let text = contentText ?? summary { + return extractFirstImageFromText(text) + } + return nil + } + + private func extractFirstImageFromHTML(_ html: String) -> URL? { + let pattern = "]+src=[\"']([^\"'<>]+)[\"']" + + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: html, range: NSRange(location: 0, length: html.utf16.count)) else { + return nil + } + + if match.numberOfRanges > 1 { + let range = match.range(at: 1) + if let swiftRange = Range(range, in: html) { + return URL(string: String(html[swiftRange])) + } + } + + return nil + } + + private func extractFirstImageFromText(_ text: String) -> URL? { + let pattern = "(https?://[^\\s<>]+\\.(?:jpg|jpeg|png|gif|webp|bmp)(?:\\?[^\\s<>]*)?)" + + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: text, range: NSRange(location: 0, length: text.utf16.count)) else { + return nil + } + + if let swiftRange = Range(match.range, in: text) { + return URL(string: String(text[swiftRange])) + } + + return nil + } +} diff --git a/iOS/MainTimeline/Cells/MainTimelineCellData.swift b/iOS/MainTimeline/Cells/MainTimelineCellData.swift index fc39d0733..617fe6492 100644 --- a/iOS/MainTimeline/Cells/MainTimelineCellData.swift +++ b/iOS/MainTimeline/Cells/MainTimelineCellData.swift @@ -27,6 +27,7 @@ import Images let starred: Bool let numberOfLines: Int let iconSize: IconSize + let thumbnailURL: URL? // Article thumbnail URL init(article: Article, showFeedName: ShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool, numberOfLines: Int, iconSize: IconSize) { @@ -64,6 +65,12 @@ import Images self.numberOfLines = numberOfLines self.iconSize = iconSize + // Prefer the article's imageURL; fall back to the first image in its content. + if let imageURL = article.imageURL { + self.thumbnailURL = imageURL + } else { + self.thumbnailURL = article.extractFirstImageURL() + } } init() { // Empty @@ -80,6 +87,7 @@ import Images self.starred = false self.numberOfLines = 0 self.iconSize = .medium + self.thumbnailURL = nil } } diff --git a/iOS/MainTimeline/Cells/MainTimelineModernCell.swift b/iOS/MainTimeline/Cells/MainTimelineModernCell.swift new file mode 100644 index 000000000..cc2cc9199 --- /dev/null +++ b/iOS/MainTimeline/Cells/MainTimelineModernCell.swift @@ -0,0 +1,377 @@ +// +// MainTimelineModernCell.swift +// NetNewsWire-iOS +// +// Copyright © 2026 Ranchero Software, LLC. All rights reserved. +// + +import UIKit + +final class MainTimelineModernCell: UICollectionViewCell { + + // MARK: - Layout Constants + + private let thumbnailSize: CGFloat = 72.0 + private let horizontalPadding: CGFloat = 16.0 + private let verticalPadding: CGFloat = 12.0 + private let leftRightGap: CGFloat = 12.0 + // Used when estimating title line count, since bounds may not be laid out yet. + private let estimatedLeftTextWidth: CGFloat = 270.0 + + // MARK: - UI Components + + private let containerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let leftContentContainer: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let feedIconView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.layer.cornerRadius = 10 + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.backgroundColor = .systemRed + return imageView + }() + + private let metadataLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 12, weight: .regular) + label.textColor = .secondaryLabel + label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byTruncatingTail + return label + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.textColor = .label + label.numberOfLines = 2 + label.lineBreakMode = .byTruncatingTail + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let summaryLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular) + label.textColor = .secondaryLabel + label.numberOfLines = 2 + label.lineBreakMode = .byTruncatingTail + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + private let thumbnailContainerView: UIView = { + let view = UIView() + view.backgroundColor = .clear + view.translatesAutoresizingMaskIntoConstraints = false + view.clipsToBounds = true + view.layer.cornerRadius = 6 + return view + }() + + private let thumbnailImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.backgroundColor = .systemGray6 + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let separatorView: UIView = { + let view = UIView() + view.backgroundColor = .separator + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + // MARK: - Properties + + var cellData: MainTimelineCellData! { + didSet { + configureWithCellData() + } + } + + // Two mutually exclusive leftContent.trailing constraints, swapped based on whether + // a thumbnail is shown. The thumbnail container itself uses a fixed size + position, + // which avoids conflicts with width=height-style constraints. + private var isShowingThumbnail = false + private var leftContentTrailingToContainer: NSLayoutConstraint? + private var leftContentTrailingToThumbnail: NSLayoutConstraint? + + private var imageDataTask: URLSessionDataTask? + + // MARK: - Initialization + + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupViews() + } + + override func prepareForReuse() { + super.prepareForReuse() + imageDataTask?.cancel() + imageDataTask = nil + thumbnailImageView.image = nil + feedIconView.image = nil + } + + // MARK: - Setup + + private func setupViews() { + contentView.addSubview(containerView) + containerView.addSubview(leftContentContainer) + containerView.addSubview(thumbnailContainerView) + containerView.addSubview(separatorView) + + leftContentContainer.addSubview(feedIconView) + leftContentContainer.addSubview(metadataLabel) + leftContentContainer.addSubview(titleLabel) + leftContentContainer.addSubview(summaryLabel) + + thumbnailContainerView.addSubview(thumbnailImageView) + + setupConstraints() + } + + private func setupConstraints() { + let trailingToContainer = leftContentContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor) + let trailingToThumbnail = leftContentContainer.trailingAnchor.constraint(equalTo: thumbnailContainerView.leadingAnchor, constant: -leftRightGap) + leftContentTrailingToContainer = trailingToContainer + leftContentTrailingToThumbnail = trailingToThumbnail + + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: verticalPadding), + containerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: horizontalPadding), + containerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -horizontalPadding), + containerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -verticalPadding), + + separatorView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + separatorView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + separatorView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + separatorView.heightAnchor.constraint(equalToConstant: 0.5), + + leftContentContainer.topAnchor.constraint(equalTo: containerView.topAnchor), + leftContentContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + leftContentContainer.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + trailingToContainer, + + thumbnailContainerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + thumbnailContainerView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + thumbnailContainerView.widthAnchor.constraint(equalToConstant: thumbnailSize), + thumbnailContainerView.heightAnchor.constraint(equalToConstant: thumbnailSize), + + feedIconView.topAnchor.constraint(equalTo: leftContentContainer.topAnchor), + feedIconView.leadingAnchor.constraint(equalTo: leftContentContainer.leadingAnchor), + feedIconView.widthAnchor.constraint(equalToConstant: 20), + feedIconView.heightAnchor.constraint(equalToConstant: 20), + + metadataLabel.centerYAnchor.constraint(equalTo: feedIconView.centerYAnchor), + metadataLabel.leadingAnchor.constraint(equalTo: feedIconView.trailingAnchor, constant: 8), + metadataLabel.trailingAnchor.constraint(equalTo: leftContentContainer.trailingAnchor), + + titleLabel.topAnchor.constraint(equalTo: feedIconView.bottomAnchor, constant: 8), + titleLabel.leadingAnchor.constraint(equalTo: leftContentContainer.leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: leftContentContainer.trailingAnchor), + + // Pinned to the container bottom to complete the vertical chain so the list + // layout can compute the self-sizing height. + summaryLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), + summaryLabel.leadingAnchor.constraint(equalTo: leftContentContainer.leadingAnchor), + summaryLabel.trailingAnchor.constraint(equalTo: leftContentContainer.trailingAnchor), + summaryLabel.bottomAnchor.constraint(equalTo: leftContentContainer.bottomAnchor), + + thumbnailImageView.topAnchor.constraint(equalTo: thumbnailContainerView.topAnchor), + thumbnailImageView.leadingAnchor.constraint(equalTo: thumbnailContainerView.leadingAnchor), + thumbnailImageView.trailingAnchor.constraint(equalTo: thumbnailContainerView.trailingAnchor), + thumbnailImageView.bottomAnchor.constraint(equalTo: thumbnailContainerView.bottomAnchor), + ]) + + thumbnailContainerView.isHidden = true + } + + // MARK: - Configuration + + private func configureWithCellData() { + guard let cellData = cellData else { + return + } + + imageDataTask?.cancel() + imageDataTask = nil + thumbnailImageView.image = nil + + if let iconImage = cellData.iconImage { + feedIconView.image = iconImage.image + feedIconView.backgroundColor = .clear + } else { + feedIconView.image = nil + feedIconView.backgroundColor = .systemRed + } + + metadataLabel.text = "\(cellData.feedName) • \(cellData.dateString)" + + titleLabel.text = cellData.title + titleLabel.textColor = cellData.read ? .secondaryLabel : .label + + // A long title wraps to 2 lines; the summary then shrinks to 1 line to keep the + // overall height balanced (1-line title → 2-line summary). + let titleLines = calculateTitleLines(cellData.title) + summaryLabel.numberOfLines = titleLines >= 2 ? 1 : 2 + + summaryLabel.text = cellData.summary + + configureThumbnail(cellData.thumbnailURL) + } + + /// Number of lines the title occupies (1 or 2). + private func calculateTitleLines(_ text: String) -> Int { + guard !text.isEmpty else { + return 1 + } + + let label = UILabel() + label.font = .systemFont(ofSize: 17, weight: .semibold) + label.text = text + label.numberOfLines = 2 + + let actualSize = label.sizeThatFits(CGSize(width: estimatedLeftTextWidth, height: .greatestFiniteMagnitude)) + let lineHeight: CGFloat = 22 + + return min(Int(ceil(actualSize.height / lineHeight)), 2) + } + + private func configureThumbnail(_ url: URL?) { + guard let url = url else { + hideThumbnailAndExpandLeft() + return + } + showThumbnailWithSplitLayout() + loadImage(from: url) + } + + private func hideThumbnailAndExpandLeft() { + guard isShowingThumbnail else { + return + } + + isShowingThumbnail = false + thumbnailContainerView.isHidden = true + + leftContentTrailingToThumbnail?.isActive = false + leftContentTrailingToContainer?.isActive = true + + setNeedsLayout() + layoutIfNeeded() + } + + private func showThumbnailWithSplitLayout() { + guard !isShowingThumbnail else { + return + } + + isShowingThumbnail = true + thumbnailContainerView.isHidden = false + + leftContentTrailingToContainer?.isActive = false + leftContentTrailingToThumbnail?.isActive = true + + setNeedsLayout() + layoutIfNeeded() + } + + private func loadImage(from url: URL) { + imageDataTask?.cancel() + + if let cachedImage = ImageCache.shared.image(for: url) { + thumbnailImageView.image = cachedImage + return + } + + imageDataTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + guard let self, + let data = data, + let image = UIImage(data: data), + error == nil else { + return + } + + // Cropping is CPU work; stay off the main thread. + let croppedImage = image.cropToSquare() + + // ImageCache is MainActor-isolated. + DispatchQueue.main.async { [weak self] in + ImageCache.shared.storeImage(croppedImage, for: url) + self?.thumbnailImageView.image = croppedImage + } + } + + imageDataTask?.resume() + } + + override var isHighlighted: Bool { + didSet { + containerView.alpha = isHighlighted ? 0.7 : 1.0 + } + } +} + +// MARK: - Image Cache + +@MainActor +final class ImageCache { + static let shared = ImageCache() + + private let cache = NSCache() + + private init() { + cache.countLimit = 100 + cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB + } + + func image(for url: URL) -> UIImage? { + return cache.object(forKey: url as NSURL) + } + + func storeImage(_ image: UIImage, for url: URL) { + cache.setObject(image, forKey: url as NSURL) + } +} + +// MARK: - UIImage Extension for Square Cropping + +extension UIImage { + /// Crops the image to a centered square. + func cropToSquare() -> UIImage { + let minDimension = min(size.width, size.height) + let x = (size.width - minDimension) / 2.0 + let y = (size.height - minDimension) / 2.0 + + let cropRect = CGRect(x: x, y: y, width: minDimension, height: minDimension) + + if let cgImage = self.cgImage?.cropping(to: cropRect) { + return UIImage(cgImage: cgImage, scale: scale, orientation: imageOrientation) + } + + return self + } +} diff --git a/iOS/MainTimeline/MainTimelineModernViewController.swift b/iOS/MainTimeline/MainTimelineModernViewController.swift index 2025945f9..5001f84c9 100644 --- a/iOS/MainTimeline/MainTimelineModernViewController.swift +++ b/iOS/MainTimeline/MainTimelineModernViewController.swift @@ -23,6 +23,13 @@ final class MainTimelineModernViewController: UIViewController, UndoableCommandR static let standardIndex0 = "MainTimelineCellIndexZero" static let icon = "MainTimelineCellIcon" static let iconIndex0 = "MainTimelineCellIconIndexZero" + static let modern = "MainTimelineModernCell" + } + + // MARK: - Layout Configuration + private var useModernLayout: Bool { + // Modern layout is on by default. + return true } // MARK: Private Variables @@ -210,6 +217,11 @@ final class MainTimelineModernViewController: UIViewController, UndoableCommandR if #available(iOS 26, *) { navigationItem.subtitleView = navigationBarSubtitleTitleLabel } + + // Enforce a minimum width so the thumbnail and title are not too cramped. + if let collectionView = collectionView { + collectionView.widthAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true + } // Do any additional setup after loading the view. } @@ -679,6 +691,9 @@ private extension MainTimelineModernViewController { } private func configureCollectionView(_ dataSource: UICollectionViewDiffableDataSource) { + // Register the modern cell. + collectionView?.register(MainTimelineModernCell.self, forCellWithReuseIdentifier: CellIdentifier.modern) + var config = UICollectionLayoutListConfiguration(appearance: .plain) config.showsSeparators = false config.headerMode = .none @@ -846,7 +861,17 @@ private extension MainTimelineModernViewController { guard let self else { return nil } + let cellData = self.configure(article: article) + + // Modern layout. + if self.useModernLayout { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CellIdentifier.modern, for: indexPath) as! MainTimelineModernCell + cell.cellData = cellData + return cell + } + + // Legacy layout, kept for compatibility. if self.showIcons { if indexPath.row == 0 { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CellIdentifier.iconIndex0, for: indexPath) as! MainTimelineCollectionViewCell