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:
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user