Compare commits

..

1 Commits

Author SHA1 Message Date
tyiu fab0e5de29 Fix quote notes to include missing q tag
Changelog-Fixed: Fix quote notes to include missing q tag

Closes: https://github.com/damus-io/damus/issues/2615
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-04-14 12:40:42 -04:00
211 changed files with 1893 additions and 10822 deletions
-1
View File
@@ -6,4 +6,3 @@ damus.xcodeproj/xcshareddata/xcbaselines
TODO.bak
tags
build-git-hash.txt
.build
-48
View File
@@ -1,51 +1,3 @@
## [1.14] - 2025-05-25
### Added
- Added safety reminder to wallets with higher balance (Daniel DAquino)
- Added one-click Coinos wallet setup (Daniel DAquino)
- 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 DAquino)
- Added NIP-65 relay list support (Daniel DAquino)
- 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 DAquino)
- Add dismiss button to wallet high balance reminders (Daniel DAquino)
- Zap receiver information now included for outgoing zaps (Daniel DAquino)
- 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 DAquino)
- Updated image cache for better stability (Daniel DAquino)
- Improved integration with Nostr Wallet Connect wallets (ericholguin)
- Added relay connectivity information to NWC settings (Daniel DAquino)
- Improved handling around NWC responses (Daniel DAquino)
- Added more human visible errors on NWC wallets to aid with troubleshooting (Daniel DAquino)
- Re-enabled note zaps as permitted by the new App Store guidelines (Daniel DAquino)
### Fixed
- Hide future notes from timeline (Terry Yiu)
- Fixed issue where profiles with a NIP-65 relay list would not display on Damus (Daniel DAquino)
- 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 DAquino)
- 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 DAquino)
[1.14]: https://github.com/damus-io/damus/releases/tag/v1.14
## [1.13.1] - 2025-03-21
### Fixed
@@ -18,7 +18,7 @@ struct NotificationExtensionState: HeadlessDamusState {
let lnurls: LNUrls
init?() {
guard let ndb = Ndb(owns_db_file: false) else { return nil }
guard let ndb = try? Ndb(owns_db_file: false) else { return nil }
self.ndb = ndb
guard let keypair = get_saved_keypair() else { return nil }
+3 -19
View File
@@ -1,26 +1,10 @@
<div align="center">
[![Run Test Suite](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml/badge.svg?branch=master)](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
<img src="./damus/Assets.xcassets/damus-home.imageset/damus-home@2x.png" alt="Damus Logo" title="Damus logo" width=""/>
# Damus
The social network you control
# damus
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
[![License: GPL-3.0](https://img.shields.io/github/license/damus-io/damus?labelColor=27303D&color=0877d2)](/LICENSE)
## Download and Install
[![Apple](https://img.shields.io/badge/Apple-%23000000.svg?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/us/app/damus/id1628663131)
## Supported Platforms
iOS 16.0+ • macOS 13.0+
<img src="./demo1.png" width="70%" height="50%" />
</div>
<img src="./ss.png" width="50%" height="50%" />
[nostr]: https://github.com/fiatjaf/nostr
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
{
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc",
"originHash" : "06318d35ee2e6bd681b95591e67da33a9461b48a3c652e58bd9d1a6f0d82bdac",
"pins" : [
{
"identity" : "codescanner",
@@ -35,15 +35,6 @@
"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",
"kind" : "remoteSourceControl",
@@ -58,8 +49,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher",
"state" : {
"revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3",
"version" : "8.3.1"
"revision" : "415b1d97fb38bda1e5a6b2dde63354720832110b",
"version" : "7.6.1"
}
},
{
@@ -114,15 +105,6 @@
"version" : "0.1.2"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
"version" : "2.8.7"
}
},
{
"identity" : "swiftycrop",
"kind" : "remoteSourceControl",
@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "blink.png",
"filename" : "bbw.jpg",
"idiom" : "universal"
}
],
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 216 B

+9 -45
View File
@@ -162,7 +162,6 @@ class CarouselModel: ObservableObject {
// Upon updating information, update the carousel fill size if the size for the current url has changed
if oldValue[current_url] != media_size_information[current_url] {
self.refresh_current_item_fill()
self.refresh_first_item_height()
}
}
}
@@ -187,13 +186,6 @@ class CarouselModel: ObservableObject {
/// and is automatically updated upon changes to these properties.
@Published private(set) var current_item_fill: ImageFill?
/// Holds the ideal fill dimensions for the first item in the carousel.
/// This is used to maintain a consistent height for the carousel when swiping between images.
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly.
/// **Implementation note:** This property ensures the carousel maintains a consistent height based on the first image,
/// preventing the UI from "jumping" when swiping between images of different aspect ratios.
@Published private(set) var first_image_fill: ImageFill?
// MARK: Initialization and de-initialization
@@ -215,7 +207,6 @@ class CarouselModel: ObservableObject {
self.observe_video_sizes()
Task {
self.refresh_current_item_fill()
self.refresh_first_item_height()
}
}
@@ -250,17 +241,10 @@ class CarouselModel: ObservableObject {
/// **Usage note:** This is private, do not call this directly from outside the class.
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
private func refresh_current_item_fill() {
self.current_item_fill = self.compute_item_fill(url: current_url)
}
/// Computes the image fill properties for a given URL without side effects.
/// This is a pure function that calculates the appropriate fill dimensions based on image size and container constraints.
/// **Usage note:** This is a helper method used by both `refresh_current_item_fill` and `refresh_first_item_height`.
private func compute_item_fill(url: URL?) -> ImageFill? {
if let url,
let item_size = self.media_size_information[url],
if let current_url,
let item_size = self.media_size_information[current_url],
let geo_size {
return ImageFill.calculate_image_fill(
self.current_item_fill = ImageFill.calculate_image_fill(
geo_size: geo_size,
img_size: item_size,
maxHeight: self.max_height,
@@ -268,26 +252,9 @@ class CarouselModel: ObservableObject {
)
}
else {
return nil // Not enough information to compute the proper fill. Default to nil
self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
}
}
/// This function refreshes the first item height based on the current state of the model
/// **Usage note:** This is private, do not call this directly from outside the class.
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the height.
/// When the first image dimensions change, this ensures the carousel maintains consistent dimensions.
private func refresh_first_item_height() {
self.first_image_fill = self.compute_first_item_fill()
}
/// Computes the first item fill with no side-effects.
/// **Usage note:** Not to be used outside the class. Use the `first_image_fill` property instead.
/// **Implementation note:** This retrieves the first URL from the carousel and computes its fill properties
/// to establish a consistent height for the entire carousel.
private func compute_first_item_fill() -> ImageFill? {
guard let first_url = urls[safe: 0] else { return nil }
return self.compute_item_fill(url: first_url.url)
}
}
// MARK: - Image Carousel
@@ -319,15 +286,13 @@ struct ImageCarousel<Content: View>: View {
self.content = content
}
/// Determines if the image should fill its container.
/// Always returns true to ensure images consistently fill the width of the container.
/// This simplifies the layout behavior and prevents inconsistent sizing between carousel items.
var filling: Bool { true }
var filling: Bool {
model.current_item_fill?.filling == true
}
var height: CGFloat {
// Use the first image height (to prevent height from jumping when swiping), then default to the default fill height
// This prioritization ensures consistent carousel height regardless of which image is currently displayed
model.first_image_fill?.height ?? model.default_fill_height
// Use the calculated fill height if available, otherwise use the default fill height
model.current_item_fill?.height ?? model.default_fill_height
}
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
@@ -411,7 +376,6 @@ struct ImageCarousel<Content: View>: View {
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(height: height)
.clipped() // Prevents content from overflowing the frame, ensuring clean edges in the carousel
.onChange(of: model.selectedIndex) { value in
model.selectedIndex = value
}
+6 -10
View File
@@ -94,30 +94,26 @@ enum OpenWalletError: Error {
}
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) {
return url
this_app.open(url)
} else {
guard let store_link = wallet.appStoreLink else {
throw .no_wallet_to_open
throw OpenWalletError.no_wallet_to_open
}
guard let url = URL(string: store_link) else {
throw .store_link_invalid
throw OpenWalletError.store_link_invalid
}
guard this_app.canOpenURL(url) else {
throw .system_cannot_open_store_link
throw OpenWalletError.system_cannot_open_store_link
}
return url
this_app.open(url)
}
}
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 {
+23 -38
View File
@@ -5,27 +5,27 @@
// Created by William Casarin on 2023-01-11.
//
import FaviconFinder
import Kingfisher
import SwiftUI
struct NIP05Badge: View {
let nip05: NIP05
let pubkey: Pubkey
let damus_state: DamusState
let contacts: Contacts
let show_domain: Bool
let nip05_domain_favicon: FaviconURL?
let profiles: Profiles
init(nip05: NIP05, pubkey: Pubkey, damus_state: DamusState, show_domain: Bool, nip05_domain_favicon: FaviconURL?) {
@Environment(\.openURL) var openURL
init(nip05: NIP05, pubkey: Pubkey, contacts: Contacts, show_domain: Bool, profiles: Profiles) {
self.nip05 = nip05
self.pubkey = pubkey
self.damus_state = damus_state
self.contacts = contacts
self.show_domain = show_domain
self.nip05_domain_favicon = nip05_domain_favicon
self.profiles = profiles
}
var nip05_color: Bool {
return use_nip05_color(pubkey: pubkey, contacts: damus_state.contacts)
return use_nip05_color(pubkey: pubkey, contacts: contacts)
}
var Seal: some View {
@@ -44,23 +44,8 @@ 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 {
guard let name = damus_state.profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
else {
return false
}
@@ -80,18 +65,14 @@ struct NIP05Badge: View {
HStack(spacing: 2) {
Seal
Group {
if show_domain {
Text(nip05_string)
.nip05_colorized(gradient: nip05_color)
}
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))
if show_domain {
Text(nip05_string)
.nip05_colorized(gradient: nip05_color)
.onTapGesture {
if let nip5url = nip05.siteUrl {
openURL(nip5url)
}
}
}
}
@@ -117,9 +98,13 @@ struct NIP05Badge_Previews: PreviewProvider {
static var previews: some View {
let test_state = test_damus_state
VStack {
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: "jb55", 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: "_", 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: 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)
}
}
}
+4 -4
View File
@@ -84,7 +84,7 @@ struct NoteZapButton: View {
print("cancel_zap: we already have a real zap, can't cancel")
break
case .pending(let pzap):
guard let res = cancel_zap(zap: pzap, box: damus_state.nostrNetwork.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
guard let res = cancel_zap(zap: pzap, box: damus_state.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
return
@@ -179,7 +179,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
}
// Only take the first 10 because reasons
let relays = Array(damus_state.nostrNetwork.pool.our_descriptors.prefix(10))
let relays = Array(damus_state.pool.our_descriptors.prefix(10))
let content = comment ?? ""
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
// send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation
Task { @MainActor in
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)
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)
}
})
}
@@ -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)
let delay = damus_state.settings.nozaps ? nil : 5.0
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)
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher)
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
+14 -37
View File
@@ -94,12 +94,12 @@ struct SelectableText: View {
case show_mute_word_view(highlighted_text: String)
func should_show_highlight_post_view() -> Bool {
guard case .show_highlight_post_view = self else { return false }
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
return true
}
func should_show_mute_word_view() -> Bool {
guard case .show_mute_word_view = self else { return false }
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
return true
}
@@ -119,23 +119,16 @@ struct SelectableText: View {
fileprivate class TextView: UITextView {
var postHighlight: (String) -> Void
var muteWord: (String) -> Void
private let enableHighlighting: Bool
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void, enableHighlighting: Bool) {
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
self.postHighlight = postHighlight
self.muteWord = muteWord
self.enableHighlighting = enableHighlighting
super.init(frame: frame, textContainer: textContainer)
if enableHighlighting {
self.delegate = self
}
}
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 {
if action == #selector(highlightText(_:)) {
@@ -149,44 +142,23 @@ fileprivate class TextView: UITextView {
return super.canPerformAction(action, withSender: sender)
}
private func getSelectedText() -> String? {
func getSelectedText() -> String? {
guard let selectedRange = self.selectedTextRange else { return nil }
return self.text(in: selectedRange)
}
@objc private func highlightText(_ sender: Any?) {
@objc public func highlightText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.postHighlight(selectedText)
}
@objc private func muteText(_ sender: Any?) {
@objc public func muteText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
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 {
let attributedString: AttributedString
@@ -200,7 +172,7 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
@Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord, enableHighlighting: enableHighlighting)
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
view.isEditable = false
view.dataDetectorTypes = .all
view.isSelectable = true
@@ -211,6 +183,11 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
view.textContainerInset.right = 1.0
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
}
@@ -213,6 +213,6 @@ struct UserStatusSheet: View {
struct UserStatusSheet_Previews: PreviewProvider {
static var previews: some View {
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.nostrNetwork.postbox, keypair: test_keypair, status: .init())
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.postbox, keypair: test_keypair, status: .init())
}
}
+1 -1
View File
@@ -160,7 +160,7 @@ func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, set
// Render translated note
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles, can_hide_last_previewable_refs: true)
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles)
// and cache it
return .translated(Translated(artifacts: artifacts, language: note_lang))
+50 -65
View File
@@ -9,7 +9,6 @@ import SwiftUI
import AVKit
import MediaPlayer
import EmojiPicker
import TipKit
struct ZapSheet {
let target: ZapTarget
@@ -179,7 +178,7 @@ struct ContentView: View {
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
case .dms:
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
}
}
.background(DamusColors.adaptableWhite)
@@ -200,7 +199,7 @@ struct ContentView: View {
func MaybeReportView(target: ReportTarget) -> some View {
Group {
if let keypair = damus_state.keypair.to_full() {
ReportView(postbox: damus_state.nostrNetwork.postbox, target: target, keypair: keypair)
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
} else {
EmptyView()
}
@@ -318,7 +317,7 @@ struct ContentView: View {
case .post(let action):
PostView(action: action, damus_state: damus_state!)
case .user_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)
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
.presentationDragIndicator(.visible)
case .event:
EventDetailView()
@@ -334,20 +333,7 @@ struct ContentView: View {
.presentationDetents([.height(550)])
.presentationDragIndicator(.visible)
case .onboardingSuggestions:
if let model = try? SuggestedUsersViewModel(damus_state: damus_state!) {
OnboardingSuggestionsView(model: model)
.interactiveDismissDisabled(true)
}
else {
ErrorView(
damus_state: damus_state,
error: .init(
user_visible_description: NSLocalizedString("Unexpected error loading user suggestions", comment: "Human readable error label"),
tip: NSLocalizedString("Please contact support", comment: "Human readable error tip"),
technical_info: "Error inializing SuggestedUsersViewModel"
)
)
}
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
case .purple(let purple_url):
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
case .purple_onboarding:
@@ -370,7 +356,7 @@ struct ContentView: View {
self.hide_bar = !show
}
.onReceive(timer) { n in
self.damus_state?.nostrNetwork.postbox.try_flushing_events()
self.damus_state?.postbox.try_flushing_events()
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
}
.onReceive(handle_notify(.report)) { target in
@@ -381,6 +367,10 @@ struct ContentView: View {
self.confirm_mute = true
}
.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
// wallet with an associated
guard let ds = self.damus_state,
@@ -401,12 +391,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)
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
ds.nostrNetwork.postbox.send(ev)
ds.postbox.send(ev)
}
.onReceive(handle_notify(.broadcast)) { ev in
guard let ds = self.damus_state else { return }
ds.nostrNetwork.postbox.send(ev)
ds.postbox.send(ev)
}
.onReceive(handle_notify(.unfollow)) { target in
guard let state = self.damus_state else { return }
@@ -428,7 +418,7 @@ struct ContentView: View {
return
}
if !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) {
if !handle_post_notification(keypair: keypair, postbox: state.postbox, events: state.events, post: post) {
self.active_sheet = nil
}
}
@@ -472,7 +462,7 @@ struct ContentView: View {
}
}
.onReceive(handle_notify(.disconnect_relays)) { () in
damus_state.nostrNetwork.pool.disconnect()
damus_state.pool.disconnect()
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
print("txn: 📙 DAMUS ACTIVE NOTIFY")
@@ -518,7 +508,7 @@ struct ContentView: View {
break
case .active:
print("txn: 📙 DAMUS ACTIVE")
damus_state.nostrNetwork.pool.ping()
damus_state.pool.ping()
@unknown default:
break
}
@@ -537,7 +527,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)
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
ds.nostrNetwork.postbox.send(profile_ev)
ds.postbox.send(profile_ev)
}
.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.")) {
@@ -569,7 +559,7 @@ struct ContentView: View {
}
ds.mutelist_manager.set_mutelist(mutelist)
ds.nostrNetwork.postbox.send(mutelist)
ds.postbox.send(mutelist)
confirm_overwrite_mutelist = false
confirm_mute = false
@@ -601,7 +591,7 @@ struct ContentView: View {
}
ds.mutelist_manager.set_mutelist(ev)
ds.nostrNetwork.postbox.send(ev)
ds.postbox.send(ev)
}
}
}, message: {
@@ -642,7 +632,7 @@ struct ContentView: View {
func handleNotification(notification: LossyLocalNotification) {
Log.info("ContentView is handling a notification", for: .push_notifications)
guard damus_state != nil else {
guard let damus_state else {
// 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")
Log.error("DamusState not loaded when ContentView (new handler) was handling a notification", for: .push_notifications)
@@ -670,14 +660,28 @@ struct ContentView: View {
guard let ndb = mndb else { return }
let pool = RelayPool(ndb: ndb, keypair: keypair)
let model_cache = RelayModelCache()
let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(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)
}
self.damus_state = DamusState(keypair: keypair,
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.damus_state = DamusState(pool: pool,
keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey),
@@ -693,6 +697,8 @@ struct ContentView: View {
drafts: Drafts(),
events: EventCache(ndb: ndb),
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
wallet: WalletModel(settings: settings),
nav: self.navigationCoordinator,
@@ -700,8 +706,7 @@ struct ContentView: View {
video: DamusVideoCoordinator(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
home.damus_state = self.damus_state!
@@ -717,23 +722,7 @@ struct ContentView: View {
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
}
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)
}
}
pool.connect()
}
func music_changed(_ state: MusicState) {
@@ -756,7 +745,7 @@ struct ContentView: View {
pdata.status.music = music
guard let ev = music.to_note(keypair: kp) else { return }
damus_state.nostrNetwork.postbox.send(ev)
damus_state.postbox.send(ev)
}
}
@@ -772,8 +761,6 @@ struct ContentView: View {
case route(Route)
/// Open a sheet
case sheet(Sheets)
/// Open an external URL
case external_url(URL)
/// Do nothing.
///
/// ## Implementation notes
@@ -790,8 +777,6 @@ struct ContentView: View {
navigationCoordinator.push(route: route)
case .sheet(let sheet):
self.active_sheet = sheet
case .external_url(let url):
this_app.open(url)
case .no_action:
return
}
@@ -1009,7 +994,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
var has_event = false
guard let filter else { return }
state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
guard case .nostr_event(let ev) = res else {
return
}
@@ -1023,7 +1008,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
break
case .event(_, let ev):
has_event = true
state.nostrNetwork.pool.unsubscribe(sub_id: subid)
state.pool.unsubscribe(sub_id: subid)
switch query {
case .profile:
@@ -1036,11 +1021,11 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
case .eose:
if !has_event {
attempts += 1
if attempts >= state.nostrNetwork.pool.our_descriptors.count {
if attempts >= state.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
}
}
state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
state.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
case .notice:
break
case .auth:
@@ -1059,15 +1044,15 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
/// - naddr: the `naddr` address
/// - callback: A function to handle the found event
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
let subid = UUID().description
damus_state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
guard case .nostr_event(let ev) = res else {
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
return
}
@@ -1075,14 +1060,14 @@ func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (Nos
for tag in ev.tags {
if(tag.count >= 2 && tag[0].string() == "d"){
if (tag[1].string() == naddr.identifier){
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
callback(ev)
return
}
}
}
}
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
}
}
@@ -1130,7 +1115,7 @@ func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
let old_contacts = state.contacts.event
guard let ev = unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
else {
return false
}
@@ -1156,7 +1141,7 @@ func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
return false
}
guard let ev = follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
else {
return false
}
@@ -1231,7 +1216,7 @@ extension LossyLocalNotification {
case .nprofile(let nProfile):
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
return .route(.ProfileByKey(pubkey: nProfile.author))
case .nrelay:
case .nrelay(let string):
// 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
return .sheet(.error(ErrorView.UserPresentableError(
-77
View File
@@ -1,77 +0,0 @@
//
// Interests.swift
// damus
//
// Created by Daniel DAquino on 2025-06-25.
//
import Foundation
struct DIP06 {
/// Standard general interest topics.
/// See https://github.com/damus-io/dips/pull/3
enum Interest: String, CaseIterable {
/// Bitcoin-related topics (e.g. Bitcoin, Lightning, e-cash etc)
case bitcoin = "bitcoin"
/// Any non-Bitcoin technology-related topic (e.g. Linux, new releases, software development, supersonic flight, etc)
case technology = "technology"
/// Any science-related topic (e.g. astronomy, biology, physics, etc)
case science = "science"
/// Lifestyle topics (e.g. Worldschooling, Digital nomading, vagabonding, homesteading, digital minimalism, life hacks, etc)
case lifestyle = "lifestyle"
/// Travel-related topics (e.g. Information about locations to visit, travel logs, etc)
case travel = "travel"
/// Any art-related topic (e.g. poetry, painting, sculpting, photography, etc)
case art = "art"
/// Topics focused on improving human health (e.g. advances in medicine, exercising, nutrition, meditation, sleep, etc)
case health = "health"
/// Any music-related topic (e.g. Bands, fan pages, instruments, classical music theory, etc)
case music = "music"
/// Any topic related to food (e.g. Cooking, recipes, meal planning, nutrition)
case food = "food"
/// Any topic related to sports (e.g. Athlete fan pages, general sports information, sports news, sports equipment, etc)
case sports = "sports"
/// Any topic related to religion, spirituality, or faith (e.g. Christianity, Judaism, Buddhism, Islamism, Hinduism, Taoism, general meditation practice, etc)
case religionSpirituality = "religion-spirituality"
/// General humanities topics (e.g. philosophy, sociology, culture, etc)
case humanities = "humanities"
/// General topics about politics
case politics = "politics"
/// Other miscellaneous topics that do not fit in any of the previous items of the list
case other = "other"
var label: String {
switch self {
case .bitcoin:
return NSLocalizedString("₿ Bitcoin", comment: "Interest topic label")
case .technology:
return NSLocalizedString("💻 Tech", comment: "Interest topic label")
case .science:
return NSLocalizedString("🔭 Science", comment: "Interest topic label")
case .lifestyle:
return NSLocalizedString("🏝️ Lifestyle", comment: "Interest topic label")
case .travel:
return NSLocalizedString("✈️ Travel", comment: "Interest topic label")
case .art:
return NSLocalizedString("🎨 Art", comment: "Interest topic label")
case .health:
return NSLocalizedString("🏃 Health", comment: "Interest topic label")
case .music:
return NSLocalizedString("🎶 Music", comment: "Interest topic label")
case .food:
return NSLocalizedString("🍱 Food", comment: "Interest topic label")
case .sports:
return NSLocalizedString("⚾️ Sports", comment: "Interest topic label")
case .religionSpirituality:
return NSLocalizedString("🛐 Religion", comment: "Interest topic label")
case .humanities:
return NSLocalizedString("📚 Humanities", comment: "Interest topic label")
case .politics:
return NSLocalizedString("🏛️ Politics", comment: "Interest topic label")
case .other:
return NSLocalizedString("♾️ Other", comment: "Interest topic label")
}
}
}
}
+3 -6
View File
@@ -25,13 +25,12 @@ class ActionBarModel: ObservableObject {
@Published private(set) var zaps: Int
@Published var zap_total: Int64
@Published var replies: Int
@Published var relays: Int
static func empty() -> ActionBarModel {
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
}
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0, relays: Int = 0) {
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0) {
self.likes = likes
self.boosts = boosts
self.zaps = zaps
@@ -43,7 +42,6 @@ class ActionBarModel: ObservableObject {
self.our_reply = our_reply
self.our_quote_repost = our_quote_repost
self.quote_reposts = quote_reposts
self.relays = relays
}
func update(damus: DamusState, evid: NoteId) {
@@ -58,12 +56,11 @@ class ActionBarModel: ObservableObject {
self.our_zap = damus.zaps.our_zaps[evid]?.first
self.our_reply = damus.replies.our_reply(evid)
self.our_quote_repost = damus.quote_reposts.our_events[evid]
self.relays = (damus.nostrNetwork.pool.seen[evid] ?? []).count
self.objectWillChange.send()
}
var is_empty: Bool {
return likes == 0 && boosts == 0 && zaps == 0 && quote_reposts == 0 && relays == 0
return likes == 0 && boosts == 0 && zaps == 0
}
var liked: Bool {
@@ -0,0 +1,32 @@
//
// 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
}
}
}
+54 -1
View File
@@ -63,10 +63,44 @@ func follow_user_event(our_contacts: NostrEvent?, keypair: FullKeypair, follow:
}
func decode_json_relays(_ content: String) -> [RelayURL: LegacyKind3RelayRWConfiguration]? {
func decode_json_relays(_ content: String) -> [RelayURL: RelayInfo]? {
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 {
return contacts.references.contains { ref in
switch (ref, follow) {
@@ -94,3 +128,22 @@ func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEven
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)
}
-4
View File
@@ -38,10 +38,6 @@ class Contacts {
return friends
}
func get_friend_of_friends_list() -> Set<Pubkey> {
return friend_of_friends
}
func get_followed_hashtags() -> Set<String> {
guard let ev = self.event else { return Set() }
return Set(ev.referenced_hashtags.map({ $0.hashtag }))
+1 -11
View File
@@ -13,7 +13,6 @@ enum FilterState : Int {
case posts = 0
case posts_and_replies = 1
case conversations = 2
case follow_list = 3
func filter(ev: NostrEvent) -> Bool {
switch self {
@@ -23,15 +22,13 @@ enum FilterState : Int {
return true
case .conversations:
return true
case .follow_list:
return ev.known_kind == .follow_list
}
}
}
/// Simple filter to determine whether to show posts with #nsfw tags
func nsfw_tag_filter(ev: NostrEvent) -> Bool {
return ev.referenced_hashtags.first(where: { t in t.hashtag.caseInsensitiveCompare("nsfw") == .orderedSame }) == nil
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
}
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
@@ -43,12 +40,6 @@ 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
struct ContentFilters {
var filters: [(NostrEvent) -> Bool]
@@ -75,7 +66,6 @@ extension ContentFilters {
filters.append(nsfw_tag_filter)
}
filters.append(get_repost_of_muted_user_filter(damus_state: damus_state))
filters.append(timestamp_filter)
return filters
}
}
-4
View File
@@ -27,10 +27,6 @@ class CreateAccountModel: ObservableObject {
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 = "") {
let keypair = generate_new_keypair()
self.pubkey = keypair.pubkey
+29 -38
View File
@@ -10,6 +10,7 @@ import LinkPresentation
import EmojiPicker
class DamusState: HeadlessDamusState {
let pool: RelayPool
let keypair: Keypair
let likes: EventCounter
let boosts: EventCounter
@@ -27,6 +28,8 @@ class DamusState: HeadlessDamusState {
let drafts: Drafts
let events: EventCache
let bookmarks: BookmarksManager
let postbox: PostBox
let bootstrap_relays: [RelayURL]
let replies: ReplyCounter
let wallet: WalletModel
let nav: NavigationCoordinator
@@ -36,10 +39,9 @@ class DamusState: HeadlessDamusState {
var purple: DamusPurple
var push_notification_client: PushNotificationClient
let emoji_provider: EmojiProvider
let favicon_cache: FaviconCache
private(set) var nostrNetwork: NostrNetworkManager
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) {
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) {
self.pool = pool
self.keypair = keypair
self.likes = likes
self.boosts = boosts
@@ -56,6 +58,8 @@ class DamusState: HeadlessDamusState {
self.drafts = drafts
self.events = events
self.bookmarks = bookmarks
self.postbox = postbox
self.bootstrap_relays = bootstrap_relays
self.replies = replies
self.wallet = wallet
self.nav = nav
@@ -69,10 +73,6 @@ class DamusState: HeadlessDamusState {
self.quote_reposts = quote_reposts
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
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
@@ -98,13 +98,27 @@ class DamusState: HeadlessDamusState {
guard let ndb = mndb else { return nil }
let pubkey = keypair.pubkey
let pool = RelayPool(ndb: ndb, keypair: keypair)
let model_cache = RelayModelCache()
let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(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(
pool: pool,
keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey),
@@ -121,6 +135,8 @@ class DamusState: HeadlessDamusState {
drafts: Drafts(),
events: EventCache(ndb: ndb),
bookmarks: BookmarksManager(pubkey: pubkey),
postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey),
wallet: WalletModel(settings: settings),
nav: navigationCoordinator,
@@ -128,8 +144,7 @@ class DamusState: HeadlessDamusState {
video: DamusVideoCoordinator(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
}
@@ -164,7 +179,7 @@ class DamusState: HeadlessDamusState {
try await self.push_notification_client.revoke_token()
}
wallet.disconnect()
nostrNetwork.pool.close()
pool.close()
ndb.close()
}
@@ -174,6 +189,7 @@ class DamusState: HeadlessDamusState {
let kp = Keypair(pubkey: empty_pub, privkey: nil)
return DamusState.init(
pool: RelayPool(ndb: .empty),
keypair: Keypair(pubkey: empty_pub, privkey: empty_sec),
likes: EventCounter(our_pubkey: empty_pub),
boosts: EventCounter(our_pubkey: empty_pub),
@@ -190,6 +206,8 @@ class DamusState: HeadlessDamusState {
drafts: Drafts(),
events: EventCache(ndb: .empty),
bookmarks: BookmarksManager(pubkey: empty_pub),
postbox: PostBox(pool: RelayPool(ndb: .empty)),
bootstrap_relays: [],
replies: ReplyCounter(our_pubkey: empty_pub),
wallet: WalletModel(settings: UserSettingsStore()),
nav: NavigationCoordinator(),
@@ -197,34 +215,7 @@ class DamusState: HeadlessDamusState {
video: DamusVideoCoordinator(),
ndb: .empty,
quote_reposts: .init(our_pubkey: empty_pub),
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
}
}
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)
}
}
}
+1 -1
View File
@@ -251,7 +251,7 @@ class Drafts: ObservableObject {
// 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
// - 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.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
damus_state.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
}
damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() })
+2 -2
View File
@@ -68,13 +68,13 @@ class EventsModel: ObservableObject {
}
func subscribe() {
state.nostrNetwork.pool.subscribe(sub_id: sub_id,
state.pool.subscribe(sub_id: sub_id,
filters: [get_filter()],
handler: handle_nostr_event)
}
func unsubscribe() {
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
state.pool.unsubscribe(sub_id: sub_id)
}
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
-44
View File
@@ -1,44 +0,0 @@
//
// FollowPackEvent.swift
// damus
//
// Created by eric on 4/30/25.
//
import Foundation
struct FollowPackEvent: Hashable {
let event: NostrEvent
var title: String? = nil
var uuid: String? = nil
var image: URL? = nil
var description: String? = nil
var publicKeys: [Pubkey] = []
var interests: Set<DIP06.Interest> = []
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())))
case "t":
if let interest = DIP06.Interest(rawValue: tag[1].string()) {
followlist.interests.insert(interest)
}
default:
break
}
}
return followlist
}
}
-77
View File
@@ -1,77 +0,0 @@
//
// 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
}
}
}
+4 -4
View File
@@ -37,11 +37,11 @@ class FollowersModel: ObservableObject {
let filter = get_filter()
let filters = [filter]
//print_filters(relay_id: "following", filters: [filters])
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
}
func unsubscribe() {
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
self.damus_state.pool.unsubscribe(sub_id: sub_id)
}
func handle_contact_event(_ ev: NostrEvent) {
@@ -61,7 +61,7 @@ class FollowersModel: ObservableObject {
let filter = NostrFilter(kinds: [.metadata],
authors: authors)
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
damus_state.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
}
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 }
load_profiles(relay_id: relay_id, txn: txn)
} else if sub_id == self.profiles_id {
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
}
case .ok:
+2 -2
View File
@@ -42,7 +42,7 @@ class FollowingModel {
}
let filters = [filter]
//print_filters(relay_id: "following", filters: [filters])
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
}
func unsubscribe() {
@@ -50,7 +50,7 @@ class FollowingModel {
return
}
print("unsubscribing from following \(sub_id)")
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
self.damus_state.pool.unsubscribe(sub_id: sub_id)
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
+2 -2
View File
@@ -35,9 +35,9 @@ enum FriendFilter: String, StringCodable {
func description() -> String {
switch self {
case .all:
return NSLocalizedString("All", comment: "Human-readable short description of the 'trusted network filter' when it is disabled, and therefore is showing all content.")
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
case .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.")
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
}
}
}
+99 -40
View File
@@ -95,13 +95,13 @@ class HomeModel: ContactsDelegate {
}
var pool: RelayPool {
self.damus_state.nostrNetwork.pool
return damus_state.pool
}
var dms: DirectMessagesModel {
return damus_state.dms
}
func has_sub_id_event(sub_id: String, ev_id: NoteId) -> Bool {
if !has_event.keys.contains(sub_id) {
has_event[sub_id] = Set()
@@ -225,12 +225,6 @@ class HomeModel: ContactsDelegate {
// 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)
break
case .relay_list:
break // This will be handled by `UserRelayListManager`
case .follow_list:
break
case .interest_list:
break // Don't care for now
}
}
@@ -265,41 +259,34 @@ class HomeModel: ContactsDelegate {
Task { @MainActor in
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: nwc_str) else {
let nwc = WalletConnectURL(str: nwc_str),
let resp = await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) else {
return
}
// since command results are not returned for ephemeral events,
// 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) {
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
} else {
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
}
guard resp.response.error == nil else {
print("nwc error: \(resp.response)")
WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
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.
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
}
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,
// remove the request from the postbox which is likely failing over and over
if damus_state.nostrNetwork.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
Log.debug("HomeModel: got NWC response, removed %s from the postbox [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
} else {
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 {
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)
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
}
@@ -491,7 +478,7 @@ class HomeModel: ContactsDelegate {
break
}
update_signal_from_pool(signal: self.signal, pool: damus_state.nostrNetwork.pool)
update_signal_from_pool(signal: self.signal, pool: damus_state.pool)
case .nostr_event(let ev):
switch ev {
case .event(let sub_id, let ev):
@@ -961,6 +948,7 @@ func load_our_stuff(state: DamusState, ev: NostrEvent) {
state.contacts.event = 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) {
@@ -968,6 +956,78 @@ func process_contact_event(state: DamusState, ev: NostrEvent) {
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? {
var urlString = relay_id.absoluteString.replacingOccurrences(of: "wss://", with: "https://")
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
@@ -1190,4 +1250,3 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale:
}
}
}
+3 -50
View File
@@ -64,35 +64,10 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
switch self {
case .pubkey(let pubkey): return ["p", pubkey.hex()]
case .note(let noteId): return ["e", noteId.hex()]
case .nevent(let nevent):
var tagBuilder = ["e", nevent.noteid.hex()]
let relay = nevent.relays.first
if let author = nevent.author?.hex() {
tagBuilder.append(relay ?? "")
tagBuilder.append(author)
} else if let relay {
tagBuilder.append(relay)
}
return tagBuilder
case .nprofile(let nprofile):
var tagBuilder = ["p", nprofile.author.hex()]
if let relay = nprofile.relays.first {
tagBuilder.append(relay)
}
return tagBuilder
case .nevent(let nevent): return ["e", nevent.noteid.hex()]
case .nprofile(let nprofile): return ["p", nprofile.author.hex()]
case .nrelay(let url): return ["r", url]
case .naddr(let naddr):
var tagBuilder = ["a", "\(naddr.kind.description):\(naddr.author.hex()):\(naddr.identifier.string())"]
if let relay = naddr.relays.first {
tagBuilder.append(relay)
}
return tagBuilder
case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()]
}
}
@@ -188,10 +163,6 @@ struct LightningInvoice<T> {
let payment_hash: Data
let created_at: UInt64
var abbreviated: String {
return self.string.prefix(8) + "" + self.string.suffix(8)
}
var description_string: String {
switch description {
case .description(let string):
@@ -200,17 +171,6 @@ struct LightningInvoice<T> {
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? {
@@ -232,13 +192,6 @@ enum Amount: Equatable {
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 {
+10 -10
View File
@@ -47,16 +47,16 @@ enum MuteItem: Hashable, Equatable {
// rhs is the item we want to check against (ie. the item in the mute list)
switch (lhs, rhs) {
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, _)):
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, _)):
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
case (.word(let lhs_word, _), .word(let rhs_word, _)):
return lhs_word == rhs_word && !rhs.is_expired()
case (.thread(let lhs_thread, _), .thread(let rhs_thread, _)):
return lhs_thread == rhs_thread && !rhs.is_expired()
default:
return false
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, let rhs_expiration_date)):
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, let rhs_expiration_date)):
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
case (.word(let lhs_word, _), .word(let rhs_word, let rhs_expiration_date)):
return lhs_word == rhs_word && !rhs.is_expired()
case (.thread(let lhs_thread, _), .thread(let rhs_thread, let rhs_expiration_date)):
return lhs_thread == rhs_thread && !rhs.is_expired()
default:
return false
}
}
+1 -1
View File
@@ -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
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.nostrNetwork.postbox.send(new_mutelist_event)
damus_state.postbox.send(new_mutelist_event)
// Set existing muted threads to an empty array
UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey))
}
-97
View File
@@ -1,97 +0,0 @@
//
// 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)
}
}
}
@@ -1,105 +0,0 @@
//
// NostrNetworkManager.swift
// damus
//
// Created by Daniel DAquino 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()
}
func relaysForEvent(event: NostrEvent) -> [RelayURL] {
// TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences
// and reliability of relays to maximize chances of others finding this event.
if let relays = pool.seen[event.id] {
return Array(relays)
}
return []
}
}
// 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 }
}
}
@@ -1,70 +0,0 @@
//
// SubscriptionManager.swift
// damus
//
// Created by Daniel DAquino 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
}
}
@@ -1,85 +0,0 @@
//
// UserRelayListErrors.swift
// damus
//
// Created by Daniel DAquino 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."
)
}
}
}
}
@@ -1,311 +0,0 @@
//
// UserRelayListManager.swift
// damus
//
// Created by Daniel DAquino 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)
}
}
}
}
+62 -127
View File
@@ -73,143 +73,85 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -
return .longform(LongformContent(ev.content))
}
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
return .separated(render_blocks(blocks: blocks, profiles: profiles))
}
func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated {
var invoices: [Invoice] = []
var urls: [UrlType] = []
let blocks = bs.blocks
var end_mention_count = 0
var end_url_count = 0
// Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
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
let one_note_ref = blocks
.filter({
if case .mention(let mention) = $0,
case .note = mention.ref {
return true
}
}
}
else {
return false
}
})
.count == 1
var ind: Int = -1
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
ind = ind + 1
// Add the rendered previewable blocks to their type-specific lists.
switch block {
case .invoice(let invoice):
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
}
}
switch block {
case .mention(let m):
if case .note = m.ref, one_note_ref {
return str
}
return str + mention_str(m, profiles: profiles)
case .text(let txt):
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
return str + CompatibleText(stringLiteral: reduce_text_block(blocks: blocks, ind: ind, txt: txt, one_note_ref: one_note_ref))
case .relay(let relay):
return str + CompatibleText(stringLiteral: relay)
case .hashtag(let htag):
return str + hashtag_str(htag)
case .invoice(let invoice):
return str + invoice_str(invoice)
invoices.append(invoice)
return str
case .url(let url):
return str + url_str(url)
let url_type = classify_url(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)
}
func reduce_text_block(ind: Int, hide_text_index: Int, txt: String) -> String {
func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String {
var trimmed = txt
// Trim leading whitespaces.
if ind == 0 {
trimmed = trim_prefix(trimmed)
if let prev = blocks[safe: ind-1],
case .url(let u) = prev,
classify_url(u).is_media != nil {
trimmed = " " + trim_prefix(trimmed)
}
// Trim trailing whitespaces if the following blocks will be hidden or if this is the last block.
if ind == hide_text_index - 1 {
trimmed = trim_suffix(trimmed)
if let next = blocks[safe: ind+1] {
if case .url(let u) = next, classify_url(u).is_media != nil {
trimmed = trim_suffix(trimmed)
} else if case .mention(let m) = next,
case .note = m.ref,
one_note_ref {
trimmed = trim_suffix(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 {
var attributedString = AttributedString(stringLiteral: url.absoluteString)
attributedString.link = url
@@ -219,16 +161,17 @@ func url_str(_ url: URL) -> CompatibleText {
}
func classify_url(_ url: URL) -> UrlType {
let fileExtension = url.lastPathComponent.lowercased().components(separatedBy: ".").last
switch fileExtension {
case "png", "jpg", "jpeg", "gif", "webp":
let str = url.lastPathComponent.lowercased()
if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") {
return .media(.image(url))
case "mp4", "mov", "m3u8":
return .media(.video(url))
default:
return .link(url)
}
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
return .media(.video(url))
}
return .link(url)
}
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
@@ -251,11 +194,11 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
let display_str: String = {
switch m.ref {
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
case .note: return abbrev_identifier(bech32String)
case .nevent: return abbrev_identifier(bech32String)
case .note: return abbrev_pubkey(bech32String)
case .nevent: return abbrev_pubkey(bech32String)
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
case .nrelay(let url): return url
case .naddr: return abbrev_identifier(bech32String)
case .naddr: return abbrev_pubkey(bech32String)
}
}()
@@ -270,20 +213,12 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
// trim suffix whitespace and newlines
func trim_suffix(_ str: String) -> String {
var result = str
while result.last?.isWhitespace == true {
result.removeLast()
}
return result
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
}
// trim prefix whitespace and newlines
func trim_prefix(_ str: String) -> String {
var result = str
while result.first?.isWhitespace == true {
result.removeFirst()
}
return result
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
}
struct LongformContent {
+1 -12
View File
@@ -41,10 +41,6 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
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.
if state.mutelist_manager.is_event_muted(ev) {
return false
@@ -54,14 +50,7 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else {
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
}
+14 -26
View File
@@ -10,18 +10,10 @@ import Foundation
class ProfileModel: ObservableObject, Equatable {
@Published var contacts: NostrEvent? = nil
@Published var following: Int = 0
@Published var relay_list: NIP65.RelayList? = nil
@Published var legacy_relay_list: [RelayURL: LegacyKind3RelayRWConfiguration]? = nil
@Published var relays: [RelayURL: RelayInfo]? = nil
@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
var events: EventHolder
let pubkey: Pubkey
@@ -67,17 +59,16 @@ class ProfileModel: ObservableObject, Equatable {
func unsubscribe() {
print("unsubscribing from profile \(pubkey) with sub_id \(sub_id)")
damus.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
damus.nostrNetwork.pool.unsubscribe(sub_id: prof_subid)
damus.pool.unsubscribe(sub_id: sub_id)
damus.pool.unsubscribe(sub_id: prof_subid)
if pubkey != damus.pubkey {
damus.nostrNetwork.pool.unsubscribe(sub_id: conversations_subid)
damus.pool.unsubscribe(sub_id: conversations_subid)
}
}
func subscribe() {
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
var relay_list_filter = NostrFilter(kinds: [.relay_list], authors: [pubkey])
profile_filter.authors = [pubkey]
@@ -86,8 +77,8 @@ class ProfileModel: ObservableObject, Equatable {
print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)")
//print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]])
damus.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event)
damus.nostrNetwork.pool.subscribe(sub_id: prof_subid, filters: [profile_filter, relay_list_filter], handler: handle_event)
damus.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)
subscribe_to_conversations()
}
@@ -103,7 +94,7 @@ class ProfileModel: ObservableObject, Equatable {
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])
print("subscribing to conversation events from and to profile \(pubkey) with sub_id \(conversations_subid)")
damus.nostrNetwork.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event)
damus.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event)
}
func handle_profile_contact_event(_ ev: NostrEvent) {
@@ -118,7 +109,7 @@ class ProfileModel: ObservableObject, Equatable {
self.contacts = ev
self.following = count_pubkeys(ev.tags)
self.legacy_relay_list = decode_json_relays(ev.content)
self.relays = decode_json_relays(ev.content)
}
private func add_event(_ ev: NostrEvent) {
@@ -129,9 +120,6 @@ class ProfileModel: ObservableObject, Equatable {
} else if ev.known_kind == .contacts {
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)
}
@@ -204,7 +192,7 @@ class ProfileModel: ObservableObject, Equatable {
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 {
self.legacy_relay_list = decode_json_relays(event.content)
self.relays = decode_json_relays(event.content)
}
}
@@ -212,15 +200,15 @@ class ProfileModel: ObservableObject, Equatable {
var profile_filter = NostrFilter(kinds: [.contacts])
profile_filter.authors = [pubkey]
damus.nostrNetwork.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
damus.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
}
func unsubscribeFindRelays() {
damus.nostrNetwork.pool.unsubscribe(sub_id: findRelay_subid)
damus.pool.unsubscribe(sub_id: findRelay_subid)
}
func getCappedRelayStrings() -> [String] {
return self.relay_urls?.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
return relays?.keys.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
}
}
+8 -34
View File
@@ -7,12 +7,6 @@
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 {
let keypair: Keypair
let settings: UserSettingsStore
@@ -181,33 +175,15 @@ extension PushNotificationClient {
}
struct NotificationSettings: Codable, Equatable {
let zap_notifications_enabled: Bool?
let mention_notifications_enabled: Bool?
let repost_notifications_enabled: Bool?
let reaction_notifications_enabled: Bool?
let dm_notifications_enabled: Bool?
let only_notifications_from_following_enabled: Bool?
let hellthread_notifications_disabled: Bool?
let hellthread_notifications_max_pubkeys: Int?
let zap_notifications_enabled: Bool
let mention_notifications_enabled: Bool
let repost_notifications_enabled: Bool
let reaction_notifications_enabled: Bool
let dm_notifications_enabled: Bool
let only_notifications_from_following_enabled: Bool
static func from(json_data: Data) -> Self? {
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
}
@@ -218,9 +194,7 @@ extension PushNotificationClient {
repost_notifications_enabled: settings.repost_notification,
reaction_notifications_enabled: settings.like_notification,
dm_notifications_enabled: settings.dm_notification,
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
only_notifications_from_following_enabled: settings.notification_only_from_following
)
}
+7 -13
View File
@@ -1,3 +1,4 @@
//
// SearchHomeModel.swift
// damus
//
@@ -15,7 +16,6 @@ class SearchHomeModel: ObservableObject {
var seen_pubkey: Set<Pubkey> = Set()
let damus_state: DamusState
let base_subid = UUID().description
let follow_pack_subid = UUID().description
let profiles_subid = UUID().description
let limit: UInt32 = 500
//let multiple_events_per_pubkey: Bool = false
@@ -41,19 +41,13 @@ class SearchHomeModel: ObservableObject {
func subscribe() {
loading = true
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
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)
let to_relays = determine_to_relays(pool: damus_state.pool, filters: damus_state.relay_filters)
damus_state.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
}
func unsubscribe(to: RelayURL? = nil) {
loading = false
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] })
damus_state.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
}
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
@@ -63,7 +57,7 @@ class SearchHomeModel: ObservableObject {
switch event {
case .event(let sub_id, let ev):
guard sub_id == self.base_subid || sub_id == self.profiles_subid || sub_id == self.follow_pack_subid else {
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
return
}
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
@@ -146,7 +140,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR
let filter = NostrFilter(kinds: [.metadata], authors: authors)
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
damus_state.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
let now = UInt64(Date.now.timeIntervalSince1970)
switch conn_ev {
@@ -162,7 +156,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR
}
case .eose:
print("load_profiles[\(context)]: done loading \(authors.count) profiles from \(relay_id)")
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
damus_state.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
case .ok:
break
case .notice:
+5 -5
View File
@@ -36,18 +36,18 @@ class SearchModel: ObservableObject {
func subscribe() {
// since 1 month
search.limit = self.limit
search.kinds = [.text, .like, .longform, .highlight, .follow_list]
search.kinds = [.text, .like, .longform, .highlight]
//likes_filter.ids = ref_events.referenced_ids!
print("subscribing to search '\(search)' with sub_id \(sub_id)")
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
state.pool.register_handler(sub_id: sub_id, handler: handle_event)
loading = true
state.nostrNetwork.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
state.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
}
func unsubscribe() {
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
state.pool.unsubscribe(sub_id: sub_id)
loading = false
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) {
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
let (sub_id, done) = handle_subid_event(pool: state.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
if ev.is_textlike && ev.should_show_event {
self.add_event(ev)
}
+9 -9
View File
@@ -88,12 +88,12 @@ class ThreadModel: ObservableObject {
/// Unsubscribe from events in the relay pool. Call this when unloading the view
func unsubscribe() {
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: base_subid)
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: meta_subid)
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: profiles_subid)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: meta_subid)
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid)
self.damus_state.pool.remove_handler(sub_id: base_subid)
self.damus_state.pool.remove_handler(sub_id: meta_subid)
self.damus_state.pool.remove_handler(sub_id: profiles_subid)
self.damus_state.pool.unsubscribe(sub_id: base_subid)
self.damus_state.pool.unsubscribe(sub_id: meta_subid)
self.damus_state.pool.unsubscribe(sub_id: profiles_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]
Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
damus_state.nostrNetwork.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
damus_state.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)
}
/// 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
@MainActor
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
let (sub_id, done) = handle_subid_event(pool: damus_state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sid, ev in
let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in
guard subids.contains(sid) else {
return
}
-15
View File
@@ -43,15 +43,6 @@ struct DamusURLHandler {
return .route(.Script(script: model))
case .purple(let 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:
break
}
@@ -100,11 +91,6 @@ struct DamusURLHandler {
return .filter(filt)
case .script(let script):
return .script(script)
case .invoice(let bolt11):
if let invoice = decode_bolt11(bolt11) {
return .invoice(invoice)
}
return nil
}
return nil
}
@@ -117,6 +103,5 @@ struct DamusURLHandler {
case wallet_connect(WalletConnectURL)
case script([UInt8])
case purple(DamusPurpleURL)
case invoice(Invoice)
}
}
+4 -33
View File
@@ -113,12 +113,6 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "show_wallet_selector", default_value: false)
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)
var left_handed: Bool
@@ -127,19 +121,10 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "media_previews", default_value: true)
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)
var hide_nsfw_tagged_content: Bool
@Setting(key: "reduce_bitcoin_content", default_value: false)
var reduce_bitcoin_content: Bool
@Setting(key: "show_profile_action_sheet_on_pfp_click", default_value: true)
var show_profile_action_sheet_on_pfp_click: Bool
@@ -175,13 +160,7 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "notification_only_from_following", default_value: false)
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)
var translate_dms: Bool
@@ -189,12 +168,8 @@ class UserSettingsStore: ObservableObject {
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
///
/// 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: "nozaps", default_value: true)
var nozaps: Bool
@Setting(key: "truncate_mention_text", default_value: true)
var truncate_mention_text: Bool
@@ -361,10 +336,6 @@ class UserSettingsStore: ObservableObject {
@Setting(key: "draft_event_ids", default_value: nil)
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
enum NotificationsMode: String, CaseIterable, Identifiable, StringCodable, Equatable {
+2 -4
View File
@@ -83,10 +83,8 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:",
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
case .bitcoinbeach:
// Blink used to be called Bitcoin Beach.
// 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")
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://",
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
case .blixtwallet:
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
+4 -50
View File
@@ -27,11 +27,6 @@ class WalletModel: ObservableObject {
@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) {
self.connect_state = state
self.previous_state = .none
@@ -80,16 +75,12 @@ class WalletModel: ObservableObject {
///
/// - Parameter response: The NWC response received from the network
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
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 {
switch response.response.result {
case .get_balance(let balanceResp):
self.balance = balanceResp.balance / 1000
case .pay_invoice(_):
case .none:
return
case .some(.pay_invoice(_)):
return
case .list_transactions(let transactionsResp):
self.transactions = transactionsResp.transactions
@@ -100,41 +91,4 @@ class WalletModel: ObservableObject {
self.transactions = 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
}
}
+2 -2
View File
@@ -31,11 +31,11 @@ class ZapsModel: ObservableObject {
case .note(let note_target):
filter.referenced_ids = [note_target.note_id]
}
state.nostrNetwork.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
state.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
}
func unsubscribe() {
state.nostrNetwork.pool.unsubscribe(sub_id: zaps_subid)
state.pool.unsubscribe(sub_id: zaps_subid)
}
@MainActor
-24
View File
@@ -52,28 +52,4 @@ extension NIP04 {
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
}
}
-111
View File
@@ -1,111 +0,0 @@
//
// InterestList.swift
// damus
//
// Created by Daniel D'Aquino on 2025-06-23.
//
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
import Foundation
/// Includes models and functions for working with NIP-51
struct NIP51: Sendable {}
extension NIP51 {
/// An error thrown when decoding an item into a NIP-51 list
enum NIP51DecodingError: Error {
/// The Nostr event being converted is not a NIP-51 interest list
case notInterestList
}
}
extension NIP51 {
/// Models a NIP-51 Interest List (kind:10015)
struct InterestList: NostrEventConvertible, Sendable {
typealias E = NIP51DecodingError
enum InterestItem: Sendable, Hashable {
case hashtag(String)
case interestSet(String, String, String) // a-tag: kind, pubkey, identifier
var tag: [String] {
switch self {
case .hashtag(let tag):
return ["t", tag]
case .interestSet(let kind, let pubkey, let identifier):
var tag = ["a", "\(kind):\(pubkey):\(identifier)"]
return tag
}
}
static func fromTag(tag: TagSequence) -> InterestItem? {
var i = tag.makeIterator()
guard let t0 = i.next(),
let t1 = i.next() else { return nil }
let tagName = t0.string()
if tagName == "t" {
return .hashtag(t1.string())
} else if tagName == "a" {
let components = t1.string().split(separator: ":")
guard components.count > 2 else { return nil }
let kind = String(components[0])
let pubkey = String(components[1])
let identifier = String(components[2])
return .interestSet(kind, pubkey, identifier)
}
return nil
}
}
let interests: [InterestItem]
// MARK: - Initialization
init(event: NdbNote) throws(E) {
try self.init(event: UnownedNdbNote(event))
}
init(event: borrowing UnownedNdbNote) throws(E) {
guard event.known_kind == .interest_list else {
throw E.notInterestList
}
var interests: [InterestItem] = []
for tag in event.tags {
if let interest = InterestItem.fromTag(tag: tag) {
interests.append(interest)
}
}
self.interests = interests
}
init?(event: NdbNote?) throws(E) {
guard let event else { return nil }
try self.init(event: event)
}
init(interests: [InterestItem]) {
self.interests = interests
}
// MARK: - Conversion to a Nostr Event
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
return NdbNote(
content: "",
keypair: keypair.to_keypair(),
kind: NostrKind.interest_list.rawValue,
tags: self.interests.map { $0.tag },
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
)
}
}
}
-171
View File
@@ -1,171 +0,0 @@
//
// NIP65.swift
// damus
//
// Created by Daniel DAquino 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
}
}
}
+1 -22
View File
@@ -34,19 +34,6 @@ protocol TagConvertible {
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 {
let id: Data
@@ -143,16 +130,8 @@ struct ReplaceableParam: TagConvertible {
var keychar: AsciiCharacter { "d" }
}
struct Signature: Codable, Hashable, Equatable {
struct Signature: Hashable, Equatable {
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) {
self.data = p
+1 -1
View File
@@ -7,7 +7,7 @@
import Foundation
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: RelayPool.Relay) -> NostrEvent? {
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: Relay) -> NostrEvent? {
let tags: [[String]] = [["relay", relay.descriptor.url.absoluteString],["challenge", challenge_string]]
let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags)
return event
+3 -3
View File
@@ -7,7 +7,7 @@
import Foundation
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayPool.RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
var tags = zap_target_to_tags(target)
var relay_tag = ["relays"]
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? {
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
let rw_relay_info = LegacyKind3RelayRWConfiguration(read: true, write: true)
var relays: [RelayURL: LegacyKind3RelayRWConfiguration] = [:]
let rw_relay_info = RelayInfo(read: true, write: true)
var relays: [RelayURL: RelayInfo] = [:]
for relay in bootstrap_relays {
relays[relay] = rw_relay_info
+6 -41
View File
@@ -13,18 +13,6 @@ import CryptoKit
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 {
case unknown
case ok
@@ -379,10 +367,6 @@ func decode_json<T: Decodable>(_ val: String) -> T? {
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? {
let decoder = JSONDecoder()
do {
@@ -448,26 +432,17 @@ func random_bytes(count: Int) -> Data {
return Data(bytes: bytes, count: count)
}
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent, relayURL: RelayURL?) -> NostrEvent? {
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent) -> NostrEvent? {
var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
var eTagBuilder = ["e", boosted.id.hex()]
var pTagBuilder = ["p", boosted.pubkey.hex()]
let relayURLString = relayURL?.absoluteString
if let relayURLString {
pTagBuilder.append(relayURLString)
}
eTagBuilder.append(contentsOf: [relayURLString ?? "", "root", boosted.pubkey.hex()])
tags.append(eTagBuilder)
tags.append(pTagBuilder)
tags.append(["e", boosted.id.hex(), "", "root"])
tags.append(["p", boosted.pubkey.hex()])
let content = event_to_json(ev: boosted)
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags)
}
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙", relayURL: RelayURL?) -> NostrEvent? {
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙") -> NostrEvent? {
var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in
guard tag.count >= 2,
(tag[0].matches_char("e") || tag[0].matches_char("p")) else {
@@ -476,17 +451,8 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
ts.append(tag.strings())
}
var eTagBuilder = ["e", liked.id.hex()]
var pTagBuilder = ["p", liked.pubkey.hex()]
let relayURLString = relayURL?.absoluteString
if let relayURLString {
pTagBuilder.append(relayURLString)
}
eTagBuilder.append(contentsOf: [relayURLString ?? "", liked.pubkey.hex()])
tags.append(eTagBuilder)
tags.append(pTagBuilder)
tags.append(["e", liked.id.hex()])
tags.append(["p", liked.pubkey.hex()])
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
}
@@ -561,7 +527,6 @@ func event_to_json(ev: NostrEvent) -> String {
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? {
guard let privkey = privkey else {
return nil
-4
View File
@@ -8,7 +8,6 @@
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 {
case metadata = 0
case text = 1
@@ -19,8 +18,6 @@ enum NostrKind: UInt32, Codable {
case like = 7
case chat = 42
case mute_list = 10000
case relay_list = 10002
case interest_list = 10015
case list_deprecated = 30000
case draft = 31234
case longform = 30023
@@ -31,5 +28,4 @@ enum NostrKind: UInt32, Codable {
case nwc_response = 23195
case http_auth = 27235
case status = 30315
case follow_list = 39089
}
+2 -10
View File
@@ -12,7 +12,6 @@ enum NostrLink: Equatable {
case ref(RefId)
case filter(NostrFilter)
case script([UInt8])
case invoice(String)
}
func encode_pubkey_uri(_ pubkey: Pubkey) -> String {
@@ -94,15 +93,8 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
return
}
if parts.count >= 2 {
switch parts[0] {
case "t":
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
case "lightning":
return .invoice(parts[1])
default:
break
}
if parts.count >= 2 && parts[0] == "t" {
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
}
guard parts.count == 1 else {
+1 -12
View File
@@ -12,14 +12,11 @@ struct NostrSubscribe {
let sub_id: String
}
/// Models a request/message that is sent to a Nostr relay
enum NostrRequestType {
/// A standard nostr request
case typical(NostrRequest)
/// A customized nostr request. Generally used in the context of a nostrscript.
case custom(String)
/// Whether this request is meant to write data to a relay
var is_write: Bool {
guard case .typical(let req) = self else {
return true
@@ -28,7 +25,6 @@ enum NostrRequestType {
return req.is_write
}
/// Whether this request is meant to read data from a relay
var is_read: Bool {
guard case .typical(let req) = self else {
return true
@@ -38,18 +34,12 @@ enum NostrRequestType {
}
}
/// Models a standard request/message that is sent to a Nostr relay.
enum NostrRequest {
/// Subscribes to receive information from the relay
case subscribe(NostrSubscribe)
/// Unsubscribes from an existing subscription, addressed by its id
case unsubscribe(String)
/// Posts an event
case event(NostrEvent)
/// Authenticate with the relay
case auth(NostrEvent)
/// Whether this request is meant to write data to a relay
var is_write: Bool {
switch self {
case .subscribe:
@@ -63,7 +53,6 @@ enum NostrRequest {
}
}
/// Whether this request is meant to read data from a relay
var is_read: Bool {
return !is_write
}
-1
View File
@@ -35,7 +35,6 @@ class Profiles {
@MainActor
private var profiles: [Pubkey: ProfileData] = [:]
// Map of validated NIP-05 address to pubkey.
@MainActor
var nip05_pubkey: [String: Pubkey] = [:]
+1 -26
View File
@@ -115,19 +115,6 @@ 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 {
case event(NoteId)
case pubkey(Pubkey)
@@ -137,7 +124,6 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
case naddr(NAddr)
case reference(String)
/// The key that defines the type of reference being made
var key: RefKey {
switch self {
case .event: return .e
@@ -150,14 +136,6 @@ 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 {
case e, p, t, d, q, a, r
@@ -170,12 +148,10 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
}
}
/// A raw nostr-style tag sequence representation of this object
var tag: [String] {
[self.key.description, self.description]
}
/// Describes what is being referenced, as a `String`
var description: String {
switch self {
case .event(let noteId): return noteId.hex()
@@ -190,7 +166,6 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
}
}
/// Parses a raw tag sequence
static func from_tag(tag: TagSequence) -> RefId? {
var i = tag.makeIterator()
+47 -85
View File
@@ -7,25 +7,16 @@
import Foundation
public struct LegacyKind3RelayRWConfiguration: Codable, Sendable {
public let read: Bool?
public let write: Bool?
public struct RelayInfo: Codable {
let read: Bool?
let write: Bool?
init(read: Bool, write: Bool) {
self.read = read
self.write = write
}
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
}
}
static let rw = RelayInfo(read: true, write: true)
}
enum RelayVariant {
@@ -34,34 +25,31 @@ enum RelayVariant {
case nwc
}
extension RelayPool {
/// Describes a relay for use in `RelayPool`
public struct RelayDescriptor {
let url: RelayURL
var info: NIP65.RelayList.RelayItem.RWConfiguration
let variant: RelayVariant
init(url: RelayURL, info: NIP65.RelayList.RelayItem.RWConfiguration, variant: RelayVariant = .regular) {
self.url = url
self.info = info
self.variant = variant
}
var ephemeral: Bool {
switch variant {
case .regular:
return false
case .ephemeral:
return true
case .nwc:
return true
}
}
static func nwc(url: RelayURL) -> RelayDescriptor {
return RelayDescriptor(url: url, info: .readWrite, variant: .nwc)
public struct RelayDescriptor {
let url: RelayURL
let info: RelayInfo
let variant: RelayVariant
init(url: RelayURL, info: RelayInfo, variant: RelayVariant = .regular) {
self.url = url
self.info = info
self.variant = variant
}
var ephemeral: Bool {
switch variant {
case .regular:
return false
case .ephemeral:
return true
case .nwc:
return true
}
}
static func nwc(url: RelayURL) -> RelayDescriptor {
return RelayDescriptor(url: url, info: .rw, variant: .nwc)
}
}
enum RelayFlags: Int {
@@ -141,56 +129,30 @@ struct RelayMetadata: Codable {
}
}
extension RelayPool {
class Relay: Identifiable {
var descriptor: RelayDescriptor
let connection: RelayConnection
var authentication_state: RelayAuthenticationState
var flags: Int
init(descriptor: RelayDescriptor, connection: RelayConnection) {
self.flags = 0
self.descriptor = descriptor
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
}
}
}
class Relay: Identifiable {
let descriptor: RelayDescriptor
let connection: RelayConnection
var authentication_state: RelayAuthenticationState
extension RelayPool {
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)
var flags: Int
init(descriptor: RelayDescriptor, connection: RelayConnection) {
self.flags = 0
self.descriptor = descriptor
self.connection = connection
self.authentication_state = RelayAuthenticationState.none
}
static func fromLegacyContactList(_ contactList: NdbNote?) throws(BridgeError) -> Self? {
guard let contactList = contactList else { return nil }
return try fromLegacyContactList(contactList)
var is_broken: Bool {
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
}
enum BridgeError: Error {
case couldNotDecodeRelayListInfo
var id: RelayURL {
return descriptor.url
}
}
enum RelayError: Error {
case RelayAlreadyExists
}
+23 -75
View File
@@ -19,15 +19,18 @@ struct QueuedRequest {
let skip_ephemeral: Bool
}
/// Establishes and manages connections and subscriptions to a list of relays.
struct SeenEvent: Hashable {
let relay_id: RelayURL
let evid: NoteId
}
class RelayPool {
private(set) var relays: [Relay] = []
var relays: [Relay] = []
var handlers: [RelayHandler] = []
var request_queue: [QueuedRequest] = []
var seen: [NoteId: Set<RelayURL>] = [:]
var seen: Set<SeenEvent> = Set()
var counts: [RelayURL: UInt64] = [:]
var ndb: Ndb
/// The keypair used to authenticate with relays
var keypair: Keypair?
var message_received_function: (((String, RelayDescriptor)) -> Void)?
var message_sent_function: (((String, Relay)) -> Void)?
@@ -119,7 +122,7 @@ class RelayPool {
}
}
func add_relay(_ desc: RelayDescriptor) throws(RelayError) {
func add_relay(_ desc: RelayDescriptor) throws {
let relay_id = desc.url
if get_relay(relay_id) != nil {
throw RelayError.RelayAlreadyExists
@@ -197,64 +200,6 @@ class RelayPool {
register_handler(sub_id: sub_id, handler: handler)
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) -> ()) {
register_handler(sub_id: sub_id, handler: handler)
@@ -298,19 +243,19 @@ class RelayPool {
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
let relays = to.map{ get_relays($0) } ?? self.relays
self.send_raw_to_local_ndb(req) // Always send Nostr events and data to NostrDB for a local copy
self.send_raw_to_local_ndb(req)
for relay in relays {
if req.is_read && !(relay.descriptor.info.canRead) {
continue // Do not send read requests to relays that are not READ relays
if req.is_read && !(relay.descriptor.info.read ?? true) {
continue
}
if req.is_write && !(relay.descriptor.info.canWrite) {
continue // Do not send write requests to relays that are not WRITE relays
if req.is_write && !(relay.descriptor.info.write ?? true) {
continue
}
if relay.descriptor.ephemeral && skip_ephemeral {
continue // Do not send requests to ephemeral relays if we want to skip them
continue
}
guard relay.connection.isConnected else {
@@ -352,12 +297,15 @@ class RelayPool {
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
if case .nostr_event(let ev) = event {
if case .event(_, let nev) = ev {
if seen[nev.id]?.contains(relay_id) == true {
return
let k = SeenEvent(relay_id: relay_id, evid: nev.id)
if !seen.contains(k) {
seen.insert(k)
if counts[relay_id] == nil {
counts[relay_id] = 1
} else {
counts[relay_id] = (counts[relay_id] ?? 0) + 1
}
}
seen[nev.id, default: Set()].insert(relay_id)
counts[relay_id, default: 0] += 1
notify(.update_stats(note_id: nev.id))
}
}
}
@@ -406,7 +354,7 @@ class RelayPool {
}
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
try? pool.add_relay(RelayDescriptor(url: url, info: .rw))
}
+5 -3
View File
@@ -83,7 +83,8 @@ var test_damus_state: DamusState = ({
let our_pubkey = test_pubkey
let pool = RelayPool(ndb: ndb)
let settings = UserSettingsStore()
let damus = DamusState(keypair: test_keypair,
let damus = DamusState(pool: pool,
keypair: test_keypair,
likes: .init(our_pubkey: our_pubkey),
boosts: .init(our_pubkey: our_pubkey),
contacts: .init(our_pubkey: our_pubkey),
@@ -99,6 +100,8 @@ var test_damus_state: DamusState = ({
drafts: .init(),
events: .init(ndb: ndb),
bookmarks: .init(pubkey: our_pubkey),
postbox: .init(pool: pool),
bootstrap_relays: .init(),
replies: .init(our_pubkey: our_pubkey),
wallet: .init(settings: settings),
nav: .init(),
@@ -106,8 +109,7 @@ var test_damus_state: DamusState = ({
video: .init(),
ndb: ndb,
quote_reposts: .init(our_pubkey: our_pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: .init()
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
/*
+1 -27
View File
@@ -37,23 +37,7 @@ enum Block: Equatable {
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 mention(Mention<MentionRef>)
case hashtag(String)
@@ -202,13 +186,3 @@ extension Block {
}
}
}
extension Block {
var asInvoice: Invoice? {
switch self {
case .invoice(let invoice):
return invoice
default:
return nil
}
}
}
+1
View File
@@ -45,3 +45,4 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
}
}
-4
View File
@@ -47,10 +47,6 @@ struct NEvent : Equatable, Hashable {
self.author = author
self.kind = kind
}
init(event: NostrEvent, relays: [String]) {
self.init(noteid: event.id, relays: relays, author: event.pubkey, kind: event.kind)
}
}
struct NProfile : Equatable, Hashable {
@@ -1,389 +0,0 @@
//
// CoinosDeterministicClient.swift
// damus
//
// Created by Daniel DAquino on 2025-04-14.
//
import Foundation
/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
///
/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
class CoinosDeterministicAccountClient {
// MARK: - State
/// The user's normal keypair for using Nostr
private let userKeypair: FullKeypair
/// The JWT authentication token with Coinos
private var jwtAuthToken: String? = nil
// MARK: - Computed properties for a deterministic wallet
/// A deterministic keypair for the NWC connection derived from the user's private key
private var nwcKeypair: FullKeypair? {
let nwcPrivateKey: Privkey = Privkey(sha256(self.userKeypair.privkey.id)) // SHA256 is an irreversible operation, user's nsec should not be deriveable from this new private key
return FullKeypair(privkey: nwcPrivateKey)
}
/// A deterministic username for a Coinos account
private var username: String? {
// Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
// Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
// There is very little risk of a birthday attack on getting only the first 16 characters, because:
// 1. before this user creates an account, no one else knows the private key in order to know the expected username and create an account before them
// 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
//
// In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
// According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
// even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
return String(fullText.prefix(16))
}
var expectedLud16: String? {
guard let username else { return nil }
return username + "@coinos.io"
}
/// 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
}
/// Updates an existing NWC connection with a new maximum budget
///
/// Note: Account and NWC connection must exist before calling this endpoint
func updateNWCConnection(maxAmount: UInt64) 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()
// Get existing config first
guard let existingConfig = try await self.getNWCAppConnectionConfig() else {
throw ClientError.errorProcessingResponse
}
// Create updated config with new max amount
let updatedConfig = NewWalletConnectionConfig(
name: existingConfig.name ?? self.nwcConnectionName,
secret: existingConfig.secret ?? nwcKeypair.privkey.hex(),
pubkey: existingConfig.pubkey ?? nwcKeypair.pubkey.hex(),
max_amount: maxAmount,
budget_renewal: .weekly
)
let configData = try encode_json_data(updatedConfig)
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()
}
-4
View File
@@ -18,9 +18,6 @@ class Constants {
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
// MARK: Curation
static let ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY: Pubkey = Pubkey(hex: "895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba")!
// MARK: Push notification server
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
static let PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL: URL = URL(string: "https://notify-staging.damus.io")!
@@ -45,5 +42,4 @@ class Constants {
// MARK: General constants
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
static let MAX_SHARE_RELAYS = 4
}
+2 -2
View File
@@ -80,9 +80,9 @@ func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) ->
}
func abbrev_bech32_pubkey(pubkey: Pubkey) -> String {
return abbrev_identifier(String(pubkey.npub.dropFirst(4)))
return abbrev_pubkey(String(pubkey.npub.dropFirst(4)))
}
func abbrev_identifier(_ pubkey: String, amount: Int = 8) -> String {
func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String {
return pubkey.prefix(amount) + ":" + pubkey.suffix(amount)
}
+1 -1
View File
@@ -354,7 +354,7 @@ func preload_image(url: URL) {
//print("Preloading image \(url.absoluteString)")
KingfisherManager.shared.retrieveImage(with: Kingfisher.KF.ImageResource(downloadURL: url)) { val in
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: url)) { val in
//print("Preloaded image \(url.absoluteString)")
}
}
+17 -27
View File
@@ -29,15 +29,15 @@ extension KFOptionSetter {
options.onlyLoadFirstFrame = disable_animation
switch imageContext {
case .pfp, .favicon:
options.diskCacheExpiration = .days(60)
break
case .banner:
options.diskCacheExpiration = .days(5)
break
case .note:
options.diskCacheExpiration = .days(1)
break
case .pfp:
options.diskCacheExpiration = .days(60)
break
case .banner:
options.diskCacheExpiration = .days(5)
break
case .note:
options.diskCacheExpiration = .days(1)
break
}
return self
@@ -52,7 +52,7 @@ extension KFOptionSetter {
func onFailure(fallbackUrl: URL?, cacheKey: String?) -> Self {
guard let url = fallbackUrl, let key = cacheKey else { return self }
let imageResource = Kingfisher.KF.ImageResource(downloadURL: url, cacheKey: key)
let imageResource = Kingfisher.ImageResource(downloadURL: url, cacheKey: key)
let source = imageResource.convertToSource()
options.alternativeSources = [source]
@@ -82,14 +82,11 @@ enum ImageContext {
case pfp
case banner
case note
case favicon
func maxMebibyteSize() -> Int {
switch self {
case .favicon:
return 512_000 // 500KiB
case .pfp:
return 5_242_880 // 5MiB
return 5_242_880 // 5Mib
case .banner, .note:
return 20_971_520 // 20MiB
}
@@ -97,8 +94,6 @@ enum ImageContext {
func downsampleSize() -> CGSize {
switch self {
case .favicon:
return CGSize(width: 18, height: 18)
case .pfp:
return CGSize(width: 200, height: 200)
case .banner:
@@ -164,25 +159,20 @@ struct CustomCacheSerializer: CacheSerializer {
}
}
class CustomSessionDelegate: SessionDelegate, @unchecked Sendable {
override func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive response: URLResponse
) async -> URLSession.ResponseDisposition {
class CustomSessionDelegate: SessionDelegate {
override func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
let contentLength = response.expectedContentLength
// Content-Length header is optional (-1 when missing)
if (contentLength != -1 && contentLength > MAX_FILE_SIZE) {
return await super.urlSession(session, dataTask: dataTask, didReceive: URLResponse())
return super.urlSession(session, dataTask: dataTask, didReceive: URLResponse(), completionHandler: completionHandler)
}
return await super.urlSession(session, dataTask: dataTask, didReceive: response)
super.urlSession(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler)
}
}
class CustomImageDownloader: ImageDownloader, @unchecked Sendable {
class CustomImageDownloader: ImageDownloader {
static let shared = CustomImageDownloader(name: "shared")
-41
View File
@@ -1,41 +0,0 @@
//
// 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
}
}
-79
View File
@@ -1,79 +0,0 @@
//
// ImageCacheMigrations.swift
// damus
//
// Created by Daniel DAquino 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)
}
}
-1
View File
@@ -21,7 +21,6 @@ enum LogCategory: String {
case damus_purple
case image_uploading
case video_coordination
case tips
}
/// Damus structured logger
+1 -1
View File
@@ -54,7 +54,7 @@ enum CancelSendErr {
}
class PostBox {
private let pool: RelayPool
let pool: RelayPool
var events: [NoteId: PostedEvent]
init(pool: RelayPool) {
-7
View File
@@ -7,13 +7,6 @@
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 {
private var models = [RelayURL: RelayModel]()
+2 -21
View File
@@ -5,7 +5,6 @@
// Created by Scott Penrose on 5/7/23.
//
import FaviconFinder
import SwiftUI
enum Route: Hashable {
@@ -47,9 +46,6 @@ enum Route: Hashable {
case Wallet(wallet: WalletModel)
case WalletScanner(result: Binding<WalletScanResult>)
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
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
@@ -130,13 +126,7 @@ enum Route: Hashable {
case .FollowersYouKnow(let friendedFollowers, let followers):
FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers)
case .Script(let 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)
LoadScript(pool: damusState.pool, model: load_model)
}
}
@@ -219,7 +209,7 @@ enum Route: Hashable {
case .Search(let search):
hasher.combine("search")
hasher.combine(search.search)
case .NDBSearch:
case .NDBSearch(let results):
hasher.combine("results")
case .EULA:
hasher.combine("eula")
@@ -241,15 +231,6 @@ enum Route: Hashable {
case .Script(let model):
hasher.combine("script")
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)
}
}
}
+3 -3
View File
@@ -35,11 +35,11 @@ func remove_nostr_uri_prefix(_ s: String) -> String {
return uri
}
func abbreviateURL(_ url: URL, maxLength: Int = MAX_CHAR_URL) -> String {
func abbreviateURL(_ url: URL) -> String {
let urlString = url.absoluteString
if urlString.count > maxLength {
return String(urlString.prefix(maxLength)) + ""
if urlString.count > MAX_CHAR_URL {
return String(urlString.prefix(MAX_CHAR_URL)) + "..."
}
return urlString
}
@@ -1,97 +0,0 @@
//
// HumanReadableErrors.swift
// damus
//
// Created by Daniel DAquino 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")"
)
}
}
}
+4 -44
View File
@@ -13,11 +13,7 @@ extension WalletConnect {
/// Pay an invoice
case payInvoice(
/// bolt-11 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?
invoice: String
)
/// Get the current wallet balance
case getBalance
@@ -37,38 +33,6 @@ extension WalletConnect {
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
@@ -97,7 +61,7 @@ extension WalletConnect {
/// Keys for the JSON inside the "params" object
private enum ParamKeys: String, CodingKey {
case invoice, description, metadata
case invoice
case from, until, limit, offset, unpaid, type
}
@@ -118,9 +82,7 @@ extension WalletConnect {
case Method.payInvoice.rawValue:
let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
let invoice = try paramsContainer.decode(String.self, forKey: .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)
self = .payInvoice(invoice: invoice)
case Method.getBalance.rawValue:
// No params to decode
@@ -150,12 +112,10 @@ extension WalletConnect {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .payInvoice(let invoice, let description, let metadata):
case .payInvoice(let invoice):
try container.encode(Method.payInvoice.rawValue, forKey: .method)
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
try paramsContainer.encode(invoice, forKey: .invoice)
try paramsContainer.encodeIfPresent(description, forKey: .description)
try paramsContainer.encodeIfPresent(metadata, forKey: .metadata)
case .getBalance:
try container.encode(Method.getBalance.rawValue, forKey: .method)
+21 -68
View File
@@ -5,8 +5,6 @@
// Created by Daniel DAquino on 2025-03-10.
//
import Combine
extension WalletConnect {
/// Models a response from the NWC provider
struct Response: Decodable {
@@ -52,80 +50,35 @@ extension WalletConnect {
let req_id: NoteId
let response: Response
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) throws(InitializationError) {
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
init?(from: NostrEvent, nwc: WalletConnect.ConnectURL) async {
guard let note_id = from.referenced_ids.first else {
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
)
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
}
catch { throw .failedToDecrypt(error) }
do {
let response: WalletConnect.Response = try decode_json_throwing(json)
self.response = response
guard let res = await ares.value else {
return nil
}
catch { throw .failedToDecodeJSON(error) }
}
enum InitializationError: Error {
case incorrectAuthorPubkey
case missingRequestIdReference
case failedToDecodeJSON(any Error)
case failedToDecrypt(any Error)
self.response = res
}
}
struct WalletResponseErr: Codable, Error {
let code: Code?
struct WalletResponseErr: Codable {
let code: 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)
}
}
}
+3 -27
View File
@@ -20,7 +20,6 @@ extension WalletConnect {
static func subscribe(url: WalletConnectURL, pool: RelayPool) {
var filter = NostrFilter(kinds: [.nwc_response])
filter.authors = [url.pubkey]
filter.pubkeys = [url.keypair.pubkey]
filter.limit = 0
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
@@ -41,9 +40,8 @@ extension WalletConnect {
/// - 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
@discardableResult
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.payZapRequest(invoice: invoice, zapRequest: zap_request)
static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
let req = WalletConnect.Request.payInvoice(invoice: invoice)
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
return nil
}
@@ -105,28 +103,6 @@ extension WalletConnect {
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
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) {
// find the pending zap and mark it as pending-confirmed
@@ -166,7 +142,7 @@ extension WalletConnect {
}
print("damus-donation donating...")
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, zap_request: nil, delay: nil)
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil)
}
/// Handles a received Nostr Wallet Connect error
+1 -1
View File
@@ -86,7 +86,7 @@ extension WalletConnect {
let created_at: UInt64 // unixtimestamp, // invoice/payment creation time
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 metadata: WalletConnect.Request.Metadata? // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc.
//"metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc.
}
}
+4 -15
View File
@@ -217,16 +217,7 @@ struct EventActionBar: View {
AnyView(self.action_bar_content)
}
}
var event_relay_url_strings: [String] {
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
}
return userProfile.getCappedRelayStrings()
}
var body: some View {
self.content
.onAppear {
@@ -242,9 +233,7 @@ struct EventActionBar: View {
}
}
.sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
if let url = URL(string: "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))) {
ShareSheet(activityItems: [url])
}
ShareSheet(activityItems: [URL(string: "https://damus.io/" + event.id.bech32)!])
}
.sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
@@ -273,7 +262,7 @@ struct EventActionBar: View {
func send_like(emoji: String) {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
return
}
@@ -281,7 +270,7 @@ struct EventActionBar: View {
generator.impactOccurred()
damus_state.nostrNetwork.postbox.send(like_ev)
damus_state.postbox.send(like_ev)
}
// MARK: Helper structures
@@ -59,16 +59,6 @@ struct EventDetailBar: View {
}
.buttonStyle(PlainButtonStyle())
}
if bar.relays > 0 {
let relays = Array(state.nostrNetwork.pool.seen[target] ?? [])
NavigationLink(value: Route.UserRelays(relays: relays)) {
let nounString = pluralizedString(key: "relays_count", count: bar.relays)
let noun = Text(nounString).foregroundColor(.gray)
Text("\(Text(verbatim: bar.relays.formatted()).font(.body.bold())) \(noun)", comment: "Sentence composed of 2 variables to describe how many relays a note was found on. In source English, the first variable is the number of relays, and the second variable is 'Relay' or 'Relays'.")
}
.buttonStyle(PlainButtonStyle())
}
}
}
}
+2 -2
View File
@@ -21,11 +21,11 @@ struct RepostAction: View {
dismiss()
guard let keypair = self.damus_state.keypair.to_full(),
let boost = make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
let boost = make_boost_event(keypair: keypair, boosted: self.event) else {
return
}
damus_state.nostrNetwork.postbox.send(boost)
damus_state.postbox.send(boost)
} label: {
Label(NSLocalizedString("Repost", comment: "Button to repost a note"), image: "repost")
.frame(maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .leading)
+2 -11
View File
@@ -26,16 +26,7 @@ struct ShareAction: View {
self.userProfile = userProfile
self._show_share = show_share
}
var event_relay_url_strings: [String] {
let relays = userProfile.damus.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
}
return userProfile.getCappedRelayStrings()
}
var body: some View {
VStack {
@@ -49,7 +40,7 @@ struct ShareAction: View {
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) {
dismiss()
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(noteid: event.id, relays: userProfile.getCappedRelayStrings())))
}
let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark"
+24 -20
View File
@@ -15,8 +15,6 @@ struct AddRelayView: View {
@Environment(\.dismiss) var dismiss
typealias UpdateError = NostrNetworkManager.UserRelayListManager.UpdateError
var body: some View {
VStack {
Text("Add relay", comment: "Title text to indicate user to an add a relay.")
@@ -84,21 +82,38 @@ struct AddRelayView: View {
new_relay = "wss://" + new_relay
}
guard let url = RelayURL(new_relay) else {
relayAddErrorTitle = NSLocalizedString("Invalid relay address", comment: "Heading for an error when adding a relay")
relayAddErrorMessage = NSLocalizedString("Please check the address and try again", comment: "Tip for an error where the relay address being added is invalid")
guard let url = RelayURL(new_relay),
let ev = state.contacts.event,
let keypair = state.keypair.to_full() else {
return
}
let info = RelayInfo.rw
let descriptor = RelayDescriptor(url: url, info: info)
do {
try state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: url, rwConfiguration: .readWrite))
try state.pool.add_relay(descriptor)
relayAddErrorTitle = nil // Clear error title
relayAddErrorMessage = nil // Clear error message
}
catch {
present_sheet(.error(self.humanReadableError(for: error)))
} catch RelayError.RelayAlreadyExists {
relayAddErrorTitle = NSLocalizedString("Duplicate relay", comment: "Title of the duplicate relay error message.")
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.")
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 = ""
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
@@ -119,17 +134,6 @@ struct AddRelayView: View {
}
.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
@@ -28,15 +28,6 @@ enum AppAccessibilityIdentifiers: String {
// MARK: Onboarding
// Prefix: `onboarding`
/// Any interest option button on the "select your interests" page during onboarding
case onboarding_interest_option_button
/// The "next" button on the onboarding interest page
case onboarding_interest_page_next_page
/// The "next" button on the onboarding content settings page
case onboarding_content_settings_page_next_page
/// The skip button on the onboarding sheet
case onboarding_sheet_skip_button
+44
View File
@@ -0,0 +1,44 @@
//
// 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)
}
}
@@ -1,54 +0,0 @@
//
// 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)
}
}
+8 -2
View File
@@ -235,7 +235,7 @@ struct ChatEventView: View {
func send_like(emoji: String) {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
return
}
@@ -244,7 +244,7 @@ struct ChatEventView: View {
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.impactOccurred()
damus_state.nostrNetwork.postbox.send(like_ev)
damus_state.postbox.send(like_ev)
}
var action_bar: some View {
@@ -337,6 +337,12 @@ struct ChatEventView: View {
}
}
extension Notification.Name {
static var toggle_thread_view: Notification.Name {
return Notification.Name("convert_to_thread")
}
}
#Preview {
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)
+94 -201
View File
@@ -7,7 +7,6 @@
import SwiftUI
import SwipeActions
import TipKit
struct ChatroomThreadView: View {
@Environment(\.dismiss) var dismiss
@@ -16,20 +15,11 @@ struct ChatroomThreadView: View {
@ObservedObject var thread: ThreadModel
@State var highlighted_note_id: NoteId? = nil
@State var user_just_posted_flag: Bool = false
@State var untrusted_network_expanded: Bool = true
@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) {
let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: adjustedAnchor)
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top)
highlighted_note_id = note_id
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
withAnimation {
@@ -37,7 +27,7 @@ struct ChatroomThreadView: View {
}
})
}
func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) {
withAnimation {
self.thread.select(event: ev)
@@ -45,202 +35,93 @@ 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 {
ScrollViewReader { scroller in
let sorted_child_events = thread.sorted_child_events
let untrusted_events = sorted_child_events.filter { !trusted_event_filter($0) }
let trusted_events = sorted_child_events.filter { trusted_event_filter($0) }
ZStack(alignment: .top) {
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 8) {
// MARK: - Parents events view
ForEach(thread.parent_events, id: \.id) { parent_event in
EventMutingContainerView(damus_state: damus, event: parent_event) {
EventView(damus: damus, event: parent_event)
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
}
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 8) {
// MARK: - Parents events view
ForEach(thread.parent_events, id: \.id) { parent_event in
EventMutingContainerView(damus_state: damus, event: parent_event) {
EventView(damus: damus, event: parent_event)
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
}
.padding(.horizontal)
.onTapGesture {
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
// 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)
.onTapGesture {
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)
.id(ev.id)
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
}
}
.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
switch notify {
case .post(_):
user_just_posted_flag = true
case .cancel:
return
case .post(_):
user_just_posted_flag = true
case .cancel:
return
}
})
.onReceive(thread.objectWillChange) {
@@ -258,8 +139,15 @@ struct ChatroomThreadView: View {
}
}
}
func toggle_thread_view() {
NotificationCenter.default.post(name: .toggle_thread_view, object: nil)
}
}
struct ChatroomView_Previews: PreviewProvider {
static var previews: some View {
Group {
@@ -279,3 +167,8 @@ 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)
}

Some files were not shown because too many files have changed in this diff Show More