1
0
mirror of git://jb55.com/damus synced 2024-09-18 19:23:49 +00:00

Inline image loading

Changelog-Added: Added inline image loading
Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
William Casarin 2022-10-16 16:11:27 -07:00
parent 66eefa0ff2
commit a47645929e
16 changed files with 267 additions and 60 deletions

View File

@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
4C06670128FC7C5900038D2A /* RelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670028FC7C5900038D2A /* RelayView.swift */; };
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4C06670328FC7EC500038D2A /* Kingfisher */; };
4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C06670528FCB08600038D2A /* ImageCarousel.swift */; };
4C0A3F8C280F5FCA000448DE /* ChatroomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F8B280F5FCA000448DE /* ChatroomView.swift */; };
4C0A3F8F280F640A000448DE /* ThreadModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F8E280F640A000448DE /* ThreadModel.swift */; };
4C0A3F91280F6528000448DE /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F90280F6528000448DE /* ChatView.swift */; };
@ -128,6 +129,7 @@
/* Begin PBXFileReference section */
4C06670028FC7C5900038D2A /* RelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayView.swift; sourceTree = "<group>"; };
4C06670528FCB08600038D2A /* ImageCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCarousel.swift; sourceTree = "<group>"; };
4C0A3F8B280F5FCA000448DE /* ChatroomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatroomView.swift; sourceTree = "<group>"; };
4C0A3F8E280F640A000448DE /* ThreadModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadModel.swift; sourceTree = "<group>"; };
4C0A3F90280F6528000448DE /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
@ -378,6 +380,7 @@
children = (
4CE4F9E0285287B800C00DD9 /* TextFieldAlert.swift */,
4CD7641A28A1641400B6928F /* EndBlock.swift */,
4C06670528FCB08600038D2A /* ImageCarousel.swift */,
);
path = Components;
sourceTree = "<group>";
@ -654,6 +657,7 @@
4C216F32286E388800040376 /* DMChatView.swift in Sources */,
4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */,
4C363A8828236948006E126D /* BlocksView.swift in Sources */,
4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */,
4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */,
4C363A9C282838B9006E126D /* EventRef.swift in Sources */,
4CD7641B28A1641400B6928F /* EndBlock.swift in Sources */,

View File

@ -0,0 +1,69 @@
//
// ImageCarousel.swift
// damus
//
// Created by William Casarin on 2022-10-16.
//
import SwiftUI
import Kingfisher
struct ImageViewer: View {
let urls: [URL]
var body: some View {
TabView {
ForEach(urls, id: \.absoluteString) { url in
VStack{
Text(url.lastPathComponent)
KFImage(url)
.loadDiskFileSynchronously()
.scaleFactor(UIScreen.main.scale)
.fade(duration: 0.1)
.tabItem {
Text(url.absoluteString)
}
.id(url.absoluteString)
}
}
}
.tabViewStyle(PageTabViewStyle())
}
}
struct ImageCarousel: View {
var urls: [URL]
@State var open_sheet: Bool = false
@State var current_url: URL? = nil
var body: some View {
TabView {
ForEach(urls, id: \.absoluteString) { url in
KFImage(url)
.loadDiskFileSynchronously()
.scaleFactor(UIScreen.main.scale)
.fade(duration: 0.1)
.tabItem {
Text(url.absoluteString)
}
.id(url.absoluteString)
}
}
.sheet(isPresented: $open_sheet) {
ImageViewer(urls: urls)
}
.frame(height: 200)
.onTapGesture {
open_sheet = true
}
.tabViewStyle(PageTabViewStyle())
}
}
struct ImageCarousel_Previews: PreviewProvider {
static var previews: some View {
ImageCarousel(urls: [URL(string: "https://jb55.com/red-me.jpg")!])
}
}

View File

@ -7,7 +7,7 @@
import SwiftUI
import Starscream
//import Kingfisher
import Kingfisher
let BOOTSTRAP_RELAYS = [
"wss://relay.damus.io",

View File

@ -80,6 +80,8 @@ func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> {
return
case .hashtag:
return
case .url:
return
}
}
}

View File

@ -37,6 +37,7 @@ enum Block {
case text(String)
case mention(Mention)
case hashtag(String)
case url(URL)
var is_hashtag: String? {
if case .hashtag(let htag) = self {
@ -45,6 +46,14 @@ enum Block {
return nil
}
var is_url: URL? {
if case .url(let url) = self {
return url
}
return nil
}
var is_text: String? {
if case .text(let txt) = self {
return txt
@ -69,6 +78,8 @@ func render_blocks(blocks: [Block]) -> String {
return str + txt
case .hashtag(let htag):
return str + "#" + htag
case .url(let url):
return str + url.absoluteString
}
}
}
@ -83,21 +94,43 @@ func parse_mentions(content: String, tags: [[String]]) -> [Block] {
var starting_from: Int = 0
while p.pos < content.count {
if !consume_until(p, match: { $0 == "#" }) {
if !consume_until(p, match: { !$0.isWhitespace}) {
break
}
let pre_mention = p.pos
if let mention = parse_mention(p, tags: tags) {
blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention))
blocks.append(.mention(mention))
starting_from = p.pos
} else if let hashtag = parse_hashtag(p) {
blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention))
blocks.append(.hashtag(hashtag))
starting_from = p.pos
let c = peek_char(p, 0)
let pr = peek_char(p, -1)
if c == "#" {
if let mention = parse_mention(p, tags: tags) {
blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention))
blocks.append(.mention(mention))
starting_from = p.pos
} else if let hashtag = parse_hashtag(p) {
blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention))
blocks.append(.hashtag(hashtag))
starting_from = p.pos
} else {
if !consume_until(p, match: { $0.isWhitespace }) {
break
}
}
} else if c == "h" && (pr == nil || pr!.isWhitespace) {
if let url = parse_url(p) {
blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention))
blocks.append(.url(url))
starting_from = p.pos
} else {
if !consume_until(p, match: { $0.isWhitespace }) {
break
}
}
} else {
p.pos += 1
if !consume_until(p, match: { $0.isWhitespace }) {
break
}
}
}
@ -145,6 +178,37 @@ func is_punctuation(_ c: Character) -> Bool {
return c.isWhitespace || c.isPunctuation
}
func parse_url(_ p: Parser) -> URL? {
let start = p.pos
if !parse_str(p, "http") {
return nil
}
if parse_char(p, "s") {
if !parse_str(p, "://") {
return nil
}
} else {
if !parse_str(p, "://") {
return nil
}
}
if !consume_until(p, match: { c in c.isWhitespace }, end_ok: true) {
p.pos = start
return nil
}
let url_str = String(substring(p.str, start: start, end: p.pos))
guard let url = URL(string: url_str) else {
p.pos = start
return nil
}
return url
}
func parse_hashtag(_ p: Parser) -> String? {
let start = p.pos

View File

@ -55,6 +55,15 @@ func parse_str(_ p: Parser, _ s: String) -> Bool {
return false
}
func peek_char(_ p: Parser, _ i: Int) -> Character? {
let offset = p.pos + i
if offset < 0 || offset > p.str.count {
return nil
}
let ind = p.str.index(p.str.startIndex, offsetBy: offset)
return p.str[ind]
}
func parse_char(_ p: Parser, _ c: Character) -> Bool {
if p.pos >= p.str.count {
return false

View File

@ -106,7 +106,7 @@ struct ChatView: View {
}
}
NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, content: event.content)
NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, show_images: true, content: event.content)
if is_active || next_ev == nil || next_ev!.pubkey != event.pubkey {
let bar = make_actionbar_model(ev: event, damus: damus_state)

View File

@ -21,7 +21,7 @@ struct DMView: View {
Spacer()
}
NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, content: event.get_content(damus_state.keypair.privkey))
NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, show_images: true, content: event.get_content(damus_state.keypair.privkey))
.foregroundColor(is_ours ? Color.white : Color.primary)
.padding(10)
.background(is_ours ? Color.accentColor : Color.secondary.opacity(0.15))

View File

@ -39,6 +39,20 @@ struct EventActionBar: View {
notify(.reply, event)
}
}
HStack(alignment: .bottom) {
Text("\(bar.boosts > 0 ? "\(bar.boosts)" : "")")
.font(.footnote)
.foregroundColor(bar.boosted ? Color.green : Color.gray)
EventActionButton(img: "arrow.2.squarepath", col: bar.boosted ? Color.green : nil) {
if bar.boosted {
notify(.delete, bar.our_boost)
} else {
self.confirm_boost = true
}
}
}
HStack(alignment: .bottom) {
Text("\(bar.likes > 0 ? "\(bar.likes)" : "")")
@ -53,21 +67,8 @@ struct EventActionBar: View {
}
}
}
HStack(alignment: .bottom) {
Text("\(bar.boosts > 0 ? "\(bar.boosts)" : "")")
.font(.footnote)
.foregroundColor(bar.boosted ? Color.green : Color.gray)
EventActionButton(img: "arrow.2.squarepath", col: bar.boosted ? Color.green : nil) {
if bar.boosted {
notify(.delete, bar.our_boost)
} else {
self.confirm_boost = true
}
}
}
/*
HStack(alignment: .bottom) {
Text("\(bar.tips > 0 ? "\(bar.tips)" : "")")
.font(.footnote)
@ -81,6 +82,7 @@ struct EventActionBar: View {
}
}
}
*/
}
.padding(.top, 1)
.alert("Boost", isPresented: $confirm_boost) {

View File

@ -127,13 +127,13 @@ func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) {
}
/*
struct EventDetailView_Previews: PreviewProvider {
static var previews: some View {
EventDetailView(event: NostrEvent(content: "Hello", pubkey: "Guy"), profile: nil)
let state = test_damus_state()
let tm = ThreadModel(evid: "4da698ceac09a16cdb439276fa3d13ef8f6620ffb45d11b76b3f103483c2d0b0", damus_state: state)
EventDetailView(damus: state, thread: tm)
}
}
*/
/// Find the entire reply path for the active event
func make_reply_map(active: NostrEvent, events: [NostrEvent], privkey: String?) -> [String: ()]

View File

@ -129,9 +129,8 @@ struct EventView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
NoteContentView(privkey: damus.keypair.privkey, event: event, profiles: damus.profiles, content: content)
NoteContentView(privkey: damus.keypair.privkey, event: event, profiles: damus.profiles, show_images: true, content: content)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
if has_action_bar {
let bar = make_actionbar_model(ev: event, damus: damus)
@ -146,7 +145,7 @@ struct EventView: View {
.contentShape(Rectangle())
.background(event_validity_color(event.validity))
.id(event.id)
.frame(minHeight: PFP_SIZE)
.frame(maxWidth: .infinity, minHeight: PFP_SIZE)
.padding([.bottom], 4)
.event_context_menu(event, privkey: damus.keypair.privkey)
}
@ -269,3 +268,8 @@ func make_actionbar_model(ev: NostrEvent, damus: DamusState) -> ActionBarModel {
}
struct EventView_Previews: PreviewProvider {
static var previews: some View {
EventView(damus: test_damus_state(), event: NostrEvent(content: "hello there https://jb55.com/s/Oct12-150217.png https://jb55.com/red-me.jb55 cool", pubkey: "pk"), show_friend_icon: true)
}
}

View File

@ -8,9 +8,10 @@
import SwiftUI
func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) -> String {
func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) -> (String, [URL]) {
let blocks = ev.blocks(privkey)
return blocks.reduce("") { str, block in
var img_urls: [URL] = []
let txt = blocks.reduce("") { str, block in
switch block {
case .mention(let m):
return str + mention_str(m, profiles: profiles)
@ -18,8 +19,20 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, privkey: String?) -
return str + txt
case .hashtag(let htag):
return str + hashtag_str(htag)
case .url(let url):
if is_image_url(url) {
img_urls.append(url)
}
return str + url.absoluteString
}
}
return (txt, img_urls)
}
func is_image_url(_ url: URL) -> Bool {
let str = url.lastPathComponent
return str.hasSuffix("png") || str.hasSuffix("jpg") || str.hasSuffix("jpeg")
}
struct NoteContentView: View {
@ -27,23 +40,33 @@ struct NoteContentView: View {
let event: NostrEvent
let profiles: Profiles
let show_images: Bool
@State var content: String
@State var images: [URL] = []
func MainContent() -> some View {
let md_opts: AttributedString.MarkdownParsingOptions =
.init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
guard let txt = try? AttributedString(markdown: content, options: md_opts) else {
return Text(content)
return VStack(alignment: .leading) {
if let txt = try? AttributedString(markdown: content, options: md_opts) {
Text(txt)
} else {
Text(content)
}
if show_images && images.count > 0 {
ImageCarousel(urls: images)
}
}
return Text(txt)
}
var body: some View {
MainContent()
.onAppear() {
self.content = render_note_content(ev: event, profiles: profiles, privkey: privkey)
let (txt, images) = render_note_content(ev: event, profiles: profiles, privkey: privkey)
self.content = txt
self.images = images
}
.onReceive(handle_notify(.profile_updated)) { notif in
let profile = notif.object as! ProfileUpdate
@ -52,10 +75,13 @@ struct NoteContentView: View {
switch block {
case .mention(let m):
if m.type == .pubkey && m.ref.ref_id == profile.pubkey {
content = render_note_content(ev: event, profiles: profiles, privkey: privkey)
let (txt, images) = render_note_content(ev: event, profiles: profiles, privkey: privkey)
self.content = txt
self.images = images
}
case .text: return
case .hashtag: return
case .url: return
}
}
}
@ -80,10 +106,10 @@ func mention_str(_ m: Mention, profiles: Profiles) -> String {
}
/*
struct NoteContentView_Previews: PreviewProvider {
static var previews: some View {
NoteContentView()
let state = test_damus_state()
let content = "hi there https://jb55.com/s/Oct12-150217.png 5739a762ef6124dd.jpg"
NoteContentView(privkey: "", event: NostrEvent(content: content, pubkey: "pk"), profiles: state.profiles, show_images: true, content: content)
}
}
*/

View File

@ -56,9 +56,8 @@ struct ProfilePicView: View {
Group {
let pic = picture ?? profiles.lookup(id: pubkey)?.picture ?? robohash(pubkey)
let url = URL(string: pic)
let processor = /*DownsamplingImageProcessor(size: CGSize(width: size, height: size))
|>*/ ResizingImageProcessor(referenceSize: CGSize(width: size, height: size))
|> RoundCornerImageProcessor(cornerRadius: 20)
let processor = ResizingImageProcessor(referenceSize: CGSize(width: size, height: size))
KFImage.url(url)
.placeholder { _ in
Placeholder
@ -67,6 +66,7 @@ struct ProfilePicView: View {
.scaleFactor(UIScreen.main.scale)
.loadDiskFileSynchronously()
.fade(duration: 0.1)
.clipShape(Circle())
.overlay(Circle().stroke(highlight_color(highlight), lineWidth: pfp_line_width(highlight)))
}
}

View File

@ -31,7 +31,7 @@ struct ReplyQuoteView: View {
.foregroundColor(.gray)
}
NoteContentView(privkey: privkey, event: event, profiles: profiles, content: event.content)
NoteContentView(privkey: privkey, event: event, profiles: profiles, show_images: false, content: event.content)
.font(.callout)
.foregroundColor(.accentColor)

View File

@ -31,8 +31,8 @@ class ReplyTests: XCTestCase {
XCTAssertNil(ref.is_reply)
XCTAssertNil(ref.is_thread_id)
XCTAssertNil(ref.is_direct_reply)
XCTAssertEqual(ref.is_mention!.type, .event)
XCTAssertEqual(ref.is_mention!.ref.ref_id, "event_id")
XCTAssertEqual(ref.is_mention?.type, .event)
XCTAssertEqual(ref.is_mention?.ref.ref_id, "event_id")
}
func testUrlAnchorsAreNotHashtags() {
@ -96,11 +96,11 @@ class ReplyTests: XCTestCase {
XCTAssertNotNil(event_refs[0].is_thread_id)
XCTAssertNotNil(event_refs[0].is_reply)
XCTAssertNotNil(event_refs[0].is_direct_reply)
XCTAssertEqual(event_refs[0].is_reply!.ref_id, "thread_id")
XCTAssertEqual(event_refs[0].is_thread_id!.ref_id, "thread_id")
XCTAssertEqual(event_refs[0].is_reply?.ref_id, "thread_id")
XCTAssertEqual(event_refs[0].is_thread_id?.ref_id, "thread_id")
XCTAssertNotNil(event_refs[1].is_mention)
XCTAssertEqual(event_refs[1].is_mention!.type, .event)
XCTAssertEqual(event_refs[1].is_mention!.ref.ref_id, "mentioned_id")
XCTAssertEqual(event_refs[1].is_mention?.type, .event)
XCTAssertEqual(event_refs[1].is_mention?.ref.ref_id, "mentioned_id")
}
func testEmptyMention() throws {

View File

@ -64,6 +64,33 @@ class damusTests: XCTestCase {
XCTAssertNotNil(parsed[0].is_text)
}
func testParseUrl() {
let parsed = parse_mentions(content: "a https://jb55.com b", tags: [])
XCTAssertNotNil(parsed)
XCTAssertEqual(parsed.count, 3)
XCTAssertEqual(parsed[1].is_url?.absoluteString, "https://jb55.com")
}
func testParseUrlEnd() {
let parsed = parse_mentions(content: "a https://jb55.com", tags: [])
XCTAssertNotNil(parsed)
XCTAssertEqual(parsed.count, 2)
XCTAssertEqual(parsed[0].is_text, "a ")
XCTAssertEqual(parsed[1].is_url?.absoluteString, "https://jb55.com")
}
func testParseUrlStart() {
let parsed = parse_mentions(content: "https://jb55.com br", tags: [])
XCTAssertNotNil(parsed)
XCTAssertEqual(parsed.count, 3)
XCTAssertEqual(parsed[0].is_text, "")
XCTAssertEqual(parsed[1].is_url?.absoluteString, "https://jb55.com")
XCTAssertEqual(parsed[2].is_text, " br")
}
func testParseMentionBlank() {
let parsed = parse_mentions(content: "", tags: [["e", "event_id"]])
@ -91,9 +118,9 @@ class damusTests: XCTestCase {
XCTAssertNotNil(parsed)
XCTAssertEqual(parsed.count, 3)
XCTAssertEqual(parsed[0].is_text!, "some hashtag ")
XCTAssertEqual(parsed[1].is_hashtag!, "bitcoin")
XCTAssertEqual(parsed[2].is_text!, " derp")
XCTAssertEqual(parsed[0].is_text, "some hashtag ")
XCTAssertEqual(parsed[1].is_hashtag, "bitcoin")
XCTAssertEqual(parsed[2].is_text, " derp")
}
func testParseHashtagEnd() {
@ -101,8 +128,8 @@ class damusTests: XCTestCase {
XCTAssertNotNil(parsed)
XCTAssertEqual(parsed.count, 2)
XCTAssertEqual(parsed[0].is_text!, "some hashtag ")
XCTAssertEqual(parsed[1].is_hashtag!, "bitcoin")
XCTAssertEqual(parsed[0].is_text, "some hashtag ")
XCTAssertEqual(parsed[1].is_hashtag, "bitcoin")
}
func testParseMentionOnlyText() {
@ -110,7 +137,7 @@ class damusTests: XCTestCase {
XCTAssertNotNil(parsed)
XCTAssertEqual(parsed.count, 1)
XCTAssertEqual(parsed[0].is_text!, "there is no mention here")
XCTAssertEqual(parsed[0].is_text, "there is no mention here")
guard case .text(let txt) = parsed[0] else {
XCTAssertTrue(false)