commit a47645929e716e1909ce35d51e885b33af245826
parent 66eefa0ff20ad67bf578f940a9d608b4b681aad3
Author: William Casarin <jb55@jb55.com>
Date: Sun, 16 Oct 2022 16:11:27 -0700
Inline image loading
Changelog-Added: Added inline image loading
Signed-off-by: William Casarin <jb55@jb55.com>
Diffstat:
16 files changed, 267 insertions(+), 60 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
@@ -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 */,
diff --git a/damus/Components/ImageCarousel.swift b/damus/Components/ImageCarousel.swift
@@ -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")!])
+ }
+}
diff --git a/damus/ContentView.swift b/damus/ContentView.swift
@@ -7,7 +7,7 @@
import SwiftUI
import Starscream
-//import Kingfisher
+import Kingfisher
let BOOTSTRAP_RELAYS = [
"wss://relay.damus.io",
diff --git a/damus/Models/EventRef.swift b/damus/Models/EventRef.swift
@@ -80,6 +80,8 @@ func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> {
return
case .hashtag:
return
+ case .url:
+ return
}
}
}
diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift
@@ -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
diff --git a/damus/Util/Parser.swift b/damus/Util/Parser.swift
@@ -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
diff --git a/damus/Views/ChatView.swift b/damus/Views/ChatView.swift
@@ -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)
diff --git a/damus/Views/DMView.swift b/damus/Views/DMView.swift
@@ -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))
diff --git a/damus/Views/EventActionBar.swift b/damus/Views/EventActionBar.swift
@@ -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) {
diff --git a/damus/Views/EventDetailView.swift b/damus/Views/EventDetailView.swift
@@ -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: ()]
diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift
@@ -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)
+ }
+}
diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift
@@ -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)
}
}
- */
diff --git a/damus/Views/ProfilePicView.swift b/damus/Views/ProfilePicView.swift
@@ -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)))
}
}
diff --git a/damus/Views/ReplyQuoteView.swift b/damus/Views/ReplyQuoteView.swift
@@ -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)
diff --git a/damusTests/ReplyTests.swift b/damusTests/ReplyTests.swift
@@ -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 {
diff --git a/damusTests/damusTests.swift b/damusTests/damusTests.swift
@@ -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)