Skip to content

Commit 969d86c

Browse files
committed
Integrate Favicon service
1 parent ff3ddc5 commit 969d86c

File tree

4 files changed

+75
-40
lines changed

4 files changed

+75
-40
lines changed

WordPress/Classes/Utility/Media/FaviconService.swift

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import UIKit
22

33
// Fetches URLs for favicons for sites.
4-
@MainActor
5-
final class FaviconService {
4+
actor FaviconService {
65
static let shared = FaviconService()
76

8-
private var cache: [URL: URL] = [:]
7+
private nonisolated let cache = FaviconCache()
98

109
private let session = URLSession(configuration: {
1110
let configuration = URLSessionConfiguration.default
@@ -15,19 +14,19 @@ final class FaviconService {
1514

1615
private var tasks: [URL: ServiceDataTask] = [:]
1716

18-
func cachedFavicon(forURL siteURL: URL) -> URL? {
19-
cache[siteURL]
17+
nonisolated func cachedFavicon(forURL siteURL: URL) -> URL? {
18+
cache.cachedFavicon(forURL: siteURL)
2019
}
2120

2221
/// Returns a favicon URL for the given site.
2322
func favicon(forURL siteURL: URL) async throws -> URL {
24-
if let faviconURL = cache[siteURL] {
23+
if let faviconURL = cache.cachedFavicon(forURL: siteURL) {
2524
return faviconURL
2625
}
2726
let (data, response) = try await session.data(from: siteURL)
2827
try validate(response: response)
2928
let faviconURL = await makeFavicon(from: data, siteURL: siteURL)
30-
cache[siteURL] = faviconURL
29+
cache.storeCachedFaviconURL(faviconURL, forURL: siteURL)
3130
return faviconURL
3231
}
3332

@@ -67,18 +66,30 @@ final class FaviconService {
6766
}
6867
}
6968

69+
private final class FaviconCache: @unchecked Sendable {
70+
private let cache = NSCache<AnyObject, AnyObject>()
71+
72+
func cachedFavicon(forURL siteURL: URL) -> URL? {
73+
cache.object(forKey: siteURL as NSURL) as? URL
74+
}
75+
76+
func storeCachedFaviconURL(_ faviconURL: URL, forURL siteURL: URL) {
77+
cache.setObject(faviconURL as NSURL, forKey: siteURL as NSURL)
78+
}
79+
}
80+
7081
private let regex: NSRegularExpression? = {
7182
let pattern = "<link[^>]*rel=\"apple-touch-icon\"[^>]*href=\"([^\"]+)\"[^>]*>"
7283
return try? NSRegularExpression(pattern: pattern, options: .caseInsensitive)
7384
}()
7485

7586
private func makeFavicon(from data: Data, siteURL: URL) async -> URL {
7687
let html = String(data: data, encoding: .utf8) ?? ""
77-
if let match = regex?.firstMatch(in: html, options: [], range: NSRange(location: 0, length: html.utf16.count)) {
78-
if let range = Range(match.range(at: 1), in: html) {
79-
let faviconPath = String(html[range])
80-
return siteURL.appendingPathComponent(faviconPath)
81-
}
88+
let range = NSRange(location: 0, length: html.utf16.count)
89+
if let match = regex?.firstMatch(in: html, options: [], range: range),
90+
let matchRange = Range(match.range(at: 1), in: html),
91+
let faviconURL = URL(string: String(html[matchRange]), relativeTo: siteURL) {
92+
return faviconURL
8293
}
8394
// Fallback to standard favicon path. It has low quality, but
8495
// it's better than nothing.

WordPress/Classes/Utility/Media/ImageView.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ final class ImageView: UIView {
1616
case spinner
1717
}
1818

19+
var isErrorViewEnabled = true
1920
var loadingStyle = LoadingStyle.background
2021

2122
override init(frame: CGRect) {
@@ -70,7 +71,9 @@ final class ImageView: UIView {
7071
imageView.isHidden = false
7172
backgroundColor = .clear
7273
case .failure:
73-
makeErrorView().isHidden = false
74+
if isErrorViewEnabled {
75+
makeErrorView().isHidden = false
76+
}
7477
}
7578
}
7679

WordPress/Classes/ViewRelated/Reader/ReaderPostCell.swift

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import SwiftUI
22
import UIKit
3+
import Combine
34

45
final class ReaderPostCell: UITableViewCell {
56
private let view = ReaderPostCellView()
@@ -98,8 +99,11 @@ private final class ReaderPostCellView: UIView {
9899
}
99100

100101
let insets = UIEdgeInsets(top: 0, left: 44, bottom: 0, right: 16)
102+
101103
private let coverAspectRatio: CGFloat = 239.0 / 358.0
102104
private var imageViewConstraints: [NSLayoutConstraint] = []
105+
private var viewModel: ReaderPostCellViewModel? // important: has to retain it
106+
private var cancellables: [AnyCancellable] = []
103107

104108
override init(frame: CGRect) {
105109
super.init(frame: frame)
@@ -117,6 +121,7 @@ private final class ReaderPostCellView: UIView {
117121
private func configureStyle() {
118122
avatarView.layer.cornerRadius = 14
119123
avatarView.layer.masksToBounds = true
124+
avatarView.isErrorViewEnabled = false
120125

121126
authorButton.maximumContentSizeCategory = .accessibilityLarge
122127
configureTimeLabel(timeLabel)
@@ -215,16 +220,17 @@ private final class ReaderPostCellView: UIView {
215220
}
216221

217222
func prepareForReuse() {
223+
cancellables = []
224+
viewModel = nil
218225
avatarView.prepareForReuse()
219226
imageView.prepareForReuse()
220227
imageView.isHidden = false
221228
}
222229

223230
func configure(with viewModel: ReaderPostCellViewModel) {
224-
avatarView.imageView.image = UIImage(named: "post-blavatar-placeholder")
225-
if let avatarURL = viewModel.avatarURL {
226-
avatarView.setImage(with: avatarURL)
227-
}
231+
self.viewModel = viewModel
232+
233+
setAvatar(with: viewModel)
228234
authorButton.configuration?.attributedTitle = AttributedString(viewModel.author, attributes: Self.authorAttributes)
229235
timeLabel.text = viewModel.time
230236
titleLabel.text = viewModel.title
@@ -239,6 +245,19 @@ private final class ReaderPostCellView: UIView {
239245
buttons.like.configuration?.attributedTitle = AttributedString("1K", attributes: Self.toolbarAttributes)
240246
}
241247

248+
private func setAvatar(with viewModel: ReaderPostCellViewModel) {
249+
avatarView.imageView.image = UIImage(named: "post-blavatar-placeholder")
250+
let avatarSize = CGSize(width: ReaderPostCell.avatarSize, height: ReaderPostCell.avatarSize)
251+
.scaled(by: UITraitCollection.current.displayScale)
252+
if let avatarURL = viewModel.avatarURL {
253+
avatarView.setImage(with: avatarURL, size: avatarSize)
254+
} else {
255+
viewModel.$avatarURL.compactMap({ $0 }).sink { [weak self] in
256+
self?.avatarView.setImage(with: $0, size: avatarSize)
257+
}.store(in: &cancellables)
258+
}
259+
}
260+
242261
private static let authorAttributes = AttributeContainer([
243262
.font: UIFont.preferredFont(forTextStyle: .footnote),
244263
.foregroundColor: UIColor.label

WordPress/Classes/ViewRelated/Reader/ReaderPostCellViewModel.swift

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,46 @@
11
import Foundation
22

33
final class ReaderPostCellViewModel {
4-
var avatarURL: URL?
4+
@Published var avatarURL: URL?
55
var author: String
66
var time: String
77
var title: String
88
var details: String
99
var imageURL: URL?
1010

11-
init() {
12-
self.avatarURL = URL(string: "https://picsum.photos/120/120.jpg")
13-
self.author = "WordPress Mobile Apps"
14-
self.time = "9d ago"
15-
self.title = "Discovering the Wonders of the Wild"
16-
self.details = "Lorem ipsum dolor sit amet. Non omnis quia et natus voluptatum et eligendi voluptate vel iusto fuga sit repellendus molestiae aut voluptatem blanditiis ad neque sapiente. Id galisum distinctio quo enim aperiam non veritatis vitae et ducimus rerum."
17-
self.imageURL = URL(string: "https://picsum.photos/1260/630.jpg")
11+
private var faviconTask: Task<Void, Never>?
12+
13+
deinit {
14+
faviconTask?.cancel()
1815
}
1916

20-
convenience init(post: ReaderPost) {
21-
self.init()
17+
init(post: ReaderPost) {
18+
self.author = post.blogNameForDisplay()
19+
self.time = post.dateForDisplay()?.toShortString() ?? ""
20+
self.title = post.titleForDisplay()
21+
self.details = post.contentPreviewForDisplay()
22+
self.imageURL = post.featuredImageURLForDisplay()
2223

2324
if let avatarURL = post.siteIconForDisplay(ofSize: Int(ReaderPostCell.avatarSize)) {
2425
self.avatarURL = avatarURL
2526
} else if let blogURL = post.blogURL.flatMap(URL.init) {
26-
self.avatarURL = blogURL.appendingPathComponent("favicon.ico")
27-
28-
Task {
29-
do {
30-
let faviconURL = try await FaviconService.shared.favicon(forURL: blogURL)
31-
print(faviconURL)
32-
} catch {
33-
print(error)
27+
if let faviconURL = FaviconService.shared.cachedFavicon(forURL: blogURL) {
28+
self.avatarURL = faviconURL
29+
} else {
30+
faviconTask = Task { @MainActor [weak self] in
31+
self?.avatarURL = try? await FaviconService.shared.favicon(forURL: blogURL)
3432
}
3533
}
3634
}
37-
self.author = post.blogNameForDisplay()
38-
self.time = post.dateForDisplay()?.toShortString() ?? ""
39-
self.title = post.titleForDisplay()
40-
self.details = post.contentPreviewForDisplay()
41-
self.imageURL = post.featuredImageURLForDisplay()
35+
}
36+
37+
private init() {
38+
self.avatarURL = URL(string: "https://picsum.photos/120/120.jpg")
39+
self.author = "WordPress Mobile Apps"
40+
self.time = "9d ago"
41+
self.title = "Discovering the Wonders of the Wild"
42+
self.details = "Lorem ipsum dolor sit amet. Non omnis quia et natus voluptatum et eligendi voluptate vel iusto fuga sit repellendus molestiae aut voluptatem blanditiis ad neque sapiente. Id galisum distinctio quo enim aperiam non veritatis vitae et ducimus rerum."
43+
self.imageURL = URL(string: "https://picsum.photos/1260/630.jpg")
4244
}
4345

4446
static func mock() -> ReaderPostCellViewModel {

0 commit comments

Comments
 (0)