damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 03748a2b02a8393056ce5d27b4ed9968b2c64a92
parent 97bca010f6345de01decfc77d6dcf4fe95a3da12
Author: William Casarin <jb55@jb55.com>
Date:   Sat,  6 Aug 2022 13:34:04 -0700

pfps: load profile pics in the background

So we don't get annoying popping artifacts when scrolling

Changelog-Fixed: Profile pics are now loaded in the background
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus/ContentView.swift | 2+-
Mdamus/Models/FollowersModel.swift | 2+-
Mdamus/Models/FollowingModel.swift | 2+-
Mdamus/Models/HomeModel.swift | 19++++++++++++++++---
Mdamus/Models/SearchHomeModel.swift | 20+++++++++-----------
Mdamus/Util/ImageCache.swift | 140+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mdamus/Views/ProfilePicView.swift | 19++++++++++++-------
Mdamus/Views/SearchHomeView.swift | 2+-
8 files changed, 132 insertions(+), 74 deletions(-)

diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -113,7 +113,7 @@ struct ContentView: View { } switch selected_timeline { case .search: - SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(pool: damus_state!.pool, profiles: damus_state!.profiles)) + SearchHomeView(damus_state: damus_state!, model: SearchHomeModel(damus_state: damus_state!)) case .home: PostingTimelineView diff --git a/damus/Models/FollowersModel.swift b/damus/Models/FollowersModel.swift @@ -79,7 +79,7 @@ class FollowersModel: ObservableObject { if ev.known_kind == .contacts { handle_contact_event(ev) } else if ev.known_kind == .metadata { - process_metadata_event(profiles: self.damus_state.profiles, ev: ev) + process_metadata_event(image_cache: damus_state.image_cache, profiles: damus_state.profiles, ev: ev) } case .notice(let msg): diff --git a/damus/Models/FollowingModel.swift b/damus/Models/FollowingModel.swift @@ -60,7 +60,7 @@ class FollowingModel { switch nev { case .event(_, let ev): if ev.kind == 0 { - process_metadata_event(profiles: damus_state.profiles, ev: ev) + process_metadata_event(image_cache: damus_state.image_cache, profiles: damus_state.profiles, ev: ev) } case .notice(let msg): print("followingmodel notice: \(msg)") diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -6,6 +6,7 @@ // import Foundation +import UIKit struct NewEventsBits { let bits: Int @@ -300,7 +301,7 @@ class HomeModel: ObservableObject { } func handle_metadata_event(_ ev: NostrEvent) { - process_metadata_event(profiles: damus_state.profiles, ev: ev) + process_metadata_event(image_cache: damus_state.image_cache, profiles: damus_state.profiles, ev: ev) } func get_last_event_of_kind(relay_id: String, kind: Int) -> NostrEvent? { @@ -489,7 +490,7 @@ func print_filters(relay_id: String?, filters groups: [[NostrFilter]]) { print("-----") } -func process_metadata_event(profiles: Profiles, ev: NostrEvent) { +func process_metadata_event(image_cache: ImageCache, profiles: Profiles, ev: NostrEvent) { guard let profile: Profile = decode_data(Data(ev.content.utf8)) else { return } @@ -503,7 +504,19 @@ func process_metadata_event(profiles: Profiles, ev: NostrEvent) { let tprof = TimestampedProfile(profile: profile, timestamp: ev.created_at) profiles.add(id: ev.pubkey, profile: tprof) - + + // load pfps asap + let picture = tprof.profile.picture ?? "https://robohash.org/\(ev.pubkey)" + if let url = URL(string: picture) { + Task<UIImage?, Never>.init(priority: .background) { + let res = await load_image(cache: image_cache, from: url) + DispatchQueue.main.async { + notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) + } + return res + } + } + notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) } diff --git a/damus/Models/SearchHomeModel.swift b/damus/Models/SearchHomeModel.swift @@ -14,15 +14,13 @@ class SearchHomeModel: ObservableObject { @Published var loading: Bool = false var seen_pubkey: Set<String> = Set() - let profiles: Profiles - let pool: RelayPool + let damus_state: DamusState let base_subid = UUID().description let profiles_subid = UUID().description let limit: UInt32 = 250 - init(pool: RelayPool, profiles: Profiles) { - self.pool = pool - self.profiles = profiles + init(damus_state: DamusState) { + self.damus_state = damus_state } func get_base_filter() -> NostrFilter { @@ -34,21 +32,21 @@ class SearchHomeModel: ObservableObject { func subscribe() { loading = true - pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event) + damus_state.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event) } func unsubscribe() { loading = false - pool.unsubscribe(sub_id: base_subid) + damus_state.pool.unsubscribe(sub_id: base_subid) } func load_profiles(relay_id: String) { var filter = NostrFilter.filter_profiles - let authors = find_profiles_to_fetch(profiles: profiles, events: events) + let authors = find_profiles_to_fetch(profiles: damus_state.profiles, events: events) filter.authors = authors if !authors.isEmpty { - pool.subscribe(sub_id: profiles_subid, filters: [filter], handler: handle_event) + damus_state.pool.subscribe(sub_id: profiles_subid, filters: [filter], handler: handle_event) } } @@ -71,7 +69,7 @@ class SearchHomeModel: ObservableObject { $0.created_at > $1.created_at } } else if ev.known_kind == .metadata { - process_metadata_event(profiles: self.profiles, ev: ev) + process_metadata_event(image_cache: damus_state.image_cache, profiles: damus_state.profiles, ev: ev) } case .notice(let msg): print("search home notice: \(msg)") @@ -81,7 +79,7 @@ class SearchHomeModel: ObservableObject { if sub_id == self.base_subid { load_profiles(relay_id: relay_id) } else if sub_id == self.profiles_subid { - pool.unsubscribe(sub_id: self.profiles_subid) + damus_state.pool.unsubscribe(sub_id: self.profiles_subid) } break diff --git a/damus/Util/ImageCache.swift b/damus/Util/ImageCache.swift @@ -9,41 +9,81 @@ import Foundation import SwiftUI import Combine -extension UIImage { - func decodedImage(_ size: Int) -> UIImage { - guard let cgImage = cgImage else { return self } - let scale = UIScreen.main.scale - let pix_size = CGFloat(size) * scale - let colorSpace = CGColorSpaceCreateDeviceRGB() - //let cgsize = CGSize(width: size, height: size) - - let context = CGContext(data: nil, width: Int(pix_size), height: Int(pix_size), bitsPerComponent: 8, bytesPerRow: cgImage.bytesPerRow, space: colorSpace, bitmapInfo: cgImage.bitmapInfo.rawValue) - - //UIGraphicsBeginImageContextWithOptions(cgsize, true, 0) - context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: pix_size, height: pix_size)) - //UIGraphicsEndImageContext() - - guard let decodedImage = context?.makeImage() else { return self } - return UIImage(cgImage: decodedImage, scale: scale, orientation: .up) - } +enum ImageProcessingStatus { + case processing + case done } class ImageCache { private let lock = NSLock() + private var state: [String: ImageProcessingStatus] = [:] + + private func get_state(_ key: String) -> ImageProcessingStatus? { + lock.lock(); defer { lock.unlock() } + + return state[key] + } - lazy var cache: NSCache<AnyObject, AnyObject> = { - let cache = NSCache<AnyObject, AnyObject>() + private func set_state(_ key: String, new_state: ImageProcessingStatus) { + lock.lock(); defer { lock.unlock() } + + state[key] = new_state + } + + lazy var cache: NSCache<AnyObject, UIImage> = { + let cache = NSCache<AnyObject, UIImage>() cache.totalCostLimit = 1024 * 1024 * 100 // 100MB return cache }() - func lookup(for url: URL) -> UIImage? { - lock.lock(); defer { lock.unlock() } + // simple polling until I can figure out a better way to do this + func wait_for_image(_ url: URL) async { + while true { + let why_would_this_happen: ()? = try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + if why_would_this_happen == nil { + return + } + if get_state(url.absoluteString) == .done { + return + } + } + } + + func lookup_sync(for url: URL) -> UIImage? { + let status = get_state(url.absoluteString) - if let decoded = cache.object(forKey: url as AnyObject) as? UIImage { + switch status { + case .done: + break + case .processing: + return nil + case .none: + return nil + } + + if let decoded = cache.object(forKey: url as AnyObject) { return decoded } + + return nil + } + + func lookup(for url: URL) async -> UIImage? { + let status = get_state(url.absoluteString) + + switch status { + case .done: + break + case .processing: + await wait_for_image(url) + case .none: + return nil + } + if let decoded = cache.object(forKey: url as AnyObject) { + return decoded + } + return nil } @@ -52,35 +92,37 @@ class ImageCache { cache.removeObject(forKey: url as AnyObject) } - func insert(_ image: UIImage?, for url: URL) { - guard let image = image else { return remove(for: url) } - let decodedImage = image.decodedImage(Int(PFP_SIZE)) - lock.lock(); defer { lock.unlock() } - cache.setObject(decodedImage, forKey: url as AnyObject) - } - - subscript(_ key: URL) -> UIImage? { - get { - return lookup(for: key) - } - set { - return insert(newValue, for: key) - } + func insert(_ image: UIImage, for url: URL) async -> UIImage? { + let scale = await UIScreen.main.scale + let size = CGSize(width: PFP_SIZE * scale, height: PFP_SIZE * scale) + + let key = url.absoluteString + + set_state(key, new_state: .processing) + + let decoded_image = await image.byPreparingThumbnail(ofSize: size) + + lock.lock() + cache.setObject(decoded_image ?? UIImage(), forKey: url as AnyObject) + state[key] = .done + lock.unlock() + + return decoded_image } } -func load_image(cache: ImageCache, from url: URL) -> AnyPublisher<UIImage?, Never> { - if let image = cache[url] { - return Just(image).eraseToAnyPublisher() +func load_image(cache: ImageCache, from url: URL) async -> UIImage? { + if let image = await cache.lookup(for: url) { + return image + } + + guard let (data, _) = try? await URLSession.shared.data(from: url) else { + return nil } - return URLSession.shared.dataTaskPublisher(for: url) - .map { (data, response) -> UIImage? in return UIImage(data: data) } - .catch { error in return Just(nil) } - .handleEvents(receiveOutput: { image in - guard let image = image else { return } - cache[url] = image - }) - .subscribe(on: DispatchQueue.global(qos: .background)) - .receive(on: RunLoop.main) - .eraseToAnyPublisher() + + guard let img = UIImage(data: data) else { + return nil + } + + return await cache.insert(img, for: url) } diff --git a/damus/Views/ProfilePicView.swift b/damus/Views/ProfilePicView.swift @@ -54,7 +54,6 @@ struct ProfilePicView: View { } func ProfilePic(_ url: URL) -> some View { - let pub = load_image(cache: image_cache, from: url) return Group { if let img = self.img { img @@ -67,9 +66,10 @@ struct ProfilePicView: View { Placeholder } } - .onReceive(pub) { mimg in - if let img = mimg { - self.img = Image(uiImage: img) + .task { + let ui_img = await load_image(cache: image_cache, from: url) + if let ui_img = ui_img { + self.img = Image(uiImage: ui_img) } } } @@ -89,12 +89,17 @@ struct ProfilePicView: View { MainContent .onReceive(handle_notify(.profile_updated)) { notif in let updated = notif.object as! ProfileUpdate - if updated.pubkey != pubkey { + + guard updated.pubkey == self.pubkey else { return } - if updated.profile.picture != picture { - picture = updated.profile.picture + if let pic = updated.profile.picture { + if let url = URL(string: pic) { + if let ui_img = image_cache.lookup_sync(for: url) { + self.img = Image(uiImage: ui_img) + } + } } } } diff --git a/damus/Views/SearchHomeView.swift b/damus/Views/SearchHomeView.swift @@ -70,7 +70,7 @@ struct SearchHomeView_Previews: PreviewProvider { let state = test_damus_state() SearchHomeView( damus_state: state, - model: SearchHomeModel(pool: state.pool, profiles: state.profiles) + model: SearchHomeModel(damus_state: state) ) } }