diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 965b9eb..45cac8a 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 4C0A3F95280F6C78000448DE /* ReplyQuoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F94280F6C78000448DE /* ReplyQuoteView.swift */; }; 4C0A3F97280F8E02000448DE /* ThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F96280F8E02000448DE /* ThreadView.swift */; }; 4C363A8428233689006E126D /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A8328233689006E126D /* Parser.swift */; }; + 4C363A8628234FDE006E126D /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A8528234FDE006E126D /* ImageCache.swift */; }; 4C3BEFD22819DB9B00B3DE84 /* ProfileModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */; }; 4C3BEFD42819DE8F00B3DE84 /* NostrKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */; }; 4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD5281D995700B3DE84 /* ActionBarModel.swift */; }; @@ -81,6 +82,7 @@ 4C0A3F94280F6C78000448DE /* ReplyQuoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyQuoteView.swift; sourceTree = ""; }; 4C0A3F96280F8E02000448DE /* ThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadView.swift; sourceTree = ""; }; 4C363A8328233689006E126D /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; + 4C363A8528234FDE006E126D /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileModel.swift; sourceTree = ""; }; 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrKind.swift; sourceTree = ""; }; 4C3BEFD5281D995700B3DE84 /* ActionBarModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBarModel.swift; sourceTree = ""; }; @@ -213,6 +215,7 @@ isa = PBXGroup; children = ( 4C363A8328233689006E126D /* Parser.swift */, + 4C363A8528234FDE006E126D /* ImageCache.swift */, ); path = Util; sourceTree = ""; @@ -428,6 +431,7 @@ buildActionMask = 2147483647; files = ( 4CE4F8CD281352B30009DFBB /* Notifications.swift in Sources */, + 4C363A8628234FDE006E126D /* ImageCache.swift in Sources */, 4C75EFB728049D990006080F /* RelayPool.swift in Sources */, 4CE6DEE927F7A08100C66700 /* ContentView.swift in Sources */, 4CEE2AF5280B29E600AB5EEF /* TimeAgo.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift index fa926b4..dd53d0c 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -303,7 +303,9 @@ struct ContentView: View { self.damus = DamusState(pool: pool, pubkey: pubkey, likes: EventCounter(our_pubkey: pubkey), - boosts: EventCounter(our_pubkey: pubkey)) + boosts: EventCounter(our_pubkey: pubkey), + image_cache: ImageCache() + ) pool.connect() } diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift index b31de71..ab0001e 100644 --- a/damus/Models/DamusState.swift +++ b/damus/Models/DamusState.swift @@ -12,5 +12,6 @@ struct DamusState { let pubkey: String let likes: EventCounter let boosts: EventCounter + let image_cache: ImageCache } diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift index 2aa4ae6..cbdc189 100644 --- a/damus/Nostr/Profiles.swift +++ b/damus/Nostr/Profiles.swift @@ -6,7 +6,65 @@ // import Foundation +import UIKit +import Combine +class ImageCache { + private let lock = NSLock() + + lazy var cache: NSCache = { + let cache = NSCache() + cache.totalCostLimit = 1024 * 1024 * 100 // 100MB + return cache + }() + + func lookup(for url: URL) -> UIImage? { + lock.lock(); defer { lock.unlock() } + + if let decoded = cache.object(forKey: url as AnyObject) as? UIImage { + return decoded + } + + return nil + } + + func remove(for url: URL) { + lock.lock(); defer { lock.unlock() } + 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 load_image(cache: ImageCache, from url: URL) -> AnyPublisher { + if let image = cache[url] { + return Just(image).eraseToAnyPublisher() + } + 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() +} class Profiles: ObservableObject { @Published var profiles: [String: TimestampedProfile] = [:] diff --git a/damus/Util/ImageCache.swift b/damus/Util/ImageCache.swift new file mode 100644 index 0000000..ae7590e --- /dev/null +++ b/damus/Util/ImageCache.swift @@ -0,0 +1,28 @@ +// +// ImageCache.swift +// damus +// +// Created by William Casarin on 2022-05-04. +// + +import Foundation +import SwiftUI + +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: CGImageAlphaInfo.noneSkipFirst.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) + } +} diff --git a/damus/Views/ChatView.swift b/damus/Views/ChatView.swift index 3c1e4d9..5920b90 100644 --- a/damus/Views/ChatView.swift +++ b/damus/Views/ChatView.swift @@ -12,8 +12,7 @@ struct ChatView: View { let prev_ev: NostrEvent? let next_ev: NostrEvent? - let likes: EventCounter - let our_pubkey: String + let damus: DamusState @EnvironmentObject var profiles: Profiles @EnvironmentObject var thread: ThreadModel @@ -91,7 +90,7 @@ struct ChatView: View { VStack { if is_active || just_started { - ProfilePicView(picture: profile?.picture, size: 32, highlight: is_active ? .main : .none) + ProfilePicView(picture: profile?.picture, size: 32, highlight: is_active ? .main : .none, image_cache: damus.image_cache) } /* if just_started { @@ -122,7 +121,7 @@ struct ChatView: View { if let ref_id = thread.replies.lookup(event.id) { if !is_reply_to_prev() { - ReplyQuoteView(quoter: event, event_id: ref_id) + ReplyQuoteView(quoter: event, event_id: ref_id, image_cache: damus.image_cache) .environmentObject(thread) .environmentObject(profiles) ReplyDescription @@ -133,7 +132,10 @@ struct ChatView: View { .textSelection(.enabled) if is_active || next_ev == nil || next_ev!.pubkey != event.pubkey { - EventActionBar(event: event, our_pubkey: our_pubkey, bar: make_actionbar_model(ev: event, counter: likes)) + EventActionBar(event: event, + our_pubkey: damus.pubkey, + bar: make_actionbar_model(ev: event, counter: damus.likes) + ) .environmentObject(profiles) } diff --git a/damus/Views/ChatroomView.swift b/damus/Views/ChatroomView.swift index b76e119..9811734 100644 --- a/damus/Views/ChatroomView.swift +++ b/damus/Views/ChatroomView.swift @@ -10,8 +10,7 @@ import SwiftUI struct ChatroomView: View { @EnvironmentObject var thread: ThreadModel @Environment(\.dismiss) var dismiss - let likes: EventCounter - let our_pubkey: String + let damus: DamusState var body: some View { ScrollViewReader { scroller in @@ -22,8 +21,7 @@ struct ChatroomView: View { ChatView(event: thread.events[ind], prev_ev: ind > 0 ? thread.events[ind-1] : nil, next_ev: ind == count-1 ? nil : thread.events[ind+1], - likes: likes, - our_pubkey: our_pubkey + damus: damus ) .onTapGesture { if thread.event.id == ev.id { diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift index 788aae4..f4a199e 100644 --- a/damus/Views/EventView.swift +++ b/damus/Views/EventView.swift @@ -53,7 +53,7 @@ struct EventView: View { .environmentObject(profiles) NavigationLink(destination: pv) { - ProfilePicView(picture: profile?.picture, size: PFP_SIZE!, highlight: highlight) + ProfilePicView(picture: profile?.picture, size: PFP_SIZE!, highlight: highlight, image_cache: damus.image_cache) } Spacer() diff --git a/damus/Views/ProfilePicView.swift b/damus/Views/ProfilePicView.swift index b2c0012..d7dfa5a 100644 --- a/damus/Views/ProfilePicView.swift +++ b/damus/Views/ProfilePicView.swift @@ -36,27 +36,46 @@ struct ProfilePicView: View { let picture: String? let size: CGFloat let highlight: Highlight + let image_cache: ImageCache + + @State var img: Image? = nil + + @EnvironmentObject var profiles: Profiles var Placeholder: some View { Color.purple.opacity(0.2) + .frame(width: size, height: size) + .cornerRadius(CORNER_RADIUS) + .overlay(Circle().stroke(highlight_color(highlight), lineWidth: pfp_line_width(highlight))) + .padding(2) + } + + func ProfilePic(_ url: URL) -> some View { + let pub = load_image(cache: image_cache, from: url) + return Group { + if let img = self.img { + img + .frame(width: size, height: size) + .clipShape(Circle()) + .overlay(Circle().stroke(highlight_color(highlight), lineWidth: pfp_line_width(highlight))) + .padding(2) + } else { + Placeholder + } + } + .onReceive(pub) { mimg in + if let img = mimg { + self.img = Image(uiImage: img) + } + } } var MainContent: some View { Group { - if let pic = picture.flatMap({ URL(string: $0) }) { - AsyncImage(url: pic) { img in - img.resizable() - } placeholder: { Placeholder } - .frame(width: size, height: size) - .clipShape(Circle()) - .overlay(Circle().stroke(highlight_color(highlight), lineWidth: pfp_line_width(highlight))) - .padding(2) + if let pic_url = picture.flatMap { URL(string: $0) } { + ProfilePic(pic_url) } else { Placeholder - .frame(width: size, height: size) - .cornerRadius(CORNER_RADIUS) - .overlay(Circle().stroke(highlight_color(highlight), lineWidth: pfp_line_width(highlight))) - .padding(2) } } } @@ -66,11 +85,13 @@ struct ProfilePicView: View { } } +/* struct ProfilePicView_Previews: PreviewProvider { static var previews: some View { ProfilePicView(picture: "http://cdn.jb55.com/img/red-me.jpg", size: 64, highlight: .none) } } + */ func hex_to_rgb(_ hex: String) -> Color { diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift index b8d1952..d644f2f 100644 --- a/damus/Views/ProfileView.swift +++ b/damus/Views/ProfileView.swift @@ -24,7 +24,7 @@ struct ProfileView: View { var TopSection: some View { HStack(alignment: .top) { let data = profiles.lookup(id: profile.pubkey) - ProfilePicView(picture: data?.picture, size: 64, highlight: .custom(Color.black, 4)) + ProfilePicView(picture: data?.picture, size: 64, highlight: .custom(Color.black, 4), image_cache: damus.image_cache) //.border(Color.blue) VStack(alignment: .leading) { if let pubkey = profile.pubkey { diff --git a/damus/Views/ReplyQuoteView.swift b/damus/Views/ReplyQuoteView.swift index 7aba95d..290a6fc 100644 --- a/damus/Views/ReplyQuoteView.swift +++ b/damus/Views/ReplyQuoteView.swift @@ -10,6 +10,7 @@ import SwiftUI struct ReplyQuoteView: View { let quoter: NostrEvent let event_id: String + let image_cache: ImageCache @EnvironmentObject var profiles: Profiles @EnvironmentObject var thread: ThreadModel @@ -22,7 +23,7 @@ struct ReplyQuoteView: View { VStack(alignment: .leading) { HStack(alignment: .top) { - ProfilePicView(picture: profiles.lookup(id: event.pubkey)?.picture, size: 16, highlight: .reply) + ProfilePicView(picture: profiles.lookup(id: event.pubkey)?.picture, size: 16, highlight: .reply, image_cache: image_cache) Text(Profile.displayName(profile: profiles.lookup(id: event.pubkey), pubkey: event.pubkey)) .foregroundColor(.accentColor) Text("\(format_relative_time(event.created_at))") diff --git a/damus/Views/ThreadView.swift b/damus/Views/ThreadView.swift index b3d8190..d98c5d0 100644 --- a/damus/Views/ThreadView.swift +++ b/damus/Views/ThreadView.swift @@ -19,7 +19,7 @@ struct ThreadView: View { var body: some View { Group { if is_chatroom { - ChatroomView(likes: damus.likes, our_pubkey: damus.pubkey) + ChatroomView(damus: damus) .navigationBarTitle("Chat") .environmentObject(profiles) .environmentObject(thread)