Compare commits

...

9 Commits

Author SHA1 Message Date
eb889a7591 Optimize classify_url function
Changelog-Fixed: Optimized classify_url function
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-06 10:58:39 -04:00
c9696dd9c8 Add inline note rendering of invoices to pull up wallet selector sheet
Changelog-Added: Added inline note rendering of invoices to pull up wallet selector sheet
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-06 10:56:39 -04:00
212b4785fb Fix note rendering for those that contain previewable items or leading and trailing whitespaces
Changelog-Fixed: Fixed note rendering for those that contain previewable items or leading and trailing whitespaces
Closes: https://github.com/damus-io/damus/issues/2187
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-06 10:55:35 -04:00
SanjaySiddharth
54d6161acd Show additional information on top of blurred images
Changelog-Changed: Added additional information on top of blurred images
Closes: https://github.com/damus-io/damus/issues/2854
Signed-off-by: SanjaySiddharth <mjsanjaysiddharth1999@gmail.com>
2025-04-21 16:28:56 -07:00
Daniel D’Aquino
b1fd84fd75 Add safety reminder for higher balances
This commit adds a reminder to users who hold more than 100K sats in
their NWC wallet, reminding them to learn about self-custody.

Changelog-Added: Added safety reminder to wallets with higher balance
Closes: https://github.com/damus-io/damus/issues/2984
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-21 15:46:33 -07:00
Daniel D’Aquino
9dbdf7928a Add network connect call to extensions
This commit fixes a regression on the highlighter and share extensions,
which was caused by a change in the code's architecture, which required
the network manager to be initialized.

Fixes: 8d48f77d95138c93ed93989989fa930b61c2d6fb
Closes: https://github.com/damus-io/damus/issues/2955
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-21 15:29:46 -07:00
Daniel D’Aquino
67f0e3d296 Add disclaimer to Coinos button
Changelog-Changed: Added disclaimer to clarify that Coinos is a third-party service
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-21 12:23:31 -07:00
Daniel D’Aquino
e498418c2d Add one-click Coinos wallet setup
This commit implements a one-click Coinos wallet setup.

This was implemented using the Coinos API, and using account details
that are deterministically generated from the user's private key.

Closes: https://github.com/damus-io/damus/issues/2961
Changelog-Added: Added one-click Coinos wallet setup
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
2025-04-21 12:23:31 -07:00
33150a42c5 Hide future notes from timeline
Changelog-Fixed: Hide future notes from timeline

Closes: https://github.com/damus-io/damus/issues/2949
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-18 16:29:12 -07:00
22 changed files with 1002 additions and 97 deletions

View File

@@ -1649,6 +1649,9 @@
D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; }; D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; };
D7CE1B482B0BE719002EDAD4 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B93D2A9AD44700DC3548 /* Message.swift */; }; D7CE1B482B0BE719002EDAD4 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B93D2A9AD44700DC3548 /* Message.swift */; };
D7CE1B492B0BE729002EDAD4 /* DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9BB83029C0ED4F00FC4E37 /* DisplayName.swift */; }; D7CE1B492B0BE729002EDAD4 /* DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9BB83029C0ED4F00FC4E37 /* DisplayName.swift */; };
D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
D7D2A3812BF815D000E4B42B /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; }; D7D2A3812BF815D000E4B42B /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
D7D68FF92C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; }; D7D68FF92C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; }; D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
@@ -2554,6 +2557,7 @@
D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; }; D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; };
D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNotificationManagement.swift; sourceTree = "<group>"; }; D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNotificationManagement.swift; sourceTree = "<group>"; };
D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleImpendingExpirationTests.swift; sourceTree = "<group>"; }; D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleImpendingExpirationTests.swift; sourceTree = "<group>"; };
D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinosDeterministicAccountClient.swift; sourceTree = "<group>"; };
D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationClient.swift; sourceTree = "<group>"; }; D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationClient.swift; sourceTree = "<group>"; };
D7D68FF82C9E01B60015A515 /* KFClickable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFClickable.swift; sourceTree = "<group>"; }; D7D68FF82C9E01B60015A515 /* KFClickable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFClickable.swift; sourceTree = "<group>"; };
D7DB1FDD2D5A78CE00CF06DA /* NIP44.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP44.swift; sourceTree = "<group>"; }; D7DB1FDD2D5A78CE00CF06DA /* NIP44.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP44.swift; sourceTree = "<group>"; };
@@ -3363,6 +3367,7 @@
D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */, D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */,
D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */, D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */,
D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */, D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */,
D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */,
); );
path = Util; path = Util;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -4859,6 +4864,7 @@
4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */, 4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */,
4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */, 4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */,
4C1253582A76C9060004F4B8 /* PresentSheetNotify.swift in Sources */, 4C1253582A76C9060004F4B8 /* PresentSheetNotify.swift in Sources */,
D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */, D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */,
4C363A962827096D006E126D /* PostBlock.swift in Sources */, 4C363A962827096D006E126D /* PostBlock.swift in Sources */,
4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */, 4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */,
@@ -5247,6 +5253,7 @@
82D6FB652CD99F7900C925F4 /* CollectionExtension.swift in Sources */, 82D6FB652CD99F7900C925F4 /* CollectionExtension.swift in Sources */,
82D6FB662CD99F7900C925F4 /* ZapDataModel.swift in Sources */, 82D6FB662CD99F7900C925F4 /* ZapDataModel.swift in Sources */,
82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */, 82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */,
D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */, 82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */,
82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */, 82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */,
82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */, 82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */,
@@ -5996,6 +6003,7 @@
D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */, D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */,
D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */, D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */,
D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */, D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */,
D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
D703D7802C670C2500A400EA /* NIP05.swift in Sources */, D703D7802C670C2500A400EA /* NIP05.swift in Sources */,
D703D7AA2C670E5D00A400EA /* verifier.c in Sources */, D703D7AA2C670E5D00A400EA /* verifier.c in Sources */,
D73E5E1D2C6A9680007EB227 /* PreviewCache.swift in Sources */, D73E5E1D2C6A9680007EB227 /* PreviewCache.swift in Sources */,

View File

@@ -160,7 +160,7 @@ func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, set
// Render translated note // Render translated note
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags)) let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles) let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles, can_hide_last_previewable_refs: true)
// and cache it // and cache it
return .translated(Translated(artifacts: artifacts, language: note_lang)) return .translated(Translated(artifacts: artifacts, language: note_lang))

View File

@@ -40,6 +40,12 @@ func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEv
} }
} }
func timestamp_filter(ev: NostrEvent) -> Bool {
// Allow notes that are created no more than 3 seconds in the future
// to account for natural clock skew between sender and receiver.
ev.age >= -3
}
/// Generic filter with various tweakable settings /// Generic filter with various tweakable settings
struct ContentFilters { struct ContentFilters {
var filters: [(NostrEvent) -> Bool] var filters: [(NostrEvent) -> Bool]
@@ -66,6 +72,7 @@ extension ContentFilters {
filters.append(nsfw_tag_filter) filters.append(nsfw_tag_filter)
} }
filters.append(get_repost_of_muted_user_filter(damus_state: damus_state)) filters.append(get_repost_of_muted_user_filter(damus_state: damus_state))
filters.append(timestamp_filter)
return filters return filters
} }
} }

View File

@@ -73,85 +73,130 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -
return .longform(LongformContent(ev.content)) return .longform(LongformContent(ev.content))
} }
return .separated(render_blocks(blocks: blocks, profiles: profiles)) return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
} }
func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated { func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
var invoices: [Invoice] = [] var invoices: [Invoice] = []
var urls: [UrlType] = [] var urls: [UrlType] = []
let blocks = bs.blocks let blocks = bs.blocks
let one_note_ref = blocks var end_mention_count = 0
.filter({ var end_url_count = 0
if case .mention(let mention) = $0,
case .note = mention.ref { // Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
return true var hide_text_index = blocks.endIndex
if can_hide_last_previewable_refs {
outerLoop: for (i, block) in blocks.enumerated().reversed() {
if block.is_previewable {
switch block {
case .mention:
end_mention_count += 1
// If there is more than one previewable mention,
// do not hide anything because we allow rich rendering of only one mention currently.
// This should be fixed in the future to show events inline instead.
if end_mention_count > 1 {
hide_text_index = blocks.endIndex
break outerLoop
}
case .url(let url):
let url_type = classify_url(url)
if case .link = url_type {
end_url_count += 1
// If there is more than one link, do not hide anything because we allow rich rendering of only
// one link.
if end_url_count > 1 {
hide_text_index = blocks.endIndex
break outerLoop
}
}
default:
break
}
hide_text_index = i
} else if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
hide_text_index = i
} else {
break
} }
else { }
return false }
}
})
.count == 1
var ind: Int = -1 var ind: Int = -1
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
ind = ind + 1 ind = ind + 1
// Add the rendered previewable blocks to their type-specific lists.
switch block { switch block {
case .mention(let m): case .invoice(let invoice):
if case .note = m.ref, one_note_ref { invoices.append(invoice)
case .url(let url):
let url_type = classify_url(url)
urls.append(url_type)
default:
break
}
if can_hide_last_previewable_refs {
// If there are previewable blocks that occur before the consecutive sequence of them at the end of the content,
// we should not hide the text representation of any previewable block to avoid altering the format of the note.
if ind < hide_text_index && block.is_previewable {
hide_text_index = blocks.endIndex
}
// No need to show the text representation of the block if the only previewables are the sequence of them
// found at the end of the content.
// This is to save unnecessary use of screen space.
if ind >= hide_text_index {
return str return str
} }
}
switch block {
case .mention(let m):
return str + mention_str(m, profiles: profiles) return str + mention_str(m, profiles: profiles)
case .text(let txt): case .text(let txt):
return str + CompatibleText(stringLiteral: reduce_text_block(blocks: blocks, ind: ind, txt: txt, one_note_ref: one_note_ref)) return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
case .relay(let relay): case .relay(let relay):
return str + CompatibleText(stringLiteral: relay) return str + CompatibleText(stringLiteral: relay)
case .hashtag(let htag): case .hashtag(let htag):
return str + hashtag_str(htag) return str + hashtag_str(htag)
case .invoice(let invoice): case .invoice(let invoice):
invoices.append(invoice) return str + invoice_str(invoice)
return str
case .url(let url): case .url(let url):
let url_type = classify_url(url) return str + url_str(url)
switch url_type {
case .media:
urls.append(url_type)
return str
case .link(let url):
urls.append(url_type)
return str + url_str(url)
}
} }
} }
return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices) return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
} }
func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String { func reduce_text_block(ind: Int, hide_text_index: Int, txt: String) -> String {
var trimmed = txt var trimmed = txt
if let prev = blocks[safe: ind-1], // Trim leading whitespaces.
case .url(let u) = prev, if ind == 0 {
classify_url(u).is_media != nil { trimmed = trim_prefix(trimmed)
trimmed = " " + trim_prefix(trimmed)
} }
if let next = blocks[safe: ind+1] { // Trim trailing whitespaces if the following blocks will be hidden or if this is the last block.
if case .url(let u) = next, classify_url(u).is_media != nil { if ind == hide_text_index - 1 {
trimmed = trim_suffix(trimmed) trimmed = trim_suffix(trimmed)
} else if case .mention(let m) = next,
case .note = m.ref,
one_note_ref {
trimmed = trim_suffix(trimmed)
}
} }
return trimmed return trimmed
} }
func invoice_str(_ invoice: Invoice) -> CompatibleText {
var attributedString = AttributedString(stringLiteral: abbrev_identifier(invoice.string))
attributedString.link = URL(string: "damus:lightning:\(invoice.string)")
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
func url_str(_ url: URL) -> CompatibleText { func url_str(_ url: URL) -> CompatibleText {
var attributedString = AttributedString(stringLiteral: url.absoluteString) var attributedString = AttributedString(stringLiteral: url.absoluteString)
attributedString.link = url attributedString.link = url
@@ -161,17 +206,16 @@ func url_str(_ url: URL) -> CompatibleText {
} }
func classify_url(_ url: URL) -> UrlType { func classify_url(_ url: URL) -> UrlType {
let str = url.lastPathComponent.lowercased() let fileExtension = url.lastPathComponent.lowercased().components(separatedBy: ".").last
if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") { switch fileExtension {
case "png", "jpg", "jpeg", "gif", "webp":
return .media(.image(url)) return .media(.image(url))
} case "mp4", "mov", "m3u8":
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
return .media(.video(url)) return .media(.video(url))
default:
return .link(url)
} }
return .link(url)
} }
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) { func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
@@ -194,11 +238,11 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
let display_str: String = { let display_str: String = {
switch m.ref { switch m.ref {
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles) case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
case .note: return abbrev_pubkey(bech32String) case .note: return abbrev_identifier(bech32String)
case .nevent: return abbrev_pubkey(bech32String) case .nevent: return abbrev_identifier(bech32String)
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles) case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
case .nrelay(let url): return url case .nrelay(let url): return url
case .naddr: return abbrev_pubkey(bech32String) case .naddr: return abbrev_identifier(bech32String)
} }
}() }()

View File

@@ -54,7 +54,14 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else { guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else {
return false return false
} }
// Don't show notifications for future events.
// Allow notes that are created no more than 3 seconds in the future
// to account for natural clock skew between sender and receiver.
guard ev.age >= -3 else {
return false
}
return true return true
} }

View File

@@ -43,6 +43,18 @@ struct DamusURLHandler {
return .route(.Script(script: model)) return .route(.Script(script: model))
case .purple(let purple_url): case .purple(let purple_url):
return await damus_state.purple.handle(purple_url: purple_url) return await damus_state.purple.handle(purple_url: purple_url)
case .invoice(let invoice):
if damus_state.settings.show_wallet_selector {
return .sheet(.select_wallet(invoice: invoice.string))
} else {
do {
try open_with_wallet(wallet: damus_state.settings.default_wallet.model, invoice: invoice.string)
return .no_action
}
catch {
return .sheet(.select_wallet(invoice: invoice.string))
}
}
case nil: case nil:
break break
} }
@@ -91,6 +103,11 @@ struct DamusURLHandler {
return .filter(filt) return .filter(filt)
case .script(let script): case .script(let script):
return .script(script) return .script(script)
case .invoice(let bolt11):
if let invoice = decode_bolt11(bolt11) {
return .invoice(invoice)
}
return nil
} }
return nil return nil
} }
@@ -103,5 +120,6 @@ struct DamusURLHandler {
case wallet_connect(WalletConnectURL) case wallet_connect(WalletConnectURL)
case script([UInt8]) case script([UInt8])
case purple(DamusPurpleURL) case purple(DamusPurpleURL)
case invoice(Invoice)
} }
} }

View File

@@ -12,6 +12,7 @@ enum NostrLink: Equatable {
case ref(RefId) case ref(RefId)
case filter(NostrFilter) case filter(NostrFilter)
case script([UInt8]) case script([UInt8])
case invoice(String)
} }
func encode_pubkey_uri(_ pubkey: Pubkey) -> String { func encode_pubkey_uri(_ pubkey: Pubkey) -> String {
@@ -93,8 +94,15 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
return return
} }
if parts.count >= 2 && parts[0] == "t" { if parts.count >= 2 {
return .filter(NostrFilter(hashtag: [parts[1].lowercased()])) switch parts[0] {
case "t":
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
case "lightning":
return .invoice(parts[1])
default:
break
}
} }
guard parts.count == 1 else { guard parts.count == 1 else {

View File

@@ -37,7 +37,23 @@ enum Block: Equatable {
return false return false
} }
} }
var is_previewable: Bool {
switch self {
case .mention(let m):
switch m.ref {
case .note, .nevent: return true
default: return false
}
case .invoice:
return true
case .url:
return true
default:
return false
}
}
case text(String) case text(String)
case mention(Mention<MentionRef>) case mention(Mention<MentionRef>)
case hashtag(String) case hashtag(String)

View File

@@ -0,0 +1,340 @@
//
// CoinosDeterministicClient.swift
// damus
//
// Created by Daniel DAquino on 2025-04-14.
//
import Foundation
/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
///
/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
class CoinosDeterministicAccountClient {
// MARK: - State
/// The user's normal keypair for using Nostr
private let userKeypair: FullKeypair
/// The JWT authentication token with Coinos
private var jwtAuthToken: String? = nil
// MARK: - Computed properties for a deterministic wallet
/// A deterministic keypair for the NWC connection derived from the user's private key
private var nwcKeypair: FullKeypair? {
let nwcPrivateKey: Privkey = Privkey(sha256(self.userKeypair.privkey.id)) // SHA256 is an irreversible operation, user's nsec should not be deriveable from this new private key
return FullKeypair(privkey: nwcPrivateKey)
}
/// A deterministic username for a Coinos account
private var username: String? {
// Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
// Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
// There is very little risk of a birthday attack on getting only the first 16 characters, because:
// 1. before this user creates an account, no one else knows the private key in order to know the expected username and create an account before them
// 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
//
// In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
// According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
// even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
return String(fullText.prefix(16))
}
/// A deterministic password for a Coinos account
private var password: String? {
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
return sha256Hex(text: "coinos_password:" + self.userKeypair.privkey.hex())
}
/// A deterministic NWC app connection name
private var nwcConnectionName: String { return "Damus" }
// MARK: - Initialization
/// Initializes the client with the user's keypair
init(userKeypair: FullKeypair) {
self.userKeypair = userKeypair
}
// MARK: - Authentication and registration
/// Tries to login to the user's deterministic account. If it cannot be found, it will register for one and log into that.
func loginOrRegister() async throws {
do {
// Check if client has an account
try await self.login()
}
catch {
guard let error = error as? CoinosDeterministicAccountClient.ClientError, error == .unauthorized else { throw error }
// Client does not seem to have an account, create one
try await self.register()
try await self.login()
}
}
/// Registers for a Coinos account using deterministic account details.
///
/// It succeeds if it returns without throwing errors.
func register() async throws {
guard let username, let password else { throw ClientError.errorFormingRequest }
let registerPayload = RegisterRequest(user: UserCredentials(username: username, password: password))
let jsonData = try JSONEncoder().encode(registerPayload)
let url = URL(string: "https://coinos.io/api/register")!
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
return
} else {
throw ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
}
}
/// Logs into the deterministic account, if an auth token is not present
func loginIfNeeded() async throws {
if self.jwtAuthToken == nil { try await self.login() }
}
/// Logs into to our deterministic account.
///
/// Succeeds if it returns without returning errors.
///
/// Mutating function, will update the client's internal state.
func login() async throws {
self.jwtAuthToken = try await sendLoginRequest().token
}
/// Sends the login request and return the response
///
/// Does NOT update the internal login state.
private func sendLoginRequest() async throws -> AuthResponse {
guard let url = URL(string: "https://coinos.io/api/login") else { throw ClientError.errorFormingRequest }
guard let username, let password else { throw ClientError.errorFormingRequest }
let credentials = UserCredentials(username: username, password: password)
let jsonData = try JSONEncoder().encode(credentials)
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
// MARK: - Managing NWC connections
/// Creates a new NWC connection
///
/// Note: Account must exist before calling this endpoint
func createNWCConnection() async throws -> WalletConnectURL {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
try await self.loginIfNeeded()
let config = try defaultWalletConnectionConfig()
let configData = try encode_json_data(config)
let (data, response) = try await self.makeAuthenticatedRequest(
method: .post,
url: urlEndpoint,
payload: configData,
payload_type: .json
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
return nwc
case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
/// Returns the default wallet connection config
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
return NewWalletConnectionConfig(
name: self.nwcConnectionName,
secret: nwcKeypair.privkey.hex(),
pubkey: nwcKeypair.pubkey.hex(),
max_amount: 30000, // 30K sats per week maximum
budget_renewal: .weekly
)
}
/// Gets the NWC URL for the deterministic NWC app connection
///
/// Account must already exist before calling this
///
/// Returns `nil` if no NWC url is found, (e.g. if app connection has not been configured yet)
func getNWCUrl() async throws -> WalletConnectURL? {
guard let connectionConfig = try await self.getNWCAppConnectionConfig(), let nwc = connectionConfig.nwc else { return nil }
return WalletConnectURL(str: nwc)
}
/// Gets the deterministic NWC app connection configuration details, if it exists
///
/// Account must already exist before calling this
///
/// Returns `nil` if no connection is found, (e.g. if app connection has not been configured yet)
func getNWCAppConnectionConfig() async throws -> WalletConnectionConfig? {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
guard let url = URL(string: "https://coinos.io/api/app/" + nwcKeypair.pubkey.hex()) else { throw ClientError.errorFormingRequest }
try await self.loginIfNeeded()
let (data, response) = try await self.makeAuthenticatedRequest(
method: .get,
url: url,
payload: nil,
payload_type: nil
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200: return try JSONDecoder().decode(WalletConnectionConfig.self, from: data)
case 401: throw ClientError.unauthorized
case 404: return nil
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
// MARK: - Lower level request convenience functions
/// Makes a request without any authorization
func makeRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.httpBody = payload
if let payload_type {
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
}
return try await URLSession.shared.data(for: request)
}
/// Makes an authenticated request with our JWT auth token.
///
/// Client must be logged-in before calling this, otherwise an error will be thrown.
func makeAuthenticatedRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
guard let jwtAuthToken else { throw ClientError.errorFormingRequest }
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.httpBody = payload
request.setValue("Bearer " + jwtAuthToken, forHTTPHeaderField: "Authorization")
if let payload_type {
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
}
return try await URLSession.shared.data(for: request)
}
// MARK: - Helper structures
/// Payload for registering for a new Coinos account
struct RegisterRequest: Codable {
/// New user credentials
let user: UserCredentials
}
/// Payload for user credentials (sign-up and login)
struct UserCredentials: Codable {
/// The username
let username: String
/// The user password
let password: String
}
/// A successful response to a login auth endpoint
struct AuthResponse: Codable {
/// The JWT token to be applied to any authenticated API calls
let token: String
}
/// Used by the client to define new NWC configurations
struct NewWalletConnectionConfig: Codable {
/// The name of the connection
let name: String
/// 32 Hex-encoded bytes containing a shared private key secret
let secret: String
/// 32 Hex-encoded bytes containing the pubkey for the secret
let pubkey: String
/// Max amount that can be spent in each renewal period (measured in sats)
let max_amount: UInt64
/// The period of time it takes for the budget limits to reset
let budget_renewal: BudgetRenewalPeriod
}
/// The NWC connection configuration details
///
/// ## Implementation notes
///
/// - All items defined as optionals because the Coinos API may change in the future, so this may help increase future compatibility.
struct WalletConnectionConfig: Codable {
/// The name of the connection
let name: String?
/// 32 Hex-encoded bytes containing a shared private key secret
let secret: String?
/// 32 Hex-encoded bytes containing the pubkey for the secret
let pubkey: String?
/// Max amount that can be spent in every renewal period (measured in sats)
let max_amount: UInt64?
/// The NWC url generated by the server
let nwc: String?
/// Budget renewal information
let budget_renewal: BudgetRenewalPeriod?
}
/// A period of time it takes for budget limits to be reset
enum BudgetRenewalPeriod: String, Codable {
/// Resets once a week
case weekly
}
/// A client error occured
enum ClientError: Error, Equatable {
/// Received an unexpected HTTP response
///
/// Could be for a variety of reasons.
case unexpectedHTTPResponse(status_code: Int, response: Data)
/// Error forming the request, generally due to missing or inconsistent internal data
///
/// Probably caused by a programming error.
case errorFormingRequest
/// The client could not process the response from the server
///
/// Might be a sign of an incompatibility bug
case errorProcessingResponse
/// The action performed is not authorized
/// Generally thrown if user does not exist, credentials do not match what Coinos has on file, or programming error
case unauthorized
/// Client not logged in on a call that expected login
case notLoggedIn
}
}
/// Computes a SHA256 hash digest from a piece of UTF-8 text, and returns the result as a "hex" string
///
/// When working only with strings, this can be more convenient than transforming text to data, and data back to text
fileprivate func sha256Hex(text: String) -> String? {
guard let data = text.data(using: .utf8) else { return nil }
return sha256(data).toHexString()
}

View File

@@ -80,9 +80,9 @@ func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) ->
} }
func abbrev_bech32_pubkey(pubkey: Pubkey) -> String { func abbrev_bech32_pubkey(pubkey: Pubkey) -> String {
return abbrev_pubkey(String(pubkey.npub.dropFirst(4))) return abbrev_identifier(String(pubkey.npub.dropFirst(4)))
} }
func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String { func abbrev_identifier(_ pubkey: String, amount: Int = 8) -> String {
return pubkey.prefix(amount) + ":" + pubkey.suffix(amount) return pubkey.prefix(amount) + ":" + pubkey.suffix(amount)
} }

View File

@@ -35,11 +35,11 @@ func remove_nostr_uri_prefix(_ s: String) -> String {
return uri return uri
} }
func abbreviateURL(_ url: URL) -> String { func abbreviateURL(_ url: URL, maxLength: Int = MAX_CHAR_URL) -> String {
let urlString = url.absoluteString let urlString = url.absoluteString
if urlString.count > MAX_CHAR_URL { if urlString.count > maxLength {
return String(urlString.prefix(MAX_CHAR_URL)) + "..." return String(urlString.prefix(maxLength)) + ""
} }
return urlString return urlString
} }

View File

@@ -122,10 +122,7 @@ struct LongformPreviewBody: View {
} else if blur_images || (blur_images && !state.settings.media_previews) { } else if blur_images || (blur_images && !state.settings.media_previews) {
ZStack { ZStack {
titleImage(url: url) titleImage(url: url)
Blur() BlurOverlayView(blur_images: $blur_images, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView)
.onTapGesture {
blur_images = false
}
} }
} }
} }

View File

@@ -23,6 +23,7 @@ struct Blur: UIViewRepresentable {
} }
} }
struct NoteContentView: View { struct NoteContentView: View {
let damus_state: DamusState let damus_state: DamusState
@@ -166,10 +167,7 @@ struct NoteContentView: View {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
fullscreen_preview(dismiss: dismiss) fullscreen_preview(dismiss: dismiss)
} }
Blur() BlurOverlayView(blur_images: $blur_images, artifacts: artifacts, size: size, damus_state: damus_state, parentView: .noteContentView)
.onTapGesture {
blur_images = false
}
} }
} }
} }
@@ -384,6 +382,64 @@ func lookup_cached_preview_size(previews: PreviewCache, evid: NoteId) -> CGFloat
return height return height
} }
struct BlurOverlayView: View {
@Binding var blur_images: Bool
let artifacts: NoteArtifactsSeparated?
let size: EventViewKind?
let damus_state: DamusState?
let parentView: ParentViewType
var body: some View {
ZStack {
Color.black
.opacity(0.54)
Blur()
VStack(alignment: .center) {
Image(systemName: "eye.slash")
.foregroundStyle(.white)
.bold()
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
Text(NSLocalizedString("Media from someone you \n don't follow", comment: "Label on the image blur mask"))
.multilineTextAlignment(.center)
.foregroundStyle(Color.white)
.font(.title2)
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
Button(NSLocalizedString("Tap to load", comment: "Label for button that allows user to dismiss media content warning and unblur the image")) {
blur_images = false
}
.buttonStyle(.bordered)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
if parentView == .noteContentView,
let artifacts = artifacts,
let size = size,
let damus_state = damus_state
{
switch artifacts.media[0] {
case .image(let url), .video(let url):
Text(abbreviateURL(url, maxLength: 30))
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size * 0.8))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.padding(EdgeInsets(top: 20, leading: 10, bottom: 5, trailing: 10))
}
}
}
}
.onTapGesture {
blur_images = false
}
}
enum ParentViewType {
case noteContentView, longFormView
}
}
struct NoteContentView_Previews: PreviewProvider { struct NoteContentView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let state = test_damus_state let state = test_damus_state
@@ -401,7 +457,7 @@ struct NoteContentView_Previews: PreviewProvider {
.previewDisplayName("Super short note") .previewDisplayName("Super short note")
VStack { VStack {
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: []) NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: true, size: .normal, options: [])
} }
.previewDisplayName("Note with image") .previewDisplayName("Note with image")
@@ -434,4 +490,3 @@ func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
let mediaUrls = urlBlocks.map { MediaUrl.image($0) } let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls return mediaUrls.isEmpty ? nil : mediaUrls
} }

View File

@@ -41,7 +41,10 @@ class NotificationFilter: ObservableObject, Equatable {
if let item = item.filter({ ev in if let item = item.filter({ ev in
self.friend_filter.filter(contacts: contacts, pubkey: ev.pubkey) && self.friend_filter.filter(contacts: contacts, pubkey: ev.pubkey) &&
(!hellthread_notifications_disabled || !ev.is_hellthread(max_pubkeys: hellthread_notification_max_pubkeys)) (!hellthread_notifications_disabled || !ev.is_hellthread(max_pubkeys: hellthread_notification_max_pubkeys)) &&
// Allow notes that are created no more than 3 seconds in the future
// to account for natural clock skew between sender and receiver.
ev.age >= -3
}) { }) {
acc.append(item) acc.append(item)
} }

View File

@@ -46,7 +46,7 @@ struct PubkeyView: View {
let bech32 = pubkey.npub let bech32 = pubkey.npub
HStack { HStack {
Text(verbatim: "\(abbrev_pubkey(bech32, amount: sidemenu ? 12 : 16))") Text(verbatim: "\(abbrev_identifier(bech32, amount: sidemenu ? 12 : 16))")
.font(sidemenu ? .system(size: 10) : .footnote) .font(sidemenu ? .system(size: 10) : .footnote)
.foregroundColor(keyColor()) .foregroundColor(keyColor())
.padding(5) .padding(5)

View File

@@ -16,7 +16,9 @@ struct ConnectWalletView: View {
@State var error: String? = nil @State var error: String? = nil
@State var wallet_scan_result: WalletScanResult = .scanning @State var wallet_scan_result: WalletScanResult = .scanning
@State var show_introduction: Bool = true @State var show_introduction: Bool = true
@State var show_coinos_options: Bool = false
var nav: NavigationCoordinator var nav: NavigationCoordinator
let userKeypair: Keypair
var body: some View { var body: some View {
MainContent MainContent
@@ -146,9 +148,14 @@ struct ConnectWalletView: View {
Spacer() Spacer()
CoinosButton() { VStack(spacing: 5) {
show_introduction = false CoinosButton() {
openURL(URL(string:"https://coinos.io/settings/nostr")!) self.show_coinos_options = true
}
Text("Coinos is a service operated by a third-party. We have no access to your Coinos wallet.", comment: "Small caption with a disclaimer that Damus does not own or have access to Coinos wallets, Coinos is a third-party service.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} }
.padding() .padding()
} }
@@ -161,6 +168,110 @@ struct ConnectWalletView: View {
.padding(2) // Avoids border clipping on the sides .padding(2) // Avoids border clipping on the sides
) )
.padding(.top, 20) .padding(.top, 20)
.sheet(isPresented: $show_coinos_options, content: {
CoinosConnectionOptionsSheet
})
}
var CoinosConnectionOptionsSheet: some View {
VStack(spacing: 20) {
Text("How would you like to connect to your Coinos wallet?", comment: "Question for the user when connecting a Coinos wallet.")
.font(.title3)
.bold()
.multilineTextAlignment(.center)
.padding(.bottom, 10)
.lineLimit(2)
Spacer()
VStack(spacing: 5) {
Button(
action: { self.oneClickSetup() },
label: {
HStack {
Spacer()
VStack {
HStack {
Image(systemName: "wand.and.sparkles")
Text("One-click setup", comment: "Button label for users to do a one-click Coinos wallet setup.")
}
// I have to hide this on npub logins, because otherwise SwiftUI will start truncating text
if self.userKeypair.privkey != nil {
Text("Also click here if you had a one-click setup before.", comment: "Button description hint for users who may want to do a one-click setup.")
.font(.caption)
}
}
Spacer()
}
}
)
.frame(maxWidth: .infinity)
.buttonStyle(GradientButtonStyle())
.opacity(self.userKeypair.privkey == nil ? 0.5 : 1.0)
.disabled(self.userKeypair.privkey == nil)
if self.userKeypair.privkey == nil {
Text("You must be logged in with your nsec to use this option.", comment: "Warning text for users who cannot create a Coinos account via the one-click setup without being logged in with their nsec.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Text("Your profile will not be shared with Coinos.", comment: "Label text for users to reassure them that their nsec is not shared with a third party.")
.font(.caption)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
}
Button(
action: {
show_introduction = false
show_coinos_options = false
openURL(URL(string:"https://coinos.io/settings/nostr")!)
},
label: {
HStack {
Spacer()
VStack {
HStack {
Image(systemName: "arrow.up.right")
Text("Connect via the website", comment: "Button label for users who are setting up a Coinos wallet and would like to connect via the website")
}
Text("Click here if you have a Coinos username and password.", comment: "Button description hint for users who may want to connect via the website.")
.font(.caption)
}
Spacer()
}
}
)
.frame(maxWidth: .infinity)
}
.padding()
.presentationDetents([.height(300)])
}
func oneClickSetup() {
Task {
show_coinos_options = false
do {
guard let fullKeypair = self.userKeypair.to_full() else {
throw CoinosDeterministicAccountClient.ClientError.errorFormingRequest
}
let client = CoinosDeterministicAccountClient(userKeypair: fullKeypair)
try await client.loginOrRegister()
let nwcURL = try await client.createNWCConnection()
model.connect(nwcURL) // Connect directly, to make it a true one-click setup
}
catch {
present_sheet(.error(.init(
user_visible_description: NSLocalizedString("Something went wrong when performing the one-click Coinos wallet setup.", comment: "Error label when user tries the one-click Coinos wallet setup but fails for some generic reason."),
tip: NSLocalizedString("Check your internet connection and try again. If the error persists, contact support.", comment: "Error tip when user tries to create the one-click Coinos wallet setup but fails for a generic reason."),
technical_info: error.localizedDescription
)))
}
}
} }
var ManualSetup: some View { var ManualSetup: some View {
@@ -270,7 +381,7 @@ struct ConnectWalletView: View {
struct ConnectWalletView_Previews: PreviewProvider { struct ConnectWalletView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ConnectWalletView(model: WalletModel(settings: UserSettingsStore()), nav: .init()) ConnectWalletView(model: WalletModel(settings: UserSettingsStore()), nav: .init(), userKeypair: test_keypair)
.previewDisplayName("Main Wallet Connect View") .previewDisplayName("Main Wallet Connect View")
ConnectWalletView.AreYouSure(nwc: get_test_nwc(), show_introduction: .constant(false), model: WalletModel(settings: test_damus_state.settings)) ConnectWalletView.AreYouSure(nwc: get_test_nwc(), show_introduction: .constant(false), model: WalletModel(settings: test_damus_state.settings))
.previewDisplayName("Are you sure screen") .previewDisplayName("Are you sure screen")

View File

@@ -14,6 +14,8 @@ struct NWCSettings: View {
@ObservedObject var model: WalletModel @ObservedObject var model: WalletModel
@ObservedObject var settings: UserSettingsStore @ObservedObject var settings: UserSettingsStore
@Environment(\.dismiss) var dismiss
func donation_binding() -> Binding<Double> { func donation_binding() -> Binding<Double> {
return Binding(get: { return Binding(get: {
@@ -136,6 +138,7 @@ struct NWCSettings: View {
Button(action: { Button(action: {
self.model.disconnect() self.model.disconnect()
dismiss()
}) { }) {
HStack { HStack {
Text("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.") Text("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.")

View File

@@ -7,6 +7,8 @@
import SwiftUI import SwiftUI
let WALLET_WARNING_THRESHOLD: UInt64 = 100000
struct WalletView: View { struct WalletView: View {
let damus_state: DamusState let damus_state: DamusState
@State var show_settings: Bool = false @State var show_settings: Bool = false
@@ -22,6 +24,27 @@ struct WalletView: View {
func MainWalletView(nwc: WalletConnectURL) -> some View { func MainWalletView(nwc: WalletConnectURL) -> some View {
ScrollView { ScrollView {
VStack(spacing: 35) { VStack(spacing: 35) {
if let balance = model.balance, balance > WALLET_WARNING_THRESHOLD {
VStack(spacing: 10) {
HStack {
Image(systemName: "exclamationmark.circle")
Text("Safety Reminder", comment: "Heading for a safety reminder that appears when the user has too many funds, recommending them to learn about safeguarding their funds.")
.font(.title3)
.bold()
}
.foregroundStyle(.damusWarningTertiary)
Text("If your wallet balance is getting high, it's important to understand how to keep your funds secure. Please consider learning the best practices to ensure your assets remain safe. [Click here](https://damus.io/docs/wallet/high-balance-safety-reminder/) to learn more.", comment: "Text reminding the user has a high balance, recommending them to learn about self-custody")
.foregroundStyle(.damusWarningSecondary)
.opacity(0.8)
}
.padding()
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(.damusWarningBorder, lineWidth: 1)
)
}
VStack(spacing: 5) { VStack(spacing: 5) {
BalanceView(balance: model.balance) BalanceView(balance: model.balance)
@@ -39,9 +62,9 @@ struct WalletView: View {
var body: some View { var body: some View {
switch model.connect_state { switch model.connect_state {
case .new: case .new:
ConnectWalletView(model: model, nav: damus_state.nav) ConnectWalletView(model: model, nav: damus_state.nav, userKeypair: self.damus_state.keypair)
case .none: case .none:
ConnectWalletView(model: model, nav: damus_state.nav) ConnectWalletView(model: model, nav: damus_state.nav, userKeypair: self.damus_state.keypair)
case .existing(let nwc): case .existing(let nwc):
MainWalletView(nwc: nwc) MainWalletView(nwc: nwc)
.toolbar { .toolbar {

View File

@@ -10,28 +10,292 @@ import SwiftUI
@testable import damus @testable import damus
class NoteContentViewTests: XCTestCase { class NoteContentViewTests: XCTestCase {
func testRenderBlocksWithNonLatinHashtags() { func testRenderBlocksWithNonLatinHashtags() throws {
let content = "Damusはかっこいいです #cool #かっこいい" let content = "Damusはかっこいいです #cool #かっこいい"
let note = NostrEvent(content: content, keypair: test_keypair, tags: [["t", "かっこいい"]])! let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair, tags: [["t", "かっこいい"]]))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair)) let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state let testState = test_damus_state
let text: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles) let text: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles, can_hide_last_previewable_refs: true)
let attributedText: AttributedString = text.content.attributed let attributedText: AttributedString = text.content.attributed
let runs: AttributedString.Runs = attributedText.runs let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs) let runArray: [AttributedString.Runs.Run] = Array(runs)
print(runArray.description) print(runArray.description)
XCTAssertEqual(runArray[1].link?.absoluteString, "damus:t:cool", "Latin-character hashtag is missing. Runs description :\(runArray.description)") XCTAssertEqual(runArray[1].link?.absoluteString, "damus:t:cool", "Latin-character hashtag is missing. Runs description :\(runArray.description)")
XCTAssertEqual(runArray[3].link?.absoluteString.removingPercentEncoding!, "damus:t:かっこいい", "Non-latin-character hashtag is missing. Runs description :\(runArray.description)") XCTAssertEqual(runArray[3].link?.absoluteString.removingPercentEncoding, "damus:t:かっこいい", "Non-latin-character hashtag is missing. Runs description :\(runArray.description)")
} }
func testRenderBlocksWithLeadingAndTrailingWhitespacesTrimmed() throws {
let content = " \n\n Hello, \nworld! \n\n "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles, can_hide_last_previewable_refs: true)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let text = attributedText.description
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 1)
XCTAssertTrue(text.contains("Hello, \nworld!"))
XCTAssertFalse(text.contains(content))
}
func testRenderBlocksWithMediaBlockInMiddleRendered() throws {
let content = " Check this out: https://damus.io/image.png Isn't this cool? "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 3)
XCTAssertTrue(runArray[0].description.contains("Check this out: "))
XCTAssertTrue(runArray[1].description.contains("https://damus.io/image.png "))
XCTAssertEqual(runArray[1].link?.absoluteString, "https://damus.io/image.png")
XCTAssertTrue(runArray[2].description.contains(" Isn't this cool?"))
XCTAssertEqual(noteArtifactsSeparated.images.count, 1)
XCTAssertEqual(noteArtifactsSeparated.images[0].absoluteString, "https://damus.io/image.png")
}
func testRenderBlocksWithInvoiceInMiddleAbbreviated() throws {
let invoiceString = "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r"
let content = " Donations appreciated: \(invoiceString) Pura Vida "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 3)
XCTAssertTrue(runArray[0].description.contains("Donations appreciated: "))
XCTAssertTrue(runArray[1].description.contains("lnbc100n:qpsql29r"))
XCTAssertEqual(runArray[1].link?.absoluteString, "damus:lightning:\(invoiceString)")
XCTAssertTrue(runArray[2].description.contains(" Pura Vida"))
}
func testRenderBlocksWithNoteIdInMiddleAreRendered() throws {
let noteId = test_note.id.bech32
let content = " Check this out: nostr:\(noteId) Pura Vida "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 3)
XCTAssertTrue(runArray[0].description.contains("Check this out: "))
XCTAssertTrue(runArray[1].description.contains("note1qqq:qqn2l0z3"))
XCTAssertEqual(runArray[1].link?.absoluteString, "damus:nostr:\(noteId)")
XCTAssertTrue(runArray[2].description.contains(" Pura Vida"))
}
func testRenderBlocksWithNeventInMiddleAreRendered() throws {
let nevent = "nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ez6un9d3shjtn4de6x2argwghx6egpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5nxnepm"
let content = " Check this out: nostr:\(nevent) Pura Vida "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 3)
XCTAssertTrue(runArray[0].description.contains("Check this out: "))
XCTAssertTrue(runArray[1].description.contains("nevent1q:t5nxnepm"))
XCTAssertEqual(runArray[1].link?.absoluteString, "damus:nostr:\(nevent)")
XCTAssertTrue(runArray[2].description.contains(" Pura Vida"))
}
func testRenderBlocksWithPreviewableBlocksAtEndAreHidden() throws {
let noteId = test_note.id.bech32
let invoiceString = "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r"
let content = " Check this out. \nhttps://hidden.tld/\nhttps://damus.io/hidden1.png\n\(invoiceString)\nhttps://damus.io/hidden2.png\nnostr:\(noteId) "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles, can_hide_last_previewable_refs: true)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 1)
XCTAssertTrue(runArray[0].description.contains("Check this out."))
XCTAssertFalse(runArray[0].description.contains("https://hidden.tld/"))
XCTAssertFalse(runArray[0].description.contains("https://damus.io/hidden1.png"))
XCTAssertFalse(runArray[0].description.contains("lnbc100n:qpsql29r"))
XCTAssertFalse(runArray[0].description.contains("https://damus.io/hidden2.png"))
XCTAssertFalse(runArray[0].description.contains("note1qqq:qqn2l0z3"))
XCTAssertEqual(noteArtifactsSeparated.images.count, 2)
XCTAssertEqual(noteArtifactsSeparated.images[0].absoluteString, "https://damus.io/hidden1.png")
XCTAssertEqual(noteArtifactsSeparated.images[1].absoluteString, "https://damus.io/hidden2.png")
XCTAssertEqual(noteArtifactsSeparated.media.count, 2)
XCTAssertEqual(noteArtifactsSeparated.media[0].url.absoluteString, "https://damus.io/hidden1.png")
XCTAssertEqual(noteArtifactsSeparated.media[1].url.absoluteString, "https://damus.io/hidden2.png")
XCTAssertEqual(noteArtifactsSeparated.links.count, 1)
XCTAssertEqual(noteArtifactsSeparated.links[0].absoluteString, "https://hidden.tld/")
XCTAssertEqual(noteArtifactsSeparated.invoices.count, 1)
XCTAssertEqual(noteArtifactsSeparated.invoices[0].string, invoiceString)
}
func testRenderBlocksWithMultipleLinksAtEndAreNotHidden() throws {
let noteId = test_note.id.bech32
let invoiceString = "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r"
let content = " Check this out. \nhttps://nothidden1.tld/\nhttps://nothidden2.tld/\nhttps://damus.io/nothidden1.png\n\(invoiceString)\nhttps://damus.io/nothidden2.png\nnostr:\(noteId) "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles, can_hide_last_previewable_refs: true)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 12)
XCTAssertTrue(runArray[0].description.contains("Check this out."))
XCTAssertTrue(runArray[1].description.contains("https://nothidden1.tld/"))
XCTAssertTrue(runArray[3].description.contains("https://nothidden2.tld/"))
XCTAssertTrue(runArray[5].description.contains("https://damus.io/nothidden1.png"))
XCTAssertTrue(runArray[7].description.contains("lnbc100n:qpsql29r"))
XCTAssertTrue(runArray[9].description.contains("https://damus.io/nothidden2.png"))
XCTAssertTrue(runArray[11].description.contains("note1qqq:qqn2l0z3"))
XCTAssertEqual(noteArtifactsSeparated.images.count, 2)
XCTAssertEqual(noteArtifactsSeparated.images[0].absoluteString, "https://damus.io/nothidden1.png")
XCTAssertEqual(noteArtifactsSeparated.images[1].absoluteString, "https://damus.io/nothidden2.png")
XCTAssertEqual(noteArtifactsSeparated.media.count, 2)
XCTAssertEqual(noteArtifactsSeparated.media[0].url.absoluteString, "https://damus.io/nothidden1.png")
XCTAssertEqual(noteArtifactsSeparated.media[1].url.absoluteString, "https://damus.io/nothidden2.png")
XCTAssertEqual(noteArtifactsSeparated.links.count, 2)
XCTAssertEqual(noteArtifactsSeparated.links[0].absoluteString, "https://nothidden1.tld/")
XCTAssertEqual(noteArtifactsSeparated.links[1].absoluteString, "https://nothidden2.tld/")
XCTAssertEqual(noteArtifactsSeparated.invoices.count, 1)
XCTAssertEqual(noteArtifactsSeparated.invoices[0].string, invoiceString)
}
func testRenderBlocksWithMultipleEventsAtEndAreNotHidden() throws {
let noteId = test_note.id.bech32
let nevent = "nevent1qqstna2yrezu5wghjvswqqculvvwxsrcvu7uc0f78gan4xqhvz49d9spr3mhxue69uhkummnw3ez6un9d3shjtn4de6x2argwghx6egpr4mhxue69uhkummnw3ez6ur4vgh8wetvd3hhyer9wghxuet5nxnepm"
let invoiceString = "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r"
let content = " Check this out. \nnostr:\(noteId)\nnostr:\(nevent)\nhttps://damus.io/nothidden1.png\n\(invoiceString)\nhttps://damus.io/nothidden2.png "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles, can_hide_last_previewable_refs: true)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 10)
XCTAssertTrue(runArray[0].description.contains("Check this out."))
XCTAssertTrue(runArray[1].description.contains("note1qqq:qqn2l0z3"))
XCTAssertTrue(runArray[3].description.contains("nevent1q:t5nxnepm"))
XCTAssertTrue(runArray[5].description.contains("https://damus.io/nothidden1.png"))
XCTAssertTrue(runArray[7].description.contains("lnbc100n:qpsql29r"))
XCTAssertTrue(runArray[9].description.contains("https://damus.io/nothidden2.png"))
XCTAssertEqual(noteArtifactsSeparated.images.count, 2)
XCTAssertEqual(noteArtifactsSeparated.images[0].absoluteString, "https://damus.io/nothidden1.png")
XCTAssertEqual(noteArtifactsSeparated.images[1].absoluteString, "https://damus.io/nothidden2.png")
XCTAssertEqual(noteArtifactsSeparated.media.count, 2)
XCTAssertEqual(noteArtifactsSeparated.media[0].url.absoluteString, "https://damus.io/nothidden1.png")
XCTAssertEqual(noteArtifactsSeparated.media[1].url.absoluteString, "https://damus.io/nothidden2.png")
XCTAssertEqual(noteArtifactsSeparated.links.count, 0)
XCTAssertEqual(noteArtifactsSeparated.invoices.count, 1)
XCTAssertEqual(noteArtifactsSeparated.invoices[0].string, invoiceString)
}
func testRenderBlocksWithPreviewableBlocksAtEndAreNotHiddenWhenMediaBlockPrecedesThem() throws {
let content = " Check this out: https://damus.io/image.png Isn't this cool? \nhttps://damus.io/nothidden.png "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles, can_hide_last_previewable_refs: true)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 4)
XCTAssertTrue(runArray[0].description.contains("Check this out: "))
XCTAssertTrue(runArray[1].description.contains("https://damus.io/image.png "))
XCTAssertEqual(runArray[1].link?.absoluteString, "https://damus.io/image.png")
XCTAssertTrue(runArray[2].description.contains(" Isn't this cool?"))
XCTAssertTrue(runArray[3].description.contains("https://damus.io/nothidden.png"))
XCTAssertEqual(runArray[3].link?.absoluteString, "https://damus.io/nothidden.png")
XCTAssertEqual(noteArtifactsSeparated.images.count, 2)
XCTAssertEqual(noteArtifactsSeparated.images[0].absoluteString, "https://damus.io/image.png")
XCTAssertEqual(noteArtifactsSeparated.images[1].absoluteString, "https://damus.io/nothidden.png")
}
func testRenderBlocksWithPreviewableBlocksAtEndAreNotHiddenWhenInvoicePrecedesThem() throws {
let invoiceString = "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r"
let content = " Donations appreciated: \(invoiceString) Pura Vida \nhttps://damus.io/nothidden.png "
let note = try XCTUnwrap(NostrEvent(content: content, keypair: test_keypair))
let parsed: Blocks = parse_note_content(content: .init(note: note, keypair: test_keypair))
let testState = test_damus_state
let noteArtifactsSeparated: NoteArtifactsSeparated = render_blocks(blocks: parsed, profiles: testState.profiles, can_hide_last_previewable_refs: true)
let attributedText: AttributedString = noteArtifactsSeparated.content.attributed
let runs: AttributedString.Runs = attributedText.runs
let runArray: [AttributedString.Runs.Run] = Array(runs)
XCTAssertEqual(runArray.count, 4)
XCTAssertTrue(runArray[0].description.contains("Donations appreciated: "))
XCTAssertTrue(runArray[1].description.contains("lnbc100n:qpsql29r"))
XCTAssertEqual(runArray[1].link?.absoluteString, "damus:lightning:\(invoiceString)")
XCTAssertTrue(runArray[2].description.contains(" Pura Vida"))
XCTAssertTrue(runArray[3].description.contains("https://damus.io/nothidden.png"))
XCTAssertEqual(runArray[3].link?.absoluteString, "https://damus.io/nothidden.png")
XCTAssertEqual(noteArtifactsSeparated.images.count, 1)
XCTAssertEqual(noteArtifactsSeparated.images[0].absoluteString, "https://damus.io/nothidden.png")
}
/// Based on https://github.com/damus-io/damus/issues/1468 /// Based on https://github.com/damus-io/damus/issues/1468
/// Tests whether a note content view correctly parses an image block when url in JSON content contains optional escaped slashes /// Tests whether a note content view correctly parses an image block when url in JSON content contains optional escaped slashes
func testParseImageBlockInContentWithEscapedSlashes() { func testParseImageBlockInContentWithEscapedSlashes() throws {
let testJSONWithEscapedSlashes = "{\"tags\":[],\"pubkey\":\"f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9\",\"content\":\"https:\\/\\/cdn.nostr.build\\/i\\/5c1d3296f66c2630131bf123106486aeaf051ed8466031c0e0532d70b33cddb2.jpg\",\"created_at\":1691864981,\"kind\":1,\"sig\":\"fc0033aa3d4df50b692a5b346fa816fdded698de2045e36e0642a021391468c44ca69c2471adc7e92088131872d4aaa1e90ea6e1ad97f3cc748f4aed96dfae18\",\"id\":\"e8f6eca3b161abba034dac9a02bb6930ecde9fd2fb5d6c5f22a05526e11382cb\"}" let testJSONWithEscapedSlashes = "{\"tags\":[],\"pubkey\":\"f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9\",\"content\":\"https:\\/\\/cdn.nostr.build\\/i\\/5c1d3296f66c2630131bf123106486aeaf051ed8466031c0e0532d70b33cddb2.jpg\",\"created_at\":1691864981,\"kind\":1,\"sig\":\"fc0033aa3d4df50b692a5b346fa816fdded698de2045e36e0642a021391468c44ca69c2471adc7e92088131872d4aaa1e90ea6e1ad97f3cc748f4aed96dfae18\",\"id\":\"e8f6eca3b161abba034dac9a02bb6930ecde9fd2fb5d6c5f22a05526e11382cb\"}"
let testNote = NostrEvent.owned_from_json(json: testJSONWithEscapedSlashes)! let testNote = try XCTUnwrap(NostrEvent.owned_from_json(json: testJSONWithEscapedSlashes))
let parsed = parse_note_content(content: .init(note: testNote, keypair: test_keypair)) let parsed = parse_note_content(content: .init(note: testNote, keypair: test_keypair))
XCTAssertTrue((parsed.blocks[0].asURL != nil), "NoteContentView does not correctly parse an image block when url in JSON content contains optional escaped slashes.") XCTAssertTrue((parsed.blocks[0].asURL != nil), "NoteContentView does not correctly parse an image block when url in JSON content contains optional escaped slashes.")
@@ -69,9 +333,9 @@ class NoteContentViewTests: XCTestCase {
} }
func testMentionStr_Note_ContainsFullBech32() { func testMentionStr_Note_ContainsFullBech32() {
let compatableText = createCompatibleText(test_note.id.bech32) let compatibleText = createCompatibleText(test_note.id.bech32)
assertCompatibleTextHasExpectedString(compatibleText: compatableText, expected: test_note.id.bech32) assertCompatibleTextHasExpectedString(compatibleText: compatibleText, expected: test_note.id.bech32)
} }
func testMentionStr_Nevent_ContainsAbbreviated() { func testMentionStr_Nevent_ContainsAbbreviated() {

View File

@@ -36,10 +36,9 @@ class damusTests: XCTestCase {
XCTAssertEqual(bytes.count, 32) XCTAssertEqual(bytes.count, 32)
} }
func testTrimmingFunctions() { func testTrimSuffix() {
let txt = " bobs " let txt = " bobs "
XCTAssertEqual(trim_prefix(txt), "bobs ")
XCTAssertEqual(trim_suffix(txt), " bobs") XCTAssertEqual(trim_suffix(txt), " bobs")
} }

View File

@@ -135,6 +135,7 @@ struct ShareExtensionView: View {
return return
} }
self.state = DamusState(keypair: keypair) self.state = DamusState(keypair: keypair)
self.state?.nostrNetwork.connect()
}) })
.onChange(of: self.highlighter_state) { .onChange(of: self.highlighter_state) {
if case .cancelled = highlighter_state { if case .cancelled = highlighter_state {

View File

@@ -250,6 +250,7 @@ struct ShareExtensionView: View {
return false return false
} }
state = DamusState(keypair: keypair) state = DamusState(keypair: keypair)
state?.nostrNetwork.connect()
return true return true
} }