Compare commits
114 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
a8b6b5f10e
|
|||
|
1bedb6b2bd
|
|||
| 8d9f728cf0 | |||
| 2c62741e25 | |||
| 1f612f7fde | |||
| 0e9e102d0f | |||
| b94e8765a1 | |||
| 53964f5c1a | |||
| bd574d93c3 | |||
| 47514ace79 | |||
| 298b43733f | |||
| 02116c0af5 | |||
| 92121e3b2d | |||
| c92094823e | |||
| f4b1a504a5 | |||
| 99ae7de5eb | |||
| b3d9ee3fc0 | |||
| e65219ee3e | |||
| 414c67a919 | |||
| f436291209 | |||
| a9196a39df | |||
| 6a8ee9c360 | |||
|
947e24864e
|
|||
|
b9198d6bd7
|
|||
| 14bf187a6e | |||
| c996e5f8b3 | |||
|
b6dad349c9
|
|||
|
56dde30cf6
|
|||
|
95bfbae131
|
|||
|
3da0ff7ecc
|
|||
|
b8f846ded8
|
|||
|
e74c45ad39
|
|||
|
e6a03522c6
|
|||
|
dbc7d79ecd
|
|||
|
d2b5a65eca
|
|||
|
16b19d3a96
|
|||
|
70edb8d7c5
|
|||
|
ea04ebe95c
|
|||
|
44cf47faa4
|
|||
|
612abfd862
|
|||
|
20af086273
|
|||
| e9c1671d06 | |||
| d02847d466 | |||
| 580fa954b2 | |||
| aef516ae9f | |||
| eb4e3b692b | |||
| fe52381d63 | |||
| ab8d52e685 | |||
| 1d32200ae3 | |||
| 309b00380d | |||
| 7fa2118480 | |||
| 1a6c17e308 | |||
| 82a6046620 | |||
| 241755c8c4 | |||
| b26f66f15c | |||
| 28bd0c81e8 | |||
| 0bd1814877 | |||
| ee94f67b94 | |||
| 3a25075473 | |||
| d16ff8f78f | |||
| 38dc90cb33 | |||
| 52bbc698b2 | |||
| 496a11f597 | |||
| 4a8a0ea1bd | |||
| c424d4da99 | |||
| 69d5fc1553 | |||
| bcb59896db | |||
| e1e6d9eb3d | |||
| f1fdae5957 | |||
| f96647fa40 | |||
| 5ea522d306 | |||
| 54d6161acd | |||
| b1fd84fd75 | |||
| 9dbdf7928a | |||
| 67f0e3d296 | |||
| e498418c2d | |||
| 33150a42c5 | |||
| e7fe4ab9b4 | |||
| c146bab08a | |||
| d1cced8d54 | |||
| 8849b6105c | |||
| 3a0acfaba1 | |||
| 0ec2b05070 | |||
| 130bbfafb4 | |||
| ffc75772f9 | |||
| 5b3fac70ed | |||
| 53e3f6d86b | |||
| c28ab7a57c | |||
| 09ce3af11e | |||
| e42c09883a | |||
| 77e3924809 | |||
| 3511b1ee91 | |||
| 78a62c8ef0 | |||
| 8b96b9f4e6 | |||
| 649a857c3a | |||
| cdae2c7558 | |||
| 3639110c51 | |||
|
186668512e
|
|||
|
f63666fae2
|
|||
|
68d25059b1
|
|||
|
9aef6b7f5b
|
|||
|
d2e712575f
|
|||
|
bf9674e6e4
|
|||
|
4815390cbe
|
|||
|
6ce903f1f6
|
|||
|
b2c91ffce4
|
|||
|
ae335b18bf
|
|||
|
6391819fb2
|
|||
|
5d0e56b7c7
|
|||
|
50ccc7bd7f
|
|||
|
b3a6bcf3b2
|
|||
|
38b2988bbe
|
|||
|
446c541dcb
|
|||
|
31fd48ee52
|
@@ -1,3 +1,51 @@
|
|||||||
|
## [1.14] - 2025-05-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added safety reminder to wallets with higher balance (Daniel D’Aquino)
|
||||||
|
- Added one-click Coinos wallet setup (Daniel D’Aquino)
|
||||||
|
- Add notification setting to hide hellthreads (Terry Yiu)
|
||||||
|
- Added separated first aid option for relay lists that does not need a contact list reset (Daniel D’Aquino)
|
||||||
|
- Added NIP-65 relay list support (Daniel D’Aquino)
|
||||||
|
- Added Unicode 16 emoji reactions for iOS 18.4+ by upgrading EmojiPicker (Terry Yiu)
|
||||||
|
- Added a search interface to the settings screen (SanjaySiddharth)
|
||||||
|
- Added view introducing users to Zaps (ericholguin)
|
||||||
|
- Added new wallet view with balance and transactions list (ericholguin)
|
||||||
|
- Added copy technical info button to user visible errors, so that users can more easily share errors with developers (Daniel D’Aquino)
|
||||||
|
- Add dismiss button to wallet high balance reminders (Daniel D’Aquino)
|
||||||
|
- Zap receiver information now included for outgoing zaps (Daniel D’Aquino)
|
||||||
|
- Added inline note rendering of invoices to pull up wallet selector sheet (Terry Yiu)
|
||||||
|
- Added route to profile page from wallet tx list (ericholguin)
|
||||||
|
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added additional information on top of blurred images (SanjaySiddharth)
|
||||||
|
- Improved robustness of relay list handling (Daniel D’Aquino)
|
||||||
|
- Updated image cache for better stability (Daniel D’Aquino)
|
||||||
|
- Improved integration with Nostr Wallet Connect wallets (ericholguin)
|
||||||
|
- Added relay connectivity information to NWC settings (Daniel D’Aquino)
|
||||||
|
- Improved handling around NWC responses (Daniel D’Aquino)
|
||||||
|
- Added more human visible errors on NWC wallets to aid with troubleshooting (Daniel D’Aquino)
|
||||||
|
- Re-enabled note zaps as permitted by the new App Store guidelines (Daniel D’Aquino)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hide future notes from timeline (Terry Yiu)
|
||||||
|
- Fixed issue where profiles with a NIP-65 relay list would not display on Damus (Daniel D’Aquino)
|
||||||
|
- Fix quote notes to include missing q tag (Terry Yiu)
|
||||||
|
- Fixed issue where the side menu would close when copying the npub (SanjaySiddharth)
|
||||||
|
- Fixed issue where cached images would be backed up to iCloud (Daniel D’Aquino)
|
||||||
|
- Optimized classify_url function (Terry Yiu)
|
||||||
|
- Fixed note rendering for those that contain previewable items or leading and trailing whitespaces (Terry Yiu)
|
||||||
|
- Fixed issue where some videos would become unplayable after some time using the app (Daniel D’Aquino)
|
||||||
|
|
||||||
|
|
||||||
|
[1.14]: https://github.com/damus-io/damus/releases/tag/v1.14
|
||||||
|
|
||||||
|
|
||||||
## [1.13.1] - 2025-03-21
|
## [1.13.1] - 2025-03-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ struct NotificationExtensionState: HeadlessDamusState {
|
|||||||
let lnurls: LNUrls
|
let lnurls: LNUrls
|
||||||
|
|
||||||
init?() {
|
init?() {
|
||||||
guard let ndb = try? Ndb(owns_db_file: false) else { return nil }
|
guard let ndb = Ndb(owns_db_file: false) else { return nil }
|
||||||
self.ndb = ndb
|
self.ndb = ndb
|
||||||
|
|
||||||
guard let keypair = get_saved_keypair() else { return nil }
|
guard let keypair = get_saved_keypair() else { return nil }
|
||||||
|
|||||||
+282
-23
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "06318d35ee2e6bd681b95591e67da33a9461b48a3c652e58bd9d1a6f0d82bdac",
|
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "codescanner",
|
"identity" : "codescanner",
|
||||||
@@ -35,6 +35,15 @@
|
|||||||
"version" : "0.2.0"
|
"version" : "0.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "faviconfinder",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/will-lumley/FaviconFinder.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9279f4371f4877ca302ba3bf1015f3f58ae4a56c",
|
||||||
|
"version" : "5.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "gsplayer",
|
"identity" : "gsplayer",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -49,8 +58,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/onevcat/Kingfisher",
|
"location" : "https://github.com/onevcat/Kingfisher",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "415b1d97fb38bda1e5a6b2dde63354720832110b",
|
"revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3",
|
||||||
"version" : "7.6.1"
|
"version" : "8.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -105,6 +114,15 @@
|
|||||||
"version" : "0.1.2"
|
"version" : "0.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftsoup",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
|
||||||
|
"version" : "2.8.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swiftycrop",
|
"identity" : "swiftycrop",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "bbw.jpg",
|
"filename" : "blink.png",
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 216 B After Width: | Height: | Size: 216 B |
@@ -94,26 +94,30 @@ enum OpenWalletError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
||||||
|
let url = try getUrlToOpen(invoice: invoice, with: wallet)
|
||||||
|
this_app.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUrlToOpen(invoice: String, with wallet: Wallet.Model) throws(OpenWalletError) -> URL {
|
||||||
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
|
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
|
||||||
this_app.open(url)
|
return url
|
||||||
} else {
|
} else {
|
||||||
guard let store_link = wallet.appStoreLink else {
|
guard let store_link = wallet.appStoreLink else {
|
||||||
throw OpenWalletError.no_wallet_to_open
|
throw .no_wallet_to_open
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let url = URL(string: store_link) else {
|
guard let url = URL(string: store_link) else {
|
||||||
throw OpenWalletError.store_link_invalid
|
throw .store_link_invalid
|
||||||
}
|
}
|
||||||
|
|
||||||
guard this_app.canOpenURL(url) else {
|
guard this_app.canOpenURL(url) else {
|
||||||
throw OpenWalletError.system_cannot_open_store_link
|
throw .system_cannot_open_store_link
|
||||||
}
|
}
|
||||||
|
|
||||||
this_app.open(url)
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
|
let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
|
||||||
|
|
||||||
struct InvoiceView_Previews: PreviewProvider {
|
struct InvoiceView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -5,27 +5,27 @@
|
|||||||
// Created by William Casarin on 2023-01-11.
|
// Created by William Casarin on 2023-01-11.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
|
import Kingfisher
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct NIP05Badge: View {
|
struct NIP05Badge: View {
|
||||||
let nip05: NIP05
|
let nip05: NIP05
|
||||||
let pubkey: Pubkey
|
let pubkey: Pubkey
|
||||||
let contacts: Contacts
|
let damus_state: DamusState
|
||||||
let show_domain: Bool
|
let show_domain: Bool
|
||||||
let profiles: Profiles
|
let nip05_domain_favicon: FaviconURL?
|
||||||
|
|
||||||
@Environment(\.openURL) var openURL
|
init(nip05: NIP05, pubkey: Pubkey, damus_state: DamusState, show_domain: Bool, nip05_domain_favicon: FaviconURL?) {
|
||||||
|
|
||||||
init(nip05: NIP05, pubkey: Pubkey, contacts: Contacts, show_domain: Bool, profiles: Profiles) {
|
|
||||||
self.nip05 = nip05
|
self.nip05 = nip05
|
||||||
self.pubkey = pubkey
|
self.pubkey = pubkey
|
||||||
self.contacts = contacts
|
self.damus_state = damus_state
|
||||||
self.show_domain = show_domain
|
self.show_domain = show_domain
|
||||||
self.profiles = profiles
|
self.nip05_domain_favicon = nip05_domain_favicon
|
||||||
}
|
}
|
||||||
|
|
||||||
var nip05_color: Bool {
|
var nip05_color: Bool {
|
||||||
return use_nip05_color(pubkey: pubkey, contacts: contacts)
|
return use_nip05_color(pubkey: pubkey, contacts: damus_state.contacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
var Seal: some View {
|
var Seal: some View {
|
||||||
@@ -44,8 +44,23 @@ struct NIP05Badge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var domainBadge: some View {
|
||||||
|
Group {
|
||||||
|
if let nip05_domain_favicon {
|
||||||
|
KFImage(nip05_domain_favicon.source)
|
||||||
|
.imageContext(.favicon, disable_animation: true)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var username_matches_nip05: Bool {
|
var username_matches_nip05: Bool {
|
||||||
guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
|
guard let name = damus_state.profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
|
||||||
else {
|
else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -65,14 +80,18 @@ struct NIP05Badge: View {
|
|||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Seal
|
Seal
|
||||||
|
|
||||||
if show_domain {
|
Group {
|
||||||
Text(nip05_string)
|
if show_domain {
|
||||||
.nip05_colorized(gradient: nip05_color)
|
Text(nip05_string)
|
||||||
.onTapGesture {
|
.nip05_colorized(gradient: nip05_color)
|
||||||
if let nip5url = nip05.siteUrl {
|
}
|
||||||
openURL(nip5url)
|
|
||||||
}
|
if nip05_domain_favicon != nil {
|
||||||
}
|
domainBadge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
damus_state.nav.push(route: Route.NIP05DomainEvents(events: NIP05DomainEventsModel(state: damus_state, domain: nip05.host), nip05_domain_favicon: nip05_domain_favicon))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +117,9 @@ struct NIP05Badge_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let test_state = test_damus_state
|
let test_state = test_damus_state
|
||||||
VStack {
|
VStack {
|
||||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
|
||||||
|
|
||||||
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
|
||||||
|
|
||||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
|
||||||
|
|
||||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: Contacts(our_pubkey: test_pubkey), show_domain: true, profiles: test_state.profiles)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ struct NoteZapButton: View {
|
|||||||
print("cancel_zap: we already have a real zap, can't cancel")
|
print("cancel_zap: we already have a real zap, can't cancel")
|
||||||
break
|
break
|
||||||
case .pending(let pzap):
|
case .pending(let pzap):
|
||||||
guard let res = cancel_zap(zap: pzap, box: damus_state.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
|
guard let res = cancel_zap(zap: pzap, box: damus_state.nostrNetwork.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
|
||||||
|
|
||||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||||
return
|
return
|
||||||
@@ -179,7 +179,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only take the first 10 because reasons
|
// Only take the first 10 because reasons
|
||||||
let relays = Array(damus_state.pool.our_descriptors.prefix(10))
|
let relays = Array(damus_state.nostrNetwork.pool.our_descriptors.prefix(10))
|
||||||
let content = comment ?? ""
|
let content = comment ?? ""
|
||||||
|
|
||||||
guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else {
|
guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else {
|
||||||
@@ -232,7 +232,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
|||||||
flusher = .once({ pe in
|
flusher = .once({ pe in
|
||||||
// send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation
|
// send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await WalletConnect.send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
|
await WalletConnect.send_donation_zap(pool: damus_state.nostrNetwork.pool, postbox: damus_state.nostrNetwork.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
|||||||
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
|
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
|
||||||
let delay = damus_state.settings.nozaps ? nil : 5.0
|
let delay = damus_state.settings.nozaps ? nil : 5.0
|
||||||
|
|
||||||
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher)
|
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, zap_request: zapreq, delay: delay, on_flush: flusher)
|
||||||
|
|
||||||
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
|
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
|
||||||
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
|
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
|
||||||
|
|||||||
@@ -94,12 +94,12 @@ struct SelectableText: View {
|
|||||||
case show_mute_word_view(highlighted_text: String)
|
case show_mute_word_view(highlighted_text: String)
|
||||||
|
|
||||||
func should_show_highlight_post_view() -> Bool {
|
func should_show_highlight_post_view() -> Bool {
|
||||||
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
|
guard case .show_highlight_post_view = self else { return false }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func should_show_mute_word_view() -> Bool {
|
func should_show_mute_word_view() -> Bool {
|
||||||
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
|
guard case .show_mute_word_view = self else { return false }
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,16 +119,23 @@ struct SelectableText: View {
|
|||||||
fileprivate class TextView: UITextView {
|
fileprivate class TextView: UITextView {
|
||||||
var postHighlight: (String) -> Void
|
var postHighlight: (String) -> Void
|
||||||
var muteWord: (String) -> Void
|
var muteWord: (String) -> Void
|
||||||
|
private let enableHighlighting: Bool
|
||||||
|
|
||||||
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
|
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void, enableHighlighting: Bool) {
|
||||||
self.postHighlight = postHighlight
|
self.postHighlight = postHighlight
|
||||||
self.muteWord = muteWord
|
self.muteWord = muteWord
|
||||||
|
self.enableHighlighting = enableHighlighting
|
||||||
|
|
||||||
super.init(frame: frame, textContainer: textContainer)
|
super.init(frame: frame, textContainer: textContainer)
|
||||||
|
|
||||||
|
if enableHighlighting {
|
||||||
|
self.delegate = self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||||
if action == #selector(highlightText(_:)) {
|
if action == #selector(highlightText(_:)) {
|
||||||
@@ -142,23 +149,44 @@ fileprivate class TextView: UITextView {
|
|||||||
return super.canPerformAction(action, withSender: sender)
|
return super.canPerformAction(action, withSender: sender)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSelectedText() -> String? {
|
private func getSelectedText() -> String? {
|
||||||
guard let selectedRange = self.selectedTextRange else { return nil }
|
guard let selectedRange = self.selectedTextRange else { return nil }
|
||||||
return self.text(in: selectedRange)
|
return self.text(in: selectedRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func highlightText(_ sender: Any?) {
|
@objc private func highlightText(_ sender: Any?) {
|
||||||
guard let selectedText = self.getSelectedText() else { return }
|
guard let selectedText = self.getSelectedText() else { return }
|
||||||
self.postHighlight(selectedText)
|
self.postHighlight(selectedText)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func muteText(_ sender: Any?) {
|
@objc private func muteText(_ sender: Any?) {
|
||||||
guard let selectedText = self.getSelectedText() else { return }
|
guard let selectedText = self.getSelectedText() else { return }
|
||||||
self.muteWord(selectedText)
|
self.muteWord(selectedText)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension TextView: UITextViewDelegate {
|
||||||
|
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||||
|
guard enableHighlighting,
|
||||||
|
let selectedTextRange = self.selectedTextRange,
|
||||||
|
let selectedText = self.text(in: selectedTextRange),
|
||||||
|
!selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let highlightAction = UIAction(title: NSLocalizedString("Highlight", comment: "Context menu action to highlight the selected text as context to draft a new note."), image: UIImage(systemName: "highlighter")) { [weak self] _ in
|
||||||
|
self?.postHighlight(selectedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
let muteAction = UIAction(title: NSLocalizedString("Mute", comment: "Context menu action to mute the selected word."), image: UIImage(systemName: "speaker.slash")) { [weak self] _ in
|
||||||
|
self?.muteWord(selectedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
return UIMenu(children: suggestedActions + [highlightAction, muteAction])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
||||||
|
|
||||||
let attributedString: AttributedString
|
let attributedString: AttributedString
|
||||||
@@ -172,7 +200,7 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
|||||||
@Binding var height: CGFloat
|
@Binding var height: CGFloat
|
||||||
|
|
||||||
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
|
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
|
||||||
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
|
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord, enableHighlighting: enableHighlighting)
|
||||||
view.isEditable = false
|
view.isEditable = false
|
||||||
view.dataDetectorTypes = .all
|
view.dataDetectorTypes = .all
|
||||||
view.isSelectable = true
|
view.isSelectable = true
|
||||||
@@ -183,11 +211,6 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
|||||||
view.textContainerInset.right = 1.0
|
view.textContainerInset.right = 1.0
|
||||||
view.textAlignment = textAlignment
|
view.textAlignment = textAlignment
|
||||||
|
|
||||||
let menuController = UIMenuController.shared
|
|
||||||
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
|
|
||||||
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
|
|
||||||
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
|
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,6 @@ struct UserStatusSheet: View {
|
|||||||
|
|
||||||
struct UserStatusSheet_Previews: PreviewProvider {
|
struct UserStatusSheet_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.postbox, keypair: test_keypair, status: .init())
|
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.nostrNetwork.postbox, keypair: test_keypair, status: .init())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
+51
-49
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
import AVKit
|
import AVKit
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import EmojiPicker
|
import EmojiPicker
|
||||||
|
import TipKit
|
||||||
|
|
||||||
struct ZapSheet {
|
struct ZapSheet {
|
||||||
let target: ZapTarget
|
let target: ZapTarget
|
||||||
@@ -178,7 +179,7 @@ struct ContentView: View {
|
|||||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||||
|
|
||||||
case .dms:
|
case .dms:
|
||||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(DamusColors.adaptableWhite)
|
.background(DamusColors.adaptableWhite)
|
||||||
@@ -199,7 +200,7 @@ struct ContentView: View {
|
|||||||
func MaybeReportView(target: ReportTarget) -> some View {
|
func MaybeReportView(target: ReportTarget) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if let keypair = damus_state.keypair.to_full() {
|
if let keypair = damus_state.keypair.to_full() {
|
||||||
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
|
ReportView(postbox: damus_state.nostrNetwork.postbox, target: target, keypair: keypair)
|
||||||
} else {
|
} else {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
@@ -317,7 +318,7 @@ struct ContentView: View {
|
|||||||
case .post(let action):
|
case .post(let action):
|
||||||
PostView(action: action, damus_state: damus_state!)
|
PostView(action: action, damus_state: damus_state!)
|
||||||
case .user_status:
|
case .user_status:
|
||||||
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
|
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.nostrNetwork.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
case .event:
|
case .event:
|
||||||
EventDetailView()
|
EventDetailView()
|
||||||
@@ -356,7 +357,7 @@ struct ContentView: View {
|
|||||||
self.hide_bar = !show
|
self.hide_bar = !show
|
||||||
}
|
}
|
||||||
.onReceive(timer) { n in
|
.onReceive(timer) { n in
|
||||||
self.damus_state?.postbox.try_flushing_events()
|
self.damus_state?.nostrNetwork.postbox.try_flushing_events()
|
||||||
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
|
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.report)) { target in
|
.onReceive(handle_notify(.report)) { target in
|
||||||
@@ -367,10 +368,6 @@ struct ContentView: View {
|
|||||||
self.confirm_mute = true
|
self.confirm_mute = true
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.attached_wallet)) { nwc in
|
.onReceive(handle_notify(.attached_wallet)) { nwc in
|
||||||
// Ensure to add NWC relay to the pool and connect it.
|
|
||||||
try? damus_state.pool.add_relay(.nwc(url: nwc.relay))
|
|
||||||
damus_state.pool.connect(to: [nwc.relay])
|
|
||||||
|
|
||||||
// update the lightning address on our profile when we attach a
|
// update the lightning address on our profile when we attach a
|
||||||
// wallet with an associated
|
// wallet with an associated
|
||||||
guard let ds = self.damus_state,
|
guard let ds = self.damus_state,
|
||||||
@@ -391,12 +388,12 @@ struct ContentView: View {
|
|||||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
|
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
|
||||||
|
|
||||||
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||||
ds.postbox.send(ev)
|
ds.nostrNetwork.postbox.send(ev)
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.broadcast)) { ev in
|
.onReceive(handle_notify(.broadcast)) { ev in
|
||||||
guard let ds = self.damus_state else { return }
|
guard let ds = self.damus_state else { return }
|
||||||
|
|
||||||
ds.postbox.send(ev)
|
ds.nostrNetwork.postbox.send(ev)
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.unfollow)) { target in
|
.onReceive(handle_notify(.unfollow)) { target in
|
||||||
guard let state = self.damus_state else { return }
|
guard let state = self.damus_state else { return }
|
||||||
@@ -418,7 +415,7 @@ struct ContentView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !handle_post_notification(keypair: keypair, postbox: state.postbox, events: state.events, post: post) {
|
if !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) {
|
||||||
self.active_sheet = nil
|
self.active_sheet = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,7 +459,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.disconnect_relays)) { () in
|
.onReceive(handle_notify(.disconnect_relays)) { () in
|
||||||
damus_state.pool.disconnect()
|
damus_state.nostrNetwork.pool.disconnect()
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
|
||||||
print("txn: 📙 DAMUS ACTIVE NOTIFY")
|
print("txn: 📙 DAMUS ACTIVE NOTIFY")
|
||||||
@@ -508,7 +505,7 @@ struct ContentView: View {
|
|||||||
break
|
break
|
||||||
case .active:
|
case .active:
|
||||||
print("txn: 📙 DAMUS ACTIVE")
|
print("txn: 📙 DAMUS ACTIVE")
|
||||||
damus_state.pool.ping()
|
damus_state.nostrNetwork.pool.ping()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -527,7 +524,7 @@ struct ContentView: View {
|
|||||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
|
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
|
||||||
|
|
||||||
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||||
ds.postbox.send(profile_ev)
|
ds.nostrNetwork.postbox.send(profile_ev)
|
||||||
}
|
}
|
||||||
.alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
|
.alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
|
||||||
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
|
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
|
||||||
@@ -559,7 +556,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ds.mutelist_manager.set_mutelist(mutelist)
|
ds.mutelist_manager.set_mutelist(mutelist)
|
||||||
ds.postbox.send(mutelist)
|
ds.nostrNetwork.postbox.send(mutelist)
|
||||||
|
|
||||||
confirm_overwrite_mutelist = false
|
confirm_overwrite_mutelist = false
|
||||||
confirm_mute = false
|
confirm_mute = false
|
||||||
@@ -591,7 +588,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ds.mutelist_manager.set_mutelist(ev)
|
ds.mutelist_manager.set_mutelist(ev)
|
||||||
ds.postbox.send(ev)
|
ds.nostrNetwork.postbox.send(ev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, message: {
|
}, message: {
|
||||||
@@ -632,7 +629,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
func handleNotification(notification: LossyLocalNotification) {
|
func handleNotification(notification: LossyLocalNotification) {
|
||||||
Log.info("ContentView is handling a notification", for: .push_notifications)
|
Log.info("ContentView is handling a notification", for: .push_notifications)
|
||||||
guard let damus_state else {
|
guard damus_state != nil else {
|
||||||
// This should never happen because `listenAndHandleLocalNotifications` is called after damus state is initialized in `onAppear`
|
// This should never happen because `listenAndHandleLocalNotifications` is called after damus state is initialized in `onAppear`
|
||||||
assertionFailure("DamusState not loaded when ContentView (new handler) was handling a notification")
|
assertionFailure("DamusState not loaded when ContentView (new handler) was handling a notification")
|
||||||
Log.error("DamusState not loaded when ContentView (new handler) was handling a notification", for: .push_notifications)
|
Log.error("DamusState not loaded when ContentView (new handler) was handling a notification", for: .push_notifications)
|
||||||
@@ -660,28 +657,14 @@ struct ContentView: View {
|
|||||||
|
|
||||||
guard let ndb = mndb else { return }
|
guard let ndb = mndb else { return }
|
||||||
|
|
||||||
let pool = RelayPool(ndb: ndb, keypair: keypair)
|
|
||||||
let model_cache = RelayModelCache()
|
let model_cache = RelayModelCache()
|
||||||
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
||||||
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
|
|
||||||
|
|
||||||
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
||||||
|
|
||||||
let new_relay_filters = load_relay_filters(pubkey) == nil
|
let new_relay_filters = load_relay_filters(pubkey) == nil
|
||||||
for relay in bootstrap_relays {
|
|
||||||
let descriptor = RelayDescriptor(url: relay, info: .rw)
|
|
||||||
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
self.damus_state = DamusState(keypair: keypair,
|
||||||
|
|
||||||
if let nwc_str = settings.nostr_wallet_connect,
|
|
||||||
let nwc = WalletConnectURL(str: nwc_str) {
|
|
||||||
try? pool.add_relay(.nwc(url: nwc.relay))
|
|
||||||
}
|
|
||||||
|
|
||||||
self.damus_state = DamusState(pool: pool,
|
|
||||||
keypair: keypair,
|
|
||||||
likes: EventCounter(our_pubkey: pubkey),
|
likes: EventCounter(our_pubkey: pubkey),
|
||||||
boosts: EventCounter(our_pubkey: pubkey),
|
boosts: EventCounter(our_pubkey: pubkey),
|
||||||
contacts: Contacts(our_pubkey: pubkey),
|
contacts: Contacts(our_pubkey: pubkey),
|
||||||
@@ -697,8 +680,6 @@ struct ContentView: View {
|
|||||||
drafts: Drafts(),
|
drafts: Drafts(),
|
||||||
events: EventCache(ndb: ndb),
|
events: EventCache(ndb: ndb),
|
||||||
bookmarks: BookmarksManager(pubkey: pubkey),
|
bookmarks: BookmarksManager(pubkey: pubkey),
|
||||||
postbox: PostBox(pool: pool),
|
|
||||||
bootstrap_relays: bootstrap_relays,
|
|
||||||
replies: ReplyCounter(our_pubkey: pubkey),
|
replies: ReplyCounter(our_pubkey: pubkey),
|
||||||
wallet: WalletModel(settings: settings),
|
wallet: WalletModel(settings: settings),
|
||||||
nav: self.navigationCoordinator,
|
nav: self.navigationCoordinator,
|
||||||
@@ -706,7 +687,8 @@ struct ContentView: View {
|
|||||||
video: DamusVideoCoordinator(),
|
video: DamusVideoCoordinator(),
|
||||||
ndb: ndb,
|
ndb: ndb,
|
||||||
quote_reposts: .init(our_pubkey: pubkey),
|
quote_reposts: .init(our_pubkey: pubkey),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||||
|
favicon_cache: FaviconCache()
|
||||||
)
|
)
|
||||||
|
|
||||||
home.damus_state = self.damus_state!
|
home.damus_state = self.damus_state!
|
||||||
@@ -722,7 +704,23 @@ struct ContentView: View {
|
|||||||
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
||||||
}
|
}
|
||||||
|
|
||||||
pool.connect()
|
damus_state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||||
|
damus_state.nostrNetwork.connect()
|
||||||
|
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
if damus_state.settings.developer_mode && damus_state.settings.reset_tips_on_launch {
|
||||||
|
do {
|
||||||
|
try Tips.resetDatastore()
|
||||||
|
} catch {
|
||||||
|
Log.error("Failed to reset tips datastore: %s", for: .tips, error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try Tips.configure()
|
||||||
|
} catch {
|
||||||
|
Log.error("Failed to configure tips: %s", for: .tips, error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func music_changed(_ state: MusicState) {
|
func music_changed(_ state: MusicState) {
|
||||||
@@ -745,7 +743,7 @@ struct ContentView: View {
|
|||||||
pdata.status.music = music
|
pdata.status.music = music
|
||||||
|
|
||||||
guard let ev = music.to_note(keypair: kp) else { return }
|
guard let ev = music.to_note(keypair: kp) else { return }
|
||||||
damus_state.postbox.send(ev)
|
damus_state.nostrNetwork.postbox.send(ev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,6 +759,8 @@ struct ContentView: View {
|
|||||||
case route(Route)
|
case route(Route)
|
||||||
/// Open a sheet
|
/// Open a sheet
|
||||||
case sheet(Sheets)
|
case sheet(Sheets)
|
||||||
|
/// Open an external URL
|
||||||
|
case external_url(URL)
|
||||||
/// Do nothing.
|
/// Do nothing.
|
||||||
///
|
///
|
||||||
/// ## Implementation notes
|
/// ## Implementation notes
|
||||||
@@ -777,6 +777,8 @@ struct ContentView: View {
|
|||||||
navigationCoordinator.push(route: route)
|
navigationCoordinator.push(route: route)
|
||||||
case .sheet(let sheet):
|
case .sheet(let sheet):
|
||||||
self.active_sheet = sheet
|
self.active_sheet = sheet
|
||||||
|
case .external_url(let url):
|
||||||
|
this_app.open(url)
|
||||||
case .no_action:
|
case .no_action:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -994,7 +996,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
|||||||
var has_event = false
|
var has_event = false
|
||||||
guard let filter else { return }
|
guard let filter else { return }
|
||||||
|
|
||||||
state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
|
state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
|
||||||
guard case .nostr_event(let ev) = res else {
|
guard case .nostr_event(let ev) = res else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1008,7 +1010,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
|||||||
break
|
break
|
||||||
case .event(_, let ev):
|
case .event(_, let ev):
|
||||||
has_event = true
|
has_event = true
|
||||||
state.pool.unsubscribe(sub_id: subid)
|
state.nostrNetwork.pool.unsubscribe(sub_id: subid)
|
||||||
|
|
||||||
switch query {
|
switch query {
|
||||||
case .profile:
|
case .profile:
|
||||||
@@ -1021,11 +1023,11 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
|||||||
case .eose:
|
case .eose:
|
||||||
if !has_event {
|
if !has_event {
|
||||||
attempts += 1
|
attempts += 1
|
||||||
if attempts >= state.pool.our_descriptors.count {
|
if attempts >= state.nostrNetwork.pool.our_descriptors.count {
|
||||||
callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil
|
callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
|
state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
|
||||||
case .notice:
|
case .notice:
|
||||||
break
|
break
|
||||||
case .auth:
|
case .auth:
|
||||||
@@ -1044,15 +1046,15 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
|||||||
/// - naddr: the `naddr` address
|
/// - naddr: the `naddr` address
|
||||||
/// - callback: A function to handle the found event
|
/// - callback: A function to handle the found event
|
||||||
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
|
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
|
||||||
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
||||||
|
|
||||||
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
|
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
|
||||||
|
|
||||||
let subid = UUID().description
|
let subid = UUID().description
|
||||||
|
|
||||||
damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
|
damus_state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
|
||||||
guard case .nostr_event(let ev) = res else {
|
guard case .nostr_event(let ev) = res else {
|
||||||
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,14 +1062,14 @@ func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (Nos
|
|||||||
for tag in ev.tags {
|
for tag in ev.tags {
|
||||||
if(tag.count >= 2 && tag[0].string() == "d"){
|
if(tag.count >= 2 && tag[0].string() == "d"){
|
||||||
if (tag[1].string() == naddr.identifier){
|
if (tag[1].string() == naddr.identifier){
|
||||||
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||||
callback(ev)
|
callback(ev)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,7 +1117,7 @@ func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
|
|||||||
|
|
||||||
let old_contacts = state.contacts.event
|
let old_contacts = state.contacts.event
|
||||||
|
|
||||||
guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
|
guard let ev = unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
|
||||||
else {
|
else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1141,7 +1143,7 @@ func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
|
guard let ev = follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
|
||||||
else {
|
else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1216,7 +1218,7 @@ extension LossyLocalNotification {
|
|||||||
case .nprofile(let nProfile):
|
case .nprofile(let nProfile):
|
||||||
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
|
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
|
||||||
return .route(.ProfileByKey(pubkey: nProfile.author))
|
return .route(.ProfileByKey(pubkey: nProfile.author))
|
||||||
case .nrelay(let string):
|
case .nrelay:
|
||||||
// We do not need to implement `nrelay` support, it has been deprecated.
|
// We do not need to implement `nrelay` support, it has been deprecated.
|
||||||
// See https://github.com/nostr-protocol/nips/blob/6e7a618e7f873bb91e743caacc3b09edab7796a0/BREAKING.md?plain=1#L21
|
// See https://github.com/nostr-protocol/nips/blob/6e7a618e7f873bb91e743caacc3b09edab7796a0/BREAKING.md?plain=1#L21
|
||||||
return .sheet(.error(ErrorView.UserPresentableError(
|
return .sheet(.error(ErrorView.UserPresentableError(
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
//
|
|
||||||
// CameraService+Extensions.swift
|
|
||||||
// damus
|
|
||||||
//
|
|
||||||
// Created by Suhail Saqan on 8/5/23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import UIKit
|
|
||||||
import AVFoundation
|
|
||||||
|
|
||||||
extension AVCaptureVideoOrientation {
|
|
||||||
init?(deviceOrientation: UIDeviceOrientation) {
|
|
||||||
switch deviceOrientation {
|
|
||||||
case .portrait: self = .portrait
|
|
||||||
case .portraitUpsideDown: self = .portraitUpsideDown
|
|
||||||
case .landscapeLeft: self = .landscapeRight
|
|
||||||
case .landscapeRight: self = .landscapeLeft
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(interfaceOrientation: UIInterfaceOrientation) {
|
|
||||||
switch interfaceOrientation {
|
|
||||||
case .portrait: self = .portrait
|
|
||||||
case .portraitUpsideDown: self = .portraitUpsideDown
|
|
||||||
case .landscapeLeft: self = .landscapeLeft
|
|
||||||
case .landscapeRight: self = .landscapeRight
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -63,44 +63,10 @@ func follow_user_event(our_contacts: NostrEvent?, keypair: FullKeypair, follow:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func decode_json_relays(_ content: String) -> [RelayURL: RelayInfo]? {
|
func decode_json_relays(_ content: String) -> [RelayURL: LegacyKind3RelayRWConfiguration]? {
|
||||||
return decode_json(content)
|
return decode_json(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove_relay(ev: NostrEvent, current_relays: [RelayDescriptor], keypair: FullKeypair, relay: RelayURL) -> NostrEvent?{
|
|
||||||
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
|
|
||||||
|
|
||||||
relays.removeValue(forKey: relay)
|
|
||||||
|
|
||||||
guard let content = encode_json(relays) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
|
|
||||||
}
|
|
||||||
|
|
||||||
func add_relay(ev: NostrEvent, keypair: FullKeypair, current_relays: [RelayDescriptor], relay: RelayURL, info: RelayInfo) -> NostrEvent? {
|
|
||||||
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
|
|
||||||
|
|
||||||
// If kind:3 content is empty, or if the relay doesn't exist in the list,
|
|
||||||
// we want to create a kind:3 event with the new relay
|
|
||||||
guard ev.content.isEmpty || relays.index(forKey: relay) == nil else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
relays[relay] = info
|
|
||||||
|
|
||||||
guard let content = encode_json(relays) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensure_relay_info(relays: [RelayDescriptor], content: String) -> [RelayURL: RelayInfo] {
|
|
||||||
return decode_json_relays(content) ?? make_contact_relays(relays)
|
|
||||||
}
|
|
||||||
|
|
||||||
func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
|
func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
|
||||||
return contacts.references.contains { ref in
|
return contacts.references.contains { ref in
|
||||||
switch (ref, follow) {
|
switch (ref, follow) {
|
||||||
@@ -128,22 +94,3 @@ func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEven
|
|||||||
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: tags)
|
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
func make_contact_relays(_ relays: [RelayDescriptor]) -> [RelayURL: RelayInfo] {
|
|
||||||
return relays.reduce(into: [:]) { acc, relay in
|
|
||||||
acc[relay.url] = relay.info
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func make_relay_metadata(relays: [RelayDescriptor], keypair: FullKeypair) -> NostrEvent? {
|
|
||||||
let tags = relays.compactMap { r -> [String]? in
|
|
||||||
var tag = ["r", r.url.absoluteString]
|
|
||||||
if (r.info.read ?? true) != (r.info.write ?? true) {
|
|
||||||
tag += r.info.read == true ? ["read"] : ["write"]
|
|
||||||
}
|
|
||||||
if ((r.info.read ?? true) || (r.info.write ?? true)) && r.variant == .regular {
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 10_002, tags: tags)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class Contacts {
|
|||||||
return friends
|
return friends
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func get_friend_of_friends_list() -> Set<Pubkey> {
|
||||||
|
return friend_of_friends
|
||||||
|
}
|
||||||
|
|
||||||
func get_followed_hashtags() -> Set<String> {
|
func get_followed_hashtags() -> Set<String> {
|
||||||
guard let ev = self.event else { return Set() }
|
guard let ev = self.event else { return Set() }
|
||||||
return Set(ev.referenced_hashtags.map({ $0.hashtag }))
|
return Set(ev.referenced_hashtags.map({ $0.hashtag }))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ enum FilterState : Int {
|
|||||||
case posts = 0
|
case posts = 0
|
||||||
case posts_and_replies = 1
|
case posts_and_replies = 1
|
||||||
case conversations = 2
|
case conversations = 2
|
||||||
|
case follow_list = 3
|
||||||
|
|
||||||
func filter(ev: NostrEvent) -> Bool {
|
func filter(ev: NostrEvent) -> Bool {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -22,6 +23,8 @@ enum FilterState : Int {
|
|||||||
return true
|
return true
|
||||||
case .conversations:
|
case .conversations:
|
||||||
return true
|
return true
|
||||||
|
case .follow_list:
|
||||||
|
return ev.known_kind == .follow_list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,6 +43,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 +75,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class CreateAccountModel: ObservableObject {
|
|||||||
return Keypair(pubkey: self.pubkey, privkey: self.privkey)
|
return Keypair(pubkey: self.pubkey, privkey: self.privkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var full_keypair: FullKeypair {
|
||||||
|
return FullKeypair(pubkey: self.pubkey, privkey: self.privkey)
|
||||||
|
}
|
||||||
|
|
||||||
init(display_name: String = "", name: String = "", about: String = "") {
|
init(display_name: String = "", name: String = "", about: String = "") {
|
||||||
let keypair = generate_new_keypair()
|
let keypair = generate_new_keypair()
|
||||||
self.pubkey = keypair.pubkey
|
self.pubkey = keypair.pubkey
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import LinkPresentation
|
|||||||
import EmojiPicker
|
import EmojiPicker
|
||||||
|
|
||||||
class DamusState: HeadlessDamusState {
|
class DamusState: HeadlessDamusState {
|
||||||
let pool: RelayPool
|
|
||||||
let keypair: Keypair
|
let keypair: Keypair
|
||||||
let likes: EventCounter
|
let likes: EventCounter
|
||||||
let boosts: EventCounter
|
let boosts: EventCounter
|
||||||
@@ -28,8 +27,6 @@ class DamusState: HeadlessDamusState {
|
|||||||
let drafts: Drafts
|
let drafts: Drafts
|
||||||
let events: EventCache
|
let events: EventCache
|
||||||
let bookmarks: BookmarksManager
|
let bookmarks: BookmarksManager
|
||||||
let postbox: PostBox
|
|
||||||
let bootstrap_relays: [RelayURL]
|
|
||||||
let replies: ReplyCounter
|
let replies: ReplyCounter
|
||||||
let wallet: WalletModel
|
let wallet: WalletModel
|
||||||
let nav: NavigationCoordinator
|
let nav: NavigationCoordinator
|
||||||
@@ -39,9 +36,10 @@ class DamusState: HeadlessDamusState {
|
|||||||
var purple: DamusPurple
|
var purple: DamusPurple
|
||||||
var push_notification_client: PushNotificationClient
|
var push_notification_client: PushNotificationClient
|
||||||
let emoji_provider: EmojiProvider
|
let emoji_provider: EmojiProvider
|
||||||
|
let favicon_cache: FaviconCache
|
||||||
|
private(set) var nostrNetwork: NostrNetworkManager
|
||||||
|
|
||||||
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
|
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache) {
|
||||||
self.pool = pool
|
|
||||||
self.keypair = keypair
|
self.keypair = keypair
|
||||||
self.likes = likes
|
self.likes = likes
|
||||||
self.boosts = boosts
|
self.boosts = boosts
|
||||||
@@ -58,8 +56,6 @@ class DamusState: HeadlessDamusState {
|
|||||||
self.drafts = drafts
|
self.drafts = drafts
|
||||||
self.events = events
|
self.events = events
|
||||||
self.bookmarks = bookmarks
|
self.bookmarks = bookmarks
|
||||||
self.postbox = postbox
|
|
||||||
self.bootstrap_relays = bootstrap_relays
|
|
||||||
self.replies = replies
|
self.replies = replies
|
||||||
self.wallet = wallet
|
self.wallet = wallet
|
||||||
self.nav = nav
|
self.nav = nav
|
||||||
@@ -73,6 +69,10 @@ class DamusState: HeadlessDamusState {
|
|||||||
self.quote_reposts = quote_reposts
|
self.quote_reposts = quote_reposts
|
||||||
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
||||||
self.emoji_provider = emoji_provider
|
self.emoji_provider = emoji_provider
|
||||||
|
self.favicon_cache = FaviconCache()
|
||||||
|
|
||||||
|
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
|
||||||
|
self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -98,27 +98,13 @@ class DamusState: HeadlessDamusState {
|
|||||||
guard let ndb = mndb else { return nil }
|
guard let ndb = mndb else { return nil }
|
||||||
let pubkey = keypair.pubkey
|
let pubkey = keypair.pubkey
|
||||||
|
|
||||||
let pool = RelayPool(ndb: ndb, keypair: keypair)
|
|
||||||
let model_cache = RelayModelCache()
|
let model_cache = RelayModelCache()
|
||||||
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
||||||
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
|
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
|
||||||
|
|
||||||
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
||||||
|
|
||||||
let new_relay_filters = load_relay_filters(pubkey) == nil
|
|
||||||
for relay in bootstrap_relays {
|
|
||||||
let descriptor = RelayDescriptor(url: relay, info: .rw)
|
|
||||||
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
|
||||||
|
|
||||||
if let nwc_str = settings.nostr_wallet_connect,
|
|
||||||
let nwc = WalletConnectURL(str: nwc_str) {
|
|
||||||
try? pool.add_relay(.nwc(url: nwc.relay))
|
|
||||||
}
|
|
||||||
self.init(
|
self.init(
|
||||||
pool: pool,
|
|
||||||
keypair: keypair,
|
keypair: keypair,
|
||||||
likes: EventCounter(our_pubkey: pubkey),
|
likes: EventCounter(our_pubkey: pubkey),
|
||||||
boosts: EventCounter(our_pubkey: pubkey),
|
boosts: EventCounter(our_pubkey: pubkey),
|
||||||
@@ -135,8 +121,6 @@ class DamusState: HeadlessDamusState {
|
|||||||
drafts: Drafts(),
|
drafts: Drafts(),
|
||||||
events: EventCache(ndb: ndb),
|
events: EventCache(ndb: ndb),
|
||||||
bookmarks: BookmarksManager(pubkey: pubkey),
|
bookmarks: BookmarksManager(pubkey: pubkey),
|
||||||
postbox: PostBox(pool: pool),
|
|
||||||
bootstrap_relays: bootstrap_relays,
|
|
||||||
replies: ReplyCounter(our_pubkey: pubkey),
|
replies: ReplyCounter(our_pubkey: pubkey),
|
||||||
wallet: WalletModel(settings: settings),
|
wallet: WalletModel(settings: settings),
|
||||||
nav: navigationCoordinator,
|
nav: navigationCoordinator,
|
||||||
@@ -144,7 +128,8 @@ class DamusState: HeadlessDamusState {
|
|||||||
video: DamusVideoCoordinator(),
|
video: DamusVideoCoordinator(),
|
||||||
ndb: ndb,
|
ndb: ndb,
|
||||||
quote_reposts: .init(our_pubkey: pubkey),
|
quote_reposts: .init(our_pubkey: pubkey),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||||
|
favicon_cache: FaviconCache()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +164,7 @@ class DamusState: HeadlessDamusState {
|
|||||||
try await self.push_notification_client.revoke_token()
|
try await self.push_notification_client.revoke_token()
|
||||||
}
|
}
|
||||||
wallet.disconnect()
|
wallet.disconnect()
|
||||||
pool.close()
|
nostrNetwork.pool.close()
|
||||||
ndb.close()
|
ndb.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +174,6 @@ class DamusState: HeadlessDamusState {
|
|||||||
let kp = Keypair(pubkey: empty_pub, privkey: nil)
|
let kp = Keypair(pubkey: empty_pub, privkey: nil)
|
||||||
|
|
||||||
return DamusState.init(
|
return DamusState.init(
|
||||||
pool: RelayPool(ndb: .empty),
|
|
||||||
keypair: Keypair(pubkey: empty_pub, privkey: empty_sec),
|
keypair: Keypair(pubkey: empty_pub, privkey: empty_sec),
|
||||||
likes: EventCounter(our_pubkey: empty_pub),
|
likes: EventCounter(our_pubkey: empty_pub),
|
||||||
boosts: EventCounter(our_pubkey: empty_pub),
|
boosts: EventCounter(our_pubkey: empty_pub),
|
||||||
@@ -206,8 +190,6 @@ class DamusState: HeadlessDamusState {
|
|||||||
drafts: Drafts(),
|
drafts: Drafts(),
|
||||||
events: EventCache(ndb: .empty),
|
events: EventCache(ndb: .empty),
|
||||||
bookmarks: BookmarksManager(pubkey: empty_pub),
|
bookmarks: BookmarksManager(pubkey: empty_pub),
|
||||||
postbox: PostBox(pool: RelayPool(ndb: .empty)),
|
|
||||||
bootstrap_relays: [],
|
|
||||||
replies: ReplyCounter(our_pubkey: empty_pub),
|
replies: ReplyCounter(our_pubkey: empty_pub),
|
||||||
wallet: WalletModel(settings: UserSettingsStore()),
|
wallet: WalletModel(settings: UserSettingsStore()),
|
||||||
nav: NavigationCoordinator(),
|
nav: NavigationCoordinator(),
|
||||||
@@ -215,7 +197,34 @@ class DamusState: HeadlessDamusState {
|
|||||||
video: DamusVideoCoordinator(),
|
video: DamusVideoCoordinator(),
|
||||||
ndb: .empty,
|
ndb: .empty,
|
||||||
quote_reposts: .init(our_pubkey: empty_pub),
|
quote_reposts: .init(our_pubkey: empty_pub),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||||
|
favicon_cache: FaviconCache()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate extension DamusState {
|
||||||
|
struct NostrNetworkManagerDelegate: NostrNetworkManager.Delegate {
|
||||||
|
let settings: UserSettingsStore
|
||||||
|
let contacts: Contacts
|
||||||
|
|
||||||
|
var ndb: Ndb
|
||||||
|
var keypair: Keypair
|
||||||
|
|
||||||
|
var latestRelayListEventIdHex: String? {
|
||||||
|
get { self.settings.latestRelayListEventIdHex }
|
||||||
|
set { self.settings.latestRelayListEventIdHex = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestContactListEvent: NostrEvent? { self.contacts.event }
|
||||||
|
var bootstrapRelays: [RelayURL] { get_default_bootstrap_relays() }
|
||||||
|
var developerMode: Bool { self.settings.developer_mode }
|
||||||
|
var relayModelCache: RelayModelCache
|
||||||
|
var relayFilters: RelayFilters
|
||||||
|
|
||||||
|
var nwcWallet: WalletConnectURL? {
|
||||||
|
guard let nwcString = self.settings.nostr_wallet_connect else { return nil }
|
||||||
|
return WalletConnectURL(str: nwcString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ class Drafts: ObservableObject {
|
|||||||
// TODO: Once it is time to implement draft syncing with relays, please consider the following:
|
// TODO: Once it is time to implement draft syncing with relays, please consider the following:
|
||||||
// - Privacy: Sending drafts to the network leaks metadata about app activity, and may break user expectations
|
// - Privacy: Sending drafts to the network leaks metadata about app activity, and may break user expectations
|
||||||
// - Down-sync conflict resolution: Consider how to solve conflicts for different draft versions holding the same ID (e.g. edited in Damus, then another client, then Damus again)
|
// - Down-sync conflict resolution: Consider how to solve conflicts for different draft versions holding the same ID (e.g. edited in Damus, then another client, then Damus again)
|
||||||
damus_state.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
|
damus_state.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
|
||||||
}
|
}
|
||||||
|
|
||||||
damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() })
|
damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() })
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ class EventsModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func subscribe() {
|
func subscribe() {
|
||||||
state.pool.subscribe(sub_id: sub_id,
|
state.nostrNetwork.pool.subscribe(sub_id: sub_id,
|
||||||
filters: [get_filter()],
|
filters: [get_filter()],
|
||||||
handler: handle_nostr_event)
|
handler: handle_nostr_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe() {
|
func unsubscribe() {
|
||||||
state.pool.unsubscribe(sub_id: sub_id)
|
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
|
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// FollowPackEvent.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/30/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FollowPackEvent {
|
||||||
|
let event: NostrEvent
|
||||||
|
var title: String? = nil
|
||||||
|
var uuid: String? = nil
|
||||||
|
var image: URL? = nil
|
||||||
|
var description: String? = nil
|
||||||
|
var publicKeys: [Pubkey] = []
|
||||||
|
|
||||||
|
|
||||||
|
static func parse(from ev: NostrEvent) -> FollowPackEvent {
|
||||||
|
var followlist = FollowPackEvent(event: ev)
|
||||||
|
|
||||||
|
for tag in ev.tags {
|
||||||
|
guard tag.count >= 2 else { continue }
|
||||||
|
switch tag[0].string() {
|
||||||
|
case "title": followlist.title = tag[1].string()
|
||||||
|
case "d": followlist.uuid = tag[1].string()
|
||||||
|
case "image": followlist.image = URL(string: tag[1].string())
|
||||||
|
case "description": followlist.description = tag[1].string()
|
||||||
|
case "p":
|
||||||
|
followlist.publicKeys.append(Pubkey(Data(hex: tag[1].string())))
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return followlist
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// FollowPackModel.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 6/5/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
|
||||||
|
class FollowPackModel: ObservableObject {
|
||||||
|
var events: EventHolder
|
||||||
|
@Published var loading: Bool = false
|
||||||
|
|
||||||
|
let damus_state: DamusState
|
||||||
|
let subid = UUID().description
|
||||||
|
let limit: UInt32 = 500
|
||||||
|
|
||||||
|
init(damus_state: DamusState) {
|
||||||
|
self.damus_state = damus_state
|
||||||
|
self.events = EventHolder(on_queue: { ev in
|
||||||
|
preload_events(state: damus_state, events: [ev])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribe(follow_pack_users: [Pubkey]) {
|
||||||
|
loading = true
|
||||||
|
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||||
|
var filter = NostrFilter(kinds: [.text, .chat])
|
||||||
|
filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||||
|
filter.authors = follow_pack_users
|
||||||
|
filter.limit = 500
|
||||||
|
|
||||||
|
damus_state.nostrNetwork.pool.subscribe(sub_id: subid, filters: [filter], handler: handle_event, to: to_relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsubscribe(to: RelayURL? = nil) {
|
||||||
|
loading = false
|
||||||
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: to.map { [$0] })
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||||
|
guard case .nostr_event(let event) = conn_ev else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .event(let sub_id, let ev):
|
||||||
|
guard sub_id == self.subid else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||||
|
{
|
||||||
|
if self.events.insert(ev) {
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .notice(let msg):
|
||||||
|
print("follow pack notice: \(msg)")
|
||||||
|
case .ok:
|
||||||
|
break
|
||||||
|
case .eose(let sub_id):
|
||||||
|
loading = false
|
||||||
|
|
||||||
|
if sub_id == self.subid {
|
||||||
|
unsubscribe(to: relay_id)
|
||||||
|
|
||||||
|
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case .auth:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -37,11 +37,11 @@ class FollowersModel: ObservableObject {
|
|||||||
let filter = get_filter()
|
let filter = get_filter()
|
||||||
let filters = [filter]
|
let filters = [filter]
|
||||||
//print_filters(relay_id: "following", filters: [filters])
|
//print_filters(relay_id: "following", filters: [filters])
|
||||||
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe() {
|
func unsubscribe() {
|
||||||
self.damus_state.pool.unsubscribe(sub_id: sub_id)
|
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_contact_event(_ ev: NostrEvent) {
|
func handle_contact_event(_ ev: NostrEvent) {
|
||||||
@@ -61,7 +61,7 @@ class FollowersModel: ObservableObject {
|
|||||||
|
|
||||||
let filter = NostrFilter(kinds: [.metadata],
|
let filter = NostrFilter(kinds: [.metadata],
|
||||||
authors: authors)
|
authors: authors)
|
||||||
damus_state.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
|
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||||
@@ -86,7 +86,7 @@ class FollowersModel: ObservableObject {
|
|||||||
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
|
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
|
||||||
load_profiles(relay_id: relay_id, txn: txn)
|
load_profiles(relay_id: relay_id, txn: txn)
|
||||||
} else if sub_id == self.profiles_id {
|
} else if sub_id == self.profiles_id {
|
||||||
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
||||||
}
|
}
|
||||||
|
|
||||||
case .ok:
|
case .ok:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class FollowingModel {
|
|||||||
}
|
}
|
||||||
let filters = [filter]
|
let filters = [filter]
|
||||||
//print_filters(relay_id: "following", filters: [filters])
|
//print_filters(relay_id: "following", filters: [filters])
|
||||||
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe() {
|
func unsubscribe() {
|
||||||
@@ -50,7 +50,7 @@ class FollowingModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
print("unsubscribing from following \(sub_id)")
|
print("unsubscribing from following \(sub_id)")
|
||||||
self.damus_state.pool.unsubscribe(sub_id: sub_id)
|
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ enum FriendFilter: String, StringCodable {
|
|||||||
func description() -> String {
|
func description() -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .all:
|
case .all:
|
||||||
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
|
return NSLocalizedString("All", comment: "Human-readable short description of the 'trusted network filter' when it is disabled, and therefore is showing all content.")
|
||||||
case .friends_of_friends:
|
case .friends_of_friends:
|
||||||
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
|
return NSLocalizedString("Trusted Network", comment: "Human-readable short description of the 'trusted network filter' when it is enabled, and therefore showing content from only the trusted network.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,13 +95,13 @@ class HomeModel: ContactsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pool: RelayPool {
|
var pool: RelayPool {
|
||||||
return damus_state.pool
|
self.damus_state.nostrNetwork.pool
|
||||||
}
|
}
|
||||||
|
|
||||||
var dms: DirectMessagesModel {
|
var dms: DirectMessagesModel {
|
||||||
return damus_state.dms
|
return damus_state.dms
|
||||||
}
|
}
|
||||||
|
|
||||||
func has_sub_id_event(sub_id: String, ev_id: NoteId) -> Bool {
|
func has_sub_id_event(sub_id: String, ev_id: NoteId) -> Bool {
|
||||||
if !has_event.keys.contains(sub_id) {
|
if !has_event.keys.contains(sub_id) {
|
||||||
has_event[sub_id] = Set()
|
has_event[sub_id] = Set()
|
||||||
@@ -225,6 +225,10 @@ class HomeModel: ContactsDelegate {
|
|||||||
// TODO: Implement draft syncing with relays. We intentionally do not support that as of writing. See `DraftsModel.swift` for other details
|
// TODO: Implement draft syncing with relays. We intentionally do not support that as of writing. See `DraftsModel.swift` for other details
|
||||||
// try? damus_state.drafts.load(wrapped_draft_note: ev, with: damus_state)
|
// try? damus_state.drafts.load(wrapped_draft_note: ev, with: damus_state)
|
||||||
break
|
break
|
||||||
|
case .relay_list:
|
||||||
|
break // This will be handled by `UserRelayListManager`
|
||||||
|
case .follow_list:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,34 +263,41 @@ class HomeModel: ContactsDelegate {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
|
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
|
||||||
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
|
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
|
||||||
let nwc = WalletConnectURL(str: nwc_str),
|
let nwc = WalletConnectURL(str: nwc_str) else {
|
||||||
let resp = await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard nwc.relay == relay else { return } // Don't process NWC responses coming from relays other than our designated one
|
||||||
|
guard ev.referenced_pubkeys.first == nwc.keypair.pubkey else {
|
||||||
|
return // This message is not for us. Ignore it.
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp: WalletConnect.FullWalletResponse? = nil
|
||||||
|
do {
|
||||||
|
resp = try await WalletConnect.FullWalletResponse(from: ev, nwc: nwc)
|
||||||
|
} catch {
|
||||||
|
Log.error("HomeModel: Error on NWC wallet response handling: %s", for: .nwc, error.localizedDescription)
|
||||||
|
if let initError = error as? WalletConnect.FullWalletResponse.InitializationError,
|
||||||
|
let humanReadableError = initError.humanReadableError {
|
||||||
|
present_sheet(.error(humanReadableError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let resp else { return }
|
||||||
|
|
||||||
// since command results are not returned for ephemeral events,
|
// since command results are not returned for ephemeral events,
|
||||||
// remove the request from the postbox which is likely failing over and over
|
// remove the request from the postbox which is likely failing over and over
|
||||||
if damus_state.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
|
if damus_state.nostrNetwork.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
|
||||||
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
|
Log.debug("HomeModel: got NWC response, removed %s from the postbox [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
|
||||||
} else {
|
} else {
|
||||||
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
|
Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
damus_state.wallet.handle_nwc_response(response: resp) // This can handle success or error cases
|
||||||
|
|
||||||
guard resp.response.error == nil else {
|
guard resp.response.error == nil else {
|
||||||
print("nwc error: \(resp.response)")
|
Log.error("HomeModel: NWC wallet raised an error: %s", for: .nwc, String(describing: resp.response))
|
||||||
WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
|
WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.response.result_type == .list_transactions {
|
|
||||||
Log.info("Received NWC transaction list from %s", for: .nwc, relay.absoluteString)
|
|
||||||
damus_state.wallet.handle_nwc_response(response: resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.response.result_type == .get_balance {
|
|
||||||
Log.info("Received NWC balance information from %s", for: .nwc, relay.absoluteString)
|
|
||||||
damus_state.wallet.handle_nwc_response(response: resp)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +489,7 @@ class HomeModel: ContactsDelegate {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
update_signal_from_pool(signal: self.signal, pool: damus_state.pool)
|
update_signal_from_pool(signal: self.signal, pool: damus_state.nostrNetwork.pool)
|
||||||
case .nostr_event(let ev):
|
case .nostr_event(let ev):
|
||||||
switch ev {
|
switch ev {
|
||||||
case .event(let sub_id, let ev):
|
case .event(let sub_id, let ev):
|
||||||
@@ -948,7 +959,6 @@ func load_our_stuff(state: DamusState, ev: NostrEvent) {
|
|||||||
state.contacts.event = ev
|
state.contacts.event = ev
|
||||||
|
|
||||||
load_our_contacts(state: state, m_old_ev: m_old_ev, ev: ev)
|
load_our_contacts(state: state, m_old_ev: m_old_ev, ev: ev)
|
||||||
load_our_relays(state: state, m_old_ev: m_old_ev, ev: ev)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func process_contact_event(state: DamusState, ev: NostrEvent) {
|
func process_contact_event(state: DamusState, ev: NostrEvent) {
|
||||||
@@ -956,78 +966,6 @@ func process_contact_event(state: DamusState, ev: NostrEvent) {
|
|||||||
add_contact_if_friend(contacts: state.contacts, ev: ev)
|
add_contact_if_friend(contacts: state.contacts, ev: ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
|
||||||
let bootstrap_dict: [RelayURL: RelayInfo] = [:]
|
|
||||||
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? state.bootstrap_relays.reduce(into: bootstrap_dict) { (d, r) in
|
|
||||||
d[r] = .rw
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let decoded: [RelayURL: RelayInfo] = decode_json_relays(ev.content) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var changed = false
|
|
||||||
|
|
||||||
var new = Set<RelayURL>()
|
|
||||||
for key in decoded.keys {
|
|
||||||
new.insert(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
var old = Set<RelayURL>()
|
|
||||||
for key in old_decoded.keys {
|
|
||||||
old.insert(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
let diff = old.symmetricDifference(new)
|
|
||||||
|
|
||||||
let new_relay_filters = load_relay_filters(state.pubkey) == nil
|
|
||||||
for d in diff {
|
|
||||||
changed = true
|
|
||||||
if new.contains(d) {
|
|
||||||
let descriptor = RelayDescriptor(url: d, info: decoded[d] ?? .rw)
|
|
||||||
add_new_relay(model_cache: state.relay_model_cache, relay_filters: state.relay_filters, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: state.settings.developer_mode)
|
|
||||||
} else {
|
|
||||||
state.pool.remove_relay(d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if changed {
|
|
||||||
save_bootstrap_relays(pubkey: state.pubkey, relays: Array(new))
|
|
||||||
state.pool.connect()
|
|
||||||
notify(.relays_changed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
|
|
||||||
try? pool.add_relay(descriptor)
|
|
||||||
let url = descriptor.url
|
|
||||||
|
|
||||||
let relay_id = url
|
|
||||||
guard model_cache.model(withURL: url) == nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Task.detached(priority: .background) {
|
|
||||||
guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
|
||||||
let model = RelayModel(url, metadata: meta)
|
|
||||||
model_cache.insert(model: model)
|
|
||||||
|
|
||||||
if logging_enabled {
|
|
||||||
pool.setLog(model.log, for: relay_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if this is the first time adding filters, we should filter non-paid relays
|
|
||||||
if new_relay_filters && !meta.is_paid {
|
|
||||||
relay_filters.insert(timeline: .search, relay_id: relay_id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetch_relay_metadata(relay_id: RelayURL) async throws -> RelayMetadata? {
|
func fetch_relay_metadata(relay_id: RelayURL) async throws -> RelayMetadata? {
|
||||||
var urlString = relay_id.absoluteString.replacingOccurrences(of: "wss://", with: "https://")
|
var urlString = relay_id.absoluteString.replacingOccurrences(of: "wss://", with: "https://")
|
||||||
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
|
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
|
||||||
@@ -1250,3 +1188,4 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ struct LightningInvoice<T> {
|
|||||||
let payment_hash: Data
|
let payment_hash: Data
|
||||||
let created_at: UInt64
|
let created_at: UInt64
|
||||||
|
|
||||||
|
var abbreviated: String {
|
||||||
|
return self.string.prefix(8) + "…" + self.string.suffix(8)
|
||||||
|
}
|
||||||
|
|
||||||
var description_string: String {
|
var description_string: String {
|
||||||
switch description {
|
switch description {
|
||||||
case .description(let string):
|
case .description(let string):
|
||||||
@@ -171,6 +175,17 @@ struct LightningInvoice<T> {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func from(string: String) -> Invoice? {
|
||||||
|
// This feels a bit hacky at first, but it is actually clean
|
||||||
|
// because it reuses the same well-tested parsing logic as the rest of the app,
|
||||||
|
// avoiding code duplication and utilizing the guarantees acquired from age and testing.
|
||||||
|
// We could also use the C function `parse_invoice`, but it requires extra C bridging logic.
|
||||||
|
// NDBTODO: This may need updating on the nostrdb upgrade.
|
||||||
|
let parsedBlocks = parse_note_content(content: .content(string,nil)).blocks
|
||||||
|
guard parsedBlocks.count == 1 else { return nil }
|
||||||
|
return parsedBlocks[0].asInvoice
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
|
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
|
||||||
@@ -192,6 +207,13 @@ enum Amount: Equatable {
|
|||||||
return format_msats(amt)
|
return format_msats(amt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func amount_sats() -> Int64? {
|
||||||
|
switch self {
|
||||||
|
case .any: nil
|
||||||
|
case .specific(let amount): amount / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func format_msats_abbrev(_ msats: Int64) -> String {
|
func format_msats_abbrev(_ msats: Int64) -> String {
|
||||||
|
|||||||
+10
-10
@@ -47,16 +47,16 @@ enum MuteItem: Hashable, Equatable {
|
|||||||
// rhs is the item we want to check against (ie. the item in the mute list)
|
// rhs is the item we want to check against (ie. the item in the mute list)
|
||||||
|
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, let rhs_expiration_date)):
|
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, _)):
|
||||||
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
|
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
|
||||||
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, let rhs_expiration_date)):
|
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, _)):
|
||||||
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
|
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
|
||||||
case (.word(let lhs_word, _), .word(let rhs_word, let rhs_expiration_date)):
|
case (.word(let lhs_word, _), .word(let rhs_word, _)):
|
||||||
return lhs_word == rhs_word && !rhs.is_expired()
|
return lhs_word == rhs_word && !rhs.is_expired()
|
||||||
case (.thread(let lhs_thread, _), .thread(let rhs_thread, let rhs_expiration_date)):
|
case (.thread(let lhs_thread, _), .thread(let rhs_thread, _)):
|
||||||
return lhs_thread == rhs_thread && !rhs.is_expired()
|
return lhs_thread == rhs_thread && !rhs.is_expired()
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func migrate_old_muted_threads_to_new_mutelist(keypair: Keypair, damus_state: Da
|
|||||||
let previous_mute_list_event = damus_state.mutelist_manager.event
|
let previous_mute_list_event = damus_state.mutelist_manager.event
|
||||||
guard let new_mutelist_event = create_or_update_mutelist(keypair: fullKeypair, mprev: previous_mute_list_event, to_add: Set(mutedThreads.map { MuteItem.thread($0, nil) })) else { return }
|
guard let new_mutelist_event = create_or_update_mutelist(keypair: fullKeypair, mprev: previous_mute_list_event, to_add: Set(mutedThreads.map { MuteItem.thread($0, nil) })) else { return }
|
||||||
damus_state.mutelist_manager.set_mutelist(new_mutelist_event)
|
damus_state.mutelist_manager.set_mutelist(new_mutelist_event)
|
||||||
damus_state.postbox.send(new_mutelist_event)
|
damus_state.nostrNetwork.postbox.send(new_mutelist_event)
|
||||||
// Set existing muted threads to an empty array
|
// Set existing muted threads to an empty array
|
||||||
UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey))
|
UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// NIP05DomainEventsModel.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 4/11/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class NIP05DomainEventsModel: ObservableObject {
|
||||||
|
let state: DamusState
|
||||||
|
var events: EventHolder
|
||||||
|
@Published var loading: Bool = false
|
||||||
|
|
||||||
|
let domain: String
|
||||||
|
var filter: NostrFilter
|
||||||
|
let sub_id = UUID().description
|
||||||
|
let profiles_subid = UUID().description
|
||||||
|
let limit: UInt32 = 500
|
||||||
|
|
||||||
|
init(state: DamusState, domain: String) {
|
||||||
|
self.state = state
|
||||||
|
self.domain = domain
|
||||||
|
self.events = EventHolder(on_queue: { ev in
|
||||||
|
preload_events(state: state, events: [ev])
|
||||||
|
})
|
||||||
|
self.filter = NostrFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor func subscribe() {
|
||||||
|
filter.limit = self.limit
|
||||||
|
filter.kinds = [.text, .longform, .highlight]
|
||||||
|
|
||||||
|
var authors = Set<Pubkey>()
|
||||||
|
for pubkey in state.contacts.get_friend_of_friends_list() {
|
||||||
|
let profile_txn = state.profiles.lookup(id: pubkey)
|
||||||
|
|
||||||
|
guard let profile = profile_txn?.unsafeUnownedValue,
|
||||||
|
let nip05_str = profile.nip05,
|
||||||
|
let nip05 = NIP05.parse(nip05_str),
|
||||||
|
nip05.host.caseInsensitiveCompare(domain) == .orderedSame else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
authors.insert(pubkey)
|
||||||
|
}
|
||||||
|
if authors.isEmpty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter.authors = Array(authors)
|
||||||
|
|
||||||
|
print("subscribing to notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
|
||||||
|
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||||
|
loading = true
|
||||||
|
state.nostrNetwork.pool.send(.subscribe(.init(filters: [filter], sub_id: sub_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsubscribe() {
|
||||||
|
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||||
|
loading = false
|
||||||
|
print("unsubscribing from notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func add_event(_ ev: NostrEvent) {
|
||||||
|
if !event_matches_filter(ev, filter: filter) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard should_show_event(state: state, ev: ev) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.events.insert(ev) {
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||||
|
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||||
|
if sub_id == self.sub_id && ev.is_textlike && ev.should_show_event {
|
||||||
|
self.add_event(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard done else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.loading = false
|
||||||
|
|
||||||
|
if sub_id == self.sub_id {
|
||||||
|
guard let txn = NdbTxn(ndb: state.ndb) else { return }
|
||||||
|
load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// NostrNetworkManager.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-02-26.
|
||||||
|
//
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Manages interactions with the Nostr Network.
|
||||||
|
///
|
||||||
|
/// This delineates a layer that is responsible for doing mid-level management of interactions with the Nostr network, controlling lower-level classes that perform more network/DB specific code, and providing an easier to use and more semantic interfaces for the rest of the app.
|
||||||
|
///
|
||||||
|
/// This is responsible for:
|
||||||
|
/// - Managing the user's relay list
|
||||||
|
/// - Establishing a `RelayPool` and maintaining it in sync with the user's relay list as it changes
|
||||||
|
/// - Abstracting away complexities of interacting with the nostr network, providing an easier-to-use interface to fetch and send content related to the Nostr network
|
||||||
|
///
|
||||||
|
/// This is **NOT** responsible for:
|
||||||
|
/// - Doing actual storage of relay list (delegated via the delegate
|
||||||
|
/// - Handling low-level relay logic (this will be delegated to lower level classes used in RelayPool/RelayConnection)
|
||||||
|
class NostrNetworkManager {
|
||||||
|
/// The relay pool that we manage
|
||||||
|
///
|
||||||
|
/// ## Implementation notes
|
||||||
|
///
|
||||||
|
/// - This will be marked `private` in the future to prevent other code from accessing the relay pool directly. Code outside this layer should use a higher level interface
|
||||||
|
let pool: RelayPool // TODO: Make this private and make higher level interface for classes outside the NostrNetworkManager
|
||||||
|
/// A delegate that allows us to interact with the rest of app without introducing hard or circular dependencies
|
||||||
|
private var delegate: Delegate
|
||||||
|
/// Manages the user's relay list, controls RelayPool's connected relays
|
||||||
|
let userRelayList: UserRelayListManager
|
||||||
|
/// Handles sending out notes to the network
|
||||||
|
let postbox: PostBox
|
||||||
|
/// Handles subscriptions and functions to read or consume data from the Nostr network
|
||||||
|
let reader: SubscriptionManager
|
||||||
|
|
||||||
|
init(delegate: Delegate) {
|
||||||
|
self.delegate = delegate
|
||||||
|
let pool = RelayPool(ndb: delegate.ndb, keypair: delegate.keypair)
|
||||||
|
self.pool = pool
|
||||||
|
let reader = SubscriptionManager(pool: pool, ndb: delegate.ndb)
|
||||||
|
let userRelayList = UserRelayListManager(delegate: delegate, pool: pool, reader: reader)
|
||||||
|
self.reader = reader
|
||||||
|
self.userRelayList = userRelayList
|
||||||
|
self.postbox = PostBox(pool: pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Control functions
|
||||||
|
|
||||||
|
/// Connects the app to the Nostr network
|
||||||
|
func connect() {
|
||||||
|
self.userRelayList.connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Helper types
|
||||||
|
|
||||||
|
extension NostrNetworkManager {
|
||||||
|
/// The delegate that provides information and structure for the `NostrNetworkManager` to function.
|
||||||
|
///
|
||||||
|
/// ## Implementation notes
|
||||||
|
///
|
||||||
|
/// This is needed to prevent a circular reference between `DamusState` and `NostrNetworkManager`, and reduce coupling.
|
||||||
|
protocol Delegate: Sendable {
|
||||||
|
/// NostrDB instance, used with `RelayPool` to send events for ingestion.
|
||||||
|
var ndb: Ndb { get }
|
||||||
|
|
||||||
|
/// The keypair to use for relay authentication and updating relay lists
|
||||||
|
var keypair: Keypair { get }
|
||||||
|
|
||||||
|
/// The latest relay list event id hex
|
||||||
|
var latestRelayListEventIdHex: String? { get set } // TODO: Update this once we have full NostrDB query support
|
||||||
|
|
||||||
|
/// The latest contact list `NostrEvent`
|
||||||
|
///
|
||||||
|
/// Note: Read-only access, because `NostrNetworkManager` does not manage contact lists.
|
||||||
|
var latestContactListEvent: NostrEvent? { get }
|
||||||
|
|
||||||
|
/// Default bootstrap relays to start with when a user relay list is not present
|
||||||
|
var bootstrapRelays: [RelayURL] { get }
|
||||||
|
|
||||||
|
/// Whether the app is in developer mode
|
||||||
|
var developerMode: Bool { get }
|
||||||
|
|
||||||
|
/// The cache of relay model information
|
||||||
|
var relayModelCache: RelayModelCache { get }
|
||||||
|
|
||||||
|
/// Relay filters
|
||||||
|
var relayFilters: RelayFilters { get }
|
||||||
|
|
||||||
|
/// The user's connected NWC wallet
|
||||||
|
var nwcWallet: WalletConnectURL? { get }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// SubscriptionManager.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-03-25.
|
||||||
|
//
|
||||||
|
|
||||||
|
extension NostrNetworkManager {
|
||||||
|
/// Reads or fetches information from RelayPool and NostrDB, and provides an easier and unified higher-level interface.
|
||||||
|
///
|
||||||
|
/// ## Implementation notes
|
||||||
|
///
|
||||||
|
/// - This class will be a key part of the local relay model migration. Most higher-level code should fetch content from this class, which will properly setup the correct relay pool subscriptions, and provide a stream from NostrDB for higher performance and reliability.
|
||||||
|
class SubscriptionManager {
|
||||||
|
private let pool: RelayPool
|
||||||
|
private var ndb: Ndb
|
||||||
|
|
||||||
|
init(pool: RelayPool, ndb: Ndb) {
|
||||||
|
self.pool = pool
|
||||||
|
self.ndb = ndb
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reading data from Nostr
|
||||||
|
|
||||||
|
/// Subscribes to data from the user's relays
|
||||||
|
///
|
||||||
|
/// ## Implementation notes
|
||||||
|
///
|
||||||
|
/// - When we migrate to the local relay model, we should modify this function to stream directly from NostrDB
|
||||||
|
///
|
||||||
|
/// - Parameter filters: The nostr filters to specify what kind of data to subscribe to
|
||||||
|
/// - Returns: An async stream of nostr data
|
||||||
|
func subscribe(filters: [NostrFilter]) -> AsyncStream<StreamItem> {
|
||||||
|
return AsyncStream<StreamItem> { continuation in
|
||||||
|
let streamTask = Task {
|
||||||
|
for await item in self.pool.subscribe(filters: filters) {
|
||||||
|
switch item {
|
||||||
|
case .eose: continuation.yield(.eose)
|
||||||
|
case .event(let nostrEvent):
|
||||||
|
// At this point of the pipeline, if the note is valid it should have been processed and verified by NostrDB,
|
||||||
|
// in which case we should pull the note from NostrDB to ensure validity.
|
||||||
|
// However, NdbNotes are unowned, so we return a function where our callers can temporarily borrow the NostrDB note
|
||||||
|
let noteId = nostrEvent.id
|
||||||
|
let lender: NdbNoteLender = { lend in
|
||||||
|
guard let ndbNoteTxn = self.ndb.lookup_note(noteId) else {
|
||||||
|
throw NdbNoteLenderError.errorLoadingNote
|
||||||
|
}
|
||||||
|
guard let unownedNote = UnownedNdbNote(ndbNoteTxn) else {
|
||||||
|
throw NdbNoteLenderError.errorLoadingNote
|
||||||
|
}
|
||||||
|
lend(unownedNote)
|
||||||
|
}
|
||||||
|
continuation.yield(.event(borrow: lender))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
streamTask.cancel() // Close the RelayPool stream when caller stops streaming
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StreamItem {
|
||||||
|
/// An event which can be borrowed from NostrDB
|
||||||
|
case event(borrow: NdbNoteLender)
|
||||||
|
/// The end of stored events
|
||||||
|
case eose
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//
|
||||||
|
// UserRelayListErrors.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-02-27.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension NostrNetworkManager.UserRelayListManager {
|
||||||
|
/// Models an error that may occur when performing operations that change the user's relay list.
|
||||||
|
///
|
||||||
|
/// Callers to functions that throw this error SHOULD handle them in order to provide a better user experience.
|
||||||
|
enum UpdateError: Error {
|
||||||
|
/// The user is not authorized to change relay list, usually because the private key is missing.
|
||||||
|
case notAuthorizedToChangeRelayList
|
||||||
|
/// An error occurred when forming the relay list Nostr event.
|
||||||
|
case cannotFormRelayListEvent
|
||||||
|
/// Cannot add item to the relay list because the relay is already present in the list.
|
||||||
|
case relayAlreadyExists
|
||||||
|
/// Cannot update the relay list because we do not have the user's previous relay list.
|
||||||
|
///
|
||||||
|
/// Implementers must be careful not to overwrite the user's existing relay list if it exists somewhere else.
|
||||||
|
case noInitialRelayList
|
||||||
|
/// Cannot remove or update a specific relay because it is not on the relay list
|
||||||
|
case noSuchRelay
|
||||||
|
|
||||||
|
/// Convert `RelayPool.RelayError` into `UserRelayListUpdateError`
|
||||||
|
static func from(_ relayPoolError: RelayPool.RelayError) -> Self {
|
||||||
|
switch relayPoolError {
|
||||||
|
case .RelayAlreadyExists: return .relayAlreadyExists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var humanReadableError: ErrorView.UserPresentableError {
|
||||||
|
switch self {
|
||||||
|
case .notAuthorizedToChangeRelayList:
|
||||||
|
ErrorView.UserPresentableError(
|
||||||
|
user_visible_description: NSLocalizedString("You do not have permission to alter this relay list.", comment: "Human readable error description"),
|
||||||
|
tip: NSLocalizedString("Please make sure you have logged-in with your private key.", comment: "Human readable tip for error"),
|
||||||
|
technical_info: nil
|
||||||
|
)
|
||||||
|
case .cannotFormRelayListEvent:
|
||||||
|
ErrorView.UserPresentableError(
|
||||||
|
user_visible_description: NSLocalizedString("There was a problem creating the relay list event.", comment: "Human readable error description"),
|
||||||
|
tip: NSLocalizedString("Please try again later or contact support if the issue persists.", comment: "Human readable tip for error"),
|
||||||
|
technical_info: "Failed forming Nostr event for the relay list update."
|
||||||
|
)
|
||||||
|
case .relayAlreadyExists:
|
||||||
|
ErrorView.UserPresentableError(
|
||||||
|
user_visible_description: NSLocalizedString("This relay is already in your list.", comment: "Human readable tip for error"),
|
||||||
|
tip: NSLocalizedString("Check the address and/or the relay list.", comment: "Human readable tip for error"),
|
||||||
|
technical_info: nil
|
||||||
|
)
|
||||||
|
case .noInitialRelayList:
|
||||||
|
ErrorView.UserPresentableError(
|
||||||
|
user_visible_description: NSLocalizedString("No initial relay list available to update.", comment: "Human readable error description"),
|
||||||
|
tip: NSLocalizedString("Please go to Settings > First Aid > Repair relay list, or contact support.", comment: "Human readable tip for error"),
|
||||||
|
technical_info: "Missing initial relay list data for reference during update."
|
||||||
|
)
|
||||||
|
case .noSuchRelay:
|
||||||
|
ErrorView.UserPresentableError(
|
||||||
|
user_visible_description: NSLocalizedString("The specified relay that you are trying to udpate was not found in your relay list.", comment: "Human readable error description"),
|
||||||
|
tip: NSLocalizedString("This is an unexpected error, please contact support.", comment: "Human readable tip for error"),
|
||||||
|
technical_info: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoadingError: Error {
|
||||||
|
case relayListParseError
|
||||||
|
|
||||||
|
var humanReadableError: ErrorView.UserPresentableError {
|
||||||
|
switch self {
|
||||||
|
case .relayListParseError:
|
||||||
|
return ErrorView.UserPresentableError(
|
||||||
|
user_visible_description: NSLocalizedString("Your relay list appears to be broken, so we cannot connect you to your Nostr network.", comment: "Human readable error description for a failure to parse the relay list due to a bad relay list"),
|
||||||
|
tip: NSLocalizedString("Please contact support for further help.", comment: "Human readable tips for what to do for a failure to find the relay list"),
|
||||||
|
technical_info: "Relay list could not be parsed."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
//
|
||||||
|
// UserRelayListManager.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-02-27.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
extension NostrNetworkManager {
|
||||||
|
/// Manages the user's relay list
|
||||||
|
///
|
||||||
|
/// - It can compute the user's current relay list
|
||||||
|
/// - It can compute the best relay list to connect to
|
||||||
|
/// - It can edit the user's relay list
|
||||||
|
class UserRelayListManager {
|
||||||
|
private var delegate: Delegate
|
||||||
|
private let pool: RelayPool
|
||||||
|
private let reader: SubscriptionManager
|
||||||
|
|
||||||
|
private var relayListObserverTask: Task<Void, Never>? = nil
|
||||||
|
private var walletUpdatesObserverTask: AnyCancellable? = nil
|
||||||
|
|
||||||
|
init(delegate: Delegate, pool: RelayPool, reader: SubscriptionManager) {
|
||||||
|
self.delegate = delegate
|
||||||
|
self.pool = pool
|
||||||
|
self.reader = reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Computing the relays to connect to
|
||||||
|
|
||||||
|
private func relaysToConnectTo() -> [RelayPool.RelayDescriptor] {
|
||||||
|
return self.computeRelaysToConnectTo(with: self.getBestEffortRelayList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func computeRelaysToConnectTo(with relayList: NIP65.RelayList) -> [RelayPool.RelayDescriptor] {
|
||||||
|
let regularRelayDescriptorList = relayList.toRelayDescriptors()
|
||||||
|
if let nwcWallet = delegate.nwcWallet {
|
||||||
|
return regularRelayDescriptorList + [.nwc(url: nwcWallet.relay)]
|
||||||
|
}
|
||||||
|
return regularRelayDescriptorList
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Getting the user's relay list
|
||||||
|
|
||||||
|
/// Gets the "best effort" relay list.
|
||||||
|
///
|
||||||
|
/// It attempts to get a relay list from the user. If one is not available, it uses the default bootstrap list.
|
||||||
|
///
|
||||||
|
/// This is always guaranteed to return a relay list.
|
||||||
|
func getBestEffortRelayList() -> NIP65.RelayList {
|
||||||
|
guard let userCurrentRelayList = self.getUserCurrentRelayList() else {
|
||||||
|
return NIP65.RelayList(relays: delegate.bootstrapRelays)
|
||||||
|
}
|
||||||
|
return userCurrentRelayList
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the user's current relay list.
|
||||||
|
///
|
||||||
|
/// It attempts to get a NIP-65 relay list from the local database, or falls back to a legacy list.
|
||||||
|
func getUserCurrentRelayList() -> NIP65.RelayList? {
|
||||||
|
if let latestRelayListEvent = try? self.getLatestNIP65RelayList() { return latestRelayListEvent }
|
||||||
|
if let latestRelayListEvent = try? self.getLatestKind3RelayList() { return latestRelayListEvent }
|
||||||
|
if let latestRelayListEvent = try? self.getLatestUserDefaultsRelayList() { return latestRelayListEvent }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the latest NIP-65 relay list from NostrDB.
|
||||||
|
///
|
||||||
|
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
|
||||||
|
///
|
||||||
|
/// - Returns: The latest NIP-65 relay list object
|
||||||
|
private func getLatestNIP65RelayList() throws(LoadingError) -> NIP65.RelayList? {
|
||||||
|
guard let latestRelayListEvent = self.getLatestNIP65RelayListEvent() else { return nil }
|
||||||
|
guard let list = try? NIP65.RelayList(event: latestRelayListEvent) else { throw .relayListParseError }
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the latest NIP-65 relay list event from NostrDB.
|
||||||
|
///
|
||||||
|
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
|
||||||
|
///
|
||||||
|
/// It is recommended to use this function only if the NostrEvent metadata is needed. For cases where only the relay list info is needed, use `getLatestNIP65RelayList` instead.
|
||||||
|
///
|
||||||
|
/// - Returns: The latest NIP-65 relay list NdbNote
|
||||||
|
private func getLatestNIP65RelayListEvent() -> NdbNote? {
|
||||||
|
guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil }
|
||||||
|
guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil }
|
||||||
|
return delegate.ndb.lookup_note(latestRelayListEventId)?.unsafeUnownedValue?.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the latest `kind:3` relay list from NostrDB.
|
||||||
|
///
|
||||||
|
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
|
||||||
|
private func getLatestKind3RelayList() throws(LoadingError) -> NIP65.RelayList? {
|
||||||
|
guard let latestContactListEvent = delegate.latestContactListEvent else { return nil }
|
||||||
|
guard let legacyContactList = try? NIP65.RelayList.fromLegacyContactList(latestContactListEvent) else { throw .relayListParseError }
|
||||||
|
return legacyContactList
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the latest relay list from `UserDefaults`
|
||||||
|
///
|
||||||
|
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
|
||||||
|
private func getLatestUserDefaultsRelayList() throws(LoadingError) -> NIP65.RelayList? {
|
||||||
|
let key = bootstrap_relays_setting_key(pubkey: delegate.keypair.pubkey)
|
||||||
|
guard let relays = UserDefaults.standard.stringArray(forKey: key) else { return nil }
|
||||||
|
let relayUrls = relays.compactMap({ RelayURL($0) })
|
||||||
|
if relayUrls.count == 0 { return nil }
|
||||||
|
return NIP65.RelayList(relays: relayUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Getting metadata from the user's relay list
|
||||||
|
|
||||||
|
/// Gets the creation date of the user's current relay list, with preference to NIP-65 relay lists
|
||||||
|
/// - Returns: The current relay list's creation date
|
||||||
|
private func getUserCurrentRelayListCreationDate() -> UInt32? {
|
||||||
|
if let latestNIP65RelayListEvent = self.getLatestNIP65RelayListEvent() { return latestNIP65RelayListEvent.created_at }
|
||||||
|
if let latestKind3RelayListEvent = delegate.latestContactListEvent { return latestKind3RelayListEvent.created_at }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Listening to and handling relay updates from the network
|
||||||
|
|
||||||
|
func connect() {
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
self.relayListObserverTask?.cancel()
|
||||||
|
self.relayListObserverTask = Task { await self.listenAndHandleRelayUpdates() }
|
||||||
|
self.walletUpdatesObserverTask?.cancel()
|
||||||
|
self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in self.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func listenAndHandleRelayUpdates() async {
|
||||||
|
let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey])
|
||||||
|
for await item in self.reader.subscribe(filters: [filter]) {
|
||||||
|
switch item {
|
||||||
|
case .event(borrow: let borrow): // Signature validity already ensured at this point
|
||||||
|
let currentRelayListCreationDate = self.getUserCurrentRelayListCreationDate()
|
||||||
|
try? borrow { note in
|
||||||
|
guard note.pubkey == self.delegate.keypair.pubkey else { return } // Ensure this new list was ours
|
||||||
|
guard note.createdAt > (currentRelayListCreationDate ?? 0) else { return } // Ensure this is a newer list
|
||||||
|
guard let relayList = try? NIP65.RelayList(event: note) else { return } // Ensure it is a valid NIP-65 list
|
||||||
|
|
||||||
|
try? self.set(userRelayList: relayList) // Set the validated list
|
||||||
|
}
|
||||||
|
case .eose: continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Editing the user's relay list
|
||||||
|
|
||||||
|
func upsert(relay: NIP65.RelayList.RelayItem, force: Bool = false, overwriteExisting: Bool = false) throws(UpdateError) {
|
||||||
|
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||||
|
guard !currentUserRelayList.relays.keys.contains(relay.url) || overwriteExisting else { throw .relayAlreadyExists }
|
||||||
|
var newList = currentUserRelayList.relays
|
||||||
|
newList[relay.url] = relay
|
||||||
|
try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) throws(UpdateError) {
|
||||||
|
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||||
|
guard currentUserRelayList.relays[relay.url] == nil else { throw .relayAlreadyExists }
|
||||||
|
try self.upsert(relay: relay, force: force)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(relayURL: RelayURL, force: Bool = false) throws(UpdateError) {
|
||||||
|
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||||
|
guard currentUserRelayList.relays.keys.contains(relayURL) || force else { throw .noSuchRelay }
|
||||||
|
var newList = currentUserRelayList.relays
|
||||||
|
newList[relayURL] = nil
|
||||||
|
try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(userRelayList: NIP65.RelayList) throws(UpdateError) {
|
||||||
|
guard let fullKeypair = delegate.keypair.to_full() else { throw .notAuthorizedToChangeRelayList }
|
||||||
|
guard let relayListEvent = userRelayList.toNostrEvent(keypair: fullKeypair) else { throw .cannotFormRelayListEvent }
|
||||||
|
|
||||||
|
self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList))
|
||||||
|
|
||||||
|
self.pool.send(.event(relayListEvent)) // This will send to NostrDB as well, which will locally save that NIP-65 event
|
||||||
|
self.delegate.latestRelayListEventIdHex = relayListEvent.id.hex() // Make sure we are able to recall this event from NostrDB
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Syncing our saved user relay list with the active `RelayPool`
|
||||||
|
|
||||||
|
/// Loads the current user relay list
|
||||||
|
func load() {
|
||||||
|
self.apply(newRelayList: self.relaysToConnectTo())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads a new relay list into the active relay pool, making sure it matches the specified relay list.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - state: The state of the app
|
||||||
|
/// - newRelayList: The new relay list to be applied
|
||||||
|
///
|
||||||
|
///
|
||||||
|
/// ## Implementation notes
|
||||||
|
///
|
||||||
|
/// - This is `private` because syncing the user's saved relay list with the relay pool is `NostrNetworkManager`'s responsibility,
|
||||||
|
/// so we do not want other classes to forcibly load this.
|
||||||
|
private func apply(newRelayList: [RelayPool.RelayDescriptor]) {
|
||||||
|
let currentRelayList = self.pool.relays.map({ $0.descriptor })
|
||||||
|
|
||||||
|
var changed = false
|
||||||
|
let new_relay_filters = load_relay_filters(delegate.keypair.pubkey) == nil
|
||||||
|
|
||||||
|
for index in self.pool.relays.indices {
|
||||||
|
guard let newDescriptor = newRelayList.first(where: { $0.url == self.pool.relays[index].descriptor.url }) else { continue }
|
||||||
|
self.pool.relays[index].descriptor.info = newDescriptor.info
|
||||||
|
// Relay read-write configuration change does not need reconnection to the relay, so we do not set the `changed` flag.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Working with URL Sets for difference analysis
|
||||||
|
let currentRelayURLs = Set(currentRelayList.map { $0.url })
|
||||||
|
let newRelayURLs = Set(newRelayList.map { $0.url })
|
||||||
|
|
||||||
|
// Analyzing which relays to add or remove
|
||||||
|
let relaysToRemove = currentRelayURLs.subtracting(newRelayURLs)
|
||||||
|
let relaysToAdd = newRelayURLs.subtracting(currentRelayURLs)
|
||||||
|
|
||||||
|
// Remove relays not in the new list
|
||||||
|
relaysToRemove.forEach { url in
|
||||||
|
pool.remove_relay(url)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new relays from the new list
|
||||||
|
relaysToAdd.forEach { url in
|
||||||
|
guard let descriptor = newRelayList.first(where: { $0.url == url }) else { return }
|
||||||
|
add_new_relay(
|
||||||
|
model_cache: delegate.relayModelCache,
|
||||||
|
relay_filters: delegate.relayFilters,
|
||||||
|
pool: pool,
|
||||||
|
descriptor: descriptor,
|
||||||
|
new_relay_filters: new_relay_filters,
|
||||||
|
logging_enabled: delegate.developerMode
|
||||||
|
)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
pool.connect()
|
||||||
|
notify(.relays_changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper extensions
|
||||||
|
|
||||||
|
fileprivate extension NIP65.RelayList.RelayItem {
|
||||||
|
func toRelayDescriptor() -> RelayPool.RelayDescriptor {
|
||||||
|
return RelayPool.RelayDescriptor(url: self.url, info: self.rwConfiguration, variant: .regular) // NIP-65 relays are regular by definition.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate extension NIP65.RelayList {
|
||||||
|
func toRelayDescriptors() -> [RelayPool.RelayDescriptor] {
|
||||||
|
return self.relays.values.map({ $0.toRelayDescriptor() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper functions
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds a new relay, taking care of other tangential concerns, such as updating the relay model cache, configuring logging, etc
|
||||||
|
///
|
||||||
|
/// ## Implementation notes
|
||||||
|
///
|
||||||
|
/// 1. This function used to be in `HomeModel.swift` and moved here when `UserRelayListManager` was first implemented
|
||||||
|
/// 2. This is `fileprivate` because only `UserRelayListManager` should be able to manage the user's relay list and apply them to the `RelayPool`
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - model_cache: The relay model cache, that keeps metadata cached
|
||||||
|
/// - relay_filters: Relay filters
|
||||||
|
/// - pool: The relay pool to add this in
|
||||||
|
/// - descriptor: The description of the relay being added
|
||||||
|
/// - new_relay_filters: Whether to insert new relay filters
|
||||||
|
/// - logging_enabled: Whether logging is enabled
|
||||||
|
fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayPool.RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
|
||||||
|
try? pool.add_relay(descriptor)
|
||||||
|
let url = descriptor.url
|
||||||
|
|
||||||
|
let relay_id = url
|
||||||
|
guard model_cache.model(withURL: url) == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.detached(priority: .background) {
|
||||||
|
guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
let model = RelayModel(url, metadata: meta)
|
||||||
|
model_cache.insert(model: model)
|
||||||
|
|
||||||
|
if logging_enabled {
|
||||||
|
pool.setLog(model.log, for: relay_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// if this is the first time adding filters, we should filter non-paid relays
|
||||||
|
if new_relay_filters && !meta.is_paid {
|
||||||
|
relay_filters.insert(timeline: .search, relay_id: relay_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
-60
@@ -73,85 +73,143 @@ 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 {
|
||||||
|
// We should hide whitespace at the end sequence.
|
||||||
|
hide_text_index = i
|
||||||
|
} else if case .hashtag = block {
|
||||||
|
// We should keep hashtags at the end sequence but hide all the other previewables around it.
|
||||||
|
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.
|
||||||
|
// The only exception is that if there are hashtags embedded in the end sequence, which is not uncommon,
|
||||||
|
// then we still want to show those hashtags but hide everything else that is previewable in the end sequence.
|
||||||
|
if ind >= hide_text_index {
|
||||||
|
if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
if case .hashtag = blocks[safe: ind+1] {
|
||||||
|
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt))
|
||||||
|
}
|
||||||
|
} else if case .hashtag(let htag) = block {
|
||||||
|
return str + hashtag_str(htag)
|
||||||
|
}
|
||||||
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 +219,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 +251,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)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -213,12 +270,20 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
|
|||||||
|
|
||||||
// trim suffix whitespace and newlines
|
// trim suffix whitespace and newlines
|
||||||
func trim_suffix(_ str: String) -> String {
|
func trim_suffix(_ str: String) -> String {
|
||||||
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
|
var result = str
|
||||||
|
while result.last?.isWhitespace == true {
|
||||||
|
result.removeLast()
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// trim prefix whitespace and newlines
|
// trim prefix whitespace and newlines
|
||||||
func trim_prefix(_ str: String) -> String {
|
func trim_prefix(_ str: String) -> String {
|
||||||
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
|
var result = str
|
||||||
|
while result.first?.isWhitespace == true {
|
||||||
|
result.removeFirst()
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LongformContent {
|
struct LongformContent {
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if state.settings.hellthread_notifications_disabled && ev.is_hellthread(max_pubkeys: state.settings.hellthread_notification_max_pubkeys) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Don't show notifications that match mute list.
|
// Don't show notifications that match mute list.
|
||||||
if state.mutelist_manager.is_event_muted(ev) {
|
if state.mutelist_manager.is_event_muted(ev) {
|
||||||
return false
|
return false
|
||||||
@@ -50,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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,18 @@ import Foundation
|
|||||||
class ProfileModel: ObservableObject, Equatable {
|
class ProfileModel: ObservableObject, Equatable {
|
||||||
@Published var contacts: NostrEvent? = nil
|
@Published var contacts: NostrEvent? = nil
|
||||||
@Published var following: Int = 0
|
@Published var following: Int = 0
|
||||||
@Published var relays: [RelayURL: RelayInfo]? = nil
|
@Published var relay_list: NIP65.RelayList? = nil
|
||||||
|
@Published var legacy_relay_list: [RelayURL: LegacyKind3RelayRWConfiguration]? = nil
|
||||||
@Published var progress: Int = 0
|
@Published var progress: Int = 0
|
||||||
|
var relay_urls: [RelayURL]? {
|
||||||
|
if let relay_list {
|
||||||
|
return relay_list.relays.values.map({ $0.url })
|
||||||
|
}
|
||||||
|
if let legacy_relay_list {
|
||||||
|
return Array(legacy_relay_list.keys)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private let MAX_SHARE_RELAYS = 4
|
private let MAX_SHARE_RELAYS = 4
|
||||||
|
|
||||||
@@ -59,16 +69,17 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
|
|
||||||
func unsubscribe() {
|
func unsubscribe() {
|
||||||
print("unsubscribing from profile \(pubkey) with sub_id \(sub_id)")
|
print("unsubscribing from profile \(pubkey) with sub_id \(sub_id)")
|
||||||
damus.pool.unsubscribe(sub_id: sub_id)
|
damus.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||||
damus.pool.unsubscribe(sub_id: prof_subid)
|
damus.nostrNetwork.pool.unsubscribe(sub_id: prof_subid)
|
||||||
if pubkey != damus.pubkey {
|
if pubkey != damus.pubkey {
|
||||||
damus.pool.unsubscribe(sub_id: conversations_subid)
|
damus.nostrNetwork.pool.unsubscribe(sub_id: conversations_subid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribe() {
|
func subscribe() {
|
||||||
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
|
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
|
||||||
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
|
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
|
||||||
|
var relay_list_filter = NostrFilter(kinds: [.relay_list], authors: [pubkey])
|
||||||
|
|
||||||
profile_filter.authors = [pubkey]
|
profile_filter.authors = [pubkey]
|
||||||
|
|
||||||
@@ -77,8 +88,8 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
|
|
||||||
print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)")
|
print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)")
|
||||||
//print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]])
|
//print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]])
|
||||||
damus.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event)
|
damus.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event)
|
||||||
damus.pool.subscribe(sub_id: prof_subid, filters: [profile_filter], handler: handle_event)
|
damus.nostrNetwork.pool.subscribe(sub_id: prof_subid, filters: [profile_filter, relay_list_filter], handler: handle_event)
|
||||||
|
|
||||||
subscribe_to_conversations()
|
subscribe_to_conversations()
|
||||||
}
|
}
|
||||||
@@ -94,7 +105,7 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
let conversations_filter_them = NostrFilter(kinds: conversation_kinds, pubkeys: [damus.pubkey], limit: limit, authors: [pubkey])
|
let conversations_filter_them = NostrFilter(kinds: conversation_kinds, pubkeys: [damus.pubkey], limit: limit, authors: [pubkey])
|
||||||
let conversations_filter_us = NostrFilter(kinds: conversation_kinds, pubkeys: [pubkey], limit: limit, authors: [damus.pubkey])
|
let conversations_filter_us = NostrFilter(kinds: conversation_kinds, pubkeys: [pubkey], limit: limit, authors: [damus.pubkey])
|
||||||
print("subscribing to conversation events from and to profile \(pubkey) with sub_id \(conversations_subid)")
|
print("subscribing to conversation events from and to profile \(pubkey) with sub_id \(conversations_subid)")
|
||||||
damus.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event)
|
damus.nostrNetwork.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_profile_contact_event(_ ev: NostrEvent) {
|
func handle_profile_contact_event(_ ev: NostrEvent) {
|
||||||
@@ -109,7 +120,7 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
|
|
||||||
self.contacts = ev
|
self.contacts = ev
|
||||||
self.following = count_pubkeys(ev.tags)
|
self.following = count_pubkeys(ev.tags)
|
||||||
self.relays = decode_json_relays(ev.content)
|
self.legacy_relay_list = decode_json_relays(ev.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func add_event(_ ev: NostrEvent) {
|
private func add_event(_ ev: NostrEvent) {
|
||||||
@@ -120,6 +131,9 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
} else if ev.known_kind == .contacts {
|
} else if ev.known_kind == .contacts {
|
||||||
handle_profile_contact_event(ev)
|
handle_profile_contact_event(ev)
|
||||||
}
|
}
|
||||||
|
else if ev.known_kind == .relay_list {
|
||||||
|
self.relay_list = try? NIP65.RelayList(event: ev) // Whether another user's list is malformatted is something beyond our control. Probably best to suppress errors
|
||||||
|
}
|
||||||
seen_event.insert(ev.id)
|
seen_event.insert(ev.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +206,7 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
|
|
||||||
private func findRelaysHandler(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
private func findRelaysHandler(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||||
if case .nostr_event(let resp) = ev, case .event(_, let event) = resp, case .contacts = event.known_kind {
|
if case .nostr_event(let resp) = ev, case .event(_, let event) = resp, case .contacts = event.known_kind {
|
||||||
self.relays = decode_json_relays(event.content)
|
self.legacy_relay_list = decode_json_relays(event.content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,15 +214,15 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
var profile_filter = NostrFilter(kinds: [.contacts])
|
var profile_filter = NostrFilter(kinds: [.contacts])
|
||||||
profile_filter.authors = [pubkey]
|
profile_filter.authors = [pubkey]
|
||||||
|
|
||||||
damus.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
|
damus.nostrNetwork.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribeFindRelays() {
|
func unsubscribeFindRelays() {
|
||||||
damus.pool.unsubscribe(sub_id: findRelay_subid)
|
damus.nostrNetwork.pool.unsubscribe(sub_id: findRelay_subid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCappedRelayStrings() -> [String] {
|
func getCappedRelayStrings() -> [String] {
|
||||||
return relays?.keys.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
return self.relay_urls?.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// Minimum threshold the hellthread pubkey tag count setting can go down to.
|
||||||
|
let HELLTHREAD_MIN_PUBKEYS: Int = 6
|
||||||
|
|
||||||
|
// Maximum threshold the hellthread pubkey tag count setting can go up to.
|
||||||
|
let HELLTHREAD_MAX_PUBKEYS: Int = 24
|
||||||
|
|
||||||
struct PushNotificationClient {
|
struct PushNotificationClient {
|
||||||
let keypair: Keypair
|
let keypair: Keypair
|
||||||
let settings: UserSettingsStore
|
let settings: UserSettingsStore
|
||||||
@@ -175,15 +181,33 @@ extension PushNotificationClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct NotificationSettings: Codable, Equatable {
|
struct NotificationSettings: Codable, Equatable {
|
||||||
let zap_notifications_enabled: Bool
|
let zap_notifications_enabled: Bool?
|
||||||
let mention_notifications_enabled: Bool
|
let mention_notifications_enabled: Bool?
|
||||||
let repost_notifications_enabled: Bool
|
let repost_notifications_enabled: Bool?
|
||||||
let reaction_notifications_enabled: Bool
|
let reaction_notifications_enabled: Bool?
|
||||||
let dm_notifications_enabled: Bool
|
let dm_notifications_enabled: Bool?
|
||||||
let only_notifications_from_following_enabled: Bool
|
let only_notifications_from_following_enabled: Bool?
|
||||||
|
let hellthread_notifications_disabled: Bool?
|
||||||
|
let hellthread_notifications_max_pubkeys: Int?
|
||||||
|
|
||||||
static func from(json_data: Data) -> Self? {
|
static func from(json_data: Data) -> Self? {
|
||||||
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
|
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
|
||||||
|
|
||||||
|
// Normalize hellthread_notifications_max_pubkeys in case
|
||||||
|
// it goes beyond the expected range supported on the client.
|
||||||
|
if let max_pubkeys = decoded.hellthread_notifications_max_pubkeys, max_pubkeys < HELLTHREAD_MIN_PUBKEYS || max_pubkeys > HELLTHREAD_MAX_PUBKEYS {
|
||||||
|
return NotificationSettings(
|
||||||
|
zap_notifications_enabled: decoded.zap_notifications_enabled,
|
||||||
|
mention_notifications_enabled: decoded.mention_notifications_enabled,
|
||||||
|
repost_notifications_enabled: decoded.repost_notifications_enabled,
|
||||||
|
reaction_notifications_enabled: decoded.reaction_notifications_enabled,
|
||||||
|
dm_notifications_enabled: decoded.dm_notifications_enabled,
|
||||||
|
only_notifications_from_following_enabled: decoded.only_notifications_from_following_enabled,
|
||||||
|
hellthread_notifications_disabled: decoded.hellthread_notifications_disabled,
|
||||||
|
hellthread_notifications_max_pubkeys: max(min(HELLTHREAD_MAX_PUBKEYS, max_pubkeys), HELLTHREAD_MIN_PUBKEYS)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return decoded
|
return decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +218,9 @@ extension PushNotificationClient {
|
|||||||
repost_notifications_enabled: settings.repost_notification,
|
repost_notifications_enabled: settings.repost_notification,
|
||||||
reaction_notifications_enabled: settings.like_notification,
|
reaction_notifications_enabled: settings.like_notification,
|
||||||
dm_notifications_enabled: settings.dm_notification,
|
dm_notifications_enabled: settings.dm_notification,
|
||||||
only_notifications_from_following_enabled: settings.notification_only_from_following
|
only_notifications_from_following_enabled: settings.notification_only_from_following,
|
||||||
|
hellthread_notifications_disabled: settings.hellthread_notifications_disabled,
|
||||||
|
hellthread_notifications_max_pubkeys: settings.hellthread_notification_max_pubkeys
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//
|
|
||||||
// SearchHomeModel.swift
|
// SearchHomeModel.swift
|
||||||
// damus
|
// damus
|
||||||
//
|
//
|
||||||
@@ -16,6 +15,7 @@ class SearchHomeModel: ObservableObject {
|
|||||||
var seen_pubkey: Set<Pubkey> = Set()
|
var seen_pubkey: Set<Pubkey> = Set()
|
||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
let base_subid = UUID().description
|
let base_subid = UUID().description
|
||||||
|
let follow_pack_subid = UUID().description
|
||||||
let profiles_subid = UUID().description
|
let profiles_subid = UUID().description
|
||||||
let limit: UInt32 = 500
|
let limit: UInt32 = 500
|
||||||
//let multiple_events_per_pubkey: Bool = false
|
//let multiple_events_per_pubkey: Bool = false
|
||||||
@@ -41,13 +41,19 @@ class SearchHomeModel: ObservableObject {
|
|||||||
|
|
||||||
func subscribe() {
|
func subscribe() {
|
||||||
loading = true
|
loading = true
|
||||||
let to_relays = determine_to_relays(pool: damus_state.pool, filters: damus_state.relay_filters)
|
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||||
damus_state.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
|
|
||||||
|
var follow_list_filter = NostrFilter(kinds: [.follow_list])
|
||||||
|
follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||||
|
|
||||||
|
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
|
||||||
|
damus_state.nostrNetwork.pool.subscribe(sub_id: follow_pack_subid, filters: [follow_list_filter], handler: handle_event, to: to_relays)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe(to: RelayURL? = nil) {
|
func unsubscribe(to: RelayURL? = nil) {
|
||||||
loading = false
|
loading = false
|
||||||
damus_state.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
||||||
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: follow_pack_subid, to: to.map { [$0] })
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||||
@@ -57,7 +63,7 @@ class SearchHomeModel: ObservableObject {
|
|||||||
|
|
||||||
switch event {
|
switch event {
|
||||||
case .event(let sub_id, let ev):
|
case .event(let sub_id, let ev):
|
||||||
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
|
guard sub_id == self.base_subid || sub_id == self.profiles_subid || sub_id == self.follow_pack_subid else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||||
@@ -140,7 +146,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR
|
|||||||
|
|
||||||
let filter = NostrFilter(kinds: [.metadata], authors: authors)
|
let filter = NostrFilter(kinds: [.metadata], authors: authors)
|
||||||
|
|
||||||
damus_state.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
|
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
|
||||||
|
|
||||||
let now = UInt64(Date.now.timeIntervalSince1970)
|
let now = UInt64(Date.now.timeIntervalSince1970)
|
||||||
switch conn_ev {
|
switch conn_ev {
|
||||||
@@ -156,7 +162,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR
|
|||||||
}
|
}
|
||||||
case .eose:
|
case .eose:
|
||||||
print("load_profiles[\(context)]: done loading \(authors.count) profiles from \(relay_id)")
|
print("load_profiles[\(context)]: done loading \(authors.count) profiles from \(relay_id)")
|
||||||
damus_state.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
|
||||||
case .ok:
|
case .ok:
|
||||||
break
|
break
|
||||||
case .notice:
|
case .notice:
|
||||||
|
|||||||
@@ -36,18 +36,18 @@ class SearchModel: ObservableObject {
|
|||||||
func subscribe() {
|
func subscribe() {
|
||||||
// since 1 month
|
// since 1 month
|
||||||
search.limit = self.limit
|
search.limit = self.limit
|
||||||
search.kinds = [.text, .like, .longform, .highlight]
|
search.kinds = [.text, .like, .longform, .highlight, .follow_list]
|
||||||
|
|
||||||
//likes_filter.ids = ref_events.referenced_ids!
|
//likes_filter.ids = ref_events.referenced_ids!
|
||||||
|
|
||||||
print("subscribing to search '\(search)' with sub_id \(sub_id)")
|
print("subscribing to search '\(search)' with sub_id \(sub_id)")
|
||||||
state.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||||
loading = true
|
loading = true
|
||||||
state.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
state.nostrNetwork.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe() {
|
func unsubscribe() {
|
||||||
state.pool.unsubscribe(sub_id: sub_id)
|
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||||
loading = false
|
loading = false
|
||||||
print("unsubscribing from search '\(search)' with sub_id \(sub_id)")
|
print("unsubscribing from search '\(search)' with sub_id \(sub_id)")
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ class SearchModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||||
let (sub_id, done) = handle_subid_event(pool: state.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||||
if ev.is_textlike && ev.should_show_event {
|
if ev.is_textlike && ev.should_show_event {
|
||||||
self.add_event(ev)
|
self.add_event(ev)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,12 +88,12 @@ class ThreadModel: ObservableObject {
|
|||||||
|
|
||||||
/// Unsubscribe from events in the relay pool. Call this when unloading the view
|
/// Unsubscribe from events in the relay pool. Call this when unloading the view
|
||||||
func unsubscribe() {
|
func unsubscribe() {
|
||||||
self.damus_state.pool.remove_handler(sub_id: base_subid)
|
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: base_subid)
|
||||||
self.damus_state.pool.remove_handler(sub_id: meta_subid)
|
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: meta_subid)
|
||||||
self.damus_state.pool.remove_handler(sub_id: profiles_subid)
|
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: profiles_subid)
|
||||||
self.damus_state.pool.unsubscribe(sub_id: base_subid)
|
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid)
|
||||||
self.damus_state.pool.unsubscribe(sub_id: meta_subid)
|
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: meta_subid)
|
||||||
self.damus_state.pool.unsubscribe(sub_id: profiles_subid)
|
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid)
|
||||||
Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
|
Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +129,8 @@ class ThreadModel: ObservableObject {
|
|||||||
let meta_filters = [meta_events, quote_events]
|
let meta_filters = [meta_events, quote_events]
|
||||||
|
|
||||||
Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
|
Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
|
||||||
damus_state.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
|
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
|
||||||
damus_state.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
|
damus_state.nostrNetwork.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds an event to this thread.
|
/// Adds an event to this thread.
|
||||||
@@ -176,7 +176,7 @@ class ThreadModel: ObservableObject {
|
|||||||
/// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface
|
/// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||||
let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in
|
let (sub_id, done) = handle_subid_event(pool: damus_state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sid, ev in
|
||||||
guard subids.contains(sid) else {
|
guard subids.contains(sid) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ 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 {
|
||||||
|
guard let url = try? getUrlToOpen(invoice: invoice.string, with: damus_state.settings.default_wallet.model) else {
|
||||||
|
return .sheet(.select_wallet(invoice: invoice.string))
|
||||||
|
}
|
||||||
|
return .external_url(url)
|
||||||
|
}
|
||||||
case nil:
|
case nil:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -91,6 +100,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 +117,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ class UserSettingsStore: ObservableObject {
|
|||||||
@Setting(key: "show_wallet_selector", default_value: false)
|
@Setting(key: "show_wallet_selector", default_value: false)
|
||||||
var show_wallet_selector: Bool
|
var show_wallet_selector: Bool
|
||||||
|
|
||||||
|
@Setting(key: "dismiss_wallet_high_balance_warning", default_value: false)
|
||||||
|
var dismiss_wallet_high_balance_warning: Bool
|
||||||
|
|
||||||
|
@Setting(key: "hide_wallet_balance", default_value: false)
|
||||||
|
var hide_wallet_balance: Bool
|
||||||
|
|
||||||
@Setting(key: "left_handed", default_value: false)
|
@Setting(key: "left_handed", default_value: false)
|
||||||
var left_handed: Bool
|
var left_handed: Bool
|
||||||
|
|
||||||
@@ -121,7 +127,13 @@ class UserSettingsStore: ObservableObject {
|
|||||||
|
|
||||||
@Setting(key: "media_previews", default_value: true)
|
@Setting(key: "media_previews", default_value: true)
|
||||||
var media_previews: Bool
|
var media_previews: Bool
|
||||||
|
|
||||||
|
@Setting(key: "show_trusted_replies_first", default_value: true)
|
||||||
|
var show_trusted_replies_first: Bool
|
||||||
|
|
||||||
|
@Setting(key: "reset_tips_on_launch", default_value: false)
|
||||||
|
var reset_tips_on_launch: Bool
|
||||||
|
|
||||||
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
|
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
|
||||||
var hide_nsfw_tagged_content: Bool
|
var hide_nsfw_tagged_content: Bool
|
||||||
|
|
||||||
@@ -160,7 +172,13 @@ class UserSettingsStore: ObservableObject {
|
|||||||
|
|
||||||
@Setting(key: "notification_only_from_following", default_value: false)
|
@Setting(key: "notification_only_from_following", default_value: false)
|
||||||
var notification_only_from_following: Bool
|
var notification_only_from_following: Bool
|
||||||
|
|
||||||
|
@Setting(key: "hellthread_notifications_disabled", default_value: false)
|
||||||
|
var hellthread_notifications_disabled: Bool
|
||||||
|
|
||||||
|
@Setting(key: "hellthread_notification_max_pubkeys", default_value: DEFAULT_HELLTHREAD_MAX_PUBKEYS)
|
||||||
|
var hellthread_notification_max_pubkeys: Int
|
||||||
|
|
||||||
@Setting(key: "translate_dms", default_value: false)
|
@Setting(key: "translate_dms", default_value: false)
|
||||||
var translate_dms: Bool
|
var translate_dms: Bool
|
||||||
|
|
||||||
@@ -168,8 +186,12 @@ class UserSettingsStore: ObservableObject {
|
|||||||
var truncate_timeline_text: Bool
|
var truncate_timeline_text: Bool
|
||||||
|
|
||||||
/// Nozaps mode gimps note zapping to fit into apple's content-tipping guidelines. It can not be configurable to end-users on the app store
|
/// Nozaps mode gimps note zapping to fit into apple's content-tipping guidelines. It can not be configurable to end-users on the app store
|
||||||
@Setting(key: "nozaps", default_value: true)
|
///
|
||||||
var nozaps: Bool
|
/// Update 2025-05-12: This can be re-enabled 🥳. See https://github.com/damus-io/damus/issues/3016
|
||||||
|
// @Setting(key: "nozaps", default_value: true)
|
||||||
|
var nozaps: Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
@Setting(key: "truncate_mention_text", default_value: true)
|
@Setting(key: "truncate_mention_text", default_value: true)
|
||||||
var truncate_mention_text: Bool
|
var truncate_mention_text: Bool
|
||||||
@@ -336,6 +358,10 @@ class UserSettingsStore: ObservableObject {
|
|||||||
@Setting(key: "draft_event_ids", default_value: nil)
|
@Setting(key: "draft_event_ids", default_value: nil)
|
||||||
var draft_event_ids: [String]?
|
var draft_event_ids: [String]?
|
||||||
|
|
||||||
|
// TODO: Get rid of this once we have NostrDB query capabilities integrated
|
||||||
|
@Setting(key: "latest_relay_list_event_id", default_value: nil)
|
||||||
|
var latestRelayListEventIdHex: String?
|
||||||
|
|
||||||
// MARK: Helper types
|
// MARK: Helper types
|
||||||
|
|
||||||
enum NotificationsMode: String, CaseIterable, Identifiable, StringCodable, Equatable {
|
enum NotificationsMode: String, CaseIterable, Identifiable, StringCodable, Equatable {
|
||||||
|
|||||||
@@ -83,8 +83,10 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
|
|||||||
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:",
|
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:",
|
||||||
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
|
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
|
||||||
case .bitcoinbeach:
|
case .bitcoinbeach:
|
||||||
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://",
|
// Blink used to be called Bitcoin Beach.
|
||||||
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
|
// We have to keep the tag called "bitcoinbeach" for backwards compatibility.
|
||||||
|
return .init(index: 10, tag: "bitcoinbeach", displayName: "Blink", link: "blink://",
|
||||||
|
appStoreLink: "https://apps.apple.com/app/blink-bitcoin-wallet/id1531383905", image: "blink")
|
||||||
case .blixtwallet:
|
case .blixtwallet:
|
||||||
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
|
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
|
||||||
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
|
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class WalletModel: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var connect_state: WalletConnectState
|
@Published private(set) var connect_state: WalletConnectState
|
||||||
|
|
||||||
|
/// A dictionary listing continuations waiting for a response for each request note id.
|
||||||
|
///
|
||||||
|
/// Please see the `waitForResponse` method for context.
|
||||||
|
private var continuations: [NoteId: CheckedContinuation<WalletConnect.Response.Result, any Error>] = [:]
|
||||||
|
|
||||||
init(state: WalletConnectState, settings: UserSettingsStore) {
|
init(state: WalletConnectState, settings: UserSettingsStore) {
|
||||||
self.connect_state = state
|
self.connect_state = state
|
||||||
self.previous_state = .none
|
self.previous_state = .none
|
||||||
@@ -75,12 +80,16 @@ class WalletModel: ObservableObject {
|
|||||||
///
|
///
|
||||||
/// - Parameter response: The NWC response received from the network
|
/// - Parameter response: The NWC response received from the network
|
||||||
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
|
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
|
||||||
switch response.response.result {
|
if let error = response.response.error {
|
||||||
|
self.resume(request: response.req_id, throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let result = response.response.result else { return }
|
||||||
|
self.resume(request: response.req_id, with: result)
|
||||||
|
switch result {
|
||||||
case .get_balance(let balanceResp):
|
case .get_balance(let balanceResp):
|
||||||
self.balance = balanceResp.balance / 1000
|
self.balance = balanceResp.balance / 1000
|
||||||
case .none:
|
case .pay_invoice(_):
|
||||||
return
|
|
||||||
case .some(.pay_invoice(_)):
|
|
||||||
return
|
return
|
||||||
case .list_transactions(let transactionsResp):
|
case .list_transactions(let transactionsResp):
|
||||||
self.transactions = transactionsResp.transactions
|
self.transactions = transactionsResp.transactions
|
||||||
@@ -91,4 +100,41 @@ class WalletModel: ObservableObject {
|
|||||||
self.transactions = nil
|
self.transactions = nil
|
||||||
self.balance = nil
|
self.balance = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Async wallet response waiting mechanism
|
||||||
|
|
||||||
|
func waitForResponse(for requestId: NoteId, timeout: Duration = .seconds(10)) async throws -> WalletConnect.Response.Result {
|
||||||
|
return try await withCheckedThrowingContinuation({ continuation in
|
||||||
|
self.continuations[requestId] = continuation
|
||||||
|
|
||||||
|
let timeoutTask = Task {
|
||||||
|
try? await Task.sleep(for: timeout)
|
||||||
|
self.resume(request: requestId, throwing: WaitError.timeout) // Must resume the continuation exactly once even if there is no response
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resume(request requestId: NoteId, with result: WalletConnect.Response.Result) {
|
||||||
|
continuations[requestId]?.resume(returning: result)
|
||||||
|
continuations[requestId] = nil // Never resume a continuation twice
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resume(request requestId: NoteId, throwing error: any Error) {
|
||||||
|
if let continuation = continuations[requestId] {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
continuations[requestId] = nil // Never resume a continuation twice
|
||||||
|
return // Error will be handled by the listener, no need for the generic error sheet
|
||||||
|
}
|
||||||
|
|
||||||
|
// No listeners to catch the error, show generic error sheet
|
||||||
|
if let error = error as? WalletConnect.WalletResponseErr,
|
||||||
|
let humanReadableError = error.humanReadableError {
|
||||||
|
present_sheet(.error(humanReadableError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WaitError: Error {
|
||||||
|
case timeout
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ class ZapsModel: ObservableObject {
|
|||||||
case .note(let note_target):
|
case .note(let note_target):
|
||||||
filter.referenced_ids = [note_target.note_id]
|
filter.referenced_ids = [note_target.note_id]
|
||||||
}
|
}
|
||||||
state.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
|
state.nostrNetwork.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe() {
|
func unsubscribe() {
|
||||||
state.pool.unsubscribe(sub_id: zaps_subid)
|
state.nostrNetwork.pool.unsubscribe(sub_id: zaps_subid)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -52,4 +52,28 @@ extension NIP04 {
|
|||||||
|
|
||||||
return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4)
|
return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypts string content
|
||||||
|
static func decryptContent(recipientPrivateKey: Privkey, senderPubkey: Pubkey, content: String, encoding: EncEncoding) throws(NIP04DecryptionError) -> String {
|
||||||
|
guard let shared_sec = get_shared_secret(privkey: recipientPrivateKey, pubkey: senderPubkey) else {
|
||||||
|
throw .failedToComputeSharedSecret
|
||||||
|
}
|
||||||
|
guard let dat = (encoding == .base64 ? decode_dm_base64(content) : decode_dm_bech32(content)) else {
|
||||||
|
throw .failedToDecodeEncryptedContent
|
||||||
|
}
|
||||||
|
guard let dat = aes_decrypt(data: dat.content, iv: dat.iv, shared_sec: shared_sec) else {
|
||||||
|
throw .failedToDecryptAES
|
||||||
|
}
|
||||||
|
guard let decryptedString = String(data: dat, encoding: .utf8) else {
|
||||||
|
throw .utf8DecodingFailedOnDecryptedPayload
|
||||||
|
}
|
||||||
|
return decryptedString
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NIP04DecryptionError: Error {
|
||||||
|
case failedToComputeSharedSecret
|
||||||
|
case failedToDecodeEncryptedContent
|
||||||
|
case failedToDecryptAES
|
||||||
|
case utf8DecodingFailedOnDecryptedPayload
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//
|
||||||
|
// NIP65.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-02-21.
|
||||||
|
//
|
||||||
|
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
|
||||||
|
|
||||||
|
import OrderedCollections
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Includes models and functions for working with NIP-65
|
||||||
|
struct NIP65: Sendable {}
|
||||||
|
|
||||||
|
extension NIP65 {
|
||||||
|
/// Models a NIP-65 relay list
|
||||||
|
struct RelayList: NostrEventConvertible, Sendable {
|
||||||
|
let relays: OrderedDictionary<RelayURL, RelayItem>
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(event: NdbNote) throws(NIP65DecodingError) {
|
||||||
|
try self.init(event: UnownedNdbNote(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
init(event: borrowing UnownedNdbNote) throws(NIP65DecodingError) {
|
||||||
|
guard event.known_kind == .relay_list else { throw .notRelayList }
|
||||||
|
var relays: [RelayItem] = []
|
||||||
|
for tag in event.tags {
|
||||||
|
guard let relay = try RelayItem.fromTag(tag: tag) else { continue }
|
||||||
|
relays.append(relay)
|
||||||
|
}
|
||||||
|
self.relays = Self.relayOrderedDictionary(from: relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(event: NdbNote?) throws(NIP65DecodingError) {
|
||||||
|
guard let event else { return nil }
|
||||||
|
try self.init(event: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(relays: [RelayItem]) {
|
||||||
|
self.relays = Self.relayOrderedDictionary(from: relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(relays: [RelayURL]) {
|
||||||
|
let relayItemList = relays.map({ RelayItem(url: $0, rwConfiguration: .readWrite) })
|
||||||
|
self.relays = Self.relayOrderedDictionary(from: relayItemList)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func relayOrderedDictionary(from relayList: [RelayItem]) -> OrderedDictionary<RelayURL, RelayItem> {
|
||||||
|
var seenUrls: Set<RelayURL> = []
|
||||||
|
return OrderedDictionary(uniqueKeysWithValues: relayList.compactMap({
|
||||||
|
// We need to ensure the keys are unique to avoid assertion errors from OrderedDictionary
|
||||||
|
guard !seenUrls.contains($0.url) else { return nil }
|
||||||
|
seenUrls.insert($0.url)
|
||||||
|
return ($0.url, $0)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Conversion to a Nostr Event
|
||||||
|
|
||||||
|
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
|
||||||
|
return NdbNote(
|
||||||
|
content: "",
|
||||||
|
keypair: keypair.to_keypair(),
|
||||||
|
kind: NostrKind.relay_list.rawValue,
|
||||||
|
tags: self.relays.values.map({ $0.tag }),
|
||||||
|
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NIP65 {
|
||||||
|
/// An error thrown when decoding an item into a NIP-65 relay list
|
||||||
|
enum NIP65DecodingError: Error {
|
||||||
|
/// The Nostr event being converted is not a NIP-65 relay list
|
||||||
|
case notRelayList
|
||||||
|
/// The relay URL is invalid
|
||||||
|
case invalidRelayURL
|
||||||
|
///The relay RW marker is invalid
|
||||||
|
case invalidRelayMarker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NIP65.RelayList {
|
||||||
|
/// An item referencing a relay and its configuration inside a relay list
|
||||||
|
struct RelayItem: ThrowingTagConvertible, Sendable {
|
||||||
|
typealias E = NIP65.NIP65DecodingError
|
||||||
|
|
||||||
|
let url: RelayURL
|
||||||
|
let rwConfiguration: RWConfiguration
|
||||||
|
|
||||||
|
/// The raw tag sequence in a Nostr event
|
||||||
|
var tag: [String] {
|
||||||
|
var tag = ["r", url.absoluteString]
|
||||||
|
if let rwMarker = rwConfiguration.tagItem { tag.append(rwMarker) }
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize a new relay item from a Nostr event's tag sequence
|
||||||
|
static func fromTag(tag: TagSequence) throws(E) -> NIP65.RelayList.RelayItem? {
|
||||||
|
var i = tag.makeIterator()
|
||||||
|
|
||||||
|
guard tag.count >= 2,
|
||||||
|
let t0 = i.next(),
|
||||||
|
let key = t0.single_char,
|
||||||
|
let rkey = RefId.RefKey(rawValue: key),
|
||||||
|
let t1 = i.next()
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let t2 = i.next()
|
||||||
|
|
||||||
|
switch rkey {
|
||||||
|
case .r: return try self.fromRawInfo(urlString: t1.string(), rwMarker: t2?.string())
|
||||||
|
// Keep options explicit to make compiler prompt developer on whether to ignore or handle new future options
|
||||||
|
case .e, .p, .q, .t, .d, .a: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes a Relay Item based on raw information
|
||||||
|
static func fromRawInfo(urlString: String, rwMarker: String?) throws(NIP65.NIP65DecodingError) -> NIP65.RelayList.RelayItem? {
|
||||||
|
guard let relayUrl = RelayURL(urlString) else { throw .invalidRelayURL }
|
||||||
|
guard let rwConfiguration = RWConfiguration.fromTagItem(rwMarker) else { throw .invalidRelayMarker }
|
||||||
|
return NIP65.RelayList.RelayItem(url: relayUrl, rwConfiguration: rwConfiguration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NIP65.RelayList.RelayItem {
|
||||||
|
/// The read/write configuration for a relay item
|
||||||
|
enum RWConfiguration: TagItemConvertible {
|
||||||
|
case read
|
||||||
|
case write
|
||||||
|
case readWrite
|
||||||
|
|
||||||
|
static let READ_MARKER: String = "read"
|
||||||
|
static let WRITE_MARKER: String = "write"
|
||||||
|
|
||||||
|
var canRead: Bool {
|
||||||
|
switch self {
|
||||||
|
case .read, .readWrite: return true
|
||||||
|
case .write: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var canWrite: Bool {
|
||||||
|
switch self {
|
||||||
|
case .write, .readWrite: return true
|
||||||
|
case .read: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A raw Nostr Event tag item
|
||||||
|
var tagItem: String? {
|
||||||
|
switch self {
|
||||||
|
case .read: Self.READ_MARKER
|
||||||
|
case .write: Self.WRITE_MARKER
|
||||||
|
case .readWrite: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize this from a raw Nostr Event tag item
|
||||||
|
static func fromTagItem(_ item: String?) -> Self? {
|
||||||
|
if item == READ_MARKER { return .read }
|
||||||
|
if item == WRITE_MARKER { return .write }
|
||||||
|
return .readWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-1
@@ -34,6 +34,19 @@ protocol TagConvertible {
|
|||||||
static func from_tag(tag: TagSequence) -> Self?
|
static func from_tag(tag: TagSequence) -> Self?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Protocol for types that can be converted from/to a tag sequence with the possibilty of an error
|
||||||
|
protocol ThrowingTagConvertible {
|
||||||
|
associatedtype E: Error
|
||||||
|
var tag: [String] { get }
|
||||||
|
static func fromTag(tag: TagSequence) throws(E) -> Self?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protocol for types that can be converted from/to a tag item
|
||||||
|
protocol TagItemConvertible {
|
||||||
|
var tagItem: String? { get }
|
||||||
|
static func fromTagItem(_ item: String?) -> Self?
|
||||||
|
}
|
||||||
|
|
||||||
struct QuoteId: IdType, TagKey, TagConvertible {
|
struct QuoteId: IdType, TagKey, TagConvertible {
|
||||||
let id: Data
|
let id: Data
|
||||||
|
|
||||||
@@ -130,8 +143,16 @@ struct ReplaceableParam: TagConvertible {
|
|||||||
var keychar: AsciiCharacter { "d" }
|
var keychar: AsciiCharacter { "d" }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Signature: Hashable, Equatable {
|
struct Signature: Codable, Hashable, Equatable {
|
||||||
let data: Data
|
let data: Data
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(try hex_decoder(decoder, expected_len: 64))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
try hex_encoder(to: encoder, data: self.data)
|
||||||
|
}
|
||||||
|
|
||||||
init(_ p: Data) {
|
init(_ p: Data) {
|
||||||
self.data = p
|
self.data = p
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: Relay) -> NostrEvent? {
|
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: RelayPool.Relay) -> NostrEvent? {
|
||||||
let tags: [[String]] = [["relay", relay.descriptor.url.absoluteString],["challenge", challenge_string]]
|
let tags: [[String]] = [["relay", relay.descriptor.url.absoluteString],["challenge", challenge_string]]
|
||||||
let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags)
|
let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags)
|
||||||
return event
|
return event
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
|
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayPool.RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
|
||||||
var tags = zap_target_to_tags(target)
|
var tags = zap_target_to_tags(target)
|
||||||
var relay_tag = ["relays"]
|
var relay_tag = ["relays"]
|
||||||
relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
|
relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
|
||||||
@@ -78,8 +78,8 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair,
|
|||||||
|
|
||||||
func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
|
func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
|
||||||
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
|
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
|
||||||
let rw_relay_info = RelayInfo(read: true, write: true)
|
let rw_relay_info = LegacyKind3RelayRWConfiguration(read: true, write: true)
|
||||||
var relays: [RelayURL: RelayInfo] = [:]
|
var relays: [RelayURL: LegacyKind3RelayRWConfiguration] = [:]
|
||||||
|
|
||||||
for relay in bootstrap_relays {
|
for relay in bootstrap_relays {
|
||||||
relays[relay] = rw_relay_info
|
relays[relay] = rw_relay_info
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ import CryptoKit
|
|||||||
import NaturalLanguage
|
import NaturalLanguage
|
||||||
|
|
||||||
|
|
||||||
|
/// A protocol for structs and classes that can convert themselves from/to a NostrEvent
|
||||||
|
protocol NostrEventConvertible {
|
||||||
|
associatedtype E: Error
|
||||||
|
|
||||||
|
/// Iniitialize this type from a NostrEvent
|
||||||
|
init(event: NostrEvent) throws(E)
|
||||||
|
|
||||||
|
/// Convert this type into a Nostr Event, using a keypair for signing and a specific timestamp
|
||||||
|
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32?) -> NostrEvent?
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
enum ValidationResult: Decodable {
|
enum ValidationResult: Decodable {
|
||||||
case unknown
|
case unknown
|
||||||
case ok
|
case ok
|
||||||
@@ -367,6 +379,10 @@ func decode_json<T: Decodable>(_ val: String) -> T? {
|
|||||||
return try? JSONDecoder().decode(T.self, from: Data(val.utf8))
|
return try? JSONDecoder().decode(T.self, from: Data(val.utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decode_json_throwing<T: Decodable>(_ val: String) throws -> T {
|
||||||
|
return try JSONDecoder().decode(T.self, from: Data(val.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
func decode_data<T: Decodable>(_ data: Data) -> T? {
|
func decode_data<T: Decodable>(_ data: Data) -> T? {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
do {
|
do {
|
||||||
@@ -527,6 +543,7 @@ func event_to_json(ev: NostrEvent) -> String {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(*, deprecated, renamed: "NIP04.decryptContent", message: "Deprecated, please use NIP04.decryptContent instead")
|
||||||
func decrypt_dm(_ privkey: Privkey?, pubkey: Pubkey, content: String, encoding: EncEncoding) -> String? {
|
func decrypt_dm(_ privkey: Privkey?, pubkey: Pubkey, content: String, encoding: EncEncoding) -> String? {
|
||||||
guard let privkey = privkey else {
|
guard let privkey = privkey else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
|
||||||
|
/// A known Nostr event kind, addressable by name, with the actual number assigned by the protocol as the value
|
||||||
enum NostrKind: UInt32, Codable {
|
enum NostrKind: UInt32, Codable {
|
||||||
case metadata = 0
|
case metadata = 0
|
||||||
case text = 1
|
case text = 1
|
||||||
@@ -18,6 +19,7 @@ enum NostrKind: UInt32, Codable {
|
|||||||
case like = 7
|
case like = 7
|
||||||
case chat = 42
|
case chat = 42
|
||||||
case mute_list = 10000
|
case mute_list = 10000
|
||||||
|
case relay_list = 10002
|
||||||
case list_deprecated = 30000
|
case list_deprecated = 30000
|
||||||
case draft = 31234
|
case draft = 31234
|
||||||
case longform = 30023
|
case longform = 30023
|
||||||
@@ -28,4 +30,5 @@ enum NostrKind: UInt32, Codable {
|
|||||||
case nwc_response = 23195
|
case nwc_response = 23195
|
||||||
case http_auth = 27235
|
case http_auth = 27235
|
||||||
case status = 30315
|
case status = 30315
|
||||||
|
case follow_list = 39089
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ struct NostrSubscribe {
|
|||||||
let sub_id: String
|
let sub_id: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Models a request/message that is sent to a Nostr relay
|
||||||
enum NostrRequestType {
|
enum NostrRequestType {
|
||||||
|
/// A standard nostr request
|
||||||
case typical(NostrRequest)
|
case typical(NostrRequest)
|
||||||
|
/// A customized nostr request. Generally used in the context of a nostrscript.
|
||||||
case custom(String)
|
case custom(String)
|
||||||
|
|
||||||
|
/// Whether this request is meant to write data to a relay
|
||||||
var is_write: Bool {
|
var is_write: Bool {
|
||||||
guard case .typical(let req) = self else {
|
guard case .typical(let req) = self else {
|
||||||
return true
|
return true
|
||||||
@@ -25,6 +28,7 @@ enum NostrRequestType {
|
|||||||
return req.is_write
|
return req.is_write
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this request is meant to read data from a relay
|
||||||
var is_read: Bool {
|
var is_read: Bool {
|
||||||
guard case .typical(let req) = self else {
|
guard case .typical(let req) = self else {
|
||||||
return true
|
return true
|
||||||
@@ -34,12 +38,18 @@ enum NostrRequestType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Models a standard request/message that is sent to a Nostr relay.
|
||||||
enum NostrRequest {
|
enum NostrRequest {
|
||||||
|
/// Subscribes to receive information from the relay
|
||||||
case subscribe(NostrSubscribe)
|
case subscribe(NostrSubscribe)
|
||||||
|
/// Unsubscribes from an existing subscription, addressed by its id
|
||||||
case unsubscribe(String)
|
case unsubscribe(String)
|
||||||
|
/// Posts an event
|
||||||
case event(NostrEvent)
|
case event(NostrEvent)
|
||||||
|
/// Authenticate with the relay
|
||||||
case auth(NostrEvent)
|
case auth(NostrEvent)
|
||||||
|
|
||||||
|
/// Whether this request is meant to write data to a relay
|
||||||
var is_write: Bool {
|
var is_write: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .subscribe:
|
case .subscribe:
|
||||||
@@ -53,6 +63,7 @@ enum NostrRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this request is meant to read data from a relay
|
||||||
var is_read: Bool {
|
var is_read: Bool {
|
||||||
return !is_write
|
return !is_write
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class Profiles {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private var profiles: [Pubkey: ProfileData] = [:]
|
private var profiles: [Pubkey: ProfileData] = [:]
|
||||||
|
|
||||||
|
// Map of validated NIP-05 address to pubkey.
|
||||||
@MainActor
|
@MainActor
|
||||||
var nip05_pubkey: [String: Pubkey] = [:]
|
var nip05_pubkey: [String: Pubkey] = [:]
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,19 @@ enum FollowRef: TagKeys, Hashable, TagConvertible, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Models common tag references defined by the Nostr protocol, and their associated values.
|
||||||
|
///
|
||||||
|
/// For example, this raw JSON tag sequence:
|
||||||
|
/// ```json
|
||||||
|
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// would be parsed into something equivalent to `.pubkey(Pubkey(hex: "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"))`
|
||||||
|
///
|
||||||
|
/// ## Notes
|
||||||
|
///
|
||||||
|
/// - Not all tag information from all NIPs can be modelled using this alone, as some NIPs may define extra associated values for specific event types. You may need to use a specialized type for some event kinds
|
||||||
|
///
|
||||||
enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||||
case event(NoteId)
|
case event(NoteId)
|
||||||
case pubkey(Pubkey)
|
case pubkey(Pubkey)
|
||||||
@@ -124,6 +137,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
|||||||
case naddr(NAddr)
|
case naddr(NAddr)
|
||||||
case reference(String)
|
case reference(String)
|
||||||
|
|
||||||
|
/// The key that defines the type of reference being made
|
||||||
var key: RefKey {
|
var key: RefKey {
|
||||||
switch self {
|
switch self {
|
||||||
case .event: return .e
|
case .event: return .e
|
||||||
@@ -136,6 +150,14 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Defines the type of reference being made on a Nostr event tag
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```json
|
||||||
|
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The `RefKey` is "p"
|
||||||
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
|
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
|
||||||
case e, p, t, d, q, a, r
|
case e, p, t, d, q, a, r
|
||||||
|
|
||||||
@@ -148,10 +170,12 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A raw nostr-style tag sequence representation of this object
|
||||||
var tag: [String] {
|
var tag: [String] {
|
||||||
[self.key.description, self.description]
|
[self.key.description, self.description]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Describes what is being referenced, as a `String`
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .event(let noteId): return noteId.hex()
|
case .event(let noteId): return noteId.hex()
|
||||||
@@ -166,6 +190,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parses a raw tag sequence
|
||||||
static func from_tag(tag: TagSequence) -> RefId? {
|
static func from_tag(tag: TagSequence) -> RefId? {
|
||||||
var i = tag.makeIterator()
|
var i = tag.makeIterator()
|
||||||
|
|
||||||
|
|||||||
+88
-50
@@ -7,16 +7,25 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct RelayInfo: Codable {
|
public struct LegacyKind3RelayRWConfiguration: Codable, Sendable {
|
||||||
let read: Bool?
|
public let read: Bool?
|
||||||
let write: Bool?
|
public let write: Bool?
|
||||||
|
|
||||||
init(read: Bool, write: Bool) {
|
init(read: Bool, write: Bool) {
|
||||||
self.read = read
|
self.read = read
|
||||||
self.write = write
|
self.write = write
|
||||||
}
|
}
|
||||||
|
|
||||||
static let rw = RelayInfo(read: true, write: true)
|
static let rw = LegacyKind3RelayRWConfiguration(read: true, write: true)
|
||||||
|
|
||||||
|
func toNIP65RWConfiguration() -> NIP65.RelayList.RelayItem.RWConfiguration? {
|
||||||
|
switch (self.read, self.write) {
|
||||||
|
case (false, true): return .write
|
||||||
|
case (true, false): return .read
|
||||||
|
case (true, true): return .readWrite
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RelayVariant {
|
enum RelayVariant {
|
||||||
@@ -25,30 +34,33 @@ enum RelayVariant {
|
|||||||
case nwc
|
case nwc
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct RelayDescriptor {
|
extension RelayPool {
|
||||||
let url: RelayURL
|
/// Describes a relay for use in `RelayPool`
|
||||||
let info: RelayInfo
|
public struct RelayDescriptor {
|
||||||
let variant: RelayVariant
|
let url: RelayURL
|
||||||
|
var info: NIP65.RelayList.RelayItem.RWConfiguration
|
||||||
init(url: RelayURL, info: RelayInfo, variant: RelayVariant = .regular) {
|
let variant: RelayVariant
|
||||||
self.url = url
|
|
||||||
self.info = info
|
init(url: RelayURL, info: NIP65.RelayList.RelayItem.RWConfiguration, variant: RelayVariant = .regular) {
|
||||||
self.variant = variant
|
self.url = url
|
||||||
}
|
self.info = info
|
||||||
|
self.variant = variant
|
||||||
var ephemeral: Bool {
|
}
|
||||||
switch variant {
|
|
||||||
case .regular:
|
var ephemeral: Bool {
|
||||||
return false
|
switch variant {
|
||||||
case .ephemeral:
|
case .regular:
|
||||||
return true
|
return false
|
||||||
case .nwc:
|
case .ephemeral:
|
||||||
return true
|
return true
|
||||||
|
case .nwc:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func nwc(url: RelayURL) -> RelayDescriptor {
|
||||||
|
return RelayDescriptor(url: url, info: .readWrite, variant: .nwc)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
static func nwc(url: RelayURL) -> RelayDescriptor {
|
|
||||||
return RelayDescriptor(url: url, info: .rw, variant: .nwc)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,30 +141,56 @@ struct RelayMetadata: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Relay: Identifiable {
|
extension RelayPool {
|
||||||
let descriptor: RelayDescriptor
|
class Relay: Identifiable {
|
||||||
let connection: RelayConnection
|
var descriptor: RelayDescriptor
|
||||||
var authentication_state: RelayAuthenticationState
|
let connection: RelayConnection
|
||||||
|
var authentication_state: RelayAuthenticationState
|
||||||
var flags: Int
|
|
||||||
|
var flags: Int
|
||||||
init(descriptor: RelayDescriptor, connection: RelayConnection) {
|
|
||||||
self.flags = 0
|
init(descriptor: RelayDescriptor, connection: RelayConnection) {
|
||||||
self.descriptor = descriptor
|
self.flags = 0
|
||||||
self.connection = connection
|
self.descriptor = descriptor
|
||||||
self.authentication_state = RelayAuthenticationState.none
|
self.connection = connection
|
||||||
|
self.authentication_state = RelayAuthenticationState.none
|
||||||
|
}
|
||||||
|
|
||||||
|
var is_broken: Bool {
|
||||||
|
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
var id: RelayURL {
|
||||||
|
return descriptor.url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var is_broken: Bool {
|
|
||||||
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
var id: RelayURL {
|
|
||||||
return descriptor.url
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RelayError: Error {
|
extension RelayPool {
|
||||||
case RelayAlreadyExists
|
enum RelayError: Error {
|
||||||
|
case RelayAlreadyExists
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Extension to bridge NIP-65 relay list structs with app-native objects
|
||||||
|
|
||||||
|
extension NIP65.RelayList {
|
||||||
|
static func fromLegacyContactList(_ contactList: NdbNote) throws(BridgeError) -> Self {
|
||||||
|
guard let relayListInfo = decode_json_relays(contactList.content) else { throw .couldNotDecodeRelayListInfo }
|
||||||
|
let relayItems = relayListInfo.map({ url, rwConfiguration in
|
||||||
|
return RelayItem(url: url, rwConfiguration: rwConfiguration.toNIP65RWConfiguration() ?? .readWrite)
|
||||||
|
})
|
||||||
|
return NIP65.RelayList(relays: relayItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromLegacyContactList(_ contactList: NdbNote?) throws(BridgeError) -> Self? {
|
||||||
|
guard let contactList = contactList else { return nil }
|
||||||
|
return try fromLegacyContactList(contactList)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BridgeError: Error {
|
||||||
|
case couldNotDecodeRelayListInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,15 @@ struct SeenEvent: Hashable {
|
|||||||
let evid: NoteId
|
let evid: NoteId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Establishes and manages connections and subscriptions to a list of relays.
|
||||||
class RelayPool {
|
class RelayPool {
|
||||||
var relays: [Relay] = []
|
private(set) var relays: [Relay] = []
|
||||||
var handlers: [RelayHandler] = []
|
var handlers: [RelayHandler] = []
|
||||||
var request_queue: [QueuedRequest] = []
|
var request_queue: [QueuedRequest] = []
|
||||||
var seen: Set<SeenEvent> = Set()
|
var seen: Set<SeenEvent> = Set()
|
||||||
var counts: [RelayURL: UInt64] = [:]
|
var counts: [RelayURL: UInt64] = [:]
|
||||||
var ndb: Ndb
|
var ndb: Ndb
|
||||||
|
/// The keypair used to authenticate with relays
|
||||||
var keypair: Keypair?
|
var keypair: Keypair?
|
||||||
var message_received_function: (((String, RelayDescriptor)) -> Void)?
|
var message_received_function: (((String, RelayDescriptor)) -> Void)?
|
||||||
var message_sent_function: (((String, Relay)) -> Void)?
|
var message_sent_function: (((String, Relay)) -> Void)?
|
||||||
@@ -122,7 +124,7 @@ class RelayPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func add_relay(_ desc: RelayDescriptor) throws {
|
func add_relay(_ desc: RelayDescriptor) throws(RelayError) {
|
||||||
let relay_id = desc.url
|
let relay_id = desc.url
|
||||||
if get_relay(relay_id) != nil {
|
if get_relay(relay_id) != nil {
|
||||||
throw RelayError.RelayAlreadyExists
|
throw RelayError.RelayAlreadyExists
|
||||||
@@ -200,6 +202,64 @@ class RelayPool {
|
|||||||
register_handler(sub_id: sub_id, handler: handler)
|
register_handler(sub_id: sub_id, handler: handler)
|
||||||
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
|
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribes to data from the `RelayPool` based on a filter and a list of desired relays.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - filters: The filters specifying the desired content.
|
||||||
|
/// - desiredRelays: The desired relays which to subsctibe to. If `nil`, it defaults to the `RelayPool`'s default list
|
||||||
|
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal, in seconds
|
||||||
|
/// - Returns: Returns an async stream that callers can easily consume via a for-loop
|
||||||
|
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: TimeInterval = 10) -> AsyncStream<StreamItem> {
|
||||||
|
let desiredRelays = desiredRelays ?? self.relays.map({ $0.descriptor.url })
|
||||||
|
return AsyncStream<StreamItem> { continuation in
|
||||||
|
let sub_id = UUID().uuidString
|
||||||
|
var seenEvents: Set<NoteId> = []
|
||||||
|
var relaysWhoFinishedInitialResults: Set<RelayURL> = []
|
||||||
|
var eoseSent = false
|
||||||
|
self.subscribe(sub_id: sub_id, filters: filters, handler: { (relayUrl, connectionEvent) in
|
||||||
|
switch connectionEvent {
|
||||||
|
case .ws_event(let ev):
|
||||||
|
// Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here.
|
||||||
|
// For the future, perhaps we should abstract away `.ws_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
|
||||||
|
break
|
||||||
|
case .nostr_event(let nostrResponse):
|
||||||
|
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
|
||||||
|
switch nostrResponse {
|
||||||
|
case .event(_, let nostrEvent):
|
||||||
|
if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events.
|
||||||
|
continuation.yield(with: .success(.event(nostrEvent)))
|
||||||
|
seenEvents.insert(nostrEvent.id)
|
||||||
|
case .notice(let note):
|
||||||
|
break // We do not support handling these yet
|
||||||
|
case .eose(_):
|
||||||
|
relaysWhoFinishedInitialResults.insert(relayUrl)
|
||||||
|
if relaysWhoFinishedInitialResults == Set(desiredRelays) {
|
||||||
|
continuation.yield(with: .success(.eose))
|
||||||
|
eoseSent = true
|
||||||
|
}
|
||||||
|
case .ok(_): break // No need to handle this, we are not sending an event to the relay
|
||||||
|
case .auth(_): break // Handled in a separate function in RelayPool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, to: desiredRelays)
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(eoseTimeout))
|
||||||
|
if !eoseSent { continuation.yield(with: .success(.eose)) }
|
||||||
|
}
|
||||||
|
continuation.onTermination = { @Sendable _ in
|
||||||
|
self.unsubscribe(sub_id: sub_id, to: desiredRelays)
|
||||||
|
self.remove_handler(sub_id: sub_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StreamItem {
|
||||||
|
/// A Nostr event
|
||||||
|
case event(NostrEvent)
|
||||||
|
/// The "end of stored events" signal
|
||||||
|
case eose
|
||||||
|
}
|
||||||
|
|
||||||
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
|
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
|
||||||
register_handler(sub_id: sub_id, handler: handler)
|
register_handler(sub_id: sub_id, handler: handler)
|
||||||
@@ -243,19 +303,19 @@ class RelayPool {
|
|||||||
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
|
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
|
||||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||||
|
|
||||||
self.send_raw_to_local_ndb(req)
|
self.send_raw_to_local_ndb(req) // Always send Nostr events and data to NostrDB for a local copy
|
||||||
|
|
||||||
for relay in relays {
|
for relay in relays {
|
||||||
if req.is_read && !(relay.descriptor.info.read ?? true) {
|
if req.is_read && !(relay.descriptor.info.canRead) {
|
||||||
continue
|
continue // Do not send read requests to relays that are not READ relays
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.is_write && !(relay.descriptor.info.write ?? true) {
|
if req.is_write && !(relay.descriptor.info.canWrite) {
|
||||||
continue
|
continue // Do not send write requests to relays that are not WRITE relays
|
||||||
}
|
}
|
||||||
|
|
||||||
if relay.descriptor.ephemeral && skip_ephemeral {
|
if relay.descriptor.ephemeral && skip_ephemeral {
|
||||||
continue
|
continue // Do not send requests to ephemeral relays if we want to skip them
|
||||||
}
|
}
|
||||||
|
|
||||||
guard relay.connection.isConnected else {
|
guard relay.connection.isConnected else {
|
||||||
@@ -354,7 +414,7 @@ class RelayPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
|
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
|
||||||
try? pool.add_relay(RelayDescriptor(url: url, info: .rw))
|
try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -83,8 +83,7 @@ var test_damus_state: DamusState = ({
|
|||||||
let our_pubkey = test_pubkey
|
let our_pubkey = test_pubkey
|
||||||
let pool = RelayPool(ndb: ndb)
|
let pool = RelayPool(ndb: ndb)
|
||||||
let settings = UserSettingsStore()
|
let settings = UserSettingsStore()
|
||||||
let damus = DamusState(pool: pool,
|
let damus = DamusState(keypair: test_keypair,
|
||||||
keypair: test_keypair,
|
|
||||||
likes: .init(our_pubkey: our_pubkey),
|
likes: .init(our_pubkey: our_pubkey),
|
||||||
boosts: .init(our_pubkey: our_pubkey),
|
boosts: .init(our_pubkey: our_pubkey),
|
||||||
contacts: .init(our_pubkey: our_pubkey),
|
contacts: .init(our_pubkey: our_pubkey),
|
||||||
@@ -100,8 +99,6 @@ var test_damus_state: DamusState = ({
|
|||||||
drafts: .init(),
|
drafts: .init(),
|
||||||
events: .init(ndb: ndb),
|
events: .init(ndb: ndb),
|
||||||
bookmarks: .init(pubkey: our_pubkey),
|
bookmarks: .init(pubkey: our_pubkey),
|
||||||
postbox: .init(pool: pool),
|
|
||||||
bootstrap_relays: .init(),
|
|
||||||
replies: .init(our_pubkey: our_pubkey),
|
replies: .init(our_pubkey: our_pubkey),
|
||||||
wallet: .init(settings: settings),
|
wallet: .init(settings: settings),
|
||||||
nav: .init(),
|
nav: .init(),
|
||||||
@@ -109,7 +106,8 @@ var test_damus_state: DamusState = ({
|
|||||||
video: .init(),
|
video: .init(),
|
||||||
ndb: ndb,
|
ndb: ndb,
|
||||||
quote_reposts: .init(our_pubkey: our_pubkey),
|
quote_reposts: .init(our_pubkey: our_pubkey),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||||
|
favicon_cache: .init()
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
+27
-1
@@ -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)
|
||||||
@@ -186,3 +202,13 @@ extension Block {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
extension Block {
|
||||||
|
var asInvoice: Invoice? {
|
||||||
|
switch self {
|
||||||
|
case .invoice(let invoice):
|
||||||
|
return invoice
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
//
|
||||||
|
// CoinosDeterministicClient.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino 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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ func preload_image(url: URL) {
|
|||||||
|
|
||||||
//print("Preloading image \(url.absoluteString)")
|
//print("Preloading image \(url.absoluteString)")
|
||||||
|
|
||||||
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: url)) { val in
|
KingfisherManager.shared.retrieveImage(with: Kingfisher.KF.ImageResource(downloadURL: url)) { val in
|
||||||
//print("Preloaded image \(url.absoluteString)")
|
//print("Preloaded image \(url.absoluteString)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,15 +29,15 @@ extension KFOptionSetter {
|
|||||||
options.onlyLoadFirstFrame = disable_animation
|
options.onlyLoadFirstFrame = disable_animation
|
||||||
|
|
||||||
switch imageContext {
|
switch imageContext {
|
||||||
case .pfp:
|
case .pfp, .favicon:
|
||||||
options.diskCacheExpiration = .days(60)
|
options.diskCacheExpiration = .days(60)
|
||||||
break
|
break
|
||||||
case .banner:
|
case .banner:
|
||||||
options.diskCacheExpiration = .days(5)
|
options.diskCacheExpiration = .days(5)
|
||||||
break
|
break
|
||||||
case .note:
|
case .note:
|
||||||
options.diskCacheExpiration = .days(1)
|
options.diskCacheExpiration = .days(1)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
return self
|
return self
|
||||||
@@ -52,7 +52,7 @@ extension KFOptionSetter {
|
|||||||
|
|
||||||
func onFailure(fallbackUrl: URL?, cacheKey: String?) -> Self {
|
func onFailure(fallbackUrl: URL?, cacheKey: String?) -> Self {
|
||||||
guard let url = fallbackUrl, let key = cacheKey else { return self }
|
guard let url = fallbackUrl, let key = cacheKey else { return self }
|
||||||
let imageResource = Kingfisher.ImageResource(downloadURL: url, cacheKey: key)
|
let imageResource = Kingfisher.KF.ImageResource(downloadURL: url, cacheKey: key)
|
||||||
let source = imageResource.convertToSource()
|
let source = imageResource.convertToSource()
|
||||||
options.alternativeSources = [source]
|
options.alternativeSources = [source]
|
||||||
|
|
||||||
@@ -82,11 +82,14 @@ enum ImageContext {
|
|||||||
case pfp
|
case pfp
|
||||||
case banner
|
case banner
|
||||||
case note
|
case note
|
||||||
|
case favicon
|
||||||
|
|
||||||
func maxMebibyteSize() -> Int {
|
func maxMebibyteSize() -> Int {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .favicon:
|
||||||
|
return 512_000 // 500KiB
|
||||||
case .pfp:
|
case .pfp:
|
||||||
return 5_242_880 // 5Mib
|
return 5_242_880 // 5MiB
|
||||||
case .banner, .note:
|
case .banner, .note:
|
||||||
return 20_971_520 // 20MiB
|
return 20_971_520 // 20MiB
|
||||||
}
|
}
|
||||||
@@ -94,6 +97,8 @@ enum ImageContext {
|
|||||||
|
|
||||||
func downsampleSize() -> CGSize {
|
func downsampleSize() -> CGSize {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .favicon:
|
||||||
|
return CGSize(width: 18, height: 18)
|
||||||
case .pfp:
|
case .pfp:
|
||||||
return CGSize(width: 200, height: 200)
|
return CGSize(width: 200, height: 200)
|
||||||
case .banner:
|
case .banner:
|
||||||
@@ -159,20 +164,25 @@ struct CustomCacheSerializer: CacheSerializer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CustomSessionDelegate: SessionDelegate {
|
class CustomSessionDelegate: SessionDelegate, @unchecked Sendable {
|
||||||
override func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
override func urlSession(
|
||||||
|
_ session: URLSession,
|
||||||
|
dataTask: URLSessionDataTask,
|
||||||
|
didReceive response: URLResponse
|
||||||
|
) async -> URLSession.ResponseDisposition {
|
||||||
let contentLength = response.expectedContentLength
|
let contentLength = response.expectedContentLength
|
||||||
|
|
||||||
// Content-Length header is optional (-1 when missing)
|
// Content-Length header is optional (-1 when missing)
|
||||||
if (contentLength != -1 && contentLength > MAX_FILE_SIZE) {
|
if (contentLength != -1 && contentLength > MAX_FILE_SIZE) {
|
||||||
return super.urlSession(session, dataTask: dataTask, didReceive: URLResponse(), completionHandler: completionHandler)
|
return await super.urlSession(session, dataTask: dataTask, didReceive: URLResponse())
|
||||||
}
|
}
|
||||||
|
|
||||||
super.urlSession(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler)
|
return await super.urlSession(session, dataTask: dataTask, didReceive: response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CustomImageDownloader: ImageDownloader {
|
|
||||||
|
class CustomImageDownloader: ImageDownloader, @unchecked Sendable {
|
||||||
|
|
||||||
static let shared = CustomImageDownloader(name: "shared")
|
static let shared = CustomImageDownloader(name: "shared")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// FaviconCache.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 5/23/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FaviconFinder
|
||||||
|
|
||||||
|
class FaviconCache {
|
||||||
|
private var nip05DomainFavicons: [String: [FaviconURL]] = [:]
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func lookup(_ domain: String) async -> [FaviconURL] {
|
||||||
|
let lowercasedDomain = domain.lowercased()
|
||||||
|
if let faviconURLs = nip05DomainFavicons[lowercasedDomain] {
|
||||||
|
return faviconURLs
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let siteURL = URL(string: "https://\(lowercasedDomain)"),
|
||||||
|
let faviconURLs = try? await FaviconFinder(
|
||||||
|
url: siteURL,
|
||||||
|
configuration: .init(
|
||||||
|
preferredSource: .ico, // Prefer using common favicon .ico filenames at root level to avoid scraping HTML when possible.
|
||||||
|
preferences: [
|
||||||
|
.html: FaviconFormatType.appleTouchIcon.rawValue,
|
||||||
|
.ico: "favicon.ico",
|
||||||
|
.webApplicationManifestFile: FaviconFormatType.launcherIcon4x.rawValue
|
||||||
|
]
|
||||||
|
)
|
||||||
|
).fetchFaviconURLs()
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
nip05DomainFavicons[lowercasedDomain] = faviconURLs
|
||||||
|
|
||||||
|
return faviconURLs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// ImageCacheMigrations.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-04-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct ImageCacheMigrations {
|
||||||
|
static func migrateKingfisherCacheIfNeeded() {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let migration1Key = "KingfisherCacheMigrated" // Never ever changes
|
||||||
|
let migration2Key = "KingfisherCacheMigratedV2" // Never ever changes
|
||||||
|
|
||||||
|
let migration1Done = defaults.bool(forKey: migration1Key)
|
||||||
|
let migration2Done = defaults.bool(forKey: migration2Key)
|
||||||
|
|
||||||
|
guard !migration1Done || !migration2Done else {
|
||||||
|
// All migrations are already done. Skip.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldCachePath = migration1Done ? migration1KingfisherCachePath() : migration0KingfisherCachePath()
|
||||||
|
|
||||||
|
// New shared cache location
|
||||||
|
let newCachePath = kingfisherCachePath().path
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: oldCachePath) {
|
||||||
|
do {
|
||||||
|
// Move the old cache to the new location
|
||||||
|
try fileManager.moveItem(atPath: oldCachePath, toPath: newCachePath)
|
||||||
|
Log.info("Successfully migrated Kingfisher cache to %s", for: .storage, newCachePath)
|
||||||
|
} catch {
|
||||||
|
do {
|
||||||
|
// Cache data is not essential, fallback to deleting the cache and starting all over
|
||||||
|
// It's better than leaving significant garbage data stuck indefinitely on the user's phone
|
||||||
|
try fileManager.removeItem(atPath: newCachePath)
|
||||||
|
try fileManager.removeItem(atPath: oldCachePath)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Log.error("Failed to migrate cache: %s", for: .storage, error.localizedDescription)
|
||||||
|
return // Do not mark them as complete, we can try again next time the user reloads the app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark migrations as complete
|
||||||
|
defaults.set(true, forKey: migration1Key)
|
||||||
|
defaults.set(true, forKey: migration2Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static private func migration0KingfisherCachePath() -> String {
|
||||||
|
// Implementation note: These are old, so they should not be changed
|
||||||
|
let defaultCache = ImageCache.default
|
||||||
|
return defaultCache.diskStorage.directoryURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
static private func migration1KingfisherCachePath() -> String {
|
||||||
|
// Implementation note: These are old, so they are hard-coded on purpose, because we can't change these values from the past.
|
||||||
|
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.damus")!
|
||||||
|
return groupURL.appendingPathComponent("ImageCache").path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest path for kingfisher to store cached images on.
|
||||||
|
///
|
||||||
|
/// Documentation references:
|
||||||
|
/// - https://developer.apple.com/documentation/foundation/filemanager/containerurl(forsecurityapplicationgroupidentifier:)#:~:text=The%20system%20creates%20only%20the%20Library/Caches%20subdirectory%20automatically
|
||||||
|
/// - https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#:~:text=Put%20data%20cache,files%20as%20needed.
|
||||||
|
static func kingfisherCachePath() -> URL {
|
||||||
|
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER)!
|
||||||
|
return groupURL
|
||||||
|
.appendingPathComponent("Library")
|
||||||
|
.appendingPathComponent("Caches")
|
||||||
|
.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ enum LogCategory: String {
|
|||||||
case damus_purple
|
case damus_purple
|
||||||
case image_uploading
|
case image_uploading
|
||||||
case video_coordination
|
case video_coordination
|
||||||
|
case tips
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Damus structured logger
|
/// Damus structured logger
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ enum CancelSendErr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PostBox {
|
class PostBox {
|
||||||
let pool: RelayPool
|
private let pool: RelayPool
|
||||||
var events: [NoteId: PostedEvent]
|
var events: [NoteId: PostedEvent]
|
||||||
|
|
||||||
init(pool: RelayPool) {
|
init(pool: RelayPool) {
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Stores information, metadata, and logs about different relays. Generally used as a singleton.
|
||||||
|
///
|
||||||
|
/// # Discussion
|
||||||
|
///
|
||||||
|
/// This class is primarily used as a shared singleton in `DamusState`, to allow other parts of the app to access information, metadata, and logs about relays without having to fetch it themselves.
|
||||||
|
///
|
||||||
|
/// For example, it is used by `RelayView` to supplement information about the relay without having to fetch those again from the network, as well as to display logs collected throughout the use of the app.
|
||||||
final class RelayModelCache: ObservableObject {
|
final class RelayModelCache: ObservableObject {
|
||||||
private var models = [RelayURL: RelayModel]()
|
private var models = [RelayURL: RelayModel]()
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -5,6 +5,7 @@
|
|||||||
// Created by Scott Penrose on 5/7/23.
|
// Created by Scott Penrose on 5/7/23.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum Route: Hashable {
|
enum Route: Hashable {
|
||||||
@@ -46,6 +47,9 @@ enum Route: Hashable {
|
|||||||
case Wallet(wallet: WalletModel)
|
case Wallet(wallet: WalletModel)
|
||||||
case WalletScanner(result: Binding<WalletScanResult>)
|
case WalletScanner(result: Binding<WalletScanResult>)
|
||||||
case FollowersYouKnow(friendedFollowers: [Pubkey], followers: FollowersModel)
|
case FollowersYouKnow(friendedFollowers: [Pubkey], followers: FollowersModel)
|
||||||
|
case NIP05DomainEvents(events: NIP05DomainEventsModel, nip05_domain_favicon: FaviconURL?)
|
||||||
|
case NIP05DomainPubkeys(domain: String, nip05_domain_favicon: FaviconURL?, pubkeys: [Pubkey])
|
||||||
|
case FollowPack(followPack: NostrEvent, model: FollowPackModel, blur_imgs: Bool)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
|
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
|
||||||
@@ -126,7 +130,13 @@ enum Route: Hashable {
|
|||||||
case .FollowersYouKnow(let friendedFollowers, let followers):
|
case .FollowersYouKnow(let friendedFollowers, let followers):
|
||||||
FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers)
|
FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers)
|
||||||
case .Script(let load_model):
|
case .Script(let load_model):
|
||||||
LoadScript(pool: damusState.pool, model: load_model)
|
LoadScript(pool: damusState.nostrNetwork.pool, model: load_model)
|
||||||
|
case .NIP05DomainEvents(let events, let nip05_domain_favicon):
|
||||||
|
NIP05DomainTimelineView(damus_state: damusState, model: events, nip05_domain_favicon: nip05_domain_favicon)
|
||||||
|
case .NIP05DomainPubkeys(let domain, let nip05_domain_favicon, let pubkeys):
|
||||||
|
NIP05DomainPubkeysView(damus_state: damusState, domain: domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: pubkeys)
|
||||||
|
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
|
||||||
|
FollowPackView(state: damusState, ev: followPack, model: followPackModel, blur_imgs: blur_imgs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +219,7 @@ enum Route: Hashable {
|
|||||||
case .Search(let search):
|
case .Search(let search):
|
||||||
hasher.combine("search")
|
hasher.combine("search")
|
||||||
hasher.combine(search.search)
|
hasher.combine(search.search)
|
||||||
case .NDBSearch(let results):
|
case .NDBSearch:
|
||||||
hasher.combine("results")
|
hasher.combine("results")
|
||||||
case .EULA:
|
case .EULA:
|
||||||
hasher.combine("eula")
|
hasher.combine("eula")
|
||||||
@@ -231,6 +241,15 @@ enum Route: Hashable {
|
|||||||
case .Script(let model):
|
case .Script(let model):
|
||||||
hasher.combine("script")
|
hasher.combine("script")
|
||||||
hasher.combine(model.data.count)
|
hasher.combine(model.data.count)
|
||||||
|
case .NIP05DomainEvents(let events, _):
|
||||||
|
hasher.combine("nip05DomainEvents")
|
||||||
|
hasher.combine(events.domain)
|
||||||
|
case .NIP05DomainPubkeys(let domain, _, _):
|
||||||
|
hasher.combine("nip05DomainPubkeys")
|
||||||
|
hasher.combine(domain)
|
||||||
|
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
|
||||||
|
hasher.combine("followPack")
|
||||||
|
hasher.combine(followPack.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// HumanReadableErrors.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-05-05.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension WalletConnect.FullWalletResponse.InitializationError {
|
||||||
|
var humanReadableError: ErrorView.UserPresentableError? {
|
||||||
|
switch self {
|
||||||
|
case .incorrectAuthorPubkey:
|
||||||
|
nil // Anyone can send a response event with an incorrect author pubkey, it is not really an "error". We should silently ignore it.
|
||||||
|
case .missingRequestIdReference:
|
||||||
|
.init(
|
||||||
|
user_visible_description: NSLocalizedString("Wallet provider returned an invalid response.", comment: "Error description shown to the user when a response from the wallet provider is invalid"),
|
||||||
|
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
|
||||||
|
technical_info: "Wallet response does not make a reference to any request; No request ID `e` tag was found."
|
||||||
|
)
|
||||||
|
case .failedToDecodeJSON(let error):
|
||||||
|
.init(
|
||||||
|
user_visible_description: NSLocalizedString("Wallet provider returned a response that we do not understand.", comment: "Error description shown to the user when a response from the wallet provider contains data the app does not understand"),
|
||||||
|
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
|
||||||
|
technical_info: "Failed to decode NWC Wallet response JSON. Error: \(error)"
|
||||||
|
)
|
||||||
|
case .failedToDecrypt(let error):
|
||||||
|
.init(
|
||||||
|
user_visible_description: NSLocalizedString("Wallet provider returned a response that we could not decrypt.", comment: "Error description shown to the user when a response from the wallet provider contains data the app could not decrypt."),
|
||||||
|
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
|
||||||
|
technical_info: "Failed to decrypt NWC Wallet response. Error: \(error)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WalletConnect.WalletResponseErr {
|
||||||
|
var humanReadableError: ErrorView.UserPresentableError? {
|
||||||
|
guard let code = self.code else {
|
||||||
|
return .init(
|
||||||
|
user_visible_description: String(format: NSLocalizedString("Your connected wallet raised an unknown error. Message: %s", comment: "Human readable error description for unknown error"), self.message ?? NSLocalizedString("Empty error message", comment: "A human readable placeholder to indicate that the error message is empty")),
|
||||||
|
tip: NSLocalizedString("Please contact the developer of your wallet provider for help.", comment: "Human readable error description for an unknown error raised by a wallet provider."),
|
||||||
|
technical_info: "NWC wallet provider returned an error response without a valid reason code. Message: \(self.message ?? "Empty error message")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case .rateLimited:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("Your wallet is temporarily being rate limited.", comment: "Error description for rate limit error"),
|
||||||
|
tip: NSLocalizedString("Wait a few moments, and then try again.", comment: "Tip for rate limit error"),
|
||||||
|
technical_info: "Wallet returned a rate limit error with message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .notImplemented:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("This feature is not implemented by your wallet.", comment: "Error description for not implemented feature"),
|
||||||
|
tip: NSLocalizedString("Please check for updates or contact your wallet provider.", comment: "Tip for not implemented error"),
|
||||||
|
technical_info: "Wallet reported a not implemented error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .insufficientBalance:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("Your wallet does not have sufficient balance for this transaction.", comment: "Error description for insufficient balance"),
|
||||||
|
tip: NSLocalizedString("Please deposit more funds and try again.", comment: "Tip for insufficient balance errors"),
|
||||||
|
technical_info: "Wallet returned an insufficient balance error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .quotaExceeded:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("Your transaction quota has been exceeded.", comment: "Error description for quota exceeded"),
|
||||||
|
tip: NSLocalizedString("Wait for the quota to reset, or configure your wallet provider to allow a higher limit.", comment: "Tip for quota exceeded"),
|
||||||
|
technical_info: "Wallet reported a quota exceeded error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .restricted:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("This operation is restricted by your wallet.", comment: "Error description for restricted operation"),
|
||||||
|
tip: NSLocalizedString("Check your account permissions or contact support.", comment: "Tip for restricted operation"),
|
||||||
|
technical_info: "Wallet returned a restricted error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .unauthorized:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("You are not authorized to perform this action with your wallet.", comment: "Error description for unauthorized access"),
|
||||||
|
tip: NSLocalizedString("Please verify your credentials or permissions.", comment: "Tip for unauthorized access"),
|
||||||
|
technical_info: "Wallet returned an unauthorized error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .internalError:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("An internal error occurred in your wallet.", comment: "Error description for an internal error"),
|
||||||
|
tip: NSLocalizedString("Try restarting your wallet or contacting support if the problem persists.", comment: "Tip for internal error"),
|
||||||
|
technical_info: "Wallet reported an internal error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .other:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("An unspecified error occurred in your wallet.", comment: "Error description for an unspecified error"),
|
||||||
|
tip: NSLocalizedString("Please try again or contact your wallet provider for further assistance.", comment: "Tip for unspecified error"),
|
||||||
|
technical_info: "Wallet returned an error: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,11 @@ extension WalletConnect {
|
|||||||
/// Pay an invoice
|
/// Pay an invoice
|
||||||
case payInvoice(
|
case payInvoice(
|
||||||
/// bolt-11 invoice string
|
/// bolt-11 invoice string
|
||||||
invoice: String
|
invoice: String,
|
||||||
|
/// The full description of the invoice (If description does not fit in the BOLT-11 invoice, this is the pre-image of the description hash)
|
||||||
|
description: String?,
|
||||||
|
/// Optional metadata object containing more information
|
||||||
|
metadata: Metadata?
|
||||||
)
|
)
|
||||||
/// Get the current wallet balance
|
/// Get the current wallet balance
|
||||||
case getBalance
|
case getBalance
|
||||||
@@ -33,6 +37,38 @@ extension WalletConnect {
|
|||||||
type: String?
|
type: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
static func payZapRequest(invoice: String, zapRequest: NostrEvent?) -> Self {
|
||||||
|
guard let zapRequest, let zapRequestEncoded = encode_json(zapRequest) else {
|
||||||
|
return WalletConnect.Request.payInvoice(
|
||||||
|
invoice: invoice,
|
||||||
|
description: nil,
|
||||||
|
metadata: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return WalletConnect.Request.payInvoice(
|
||||||
|
invoice: invoice,
|
||||||
|
description: zapRequestEncoded,
|
||||||
|
metadata: .init(nostr: zapRequest)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Metadata: Codable, Equatable, Hashable {
|
||||||
|
/// NIP-57-compliant `kind:9734` zap request event
|
||||||
|
let nostr: NostrEvent?
|
||||||
|
|
||||||
|
init(nostr: NostrEvent?) {
|
||||||
|
self.nostr = nostr
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let container: KeyedDecodingContainer<WalletConnect.Request.Metadata.CodingKeys> = try decoder.container(keyedBy: WalletConnect.Request.Metadata.CodingKeys.self)
|
||||||
|
guard let decodedZapRequest = try? container.decodeIfPresent(NostrEvent.self, forKey: WalletConnect.Request.Metadata.CodingKeys.nostr) else {
|
||||||
|
self.nostr = nil // Be lenient and fallback to nil if the NWC provider provided something invalid, since metadata is not strictly spec'd yet.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.nostr = decodedZapRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Interface
|
// MARK: - Interface
|
||||||
|
|
||||||
@@ -61,7 +97,7 @@ extension WalletConnect {
|
|||||||
|
|
||||||
/// Keys for the JSON inside the "params" object
|
/// Keys for the JSON inside the "params" object
|
||||||
private enum ParamKeys: String, CodingKey {
|
private enum ParamKeys: String, CodingKey {
|
||||||
case invoice
|
case invoice, description, metadata
|
||||||
case from, until, limit, offset, unpaid, type
|
case from, until, limit, offset, unpaid, type
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +118,9 @@ extension WalletConnect {
|
|||||||
case Method.payInvoice.rawValue:
|
case Method.payInvoice.rawValue:
|
||||||
let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
||||||
let invoice = try paramsContainer.decode(String.self, forKey: .invoice)
|
let invoice = try paramsContainer.decode(String.self, forKey: .invoice)
|
||||||
self = .payInvoice(invoice: invoice)
|
let description: String? = try paramsContainer.decodeIfPresent(String.self, forKey: .description)
|
||||||
|
let metadata: Metadata? = try paramsContainer.decodeIfPresent(Metadata.self, forKey: .metadata)
|
||||||
|
self = .payInvoice(invoice: invoice, description: description, metadata: metadata)
|
||||||
|
|
||||||
case Method.getBalance.rawValue:
|
case Method.getBalance.rawValue:
|
||||||
// No params to decode
|
// No params to decode
|
||||||
@@ -112,10 +150,12 @@ extension WalletConnect {
|
|||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
switch self {
|
switch self {
|
||||||
case .payInvoice(let invoice):
|
case .payInvoice(let invoice, let description, let metadata):
|
||||||
try container.encode(Method.payInvoice.rawValue, forKey: .method)
|
try container.encode(Method.payInvoice.rawValue, forKey: .method)
|
||||||
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
||||||
try paramsContainer.encode(invoice, forKey: .invoice)
|
try paramsContainer.encode(invoice, forKey: .invoice)
|
||||||
|
try paramsContainer.encodeIfPresent(description, forKey: .description)
|
||||||
|
try paramsContainer.encodeIfPresent(metadata, forKey: .metadata)
|
||||||
|
|
||||||
case .getBalance:
|
case .getBalance:
|
||||||
try container.encode(Method.getBalance.rawValue, forKey: .method)
|
try container.encode(Method.getBalance.rawValue, forKey: .method)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// Created by Daniel D’Aquino on 2025-03-10.
|
// Created by Daniel D’Aquino on 2025-03-10.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
|
||||||
extension WalletConnect {
|
extension WalletConnect {
|
||||||
/// Models a response from the NWC provider
|
/// Models a response from the NWC provider
|
||||||
struct Response: Decodable {
|
struct Response: Decodable {
|
||||||
@@ -50,35 +52,80 @@ extension WalletConnect {
|
|||||||
let req_id: NoteId
|
let req_id: NoteId
|
||||||
let response: Response
|
let response: Response
|
||||||
|
|
||||||
init?(from: NostrEvent, nwc: WalletConnect.ConnectURL) async {
|
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) throws(InitializationError) {
|
||||||
guard let note_id = from.referenced_ids.first else {
|
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
self.req_id = note_id
|
|
||||||
|
|
||||||
let ares = Task {
|
|
||||||
guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64),
|
|
||||||
let resp: WalletConnect.Response = decode_json(json)
|
|
||||||
else {
|
|
||||||
let resp: WalletConnect.Response? = nil
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let res = await ares.value else {
|
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
|
||||||
return nil
|
|
||||||
|
self.req_id = referencedNoteId
|
||||||
|
|
||||||
|
var json = ""
|
||||||
|
do {
|
||||||
|
json = try NIP04.decryptContent(
|
||||||
|
recipientPrivateKey: nwc.keypair.privkey,
|
||||||
|
senderPubkey: nwc.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
encoding: .base64
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
catch { throw .failedToDecrypt(error) }
|
||||||
self.response = res
|
|
||||||
|
do {
|
||||||
|
let response: WalletConnect.Response = try decode_json_throwing(json)
|
||||||
|
self.response = response
|
||||||
|
}
|
||||||
|
catch { throw .failedToDecodeJSON(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InitializationError: Error {
|
||||||
|
case incorrectAuthorPubkey
|
||||||
|
case missingRequestIdReference
|
||||||
|
case failedToDecodeJSON(any Error)
|
||||||
|
case failedToDecrypt(any Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WalletResponseErr: Codable {
|
struct WalletResponseErr: Codable, Error {
|
||||||
let code: String?
|
let code: Code?
|
||||||
let message: String?
|
let message: String?
|
||||||
|
|
||||||
|
enum Code: String, Codable {
|
||||||
|
/// The client is sending commands too fast. It should retry in a few seconds.
|
||||||
|
case rateLimited = "RATE_LIMITED"
|
||||||
|
/// The command is not known or is intentionally not implemented.
|
||||||
|
case notImplemented = "NOT_IMPLEMENTED"
|
||||||
|
/// The wallet does not have enough funds to cover a fee reserve or the payment amount.
|
||||||
|
case insufficientBalance = "INSUFFICIENT_BALANCE"
|
||||||
|
/// The wallet has exceeded its spending quota.
|
||||||
|
case quotaExceeded = "QUOTA_EXCEEDED"
|
||||||
|
/// This public key is not allowed to do this operation.
|
||||||
|
case restricted = "RESTRICTED"
|
||||||
|
/// This public key has no wallet connected.
|
||||||
|
case unauthorized = "UNAUTHORIZED"
|
||||||
|
/// An internal error.
|
||||||
|
case internalError = "INTERNAL"
|
||||||
|
/// Other error.
|
||||||
|
case other = "OTHER"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case code, message
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
// Attempt to decode the code as a String
|
||||||
|
if let codeString = try container.decodeIfPresent(String.self, forKey: .code),
|
||||||
|
let validCode = Code(rawValue: codeString) {
|
||||||
|
self.code = validCode
|
||||||
|
} else {
|
||||||
|
// If the code is either missing or not one of the allowed cases, set it to nil
|
||||||
|
self.code = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.message = try container.decodeIfPresent(String.self, forKey: .message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ extension WalletConnect {
|
|||||||
static func subscribe(url: WalletConnectURL, pool: RelayPool) {
|
static func subscribe(url: WalletConnectURL, pool: RelayPool) {
|
||||||
var filter = NostrFilter(kinds: [.nwc_response])
|
var filter = NostrFilter(kinds: [.nwc_response])
|
||||||
filter.authors = [url.pubkey]
|
filter.authors = [url.pubkey]
|
||||||
|
filter.pubkeys = [url.keypair.pubkey]
|
||||||
filter.limit = 0
|
filter.limit = 0
|
||||||
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
|
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
|
||||||
|
|
||||||
@@ -40,8 +41,9 @@ extension WalletConnect {
|
|||||||
/// - on_flush: A callback to call after the event has been flushed to the network
|
/// - on_flush: A callback to call after the event has been flushed to the network
|
||||||
/// - Returns: The Nostr Event that was sent to the network, representing the request that was made
|
/// - Returns: The Nostr Event that was sent to the network, representing the request that was made
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
|
static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, zap_request: NostrEvent?, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
|
||||||
let req = WalletConnect.Request.payInvoice(invoice: invoice)
|
|
||||||
|
let req = WalletConnect.Request.payZapRequest(invoice: invoice, zapRequest: zap_request)
|
||||||
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
|
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,28 @@ extension WalletConnect {
|
|||||||
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
|
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
|
||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func refresh_wallet_information(damus_state: DamusState) async {
|
||||||
|
damus_state.wallet.resetWalletStateInformation()
|
||||||
|
await Self.update_wallet_information(damus_state: damus_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func update_wallet_information(damus_state: DamusState) async {
|
||||||
|
guard let url = damus_state.settings.nostr_wallet_connect,
|
||||||
|
let nwc = WalletConnectURL(str: url) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let flusher: OnFlush? = nil
|
||||||
|
|
||||||
|
let delay = 0.0 // We don't need a delay when fetching a transaction list or balance
|
||||||
|
|
||||||
|
WalletConnect.request_transaction_list(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
||||||
|
WalletConnect.request_balance_information(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) {
|
static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) {
|
||||||
// find the pending zap and mark it as pending-confirmed
|
// find the pending zap and mark it as pending-confirmed
|
||||||
@@ -142,7 +166,7 @@ extension WalletConnect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
print("damus-donation donating...")
|
print("damus-donation donating...")
|
||||||
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil)
|
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, zap_request: nil, delay: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles a received Nostr Wallet Connect error
|
/// Handles a received Nostr Wallet Connect error
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ extension WalletConnect {
|
|||||||
let created_at: UInt64 // unixtimestamp, // invoice/payment creation time
|
let created_at: UInt64 // unixtimestamp, // invoice/payment creation time
|
||||||
let expires_at: UInt64? // unixtimestamp, // invoice expiration time, optional if not applicable
|
let expires_at: UInt64? // unixtimestamp, // invoice expiration time, optional if not applicable
|
||||||
let settled_at: UInt64? // unixtimestamp, // invoice/payment settlement time, optional if unpaid
|
let settled_at: UInt64? // unixtimestamp, // invoice/payment settlement time, optional if unpaid
|
||||||
//"metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc.
|
let metadata: WalletConnect.Request.Metadata? // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ struct EventActionBar: View {
|
|||||||
|
|
||||||
generator.impactOccurred()
|
generator.impactOccurred()
|
||||||
|
|
||||||
damus_state.postbox.send(like_ev)
|
damus_state.nostrNetwork.postbox.send(like_ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Helper structures
|
// MARK: Helper structures
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ struct RepostAction: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
damus_state.postbox.send(boost)
|
damus_state.nostrNetwork.postbox.send(boost)
|
||||||
} label: {
|
} label: {
|
||||||
Label(NSLocalizedString("Repost", comment: "Button to repost a note"), image: "repost")
|
Label(NSLocalizedString("Repost", comment: "Button to repost a note"), image: "repost")
|
||||||
.frame(maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .leading)
|
.frame(maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .leading)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ struct AddRelayView: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
typealias UpdateError = NostrNetworkManager.UserRelayListManager.UpdateError
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Text("Add relay", comment: "Title text to indicate user to an add a relay.")
|
Text("Add relay", comment: "Title text to indicate user to an add a relay.")
|
||||||
@@ -82,38 +84,21 @@ struct AddRelayView: View {
|
|||||||
new_relay = "wss://" + new_relay
|
new_relay = "wss://" + new_relay
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let url = RelayURL(new_relay),
|
guard let url = RelayURL(new_relay) else {
|
||||||
let ev = state.contacts.event,
|
relayAddErrorTitle = NSLocalizedString("Invalid relay address", comment: "Heading for an error when adding a relay")
|
||||||
let keypair = state.keypair.to_full() else {
|
relayAddErrorMessage = NSLocalizedString("Please check the address and try again", comment: "Tip for an error where the relay address being added is invalid")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let info = RelayInfo.rw
|
|
||||||
let descriptor = RelayDescriptor(url: url, info: info)
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try state.pool.add_relay(descriptor)
|
try state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: url, rwConfiguration: .readWrite))
|
||||||
relayAddErrorTitle = nil // Clear error title
|
relayAddErrorTitle = nil // Clear error title
|
||||||
relayAddErrorMessage = nil // Clear error message
|
relayAddErrorMessage = nil // Clear error message
|
||||||
} catch RelayError.RelayAlreadyExists {
|
}
|
||||||
relayAddErrorTitle = NSLocalizedString("Duplicate relay", comment: "Title of the duplicate relay error message.")
|
catch {
|
||||||
relayAddErrorMessage = NSLocalizedString("The relay you are trying to add is already added.\nYou're all set!", comment: "An error message that appears when the user attempts to add a relay that has already been added.")
|
present_sheet(.error(self.humanReadableError(for: error)))
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.pool.connect(to: [url])
|
|
||||||
|
|
||||||
if let new_ev = add_relay(ev: ev, keypair: keypair, current_relays: state.pool.our_descriptors, relay: url, info: info) {
|
|
||||||
process_contact_event(state: state, ev: ev)
|
|
||||||
|
|
||||||
state.pool.send(.event(new_ev))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let relay_metadata = make_relay_metadata(relays: state.pool.our_descriptors, keypair: keypair) {
|
|
||||||
state.postbox.send(relay_metadata)
|
|
||||||
}
|
|
||||||
new_relay = ""
|
new_relay = ""
|
||||||
|
|
||||||
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
@@ -134,6 +119,17 @@ struct AddRelayView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func humanReadableError(for error: any Error) -> ErrorView.UserPresentableError {
|
||||||
|
guard let error = error as? UpdateError else {
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("An unknown error occurred while adding a relay.", comment: "Title of an unknown relay error message."),
|
||||||
|
tip: NSLocalizedString("Please contact support.", comment: "Tip for an unknown relay error message."),
|
||||||
|
technical_info: error.localizedDescription
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return error.humanReadableError
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO
|
// TODO
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
//
|
|
||||||
// FriendsButton.swift
|
|
||||||
// damus
|
|
||||||
//
|
|
||||||
// Created by William Casarin on 2023-04-21.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct FriendsButton: View {
|
|
||||||
@Binding var filter: FriendFilter
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: {
|
|
||||||
switch self.filter {
|
|
||||||
case .all:
|
|
||||||
self.filter = .friends_of_friends
|
|
||||||
case .friends_of_friends:
|
|
||||||
self.filter = .all
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
if filter == .friends_of_friends {
|
|
||||||
LINEAR_GRADIENT
|
|
||||||
.mask(Image("user-added")
|
|
||||||
.resizable()
|
|
||||||
).frame(width: 28, height: 28)
|
|
||||||
} else {
|
|
||||||
Image("user-added")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 28, height: 28)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FriendsButton_Previews: PreviewProvider {
|
|
||||||
@State static var enabled: FriendFilter = .all
|
|
||||||
|
|
||||||
static var previews: some View {
|
|
||||||
FriendsButton(filter: $enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//
|
||||||
|
// TrustedNetworkButton.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by William Casarin on 2023-04-21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TrustedNetworkButton: View {
|
||||||
|
@Binding var filter: FriendFilter
|
||||||
|
var action: (@MainActor () -> Void)? = nil
|
||||||
|
|
||||||
|
var MainButton: some View {
|
||||||
|
Button(action: {
|
||||||
|
switch self.filter {
|
||||||
|
case .all:
|
||||||
|
self.filter = .friends_of_friends
|
||||||
|
case .friends_of_friends:
|
||||||
|
self.filter = .all
|
||||||
|
}
|
||||||
|
|
||||||
|
if let action {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
if filter == .friends_of_friends {
|
||||||
|
LINEAR_GRADIENT
|
||||||
|
.mask(Image(systemName: "network.badge.shield.half.filled")
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
)
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "network.slash")
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MainButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrustedNetworkButton_Previews: PreviewProvider {
|
||||||
|
@State static var enabled: FriendFilter = .all
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
TrustedNetworkButton(filter: $enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,7 +244,7 @@ struct ChatEventView: View {
|
|||||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||||
generator.impactOccurred()
|
generator.impactOccurred()
|
||||||
|
|
||||||
damus_state.postbox.send(like_ev)
|
damus_state.nostrNetwork.postbox.send(like_ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
var action_bar: some View {
|
var action_bar: some View {
|
||||||
@@ -337,12 +337,6 @@ struct ChatEventView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
|
||||||
static var toggle_thread_view: Notification.Name {
|
|
||||||
return Notification.Name("convert_to_thread")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||||
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwipeActions
|
import SwipeActions
|
||||||
|
import TipKit
|
||||||
|
|
||||||
struct ChatroomThreadView: View {
|
struct ChatroomThreadView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -15,11 +16,20 @@ struct ChatroomThreadView: View {
|
|||||||
@ObservedObject var thread: ThreadModel
|
@ObservedObject var thread: ThreadModel
|
||||||
@State var highlighted_note_id: NoteId? = nil
|
@State var highlighted_note_id: NoteId? = nil
|
||||||
@State var user_just_posted_flag: Bool = false
|
@State var user_just_posted_flag: Bool = false
|
||||||
|
@State var untrusted_network_expanded: Bool = true
|
||||||
@Namespace private var animation
|
@Namespace private var animation
|
||||||
|
|
||||||
|
// Add state for sticky header
|
||||||
|
@State var showStickyHeader: Bool = false
|
||||||
|
@State var untrustedSectionOffset: CGFloat = 0
|
||||||
|
|
||||||
|
private static let untrusted_network_section_id = "untrusted-network-section"
|
||||||
|
private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2)
|
||||||
|
|
||||||
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
|
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
|
||||||
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top)
|
let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top
|
||||||
|
|
||||||
|
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: adjustedAnchor)
|
||||||
highlighted_note_id = note_id
|
highlighted_note_id = note_id
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@@ -27,7 +37,7 @@ struct ChatroomThreadView: View {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) {
|
func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
self.thread.select(event: ev)
|
self.thread.select(event: ev)
|
||||||
@@ -35,93 +45,202 @@ struct ChatroomThreadView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func trusted_event_filter(_ event: NostrEvent) -> Bool {
|
||||||
|
!damus.settings.show_trusted_replies_first || damus.contacts.is_in_friendosphere(event.pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ThreadedSwipeViewGroup(scroller: ScrollViewProxy, events: [NostrEvent]) -> some View {
|
||||||
|
SwipeViewGroup {
|
||||||
|
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
|
||||||
|
ChatEventView(event: events[ind],
|
||||||
|
selected_event: self.thread.selected_event,
|
||||||
|
prev_ev: ind > 0 ? events[ind-1] : nil,
|
||||||
|
next_ev: ind == events.count-1 ? nil : events[ind+1],
|
||||||
|
damus_state: damus,
|
||||||
|
thread: thread,
|
||||||
|
scroll_to_event: { note_id in
|
||||||
|
self.go_to_event(scroller: scroller, note_id: note_id)
|
||||||
|
},
|
||||||
|
focus_event: {
|
||||||
|
self.set_active_event(scroller: scroller, ev: ev)
|
||||||
|
},
|
||||||
|
highlight_bubble: highlighted_note_id == ev.id,
|
||||||
|
bar: make_actionbar_model(ev: ev.id, damus: damus)
|
||||||
|
)
|
||||||
|
.id(ev.id)
|
||||||
|
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var OutsideTrustedNetworkLabel: some View {
|
||||||
|
HStack {
|
||||||
|
Label(
|
||||||
|
NSLocalizedString(
|
||||||
|
"Replies outside your trusted network",
|
||||||
|
comment: "Section title in thread for replies from outside of the current user's trusted network, which is their follows and follows of follows."),
|
||||||
|
systemImage: "network.slash"
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.rotationEffect(.degrees(untrusted_network_expanded ? 90 : 0))
|
||||||
|
.animation(.easeInOut(duration: 0.1), value: untrusted_network_expanded)
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
var StickyHeaderView: some View {
|
||||||
|
OutsideTrustedNetworkLabel
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
Color(UIColor.systemBackground)
|
||||||
|
.shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { scroller in
|
ScrollViewReader { scroller in
|
||||||
ScrollView(.vertical) {
|
let sorted_child_events = thread.sorted_child_events
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
|
||||||
// MARK: - Parents events view
|
let untrusted_events = sorted_child_events.filter { !trusted_event_filter($0) }
|
||||||
ForEach(thread.parent_events, id: \.id) { parent_event in
|
let trusted_events = sorted_child_events.filter { trusted_event_filter($0) }
|
||||||
EventMutingContainerView(damus_state: damus, event: parent_event) {
|
|
||||||
EventView(damus: damus, event: parent_event)
|
ZStack(alignment: .top) {
|
||||||
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
|
ScrollView(.vertical) {
|
||||||
}
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
.padding(.horizontal)
|
// MARK: - Parents events view
|
||||||
.onTapGesture {
|
ForEach(thread.parent_events, id: \.id) { parent_event in
|
||||||
self.set_active_event(scroller: scroller, ev: parent_event)
|
EventMutingContainerView(damus_state: damus, event: parent_event) {
|
||||||
}
|
EventView(damus: damus, event: parent_event)
|
||||||
.id(parent_event.id)
|
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
|
||||||
|
}
|
||||||
Divider()
|
|
||||||
.padding(.top, 4)
|
|
||||||
.padding(.leading, 25 * 2)
|
|
||||||
|
|
||||||
}.background(GeometryReader { geometry in
|
|
||||||
// get the height and width of the EventView view
|
|
||||||
let eventHeight = geometry.frame(in: .global).height
|
|
||||||
// let eventWidth = geometry.frame(in: .global).width
|
|
||||||
|
|
||||||
// vertical gray line in the background
|
|
||||||
Rectangle()
|
|
||||||
.fill(Color.gray.opacity(0.25))
|
|
||||||
.frame(width: 2, height: eventHeight)
|
|
||||||
.offset(x: 40, y: 40)
|
|
||||||
})
|
|
||||||
|
|
||||||
// MARK: - Actual event view
|
|
||||||
EventMutingContainerView(
|
|
||||||
damus_state: damus,
|
|
||||||
event: self.thread.selected_event,
|
|
||||||
muteBox: { event_shown, muted_reason in
|
|
||||||
AnyView(
|
|
||||||
EventMutedBoxView(shown: event_shown, reason: muted_reason)
|
|
||||||
.padding(5)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected)
|
|
||||||
.matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center)
|
|
||||||
}
|
|
||||||
.id(self.thread.selected_event.id)
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Children view
|
|
||||||
let events = thread.sorted_child_events
|
|
||||||
let count = events.count
|
|
||||||
SwipeViewGroup {
|
|
||||||
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
|
|
||||||
ChatEventView(event: events[ind],
|
|
||||||
selected_event: self.thread.selected_event,
|
|
||||||
prev_ev: ind > 0 ? events[ind-1] : nil,
|
|
||||||
next_ev: ind == count-1 ? nil : events[ind+1],
|
|
||||||
damus_state: damus,
|
|
||||||
thread: thread,
|
|
||||||
scroll_to_event: { note_id in
|
|
||||||
self.go_to_event(scroller: scroller, note_id: note_id)
|
|
||||||
},
|
|
||||||
focus_event: {
|
|
||||||
self.set_active_event(scroller: scroller, ev: ev)
|
|
||||||
},
|
|
||||||
highlight_bubble: highlighted_note_id == ev.id,
|
|
||||||
bar: make_actionbar_model(ev: ev.id, damus: damus)
|
|
||||||
)
|
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.id(ev.id)
|
.onTapGesture {
|
||||||
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
|
self.set_active_event(scroller: scroller, ev: parent_event)
|
||||||
|
}
|
||||||
|
.id(parent_event.id)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.leading, 25 * 2)
|
||||||
|
|
||||||
|
}.background(GeometryReader { geometry in
|
||||||
|
let eventHeight = geometry.frame(in: .global).height
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.25))
|
||||||
|
.frame(width: 2, height: eventHeight)
|
||||||
|
.offset(x: 40, y: 40)
|
||||||
|
})
|
||||||
|
|
||||||
|
// MARK: - Actual event view
|
||||||
|
EventMutingContainerView(
|
||||||
|
damus_state: damus,
|
||||||
|
event: self.thread.selected_event,
|
||||||
|
muteBox: { event_shown, muted_reason in
|
||||||
|
AnyView(
|
||||||
|
EventMutedBoxView(shown: event_shown, reason: muted_reason)
|
||||||
|
.padding(5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected)
|
||||||
|
.matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center)
|
||||||
|
}
|
||||||
|
.id(self.thread.selected_event.id)
|
||||||
|
|
||||||
|
// MARK: - Children view - inside trusted network
|
||||||
|
if !trusted_events.isEmpty {
|
||||||
|
ThreadedSwipeViewGroup(scroller: scroller, events: trusted_events)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top)
|
||||||
|
|
||||||
|
// MARK: - Children view - outside trusted network
|
||||||
|
if !untrusted_events.isEmpty {
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
TipView(TrustedNetworkRepliesTip.shared, arrowEdge: .bottom)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Track this section's position
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 1)
|
||||||
|
.background(
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.onAppear {
|
||||||
|
untrustedSectionOffset = proxy.frame(in: .global).minY
|
||||||
|
}
|
||||||
|
.onChange(of: proxy.frame(in: .global).minY) { newY in
|
||||||
|
let shouldShow = newY <= 100 // Adjust this threshold as needed
|
||||||
|
if shouldShow != showStickyHeader {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
showStickyHeader = shouldShow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
withAnimation {
|
||||||
|
untrusted_network_expanded.toggle()
|
||||||
|
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
TrustedNetworkRepliesTip.shared.invalidate(reason: .actionPerformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll_to_event(scroller: scroller, id: ChatroomThreadView.untrusted_network_section_id, delay: 0.1, animate: true, anchor: ChatroomThreadView.sticky_header_adjusted_anchor)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
OutsideTrustedNetworkLabel
|
||||||
|
}
|
||||||
|
.id(ChatroomThreadView.untrusted_network_section_id)
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if untrusted_network_expanded {
|
||||||
|
withAnimation {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ThreadedSwipeViewGroup(scroller: scroller, events: untrusted_events)
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EndBlock()
|
||||||
|
|
||||||
|
HStack {}
|
||||||
|
.frame(height: tabHeight + getSafeAreaBottom())
|
||||||
|
}
|
||||||
|
|
||||||
|
if showStickyHeader && !untrusted_events.isEmpty {
|
||||||
|
VStack {
|
||||||
|
StickyHeaderView
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation {
|
||||||
|
untrusted_network_expanded.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
.zIndex(1)
|
||||||
}
|
}
|
||||||
.padding(.top)
|
|
||||||
EndBlock()
|
|
||||||
|
|
||||||
HStack {}
|
|
||||||
.frame(height: tabHeight + getSafeAreaBottom())
|
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.post), perform: { notify in
|
.onReceive(handle_notify(.post), perform: { notify in
|
||||||
switch notify {
|
switch notify {
|
||||||
case .post(_):
|
case .post(_):
|
||||||
user_just_posted_flag = true
|
user_just_posted_flag = true
|
||||||
case .cancel:
|
case .cancel:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.onReceive(thread.objectWillChange) {
|
.onReceive(thread.objectWillChange) {
|
||||||
@@ -139,15 +258,8 @@ struct ChatroomThreadView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggle_thread_view() {
|
|
||||||
NotificationCenter.default.post(name: .toggle_thread_view, object: nil)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct ChatroomView_Previews: PreviewProvider {
|
struct ChatroomView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -167,8 +279,3 @@ struct ChatroomView_Previews: PreviewProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) {
|
|
||||||
scroll_to_event(scroller: proxy, id: thread.selected_event.id, delay: 0.1, animate: false)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ struct ConfigView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(NSLocalizedString("Settings", comment: "Navigation title for Settings view."))
|
.navigationTitle(NSLocalizedString("Settings", comment: "Navigation title for Settings view."))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.searchable(text: $searchText,prompt: "Search within settings")
|
.searchable(text: $searchText, prompt: NSLocalizedString("Search within settings", comment: "Text to prompt the user to search settings."))
|
||||||
.alert(NSLocalizedString("WARNING:\n\nTHIS WILL SIGN AN EVENT THAT DELETES THIS ACCOUNT.\n\nYOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.\n\n ARE YOU SURE YOU WANT TO CONTINUE?", comment: "Alert for deleting the users account."), isPresented: $delete_account_warning) {
|
.alert(NSLocalizedString("WARNING:\n\nTHIS WILL SIGN AN EVENT THAT DELETES THIS ACCOUNT.\n\nYOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.\n\n ARE YOU SURE YOU WANT TO CONTINUE?", comment: "Alert for deleting the users account."), isPresented: $delete_account_warning) {
|
||||||
|
|
||||||
Button(NSLocalizedString("Cancel", comment: "Cancel deleting the user."), role: .cancel) {
|
Button(NSLocalizedString("Cancel", comment: "Cancel deleting the user."), role: .cancel) {
|
||||||
@@ -182,7 +182,7 @@ struct ConfigView: View {
|
|||||||
let ev = created_deleted_account_profile(keypair: keypair) else {
|
let ev = created_deleted_account_profile(keypair: keypair) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.postbox.send(ev)
|
state.nostrNetwork.postbox.send(ev)
|
||||||
logout(state)
|
logout(state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ struct DMChatView: View, KeyboardReadable {
|
|||||||
|
|
||||||
dms.draft = ""
|
dms.draft = ""
|
||||||
|
|
||||||
damus_state.postbox.send(dm)
|
damus_state.nostrNetwork.postbox.send(dm)
|
||||||
|
|
||||||
handle_incoming_dm(ev: dm, our_pubkey: damus_state.pubkey, dms: damus_state.dms, prev_events: NewEventsBits())
|
handle_incoming_dm(ev: dm, our_pubkey: damus_state.pubkey, dms: damus_state.dms, prev_events: NewEventsBits())
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import TipKit
|
||||||
|
|
||||||
enum DMType: Hashable {
|
enum DMType: Hashable {
|
||||||
case rando
|
case rando
|
||||||
@@ -18,6 +19,7 @@ struct DirectMessagesView: View {
|
|||||||
@State var dm_type: DMType = .friend
|
@State var dm_type: DMType = .friend
|
||||||
@ObservedObject var model: DirectMessagesModel
|
@ObservedObject var model: DirectMessagesModel
|
||||||
@ObservedObject var settings: UserSettingsStore
|
@ObservedObject var settings: UserSettingsStore
|
||||||
|
@Binding var subtitle: String?
|
||||||
|
|
||||||
func MainContent(requests: Bool) -> some View {
|
func MainContent(requests: Bool) -> some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -72,7 +74,15 @@ struct DirectMessagesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let showTrustedButton = would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms)
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
if #available(iOS 17, *), showTrustedButton {
|
||||||
|
TipView(TrustedNetworkButtonTip.shared)
|
||||||
|
.tipBackground(.clear)
|
||||||
|
.tipViewStyle(TrustedNetworkButtonTipViewStyle())
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
CustomPicker(tabs: [
|
CustomPicker(tabs: [
|
||||||
(NSLocalizedString("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message."), DMType.friend),
|
(NSLocalizedString("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message."), DMType.friend),
|
||||||
(NSLocalizedString("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet"), DMType.rando),
|
(NSLocalizedString("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet"), DMType.rando),
|
||||||
@@ -92,12 +102,22 @@ struct DirectMessagesView: View {
|
|||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms) {
|
if showTrustedButton {
|
||||||
|
TrustedNetworkButton(filter: $settings.friend_filter) {
|
||||||
FriendsButton(filter: $settings.friend_filter)
|
if #available(iOS 17, *) {
|
||||||
|
TrustedNetworkButtonTip.shared.invalidate(reason: .actionPerformed)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
self.subtitle = settings.friend_filter.description()
|
||||||
|
|
||||||
|
}
|
||||||
|
.onChange(of: settings.friend_filter) { val in
|
||||||
|
self.subtitle = val.description()
|
||||||
|
}
|
||||||
.navigationTitle(NSLocalizedString("DMs", comment: "Navigation title for view of DMs, where DM is an English abbreviation for Direct Message."))
|
.navigationTitle(NSLocalizedString("DMs", comment: "Navigation title for view of DMs, where DM is an English abbreviation for Direct Message."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +135,6 @@ func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageMo
|
|||||||
struct DirectMessagesView_Previews: PreviewProvider {
|
struct DirectMessagesView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let ds = test_damus_state
|
let ds = test_damus_state
|
||||||
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings)
|
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings, subtitle: .constant(nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ struct ErrorView: View {
|
|||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
.padding(.vertical, 30)
|
.padding(.vertical, 30)
|
||||||
|
|
||||||
|
if let technical_info = error.technical_info {
|
||||||
|
ErrorTechInfoCopyButton(errorInfo: technical_info)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if let damus_state, damus_state.is_privkey_user {
|
if let damus_state, damus_state.is_privkey_user {
|
||||||
@@ -69,6 +73,39 @@ struct ErrorView: View {
|
|||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ErrorTechInfoCopyButton: View {
|
||||||
|
let errorInfo: String
|
||||||
|
@State var copied: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
if !copied {
|
||||||
|
Button(action: {
|
||||||
|
UIPasteboard.general.string = errorInfo
|
||||||
|
copied = true
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
|
copied = false
|
||||||
|
}
|
||||||
|
}, label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "square.on.square.dashed")
|
||||||
|
Text("Copy technical information", comment: "Button label to allow user to copy technical information from an error screen (usually to provide our support team for further troubleshooting)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
Text("Copied!", comment: "Label indicating that the error technical information was successfully copied to the clipboard, which shows up as soon as the user clicks the copy button.")
|
||||||
|
}
|
||||||
|
.foregroundStyle(.damusGreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An error that is displayed to the user, and can be sent to the Developers as well.
|
/// An error that is displayed to the user, and can be sent to the Developers as well.
|
||||||
struct UserPresentableError {
|
struct UserPresentableError {
|
||||||
/// The description of the error to be shown to the user
|
/// The description of the error to be shown to the user
|
||||||
@@ -113,7 +150,7 @@ struct ErrorView: View {
|
|||||||
error: .init(
|
error: .init(
|
||||||
user_visible_description: "We are still too early",
|
user_visible_description: "We are still too early",
|
||||||
tip: "Stay humble, keep building, stack sats",
|
tip: "Stay humble, keep building, stack sats",
|
||||||
technical_info: nil
|
technical_info: "UTXOs too small, must stack more sats"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ struct EventLoaderView<Content: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe() {
|
func unsubscribe() {
|
||||||
damus_state.pool.unsubscribe(sub_id: subscription_uuid)
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subscription_uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribe(filters: [NostrFilter]) {
|
func subscribe(filters: [NostrFilter]) {
|
||||||
damus_state.pool.register_handler(sub_id: subscription_uuid, handler: handle_event)
|
damus_state.nostrNetwork.pool.register_handler(sub_id: subscription_uuid, handler: handle_event)
|
||||||
damus_state.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid)))
|
damus_state.nostrNetwork.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ struct MenuItems: View {
|
|||||||
if let full_keypair = self.damus_state.keypair.to_full(),
|
if let full_keypair = self.damus_state.keypair.to_full(),
|
||||||
let new_mutelist_ev = toggle_from_mutelist(keypair: full_keypair, prev: damus_state.mutelist_manager.event, to_toggle: .thread(event.thread_id(), duration?.date_from_now)) {
|
let new_mutelist_ev = toggle_from_mutelist(keypair: full_keypair, prev: damus_state.mutelist_manager.event, to_toggle: .thread(event.thread_id(), duration?.date_from_now)) {
|
||||||
damus_state.mutelist_manager.set_mutelist(new_mutelist_ev)
|
damus_state.mutelist_manager.set_mutelist(new_mutelist_ev)
|
||||||
damus_state.postbox.send(new_mutelist_ev)
|
damus_state.nostrNetwork.postbox.send(new_mutelist_ev)
|
||||||
}
|
}
|
||||||
let muted = damus_state.mutelist_manager.is_event_muted(event)
|
let muted = damus_state.mutelist_manager.is_event_muted(event)
|
||||||
isMutedThread = muted
|
isMutedThread = muted
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
//
|
||||||
|
// FollowPackPreview.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/30/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct FollowPackUsers: View {
|
||||||
|
let state: DamusState
|
||||||
|
var publicKeys: [Pubkey]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
|
||||||
|
if !publicKeys.isEmpty {
|
||||||
|
CondensedProfilePicturesView(state: state, pubkeys: publicKeys, maxPictures: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
let followPackUserCount = publicKeys.count
|
||||||
|
let nounString = pluralizedString(key: "follow_pack_user_count", count: followPackUserCount)
|
||||||
|
let nounText = Text(verbatim: nounString).font(.subheadline).foregroundColor(.gray)
|
||||||
|
Text("\(Text(verbatim: followPackUserCount.formatted()).font(.subheadline.weight(.medium))) \(nounText)", comment: "Sentence composed of 2 variables to describe how many people are in the follow pack. In source English, the first variable is the number of users, and the second variable is 'user' or 'users'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FollowPackBannerImage: View {
|
||||||
|
let state: DamusState
|
||||||
|
let options: EventViewOptions
|
||||||
|
var image: URL? = nil
|
||||||
|
var preview: Bool
|
||||||
|
@State var blur_imgs: Bool
|
||||||
|
|
||||||
|
func Placeholder(url: URL, preview: Bool) -> some View {
|
||||||
|
Group {
|
||||||
|
if let meta = state.events.lookup_img_metadata(url: url),
|
||||||
|
case .processed(let blurhash) = meta.state {
|
||||||
|
Image(uiImage: blurhash)
|
||||||
|
.resizable()
|
||||||
|
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
|
||||||
|
} else {
|
||||||
|
DamusColors.adaptableWhite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleImage(url: URL, preview: Bool) -> some View {
|
||||||
|
KFAnimatedImage(url)
|
||||||
|
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||||
|
.backgroundDecode(true)
|
||||||
|
.imageContext(.note, disable_animation: state.settings.disable_animation)
|
||||||
|
.image_fade(duration: 0.25)
|
||||||
|
.cancelOnDisappear(true)
|
||||||
|
.configure { view in
|
||||||
|
view.framePreloadCount = 3
|
||||||
|
}
|
||||||
|
.background {
|
||||||
|
Placeholder(url: url, preview: preview)
|
||||||
|
}
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
|
||||||
|
.kfClickable()
|
||||||
|
.cornerRadius(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let url = image {
|
||||||
|
if (self.options.contains(.no_media)) {
|
||||||
|
EmptyView()
|
||||||
|
} else if !blur_imgs {
|
||||||
|
titleImage(url: url, preview: preview)
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
titleImage(url: url, preview: preview)
|
||||||
|
BlurOverlayView(blur_images: $blur_imgs, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView)
|
||||||
|
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image."))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.frame(width: 350, height: 180)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FollowPackPreviewBody: View {
|
||||||
|
let state: DamusState
|
||||||
|
let event: FollowPackEvent
|
||||||
|
let options: EventViewOptions
|
||||||
|
let header: Bool
|
||||||
|
@State var blur_imgs: Bool
|
||||||
|
|
||||||
|
@ObservedObject var artifacts: NoteArtifactsModel
|
||||||
|
|
||||||
|
init(state: DamusState, ev: FollowPackEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = ev
|
||||||
|
self.options = options
|
||||||
|
self.header = header
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
|
||||||
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = FollowPackEvent.parse(from: ev)
|
||||||
|
self.options = options
|
||||||
|
self.header = header
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
|
||||||
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if options.contains(.wide) {
|
||||||
|
Main.padding(.horizontal)
|
||||||
|
} else {
|
||||||
|
Main
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var Main: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|
||||||
|
if state.settings.media_previews {
|
||||||
|
FollowPackBannerImage(state: state, options: options, image: event.image, preview: true, blur_imgs: blur_imgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
|
||||||
|
.font(header ? .title : .headline)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.top, 5)
|
||||||
|
|
||||||
|
if let description = event.description {
|
||||||
|
Text(description)
|
||||||
|
.font(header ? .body : .caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
} else {
|
||||||
|
Text("")
|
||||||
|
.font(header ? .body : .caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
|
||||||
|
.onTapGesture {
|
||||||
|
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
|
||||||
|
}
|
||||||
|
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
|
||||||
|
let profile = profile_txn?.unsafeUnownedValue
|
||||||
|
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
|
||||||
|
switch displayName {
|
||||||
|
case .one(let one):
|
||||||
|
Text(one)
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
|
||||||
|
case .both(username: let username, displayName: let displayName):
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(verbatim: displayName)
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
|
||||||
|
Text(verbatim: "@\(username)")
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
|
||||||
|
FollowPackUsers(state: state, publicKeys: event.publicKeys)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.frame(width: 350, height: state.settings.media_previews ? 330 : 150, alignment: .leading)
|
||||||
|
.background(DamusColors.neutral3)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(DamusColors.neutral1, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FollowPackPreview: View {
|
||||||
|
let state: DamusState
|
||||||
|
let event: FollowPackEvent
|
||||||
|
let options: EventViewOptions
|
||||||
|
@State var blur_imgs: Bool
|
||||||
|
|
||||||
|
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = FollowPackEvent.parse(from: ev)
|
||||||
|
self.options = options.union(.no_mentions)
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
FollowPackPreviewBody(state: state, ev: event, options: options, header: false, blur_imgs: blur_imgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let test_follow_list_event = FollowPackEvent.parse(from: NostrEvent(
|
||||||
|
content: "",
|
||||||
|
keypair: test_keypair,
|
||||||
|
kind: NostrKind.longform.rawValue,
|
||||||
|
tags: [
|
||||||
|
["title", "DAMUSES"],
|
||||||
|
["description", "Damus Team"],
|
||||||
|
["published_at", "1685638715"],
|
||||||
|
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
|
||||||
|
["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"],
|
||||||
|
["p", "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4"],
|
||||||
|
["p", "520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626"],
|
||||||
|
["p", "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1"],
|
||||||
|
["p", "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91"],
|
||||||
|
["p", "e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7"],
|
||||||
|
["p", "b88c7f007bbf3bc2fcaeff9e513f186bab33782c0baa6a6cc12add78b9110ba3"],
|
||||||
|
["p", "4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967"],
|
||||||
|
["image", "https://damus.io/img/logo.png"],
|
||||||
|
])!
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
struct FollowPackPreview_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
VStack {
|
||||||
|
FollowPackPreview(state: test_damus_state, ev: test_follow_list_event.event, options: [], blur_imgs: false)
|
||||||
|
}
|
||||||
|
.frame(height: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//
|
||||||
|
// FollowPackTimeline.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 5/6/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FollowPackTimelineView<Content: View>: View {
|
||||||
|
@ObservedObject var events: EventHolder
|
||||||
|
@Binding var loading: Bool
|
||||||
|
|
||||||
|
let damus: DamusState
|
||||||
|
let show_friend_icon: Bool
|
||||||
|
let filter: (NostrEvent) -> Bool
|
||||||
|
let content: Content?
|
||||||
|
let apply_mute_rules: Bool
|
||||||
|
|
||||||
|
init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||||
|
self.events = events
|
||||||
|
self._loading = loading
|
||||||
|
self.damus = damus
|
||||||
|
self.show_friend_icon = show_friend_icon
|
||||||
|
self.filter = filter
|
||||||
|
self.apply_mute_rules = apply_mute_rules
|
||||||
|
self.content = content?()
|
||||||
|
}
|
||||||
|
|
||||||
|
init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||||
|
self.events = events
|
||||||
|
self._loading = loading
|
||||||
|
self.damus = damus
|
||||||
|
self.show_friend_icon = show_friend_icon
|
||||||
|
self.filter = filter
|
||||||
|
self.apply_mute_rules = apply_mute_rules
|
||||||
|
self.content = content?()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MainContent
|
||||||
|
}
|
||||||
|
|
||||||
|
var MainContent: some View {
|
||||||
|
ScrollViewReader { scroller in
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
if let content {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.id("startblock")
|
||||||
|
.frame(height: 0)
|
||||||
|
|
||||||
|
FollowPackInnerView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
|
||||||
|
.redacted(reason: loading ? .placeholder : [])
|
||||||
|
.shimmer(loading)
|
||||||
|
.disabled(loading)
|
||||||
|
.background {
|
||||||
|
GeometryReader { proxy -> Color in
|
||||||
|
handle_scroll_queue(proxy, queue: self.events)
|
||||||
|
return Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.coordinateSpace(name: "scroll")
|
||||||
|
.onReceive(handle_notify(.scroll_to_top)) { () in
|
||||||
|
events.flush()
|
||||||
|
self.events.should_queue = false
|
||||||
|
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
events.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FollowPackInnerView: View {
|
||||||
|
@ObservedObject var events: EventHolder
|
||||||
|
let state: DamusState
|
||||||
|
let filter: (NostrEvent) -> Bool
|
||||||
|
|
||||||
|
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
|
||||||
|
self.events = events
|
||||||
|
self.state = damus
|
||||||
|
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
|
||||||
|
}
|
||||||
|
|
||||||
|
var event_options: EventViewOptions {
|
||||||
|
if self.state.settings.truncate_timeline_text {
|
||||||
|
return [.wide, .truncate_content]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.wide]
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyHStack(spacing: 0) {
|
||||||
|
let events = self.events.events
|
||||||
|
if events.isEmpty {
|
||||||
|
EmptyTimelineView()
|
||||||
|
} else {
|
||||||
|
let evs = events.filter(filter)
|
||||||
|
let indexed = Array(zip(evs, 0...))
|
||||||
|
ForEach(indexed, id: \.0.id) { tup in
|
||||||
|
let ev = tup.0
|
||||||
|
let ind = tup.1
|
||||||
|
let blur_imgs = should_blur_images(settings: state.settings, contacts: state.contacts, ev: ev, our_pubkey: state.pubkey)
|
||||||
|
if ev.kind == NostrKind.follow_list.rawValue {
|
||||||
|
FollowPackPreview(state: state, ev: ev, options: event_options, blur_imgs: blur_imgs)
|
||||||
|
.onTapGesture {
|
||||||
|
state.nav.push(route: Route.FollowPack(followPack: ev, model: FollowPackModel(damus_state: state), blur_imgs: blur_imgs))
|
||||||
|
}
|
||||||
|
.padding(.top, 7)
|
||||||
|
.onAppear {
|
||||||
|
let to_preload =
|
||||||
|
Array([indexed[safe: ind+1]?.0,
|
||||||
|
indexed[safe: ind+2]?.0,
|
||||||
|
indexed[safe: ind+3]?.0,
|
||||||
|
indexed[safe: ind+4]?.0,
|
||||||
|
indexed[safe: ind+5]?.0
|
||||||
|
].compactMap({ $0 }))
|
||||||
|
|
||||||
|
preload_events(state: state, events: to_preload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//
|
||||||
|
// FollowPackView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/30/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct FollowPackView: View {
|
||||||
|
let state: DamusState
|
||||||
|
let event: FollowPackEvent
|
||||||
|
@StateObject var model: FollowPackModel
|
||||||
|
@State var blur_imgs: Bool
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
@ObservedObject var artifacts: NoteArtifactsModel
|
||||||
|
|
||||||
|
init(state: DamusState, ev: FollowPackEvent, model: FollowPackModel, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = ev
|
||||||
|
self._model = StateObject(wrappedValue: model)
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
|
||||||
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(state: DamusState, ev: NostrEvent, model: FollowPackModel, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = FollowPackEvent.parse(from: ev)
|
||||||
|
self._model = StateObject(wrappedValue: model)
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
|
||||||
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) {
|
||||||
|
var filters = ContentFilters.defaults(damus_state: self.state)
|
||||||
|
filters.append({ pubkeys.contains($0.pubkey) })
|
||||||
|
return ContentFilters(filters: filters).filter
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FollowPackTabSelection: Int {
|
||||||
|
case people = 0
|
||||||
|
case posts = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@State var tab_selection: FollowPackTabSelection = .people
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ScrollView {
|
||||||
|
FollowPackHeader
|
||||||
|
|
||||||
|
FollowPackTabs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if model.events.events.isEmpty {
|
||||||
|
model.subscribe(follow_pack_users: event.publicKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
model.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabs: [(String, FollowPackTabSelection)] {
|
||||||
|
let tabs = [
|
||||||
|
(NSLocalizedString("People", comment: "Label for filter for seeing the people in this follow pack."), FollowPackTabSelection.people),
|
||||||
|
(NSLocalizedString("Posts", comment: "Label for filter for seeing the posts from the people in this follow pack."), FollowPackTabSelection.posts)
|
||||||
|
]
|
||||||
|
return tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
var FollowPackTabs: some View {
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
CustomPicker(tabs: tabs, selection: $tab_selection)
|
||||||
|
Divider()
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||||
|
|
||||||
|
if tab_selection == FollowPackTabSelection.people {
|
||||||
|
LazyVStack(alignment: .leading) {
|
||||||
|
ForEach(event.publicKeys.reversed(), id: \.self) { pk in
|
||||||
|
FollowUserView(target: .pubkey(pk), damus_state: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.padding(.bottom, 50)
|
||||||
|
.tag(FollowPackTabSelection.people)
|
||||||
|
.id(FollowPackTabSelection.people)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tab_selection == FollowPackTabSelection.posts {
|
||||||
|
InnerTimelineView(events: model.events, damus: state, filter: content_filter(event.publicKeys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear() {
|
||||||
|
model.subscribe(follow_pack_users: event.publicKeys)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
model.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var FollowPackHeader: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|
||||||
|
if state.settings.media_previews {
|
||||||
|
FollowPackBannerImage(state: state, options: EventViewOptions(), image: event.image, preview: false, blur_imgs: blur_imgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
|
||||||
|
.font(.title)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.top, 5)
|
||||||
|
|
||||||
|
if let description = event.description {
|
||||||
|
Text(description)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
|
||||||
|
.onTapGesture {
|
||||||
|
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
|
||||||
|
}
|
||||||
|
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
|
||||||
|
let profile = profile_txn?.unsafeUnownedValue
|
||||||
|
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
|
||||||
|
switch displayName {
|
||||||
|
case .one(let one):
|
||||||
|
Text("Created by \(one)", comment: "Lets the user know who created this follow pack.")
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
|
||||||
|
case .both(username: let username, displayName: let displayName):
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Created by \(displayName)", comment: "Lets the user know who created this follow pack.")
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
|
||||||
|
Text(verbatim: "@\(username)")
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
FollowPackUsers(state: state, publicKeys: event.publicKeys)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct FollowPackView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
VStack {
|
||||||
|
FollowPackView(state: test_damus_state, ev: test_follow_list_event, model: FollowPackModel(damus_state: test_damus_state), blur_imgs: false)
|
||||||
|
}
|
||||||
|
.frame(height: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject {
|
|||||||
case .zap, .zap_request:
|
case .zap, .zap_request:
|
||||||
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
|
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
|
||||||
return .loaded(route: Route.Zaps(target: zap.target))
|
return .loaded(route: Route.Zaps(target: zap.target))
|
||||||
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .zap, .zap_request, .nwc_request, .nwc_response, .http_auth, .status:
|
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list:
|
||||||
return .unknown_or_unsupported_kind
|
return .unknown_or_unsupported_kind
|
||||||
}
|
}
|
||||||
case .naddr(let naddr):
|
case .naddr(let naddr):
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ struct AddMuteItemView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.mutelist_manager.set_mutelist(mutelist)
|
state.mutelist_manager.set_mutelist(mutelist)
|
||||||
state.postbox.send(mutelist)
|
state.nostrNetwork.postbox.send(mutelist)
|
||||||
}
|
}
|
||||||
|
|
||||||
new_text = ""
|
new_text = ""
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user