1
0
mirror of git://jb55.com/damus synced 2024-09-30 00:40:45 +00:00

Fix crash on very large notes

This commit fixes a crash that occured on large notes with several artifacts.

The crash was caused by an empirically observed limitation on the amount
of `Text` objects that can be added together. Adding several `Text`
objects together causes a infinite recursion and subsequent stack
overflow.

The fix applied changes the `CompatibleText` class to store several
items in a list, which concatenates attributed strings when possible to
reducethe amount of `Text` objects used.

This commit also:

- Changes the structure to avoid storing SwiftUI objects on a variable,
  but making it into a computed property instead.

- Renders a nice error message when the note is too large to be rendered
  (instead of crashing)

With this new commit, we can render much larger notes, and the only ones
that will not be displayed are those containing more than 50 custom
hashtags.

Since we do not even have 50 custom hashtag types, the only notes that
won't be rendered are spammy notes that repeat the same hashtags over
and over again.

Testing
-------

PASS

Device: iPhone 13 Mini (Physical device)
iOS: 17.2
Damus: This commit
Setup:

- Local test relay and a test account running on a simulator to post
  those long test notes.

- Local web page server to serve a link to the problematic note.
  (nostr:note1ttfgneka3lt6yuutmr0uls5xd6z975fgdzpfkxwwf40dd38pjcqqvzvxaj)

Steps
-----

1. Click on the link to open the note
2. Check that no crash occurs and that the note is rendered correctly. PASS
3. Click on the note to render the SelectableText view (Different code
   path). Make sure that no crash occurs and that it is rendered
   correctly. PASS
4. On the simulator, post a note with 50 "#bitcoin" hashtags displayed
   (100 items).
5. Open the note on the physical iPhone. Make sure that no crash occurs
   and that the note is rendered correctly. PASS
6. Ensure that the hashtag and hashtag icons are rendered. PASS
7. Click on the note and make sure that the SelectableText view is
   rendered correctly. PASS[1]
8. On the simulator, post a note with 51 "#bitcoin" hashtags displayed
   (102 items).
9. Open the note on the physical iPhone. Make sure that a nice error
   message is displayed. PASS
10. Click on the note and make sure that the SelectableText view is
    rendered correctly. PASS[1]

Notes

[1] On the SelectableText view, special hashtags always render with the
purple color. This behavior was already present before the changes, so
it is not a regression.

Changelog-Fixed: Fix crash on very large notes
Closes: https://github.com/damus-io/damus/issues/1826
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
Daniel D’Aquino 2023-12-22 21:25:15 +00:00 committed by William Casarin
parent e547e26d99
commit f7e407e030
3 changed files with 94 additions and 39 deletions

View File

@ -8,37 +8,96 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
// Concatening too many `Text` objects can cause crashes (See https://github.com/damus-io/damus/issues/1826)
fileprivate let MAX_TEXT_ITEMS = 100
class CompatibleText: Equatable { class CompatibleText: Equatable {
var text: Text var text: some View {
var attributed: AttributedString if items.count > MAX_TEXT_ITEMS {
return AnyView(
VStack {
Image("warning")
Text(NSLocalizedString("This note contains too many items and cannot be rendered", comment: "Error message indicating that a note is too big and cannot be rendered"))
.multilineTextAlignment(.center)
}
.foregroundColor(.secondary)
)
}
return AnyView(
items.reduce(Text(""), { (accumulated, item) in
return accumulated + item.render_to_text()
})
)
}
var attributed: AttributedString {
return items.reduce(AttributedString(stringLiteral: ""), { (accumulated, item) in
guard let item_attributed_string = item.attributed_string() else { return accumulated }
return accumulated + item_attributed_string
})
}
var items: [Item]
init() { init() {
self.text = Text("") self.items = [.attributed_string(AttributedString(stringLiteral: ""))]
self.attributed = AttributedString(stringLiteral: "")
} }
init(stringLiteral: String) { init(stringLiteral: String) {
self.text = Text(stringLiteral) self.items = [.attributed_string(AttributedString(stringLiteral: stringLiteral))]
self.attributed = AttributedString(stringLiteral: stringLiteral)
}
init(text: Text, attributed: AttributedString) {
self.text = text
self.attributed = attributed
} }
init(attributed: AttributedString) { init(attributed: AttributedString) {
self.text = Text(attributed) self.items = [.attributed_string(attributed)]
self.attributed = attributed }
init(items: [Item]) {
self.items = items
} }
static func == (lhs: CompatibleText, rhs: CompatibleText) -> Bool { static func == (lhs: CompatibleText, rhs: CompatibleText) -> Bool {
return lhs.attributed == rhs.attributed return lhs.items == rhs.items
} }
static func +(lhs: CompatibleText, rhs: CompatibleText) -> CompatibleText { static func +(lhs: CompatibleText, rhs: CompatibleText) -> CompatibleText {
let combinedText = lhs.text + rhs.text if case .attributed_string(let las) = lhs.items.last,
let combinedAttributes = lhs.attributed + rhs.attributed case .attributed_string(let ras) = rhs.items.first
return CompatibleText(text: combinedText, attributed: combinedAttributes) {
// Concatenate attributed strings whenever possible to reduce item count
let combined_attributed_string = las + ras
return CompatibleText(items:
Array(lhs.items.prefix(upTo: lhs.items.count - 1)) +
[.attributed_string(combined_attributed_string)] +
Array(rhs.items.suffix(from: 1))
)
}
else {
return CompatibleText(items: lhs.items + rhs.items)
}
}
}
extension CompatibleText {
enum Item: Equatable {
case attributed_string(AttributedString)
case icon(named: String, offset: CGFloat)
func render_to_text() -> Text {
switch self {
case .attributed_string(let attributed_string):
return Text(attributed_string)
case .icon(named: let image_name, offset: let offset):
return Text(Image(image_name)).baselineOffset(offset)
}
}
func attributed_string() -> AttributedString? {
switch self {
case .attributed_string(let attributed_string):
return attributed_string
case .icon(named: let name, offset: _):
guard let img = UIImage(named: name) else { return nil }
return icon_attributed_string(img: img)
}
}
} }
} }

View File

@ -46,7 +46,6 @@ func hashtag_str(_ htag: String) -> CompatibleText {
let lowertag = htag.lowercased() let lowertag = htag.lowercased()
var text = Text(attributedString)
if let custom_hashtag = custom_hashtags[lowertag] { if let custom_hashtag = custom_hashtags[lowertag] {
if let col = custom_hashtag.color { if let col = custom_hashtag.color {
attributedString.foregroundColor = col attributedString.foregroundColor = col
@ -54,17 +53,10 @@ func hashtag_str(_ htag: String) -> CompatibleText {
let name = custom_hashtag.name let name = custom_hashtag.name
if let img = UIImage(named: "\(name)-hashtag") {
attributedString = attributedString + " " attributedString = attributedString + " "
attributed_string_attach_icon(&attributedString, img: img) return CompatibleText(items: [.attributed_string(attributedString), .icon(named: "\(name)-hashtag", offset: custom_hashtag.offset ?? 0.0)])
}
text = Text(attributedString)
let img = Image("\(name)-hashtag")
text = text + Text(img).baselineOffset(custom_hashtag.offset ?? 0.0)
} else { } else {
attributedString.foregroundColor = DamusColors.purple attributedString.foregroundColor = DamusColors.purple
return CompatibleText(items: [.attributed_string(attributedString)])
} }
return CompatibleText(text: text, attributed: attributedString)
} }

View File

@ -297,11 +297,15 @@ struct NoteContentView: View {
} }
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) { func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
let wrapped = icon_attributed_string(img: img)
astr.append(wrapped)
}
func icon_attributed_string(img: UIImage) -> AttributedString {
let attachment = NSTextAttachment() let attachment = NSTextAttachment()
attachment.image = img attachment.image = img
let attachmentString = NSAttributedString(attachment: attachment) let attachmentString = NSAttributedString(attachment: attachment)
let wrapped = AttributedString(attachmentString) return AttributedString(attachmentString)
astr.append(wrapped)
} }
func url_str(_ url: URL) -> CompatibleText { func url_str(_ url: URL) -> CompatibleText {