diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellAppearance.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellAppearance.swift index 457919e6c..8b54abfa5 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineCellAppearance.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineCellAppearance.swift @@ -40,6 +40,11 @@ struct TimelineCellAppearance: Equatable { let iconAdjustmentTop: CGFloat = 4.0 let iconCornerRadius: CGFloat = 4.0 + let thumbnailHeight: CGFloat = 200.0 + let thumbnailMarginTop: CGFloat = 8.0 + let thumbnailMarginBottom: CGFloat = 10.0 + let thumbnailCornerRadius: CGFloat = 6.0 + let boxLeftMargin: CGFloat init(showIcon: Bool, fontSize: FontSize) { diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift index 3970ae4de..0253456c4 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift @@ -24,6 +24,7 @@ import Articles let showIcon: Bool // Make space even when icon is nil let read: Bool let starred: Bool + let thumbnailURL: URL? init(article: Article, showFeedName: TimelineShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool) { @@ -58,6 +59,7 @@ import Articles self.read = article.status.read self.starred = article.status.starred + self.thumbnailURL = article.firstBodyImageURL } init() { // Empty @@ -72,5 +74,6 @@ import Articles self.read = true self.starred = false self.attributedTitle = NSAttributedString() + self.thumbnailURL = nil } } diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift index a563b2f14..c17c595db 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift @@ -24,8 +24,9 @@ import RSCore let iconImageRect: NSRect let separatorRect: NSRect let paddingBottom: CGFloat + let thumbnailRect: NSRect - init(width: CGFloat, height: CGFloat, feedNameRect: NSRect, dateRect: NSRect, titleRect: NSRect, numberOfLinesForTitle: Int, summaryRect: NSRect, textRect: NSRect, unreadIndicatorRect: NSRect, starRect: NSRect, iconImageRect: NSRect, separatorRect: NSRect, paddingBottom: CGFloat) { + init(width: CGFloat, height: CGFloat, feedNameRect: NSRect, dateRect: NSRect, titleRect: NSRect, numberOfLinesForTitle: Int, summaryRect: NSRect, textRect: NSRect, unreadIndicatorRect: NSRect, starRect: NSRect, iconImageRect: NSRect, separatorRect: NSRect, paddingBottom: CGFloat, thumbnailRect: NSRect) { self.width = width self.feedNameRect = feedNameRect @@ -39,11 +40,12 @@ import RSCore self.iconImageRect = iconImageRect self.separatorRect = separatorRect self.paddingBottom = paddingBottom + self.thumbnailRect = thumbnailRect if height > 0.1 { self.height = height } else { - self.height = [feedNameRect, dateRect, titleRect, summaryRect, textRect, unreadIndicatorRect, iconImageRect].maxY() + paddingBottom + self.height = [feedNameRect, dateRect, titleRect, summaryRect, textRect, unreadIndicatorRect, iconImageRect, thumbnailRect].maxY() + paddingBottom } } @@ -52,32 +54,31 @@ import RSCore // If height == 0.0, then height is calculated. let showIcon = cellData.showIcon - var textBoxRect = TimelineCellLayout.rectForTextBox(appearance, cellData, showIcon, width) - - let (titleRect, numberOfLinesForTitle) = TimelineCellLayout.rectForTitle(textBoxRect, appearance, cellData) - let summaryRect = numberOfLinesForTitle > 0 ? TimelineCellLayout.rectForSummary(textBoxRect, titleRect, numberOfLinesForTitle, appearance, cellData) : NSRect.zero - let textRect = numberOfLinesForTitle > 0 ? NSRect.zero : TimelineCellLayout.rectForText(textBoxRect, appearance, cellData) - - var lastTextRect = titleRect - if numberOfLinesForTitle == 0 { - lastTextRect = textRect - } else if numberOfLinesForTitle < appearance.titleNumberOfLines { - if summaryRect.height > 0.1 { - lastTextRect = summaryRect - } - } - let dateRect = TimelineCellLayout.rectForDate(textBoxRect, lastTextRect, appearance, cellData) + let textBoxRect = TimelineCellLayout.rectForTextBox(appearance, cellData, showIcon, width) + + // Date and feed name sit at the top of the cell + let dateRect = TimelineCellLayout.rectForDateAtTop(textBoxRect, appearance, cellData) let feedNameRect = TimelineCellLayout.rectForFeedName(textBoxRect, dateRect, appearance, cellData) - textBoxRect.size.height = ceil([titleRect, summaryRect, textRect, dateRect, feedNameRect].maxY() - textBoxRect.origin.y) + // Title and summary start below the date/feedName row + let topRowMaxY = max(dateRect.maxY, feedNameRect.maxY) + var titleBoxRect = textBoxRect + titleBoxRect.origin.y = topRowMaxY + 4 + + let (titleRect, numberOfLinesForTitle) = TimelineCellLayout.rectForTitle(titleBoxRect, appearance, cellData) + let summaryRect = numberOfLinesForTitle > 0 ? TimelineCellLayout.rectForSummary(titleBoxRect, titleRect, numberOfLinesForTitle, appearance, cellData) : NSRect.zero + let textRect = numberOfLinesForTitle > 0 ? NSRect.zero : TimelineCellLayout.rectForText(titleBoxRect, appearance, cellData) + let iconImageRect = TimelineCellLayout.rectForIcon(cellData, appearance, showIcon, textBoxRect, width, height) let unreadIndicatorRect = TimelineCellLayout.rectForUnreadIndicator(appearance, textBoxRect) let starRect = TimelineCellLayout.rectForStar(appearance, unreadIndicatorRect) let separatorRect = TimelineCellLayout.rectForSeparator(cellData, appearance, showIcon ? iconImageRect : titleRect, width, height) - let paddingBottom = appearance.cellPadding.bottom + let contentMaxY = [titleRect, summaryRect, textRect, dateRect, feedNameRect, iconImageRect].maxY() + let thumbnailRect = TimelineCellLayout.rectForThumbnail(cellData, appearance, textBoxRect, contentMaxY, showIcon) + let paddingBottom = thumbnailRect == .zero ? appearance.cellPadding.bottom : appearance.thumbnailMarginBottom - self.init(width: width, height: height, feedNameRect: feedNameRect, dateRect: dateRect, titleRect: titleRect, numberOfLinesForTitle: numberOfLinesForTitle, summaryRect: summaryRect, textRect: textRect, unreadIndicatorRect: unreadIndicatorRect, starRect: starRect, iconImageRect: iconImageRect, separatorRect: separatorRect, paddingBottom: paddingBottom) + self.init(width: width, height: height, feedNameRect: feedNameRect, dateRect: dateRect, titleRect: titleRect, numberOfLinesForTitle: numberOfLinesForTitle, summaryRect: summaryRect, textRect: textRect, unreadIndicatorRect: unreadIndicatorRect, starRect: starRect, iconImageRect: iconImageRect, separatorRect: separatorRect, paddingBottom: paddingBottom, thumbnailRect: thumbnailRect) } static func height(for width: CGFloat, cellData: TimelineCellData, appearance: TimelineCellAppearance) -> CGFloat { @@ -156,6 +157,15 @@ import RSCore return r } + static func rectForDateAtTop(_ textBoxRect: NSRect, _ appearance: TimelineCellAppearance, _ cellData: TimelineCellData) -> NSRect { + let textFieldSize = SingleLineTextFieldSizer.size(for: cellData.dateString, font: appearance.dateFont) + var r = NSRect.zero + r.size = textFieldSize + r.origin.y = textBoxRect.origin.y + r.origin.x = textBoxRect.maxX - textFieldSize.width + return r + } + static func rectForDate(_ textBoxRect: NSRect, _ rectAbove: NSRect, _ appearance: TimelineCellAppearance, _ cellData: TimelineCellData) -> NSRect { let textFieldSize = SingleLineTextFieldSizer.size(for: cellData.dateString, font: appearance.dateFont) @@ -170,11 +180,18 @@ import RSCore } static func rectForFeedName(_ textBoxRect: NSRect, _ dateRect: NSRect, _ appearance: TimelineCellAppearance, _ cellData: TimelineCellData) -> NSRect { - if cellData.showFeedName == .none { + let name: String + switch cellData.showFeedName { + case .byline: + name = cellData.byline.isEmpty ? cellData.feedName : cellData.byline + case .feed, .none: + name = cellData.feedName + } + guard !name.isEmpty else { return NSRect.zero } - let textFieldSize = SingleLineTextFieldSizer.size(for: cellData.feedName, font: appearance.feedNameFont) + let textFieldSize = SingleLineTextFieldSizer.size(for: name, font: appearance.feedNameFont) var r = NSRect.zero r.size = textFieldSize r.origin.y = dateRect.minY @@ -222,6 +239,28 @@ import RSCore static func rectForSeparator(_ cellData: TimelineCellData, _ appearance: TimelineCellAppearance, _ alignmentRect: NSRect, _ width: CGFloat, _ height: CGFloat) -> NSRect { return NSRect(x: alignmentRect.minX, y: height - 1, width: width - alignmentRect.minX, height: 1) } + + static func rectForThumbnail(_ cellData: TimelineCellData, _ appearance: TimelineCellAppearance, _ textBoxRect: NSRect, _ contentMaxY: CGFloat, _ showIcon: Bool) -> NSRect { + guard cellData.thumbnailURL != nil else { + return .zero + } + let originX: CGFloat + let thumbnailWidth: CGFloat + if showIcon { + let iconOriginX = appearance.cellPadding.left + appearance.unreadCircleDimension + appearance.unreadCircleMarginRight + originX = iconOriginX + thumbnailWidth = textBoxRect.maxX - iconOriginX + } else { + originX = textBoxRect.origin.x + thumbnailWidth = textBoxRect.width + } + return NSRect( + x: originX, + y: contentMaxY + appearance.thumbnailMarginTop, + width: thumbnailWidth, + height: appearance.thumbnailHeight + ) + } } private extension Array where Element == NSRect { diff --git a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift index 89c10788d..cb99f2efd 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift @@ -20,6 +20,14 @@ final class TimelineTableCellView: NSTableCellView { private lazy var iconView = IconView() + private lazy var thumbnailView: NSView = { + let v = NSView(frame: .zero) + v.wantsLayer = true + v.layer?.contentsGravity = .resizeAspectFill + v.layer?.masksToBounds = true + return v + }() + private var starView = TimelineTableCellView.imageView(with: Assets.Images.timelineStarUnselected, scaling: .scaleNone) private lazy var textFields = { @@ -31,6 +39,7 @@ final class TimelineTableCellView: NSTableCellView { if cellAppearance != oldValue { updateTextFieldFonts() iconView.layer?.cornerRadius = cellAppearance.iconCornerRadius + thumbnailView.layer?.cornerRadius = cellAppearance.thumbnailCornerRadius needsLayout = true } } @@ -107,6 +116,13 @@ final class TimelineTableCellView: NSTableCellView { feedNameView.setFrame(ifNotEqualTo: layoutRects.feedNameRect) iconView.setFrame(ifNotEqualTo: layoutRects.iconImageRect) starView.setFrame(ifNotEqualTo: layoutRects.starRect) + + if layoutRects.thumbnailRect == .zero { + hideView(thumbnailView) + } else { + showView(thumbnailView) + thumbnailView.setFrame(ifNotEqualTo: layoutRects.thumbnailRect) + } } } @@ -188,6 +204,7 @@ private extension TimelineTableCellView { addSubviewAtInit(feedNameView, hidden: true) addSubviewAtInit(iconView, hidden: true) addSubviewAtInit(starView, hidden: true) + addSubviewAtInit(thumbnailView, hidden: true) makeTextFieldColorsNormal() } @@ -240,15 +257,18 @@ private extension TimelineTableCellView { } func updateFeedNameView() { + let name: String switch cellData.showFeedName { case .byline: - showView(feedNameView) - updateTextFieldText(feedNameView, cellData.byline) - case .feed: - showView(feedNameView) - updateTextFieldText(feedNameView, cellData.feedName) - case .none: + name = cellData.byline.isEmpty ? cellData.feedName : cellData.byline + case .feed, .none: + name = cellData.feedName + } + if name.isEmpty { hideView(feedNameView) + } else { + showView(feedNameView) + updateTextFieldText(feedNameView, name) } } @@ -315,5 +335,20 @@ private extension TimelineTableCellView { updateUnreadIndicator() updateStarView() updateIcon() + updateThumbnail() + } + + func updateThumbnail() { + guard let url = cellData?.thumbnailURL else { + thumbnailView.layer?.contents = nil + return + } + guard let data = ImageDownloader.shared.image(for: url.absoluteString), + let nsImage = NSImage(data: data), + let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else { + thumbnailView.layer?.contents = nil + return + } + thumbnailView.layer?.contents = cgImage } } diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 2c57dc299..13ecc95a2 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -123,6 +123,8 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr return } + rebuildThumbnailArticleIDs() + if articles.representSameArticlesInSameOrder(as: oldValue) { // When the array is the same — same articles, same order — // but some data in some of the articles may have changed. @@ -178,6 +180,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr private var showIcons = false private var currentRowHeight: CGFloat = 0.0 + private var thumbnailArticleIDs = Set() private var didRegisterForNotifications = false static let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5, maxInterval: 2.0) @@ -238,6 +241,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(thumbnailImageDidBecomeAvailable(_:)), name: .imageDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .UserDidAddAccount, object: nil) @@ -251,6 +255,10 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr didRegisterForNotifications = true } + if let clipView = tableView.enclosingScrollView?.contentView { + NotificationCenter.default.addObserver(self, selector: #selector(scrollViewDidScroll(_:)), name: NSView.boundsDidChangeNotification, object: clipView) + } + sharingServicePickerDelegate = SharingServicePickerDelegate(self.view.window) } @@ -698,6 +706,25 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } } + @objc func thumbnailImageDidBecomeAvailable(_ note: Notification) { + guard let url = note.userInfo?[UserInfoKey.url] as? String else { + return + } + let indexesToReload = tableView.indexesOfAvailableRowsPassingTest { [weak self] row -> Bool in + guard let self, let article = articles.articleAtRow(row) else { + return false + } + return article.firstBodyImageURL?.absoluteString == url + } + if let indexesToReload { + reloadCells(for: indexesToReload) + } + } + + @objc func scrollViewDidScroll(_ note: Notification) { + prefetchThumbnailsAroundVisibleRows() + } + @objc func accountDidDownloadArticles(_ note: Notification) { guard let feeds = note.userInfo?[Account.UserInfoKey.feeds] as? Set else { return @@ -886,7 +913,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. - return tableView.rowHeight + guard let article = articles.articleAtRow(row), + thumbnailArticleIDs.contains(article.articleID) else { + return currentRowHeight + } + let width = tableView.bounds.width > 0 ? tableView.bounds.width : 400 + guard let appearance = showIcons ? cellAppearanceWithIcon : cellAppearance else { + return currentRowHeight + } + let cellData = TimelineCellData(article: article, showFeedName: showFeedNames, feedName: article.feed?.nameForDisplay, byline: article.byline(), iconImage: nil, showIcon: showIcons) + return TimelineCellLayout.height(for: width, cellData: cellData, appearance: appearance) } } @@ -1056,6 +1092,34 @@ private extension TimelineViewController { tableView.rowHeight = currentRowHeight } + func rebuildThumbnailArticleIDs() { + thumbnailArticleIDs = Set(articles.compactMap { $0.firstBodyImageURL != nil ? $0.articleID : nil }) + } + + func prefetchThumbnailsAroundVisibleRows() { + guard let scrollView = tableView.enclosingScrollView else { + return + } + let visibleRect = scrollView.contentView.bounds + let visibleRows = tableView.rows(in: visibleRect) + guard visibleRows.length > 0 else { + return + } + let prefetchCount = 5 + let start = max(0, visibleRows.location - prefetchCount) + let end = min(articles.count - 1, visibleRows.location + visibleRows.length - 1 + prefetchCount) + guard start <= end else { + return + } + for row in start...end where !visibleRows.contains(row) { + guard let article = articles.articleAtRow(row), + let url = article.firstBodyImageURL else { + continue + } + ImageDownloader.shared.image(for: url.absoluteString) + } + } + func updateShowIcons() { if showFeedNames == .feed { self.showIcons = true diff --git a/Shared/Extensions/ArticleUtilities.swift b/Shared/Extensions/ArticleUtilities.swift index 7b4d6cfd4..14a448e1c 100644 --- a/Shared/Extensions/ArticleUtilities.swift +++ b/Shared/Extensions/ArticleUtilities.swift @@ -87,6 +87,61 @@ extension Article { return contentHTML ?? contentText ?? summary } + var firstBodyImageURL: URL? { + if let imageURL { + return imageURL + } + guard let html = contentHTML else { + return nil + } + // Try each attribute in priority order: data-src (lazy-load), src + for regex in [Article.imgDataSrcRegex, Article.imgSrcRegex] { + guard let match = regex.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)), + let range = Range(match.range(at: 1), in: html) else { + continue + } + let raw = String(html[range]) + .trimmingCharacters(in: .whitespaces) + .rsparser_stringByDecodingHTMLEntities() + guard !raw.isEmpty, !raw.hasPrefix("data:") else { + continue + } + if let resolved = Article.resolveImageSrc(raw, articleURL: url) { + return resolved + } + } + return nil + } + + private static func resolveImageSrc(_ src: String, articleURL: URL?) -> URL? { + // Protocol-relative: //cdn.example.com/img.jpg + if src.hasPrefix("//") { + return URL(string: "https:" + src) + } + // Absolute URL + if src.hasPrefix("http://") || src.hasPrefix("https://") { + return URL.encodeSpacesIfNeeded(src) + } + // Relative URL — resolve against article URL + if let base = articleURL { + return URL(string: src, relativeTo: base)?.absoluteURL + } + return nil + } + + // Matches data-src="..." or data-src='...' (lazy-load pattern common in WeChat etc.) + private static let imgDataSrcRegex: NSRegularExpression = { + // swiftlint:disable force_try + return try! NSRegularExpression(pattern: #"]+data-src=["']([^"']+)["']"#, options: [.caseInsensitive]) + // swiftlint:enable force_try + }() + + private static let imgSrcRegex: NSRegularExpression = { + // swiftlint:disable force_try + return try! NSRegularExpression(pattern: #"]*\ssrc=["']([^"']+)["']"#, options: [.caseInsensitive]) + // swiftlint:enable force_try + }() + var logicalDatePublished: Date { return datePublished ?? dateModified ?? status.dateArrived } diff --git a/Shared/Images/ImageDownloader.swift b/Shared/Images/ImageDownloader.swift index b630c6e56..0e1ff1e0a 100644 --- a/Shared/Images/ImageDownloader.swift +++ b/Shared/Images/ImageDownloader.swift @@ -33,6 +33,10 @@ extension Notification.Name { NotificationCenter.default.addObserver(self, selector: #selector(handleAppDidGoToBackground(_:)), name: .appDidGoToBackground, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleLowMemory(_:)), name: .lowMemory, object: nil) + + queue.async { [weak self] in + self?.cleanupExpiredImages() + } } @objc func handleAppDidGoToBackground(_ notification: Notification) { @@ -141,6 +145,27 @@ private extension ImageDownloader { url.md5String } + nonisolated func cleanupExpiredImages() { + let folderURL = URL(fileURLWithPath: diskCache.folder) + let expiryInterval: TimeInterval = 7 * 24 * 60 * 60 + guard let enumerator = FileManager.default.enumerator( + at: folderURL, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) else { + return + } + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]), + let modDate = resourceValues.contentModificationDate else { + continue + } + if Date().timeIntervalSince(modDate) > expiryInterval { + try? FileManager.default.removeItem(at: fileURL) + } + } + } + func postImageDidBecomeAvailableNotification(_ url: String) { assert(Thread.isMainThread) NotificationCenter.default.post(name: .imageDidBecomeAvailable, object: self, userInfo: [UserInfoKey.url: url]) diff --git a/iOS/MainTimeline/Cells/MainTimelineCellData.swift b/iOS/MainTimeline/Cells/MainTimelineCellData.swift index 7f6fb68dd..d212aaac2 100644 --- a/iOS/MainTimeline/Cells/MainTimelineCellData.swift +++ b/iOS/MainTimeline/Cells/MainTimelineCellData.swift @@ -26,6 +26,7 @@ import Articles let starred: Bool let numberOfLines: Int let iconSize: IconSize + let thumbnailURL: URL? init(article: Article, showFeedName: ShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool, numberOfLines: Int, iconSize: IconSize) { @@ -62,6 +63,7 @@ import Articles self.starred = article.status.starred self.numberOfLines = numberOfLines self.iconSize = iconSize + self.thumbnailURL = article.firstBodyImageURL } @@ -79,6 +81,7 @@ import Articles self.starred = false self.numberOfLines = 0 self.iconSize = .medium + self.thumbnailURL = nil } } diff --git a/iOS/MainTimeline/Cells/MainTimelineCollectionViewCell.swift b/iOS/MainTimeline/Cells/MainTimelineCollectionViewCell.swift index 049df707c..6599a136e 100644 --- a/iOS/MainTimeline/Cells/MainTimelineCollectionViewCell.swift +++ b/iOS/MainTimeline/Cells/MainTimelineCollectionViewCell.swift @@ -25,6 +25,19 @@ class MainTimelineCollectionViewCell: UICollectionViewCell { var isPreview: Bool = false + // Thumbnail + private lazy var thumbnailView: UIImageView = { + let iv = UIImageView() + iv.contentMode = .scaleAspectFill + iv.clipsToBounds = true + iv.layer.cornerRadius = 6 + iv.backgroundColor = .secondarySystemFill + iv.translatesAutoresizingMaskIntoConstraints = false + return iv + }() + private var thumbnailHeightConstraint: NSLayoutConstraint? + private var thumbnailTopConstraint: NSLayoutConstraint? + // Cached Values private var rangeOfTitle: NSRange? private var rangeOfSummary: NSRange? @@ -79,6 +92,44 @@ class MainTimelineCollectionViewCell: UICollectionViewCell { configureStackView() registerForTraitChanges([UITraitPreferredContentSizeCategory.self], target: self, action: #selector(configureStackView)) + + // Align topSeparator leading with indicatorView (icon column) rather than articleContent + if let c = contentView.constraints.first(where: { + ($0.firstItem as? UIView) === topSeparator && $0.firstAttribute == .leading + }) { + c.isActive = false + NSLayoutConstraint.activate([ + topSeparator.leadingAnchor.constraint(equalTo: indicatorView.trailingAnchor, constant: 8) + ]) + } + + // Reorder: metaDataStackView (date/feed name) on first row, articleContent below + for c in contentView.constraints { + if (c.firstItem as? UIView) === articleContent && c.firstAttribute == .top { c.isActive = false } + if (c.firstItem as? UIView) === metaDataStackView && c.firstAttribute == .top { c.isActive = false } + if c.firstAttribute == .bottom && (c.secondItem as? UIView) === metaDataStackView { c.isActive = false } + } + NSLayoutConstraint.activate([ + metaDataStackView.topAnchor.constraint(equalTo: contentView.safeAreaLayoutGuide.topAnchor, constant: 5), + articleContent.topAnchor.constraint(equalTo: metaDataStackView.bottomAnchor, constant: 2), + ]) + + // Thumbnail view — added programmatically below articleContent + contentView.addSubview(thumbnailView) + let top = thumbnailView.topAnchor.constraint(equalTo: articleContent.bottomAnchor, constant: 0) + let height = thumbnailView.heightAnchor.constraint(equalToConstant: 0) + thumbnailTopConstraint = top + thumbnailHeightConstraint = height + NSLayoutConstraint.activate([ + top, + height, + thumbnailView.leadingAnchor.constraint(equalTo: indicatorView.trailingAnchor, constant: 8), + thumbnailView.trailingAnchor.constraint(equalTo: articleContent.trailingAnchor), + ]) + NSLayoutConstraint.activate([ + contentView.bottomAnchor.constraint(greaterThanOrEqualTo: articleContent.bottomAnchor, constant: 6), + contentView.bottomAnchor.constraint(greaterThanOrEqualTo: thumbnailView.bottomAnchor, constant: 10), + ]) } } @@ -88,6 +139,9 @@ class MainTimelineCollectionViewCell: UICollectionViewCell { rangeOfSummary = nil title = "" summary = "" + thumbnailView.image = nil + thumbnailTopConstraint?.constant = 0 + thumbnailHeightConstraint?.constant = 0 } private func configure(_ cellData: MainTimelineCellData) { @@ -99,12 +153,10 @@ class MainTimelineCollectionViewCell: UICollectionViewCell { addArticleContent(configurationState) } - if cellData.showFeedName == .feed { - articleByLine.text = cellData.feedName - } else if cellData.showFeedName == .byline { + if cellData.showFeedName == .byline, !cellData.byline.isEmpty { articleByLine.text = cellData.byline - } else if cellData.showFeedName == .none { - articleByLine.text = "" + } else { + articleByLine.text = cellData.feedName } if feedIcon != nil { @@ -113,6 +165,23 @@ class MainTimelineCollectionViewCell: UICollectionViewCell { articleDate.text = cellData.dateString updateAccessibilityLabel() + updateThumbnail() + } + + private func updateThumbnail() { + guard let thumbnailURL = cellData.thumbnailURL else { + thumbnailTopConstraint?.constant = 0 + thumbnailHeightConstraint?.constant = 0 + thumbnailView.image = nil + return + } + thumbnailTopConstraint?.constant = 8 + thumbnailHeightConstraint?.constant = 200 + if let imageData = ImageDownloader.shared.image(for: thumbnailURL.absoluteString) { + thumbnailView.image = UIImage(data: imageData) + } else { + thumbnailView.image = nil + } } private func updateAccessibilityLabel() { diff --git a/iOS/MainTimeline/MainTimelineModernViewController.swift b/iOS/MainTimeline/MainTimelineModernViewController.swift index a0ebbb323..6fc83ba50 100644 --- a/iOS/MainTimeline/MainTimelineModernViewController.swift +++ b/iOS/MainTimeline/MainTimelineModernViewController.swift @@ -479,6 +479,10 @@ final class MainTimelineModernViewController: UIViewController, UndoableCommandR // MARK: - UICollectionViewDelegate extension MainTimelineModernViewController: UICollectionViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + prefetchThumbnailsAroundVisibleRows() + } + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { becomeFirstResponder() if let dataSource { @@ -611,10 +615,26 @@ private extension MainTimelineModernViewController { NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange), name: .DisplayNameDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(thumbnailImageDidBecomeAvailable(_:)), name: .imageDidBecomeAvailable, object: nil) + } + + @objc private func thumbnailImageDidBecomeAvailable(_ note: Notification) { + guard let urlStr = note.userInfo?[UserInfoKey.url] as? String, + let collectionView, + let dataSource else { + return + } + let articlesToReload = collectionView.indexPathsForVisibleItems.compactMap { + dataSource.itemIdentifier(for: $0) + }.filter { + $0.firstBodyImageURL?.absoluteString == urlStr + } + if !articlesToReload.isEmpty { + reloadCells(articlesToReload) + } } - private func configureSearchController() { - // Setup the Search Controller + private func configureSearchController() { // Setup the Search Controller searchController.delegate = self searchController.searchResultsUpdater = self searchController.obscuresBackgroundDuringPresentation = false @@ -852,6 +872,25 @@ private extension MainTimelineModernViewController { } } + private func prefetchThumbnailsAroundVisibleRows() { + guard let collectionView, let dataSource else { + return + } + let visibleRows = collectionView.indexPathsForVisibleItems.map { $0.row } + guard !visibleRows.isEmpty else { + return + } + let minRow = max(0, (visibleRows.min() ?? 0) - 5) + let maxRow = (visibleRows.max() ?? 0) + 5 + for row in minRow...maxRow { + guard let article = dataSource.itemIdentifier(for: IndexPath(row: row, section: 0)), + let thumbnailURL = article.firstBodyImageURL else { + continue + } + ImageDownloader.shared.image(for: thumbnailURL.absoluteString) + } + } + func resetUI(resetScroll: Bool) { let shouldShowFilterButton = coordinator?.shouldShowFilterButton() ?? false navigationItem.rightBarButtonItem = shouldShowFilterButton ? filterButton : nil