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>
This commit is contained in:
William Casarin
2022-08-06 13:34:04 -07:00
parent 97bca010f6
commit 03748a2b02
8 changed files with 133 additions and 75 deletions

View File

@@ -113,7 +113,7 @@ struct ContentView: View {
} }
switch selected_timeline { switch selected_timeline {
case .search: 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: case .home:
PostingTimelineView PostingTimelineView

View File

@@ -79,7 +79,7 @@ class FollowersModel: ObservableObject {
if ev.known_kind == .contacts { if ev.known_kind == .contacts {
handle_contact_event(ev) handle_contact_event(ev)
} else if ev.known_kind == .metadata { } 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): case .notice(let msg):

View File

@@ -60,7 +60,7 @@ class FollowingModel {
switch nev { switch nev {
case .event(_, let ev): case .event(_, let ev):
if ev.kind == 0 { 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): case .notice(let msg):
print("followingmodel notice: \(msg)") print("followingmodel notice: \(msg)")

View File

@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import UIKit
struct NewEventsBits { struct NewEventsBits {
let bits: Int let bits: Int
@@ -300,7 +301,7 @@ class HomeModel: ObservableObject {
} }
func handle_metadata_event(_ ev: NostrEvent) { 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? { 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("-----") 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 { guard let profile: Profile = decode_data(Data(ev.content.utf8)) else {
return return
} }
@@ -504,6 +505,18 @@ func process_metadata_event(profiles: Profiles, ev: NostrEvent) {
let tprof = TimestampedProfile(profile: profile, timestamp: ev.created_at) let tprof = TimestampedProfile(profile: profile, timestamp: ev.created_at)
profiles.add(id: ev.pubkey, profile: tprof) 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)) notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile))
} }

View File

@@ -14,15 +14,13 @@ class SearchHomeModel: ObservableObject {
@Published var loading: Bool = false @Published var loading: Bool = false
var seen_pubkey: Set<String> = Set() var seen_pubkey: Set<String> = Set()
let profiles: Profiles let damus_state: DamusState
let pool: RelayPool
let base_subid = UUID().description let base_subid = UUID().description
let profiles_subid = UUID().description let profiles_subid = UUID().description
let limit: UInt32 = 250 let limit: UInt32 = 250
init(pool: RelayPool, profiles: Profiles) { init(damus_state: DamusState) {
self.pool = pool self.damus_state = damus_state
self.profiles = profiles
} }
func get_base_filter() -> NostrFilter { func get_base_filter() -> NostrFilter {
@@ -34,21 +32,21 @@ class SearchHomeModel: ObservableObject {
func subscribe() { func subscribe() {
loading = true 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() { func unsubscribe() {
loading = false loading = false
pool.unsubscribe(sub_id: base_subid) damus_state.pool.unsubscribe(sub_id: base_subid)
} }
func load_profiles(relay_id: String) { func load_profiles(relay_id: String) {
var filter = NostrFilter.filter_profiles 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 filter.authors = authors
if !authors.isEmpty { 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 $0.created_at > $1.created_at
} }
} else if ev.known_kind == .metadata { } 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): case .notice(let msg):
print("search home notice: \(msg)") print("search home notice: \(msg)")
@@ -81,7 +79,7 @@ class SearchHomeModel: ObservableObject {
if sub_id == self.base_subid { if sub_id == self.base_subid {
load_profiles(relay_id: relay_id) load_profiles(relay_id: relay_id)
} else if sub_id == self.profiles_subid { } else if sub_id == self.profiles_subid {
pool.unsubscribe(sub_id: self.profiles_subid) damus_state.pool.unsubscribe(sub_id: self.profiles_subid)
} }
break break

View File

@@ -9,38 +9,78 @@ import Foundation
import SwiftUI import SwiftUI
import Combine import Combine
extension UIImage { enum ImageProcessingStatus {
func decodedImage(_ size: Int) -> UIImage { case processing
guard let cgImage = cgImage else { return self } case done
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)
}
} }
class ImageCache { class ImageCache {
private let lock = NSLock() private let lock = NSLock()
private var state: [String: ImageProcessingStatus] = [:]
lazy var cache: NSCache<AnyObject, AnyObject> = { private func get_state(_ key: String) -> ImageProcessingStatus? {
let cache = NSCache<AnyObject, AnyObject>() lock.lock(); defer { lock.unlock() }
return state[key]
}
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 cache.totalCostLimit = 1024 * 1024 * 100 // 100MB
return cache return cache
}() }()
func lookup(for url: URL) -> UIImage? { // simple polling until I can figure out a better way to do this
lock.lock(); defer { lock.unlock() } 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
}
}
}
if let decoded = cache.object(forKey: url as AnyObject) as? UIImage { func lookup_sync(for url: URL) -> UIImage? {
let status = get_state(url.absoluteString)
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 decoded
} }
@@ -52,35 +92,37 @@ class ImageCache {
cache.removeObject(forKey: url as AnyObject) cache.removeObject(forKey: url as AnyObject)
} }
func insert(_ image: UIImage?, for url: URL) { func insert(_ image: UIImage, for url: URL) async -> UIImage? {
guard let image = image else { return remove(for: url) } let scale = await UIScreen.main.scale
let decodedImage = image.decodedImage(Int(PFP_SIZE)) let size = CGSize(width: PFP_SIZE * scale, height: PFP_SIZE * scale)
lock.lock(); defer { lock.unlock() }
cache.setObject(decodedImage, forKey: url as AnyObject)
}
subscript(_ key: URL) -> UIImage? { let key = url.absoluteString
get {
return lookup(for: key) set_state(key, new_state: .processing)
}
set { let decoded_image = await image.byPreparingThumbnail(ofSize: size)
return insert(newValue, for: key)
} 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> { func load_image(cache: ImageCache, from url: URL) async -> UIImage? {
if let image = cache[url] { if let image = await cache.lookup(for: url) {
return Just(image).eraseToAnyPublisher() return image
} }
return URLSession.shared.dataTaskPublisher(for: url)
.map { (data, response) -> UIImage? in return UIImage(data: data) } guard let (data, _) = try? await URLSession.shared.data(from: url) else {
.catch { error in return Just(nil) } return nil
.handleEvents(receiveOutput: { image in }
guard let image = image else { return }
cache[url] = image guard let img = UIImage(data: data) else {
}) return nil
.subscribe(on: DispatchQueue.global(qos: .background)) }
.receive(on: RunLoop.main)
.eraseToAnyPublisher() return await cache.insert(img, for: url)
} }

View File

@@ -54,7 +54,6 @@ struct ProfilePicView: View {
} }
func ProfilePic(_ url: URL) -> some View { func ProfilePic(_ url: URL) -> some View {
let pub = load_image(cache: image_cache, from: url)
return Group { return Group {
if let img = self.img { if let img = self.img {
img img
@@ -67,9 +66,10 @@ struct ProfilePicView: View {
Placeholder Placeholder
} }
} }
.onReceive(pub) { mimg in .task {
if let img = mimg { let ui_img = await load_image(cache: image_cache, from: url)
self.img = Image(uiImage: img) if let ui_img = ui_img {
self.img = Image(uiImage: ui_img)
} }
} }
} }
@@ -89,12 +89,17 @@ struct ProfilePicView: View {
MainContent MainContent
.onReceive(handle_notify(.profile_updated)) { notif in .onReceive(handle_notify(.profile_updated)) { notif in
let updated = notif.object as! ProfileUpdate let updated = notif.object as! ProfileUpdate
if updated.pubkey != pubkey {
guard updated.pubkey == self.pubkey else {
return return
} }
if updated.profile.picture != picture { if let pic = updated.profile.picture {
picture = 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)
}
}
} }
} }
} }

View File

@@ -70,7 +70,7 @@ struct SearchHomeView_Previews: PreviewProvider {
let state = test_damus_state() let state = test_damus_state()
SearchHomeView( SearchHomeView(
damus_state: state, damus_state: state,
model: SearchHomeModel(pool: state.pool, profiles: state.profiles) model: SearchHomeModel(damus_state: state)
) )
} }
} }