Compare commits

..

1 Commits

Author SHA1 Message Date
tyiu d174b03648 Fix bug where profile view was showing more than just the notes and replies on the notes / notes & replies tabs
Changelog-Fixed: Fix bug where profile view was showing more than just the notes and replies on the notes / notes & replies tabs
Fixes: caa4bfe864 ("Fix bug where profile view was showing more than just the notes and replies on the notes / notes & replies tabs")
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-03-03 10:35:13 -05:00
168 changed files with 1940 additions and 7216 deletions
-107
View File
@@ -1,110 +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
- Fixed an issue where threads would not load properly (Daniel DAquino)
[1.13.1]: https://github.com/damus-io/damus/releases/tag/v1.13.1
## [1.13] - 2025-03-14
### Added
- Added local persistence of note drafts (Daniel DAquino)
- Added user-friendly error view for errors around the app that would not fit in other places (Daniel DAquino)
- Coinos connection button in Wallet view (ericholguin)
- Added Alby Go to mobile wallets selection menu (Tomek ⚡ K)
- Minor accessibility improvements around picture editing and onboarding (Daniel DAquino)
- Profile image cropping tools (Daniel DAquino)
- Added Conversations tab to profiles (Terry Yiu)
- Added profile pictures to push notifications (William Casarin)
### Changed
- Don't show reposts for the same note more than once in your home feed (William Casarin)
- Improved profile image bandwidth optimization (Daniel DAquino)
- Improved reliability of picture selector (Daniel DAquino)
- Changed spaces to newlines in new posts to provide cleaner separation between text, uploaded media, and quoted notes (Terry Yiu)
### Fixed
- Fixed issue where some push notifications would not open in the app and leave users confused (Daniel DAquino)
- Fixed issue where app would need a restart for new NWC wallets to work (Daniel DAquino)
- Fixed overly sensitive horizontal swipe on thread chat view (Daniel DAquino)
- Trim whitespaces from Lightning addresses (Terry Yiu)
- Fixed translation export script by upgrading nostr-sdk-swift dependency to support Mac Catalyst (Terry Yiu)
- Fixed issue where users continue to receive push notifications after logout (Daniel DAquino)
- Fixed an issue where events on a thread view would occasionally disappear (Daniel DAquino)
- Improved robustness of the URL handler (Daniel DAquino)
- Translate notes even if they are in a preferred language but not the current language as that is what users expect (Terry Yiu)
- Cancel ongoing uploading operations after the user cancels the post (Swift Coder)
- Fixed link and photo sharing support on macOS (Swift Coder)
- Fix bug where profile view was showing more than just the notes and replies on the notes / notes & replies tabs (Terry Yiu)
- Fixed reposts banner to be localizable (Terry Yiu)
### Removed
- Removed language filtering from Universe feed because language detection can be inaccurate (Terry Yiu)
- Removed mystery tabs meant to fix tab switching bug that no longer exists (Terry Yiu)
[1.13](https://github.com/damus-io/damus/releases/tag/v1.13): https://github.com/damus-io/damus/releases/tag/v1.13
## [1.12.3] - 2025-02-06
### Added
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.security.app-sandbox</key>
@@ -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 }
@@ -5,32 +5,15 @@
// Created by Daniel DAquino on 2023-11-10.
//
import Kingfisher
import ImageIO
import UserNotifications
import Foundation
import UniformTypeIdentifiers
import Intents
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent?
private func configureKingfisherCache() {
guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER) else {
return
}
let cachePath = groupURL.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
if let cache = try? ImageCache(name: "sharedCache", cacheDirectoryURL: cachePath) {
KingfisherManager.shared.cache = cache
}
}
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
configureKingfisherCache()
self.contentHandler = contentHandler
guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String,
@@ -57,16 +40,9 @@ class NotificationService: UNNotificationServiceExtension {
return
}
let sender_profile = {
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
let profile = txn?.unsafeUnownedValue?.profile
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
return ProfileBuf(picture: picture,
name: profile?.name,
display_name: profile?.display_name,
nip05: profile?.nip05)
}()
let sender_pubkey = nostr_event.pubkey
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
let profile = txn?.unsafeUnownedValue?.profile
let name = Profile.displayName(profile: profile, pubkey: nostr_event.pubkey).displayName
// Don't show notification details that match mute list.
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
@@ -80,7 +56,7 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(content)
return
}
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
Log.debug("should_display_notification failed", for: .push_notifications)
// We should not display notification for this event. Suppress notification.
@@ -89,7 +65,7 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content)
return
}
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
Log.debug("generate_local_notification_object failed", for: .push_notifications)
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
@@ -98,58 +74,15 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content)
return
}
Task {
let sender_dn = DisplayName(name: sender_profile.name, display_name: sender_profile.display_name, pubkey: sender_pubkey)
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: sender_dn.displayName, notify: notification_object, state: state) else {
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
return
}
do {
var options: [AnyHashable: Any] = [:]
if let imageSource = CGImageSourceCreateWithURL(sender_profile.picture as CFURL, nil),
let uti = CGImageSourceGetType(imageSource) {
options[UNNotificationAttachmentOptionsTypeHintKey] = uti
}
let attachment = try UNNotificationAttachment(identifier: sender_profile.picture.absoluteString, url: sender_profile.picture, options: options)
improvedContent.attachments = [attachment]
} catch {
Log.error("failed to get notification attachment: %s", for: .push_notifications, error.localizedDescription)
}
let kind = nostr_event.known_kind
// these aren't supported yet
if !(kind == .text || kind == .dm) {
contentHandler(improvedContent)
return
}
// rich communication notifications for kind1, dms, etc
let message_intent = await message_intent_from_note(ndb: state.ndb,
sender_profile: sender_profile,
content: improvedContent.body,
note: nostr_event,
our_pubkey: state.keypair.pubkey)
improvedContent.threadIdentifier = nostr_event.thread_id().hex()
improvedContent.categoryIdentifier = "COMMUNICATION"
let interaction = INInteraction(intent: message_intent, response: nil)
interaction.direction = .incoming
do {
try await interaction.donate()
let updated = try improvedContent.updating(from: message_intent)
contentHandler(updated)
} catch {
Log.error("failed to donate interaction: %s", for: .push_notifications, error.localizedDescription)
contentHandler(improvedContent)
}
contentHandler(improvedContent)
}
}
@@ -162,162 +95,3 @@ class NotificationService: UNNotificationServiceExtension {
}
}
struct ProfileBuf {
let picture: URL
let name: String?
let display_name: String?
let nip05: String?
}
func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: String, note: NdbNote, our_pubkey: Pubkey) async -> INSendMessageIntent {
let sender_pk = note.pubkey
let sender = await profile_to_inperson(name: sender_profile.name,
display_name: sender_profile.display_name,
picture: sender_profile.picture.absoluteString,
nip05: sender_profile.nip05,
pubkey: sender_pk,
our_pubkey: our_pubkey)
let conversationIdentifier = note.thread_id().hex()
var recipients: [INPerson] = []
var pks: [Pubkey] = []
let meta = INSendMessageIntentDonationMetadata()
// gather recipients
if let recipient_note_id = note.direct_replies() {
let replying_to = ndb.lookup_note(recipient_note_id)
if let replying_to_pk = replying_to?.unsafeUnownedValue?.pubkey {
meta.isReplyToCurrentUser = replying_to_pk == our_pubkey
if replying_to_pk != sender_pk {
// we push the actual person being replied to first
pks.append(replying_to_pk)
}
}
}
let pubkeys = Array(note.referenced_pubkeys)
meta.recipientCount = pubkeys.count
if pubkeys.contains(sender_pk) {
meta.recipientCount -= 1
}
for pk in pubkeys.prefix(3) {
if pk == sender_pk || pks.contains(pk) {
continue
}
if !meta.isReplyToCurrentUser && pk == our_pubkey {
meta.mentionsCurrentUser = true
}
pks.append(pk)
}
for pk in pks {
let recipient = await pubkey_to_inperson(ndb: ndb, pubkey: pk, our_pubkey: our_pubkey)
recipients.append(recipient)
}
// we enable default formatting this way
var groupName = INSpeakableString(spokenPhrase: "")
// otherwise we just say its a DM
if note.known_kind == .dm {
groupName = INSpeakableString(spokenPhrase: "DM")
}
let intent = INSendMessageIntent(recipients: recipients,
outgoingMessageType: .outgoingMessageText,
content: content,
speakableGroupName: groupName,
conversationIdentifier: conversationIdentifier,
serviceName: "kind\(note.kind)",
sender: sender,
attachments: nil)
intent.donationMetadata = meta
// this is needed for recipients > 0
if let img = sender.image {
intent.setImage(img, forParameterNamed: \.speakableGroupName)
}
return intent
}
func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
let profile_txn = ndb.lookup_profile(pubkey)
let profile = profile_txn?.unsafeUnownedValue?.profile
let name = profile?.name
let display_name = profile?.display_name
let nip05 = profile?.nip05
let picture = profile?.picture
return await profile_to_inperson(name: name,
display_name: display_name,
picture: picture,
nip05: nip05,
pubkey: pubkey,
our_pubkey: our_pubkey)
}
func fetch_pfp(picture: URL) async throws -> RetrieveImageResult {
try await withCheckedThrowingContinuation { continuation in
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: picture)) { result in
switch result {
case .success(let img):
continuation.resume(returning: img)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
func profile_to_inperson(name: String?, display_name: String?, picture: String?, nip05: String?, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
let npub = pubkey.npub
let handle = INPersonHandle(value: npub, type: .unknown)
var aliases: [INPersonHandle] = []
if let nip05 {
aliases.append(INPersonHandle(value: nip05, type: .emailAddress))
}
let nostrName = DisplayName(name: name, display_name: display_name, pubkey: pubkey)
let nameComponents = nostrName.nameComponents()
let displayName = nostrName.displayName
let contactIdentifier = npub
let customIdentifier = npub
let suggestionType = INPersonSuggestionType.socialProfile
var image: INImage? = nil
if let picture,
let url = URL(string: picture),
let img = try? await fetch_pfp(picture: url),
let imgdata = img.data()
{
image = INImage(imageData: imgdata)
} else {
Log.error("Failed to fetch pfp (%s) for %s", for: .push_notifications, picture ?? "nil", displayName)
}
let person = INPerson(personHandle: handle,
nameComponents: nameComponents,
displayName: displayName,
image: image,
contactIdentifier: contactIdentifier,
customIdentifier: customIdentifier,
isMe: pubkey == our_pubkey,
suggestionType: suggestionType
)
return person
}
func robohash(_ pk: Pubkey) -> String {
return "https://robohash.org/" + pk.hex()
}
+3 -32
View File
@@ -1,32 +1,3 @@
// swift-tools-version: 6.0
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "damus",
platforms: [
.iOS(.v16),
.macOS(.v12)
],
products: [
.library(
name: "damus",
targets: ["damus"]),
],
dependencies: [
.package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
],
targets: [
.target(
name: "damus",
dependencies: [
.product(name: "secp256k1", package: "secp256k1.swift")
],
path: "damus"),
.testTarget(
name: "damusTests",
dependencies: ["damus"],
path: "damusTests"),
]
)
dependencies: [
.Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
]
+36 -200
View File
@@ -189,7 +189,6 @@
4C54AA0729A540BA003E4487 /* NotificationsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C54AA0629A540BA003E4487 /* NotificationsModel.swift */; };
4C54AA0A29A55429003E4487 /* EventGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C54AA0929A55429003E4487 /* EventGroup.swift */; };
4C54AA0C29A5543C003E4487 /* ZapGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C54AA0B29A5543C003E4487 /* ZapGroup.swift */; };
4C5726BA2D72C6FA00E7FF82 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 4C5726B92D72C6FA00E7FF82 /* Kingfisher */; };
4C59B98C2A76C2550032FFEB /* ProfileUpdatedNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C59B98B2A76C2550032FFEB /* ProfileUpdatedNotify.swift */; };
4C5C7E68284ED36500A22DF5 /* SearchHomeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5C7E67284ED36500A22DF5 /* SearchHomeModel.swift */; };
4C5C7E6A284EDE2E00A22DF5 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5C7E69284EDE2E00A22DF5 /* SearchResultsView.swift */; };
@@ -207,7 +206,7 @@
4C64987C286D03E000EAE2B3 /* DirectMessagesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */; };
4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */; };
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 4C649880286E0EE300EAE2B3 /* secp256k1 */; };
4C684A552A7E91FE005E6031 /* LargeEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C684A542A7E91FE005E6031 /* LargeEventTests.swift */; };
4C684A552A7E91FE005E6031 /* LongPostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C684A542A7E91FE005E6031 /* LongPostTests.swift */; };
4C684A572A7FFAE6005E6031 /* UrlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C684A562A7FFAE6005E6031 /* UrlTests.swift */; };
4C687C212A5F7ED00092C550 /* DamusBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C202A5F7ED00092C550 /* DamusBackground.swift */; };
4C687C242A5FA86D0092C550 /* SearchHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */; };
@@ -408,22 +407,10 @@
5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */; };
5C6E1DAF2A194075008FC15A /* PinkGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAE2A194075008FC15A /* PinkGradient.swift */; };
5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B02B6EFA7100781E0A /* ProxyView.swift */; };
5C8498022D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; };
5C8498032D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; };
5C8498042D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; };
5C8711DE2C460C06007879C2 /* PostingTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */; };
5CB017212D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; };
5CB017222D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; };
5CB017232D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; };
5CB017252D42C5C400A9ED05 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */; };
5CB017262D42C5C400A9ED05 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */; };
5CB017272D42C5C400A9ED05 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */; };
5CB0172D2D42C76A00A9ED05 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */; };
5CB0172E2D42C76A00A9ED05 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */; };
5CB0172F2D42C76A00A9ED05 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */; };
5CB017312D4422DB00A9ED05 /* NWCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017302D4422D600A9ED05 /* NWCSettings.swift */; };
5CB017322D4422DB00A9ED05 /* NWCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017302D4422D600A9ED05 /* NWCSettings.swift */; };
5CB017332D4422DB00A9ED05 /* NWCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017302D4422D600A9ED05 /* NWCSettings.swift */; };
5CC8529D2BD741CD0039FFC5 /* HighlightEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */; };
5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */; };
5CC852A22BDED9B90039FFC5 /* HighlightDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */; };
@@ -642,6 +629,7 @@
82D6FB6C2CD99F7900C925F4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; };
82D6FB6D2CD99F7900C925F4 /* DamusPurpleEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72341182B6864F200E1E135 /* DamusPurpleEnvironment.swift */; };
82D6FB6E2CD99F7900C925F4 /* PurpleStoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */; };
82D6FB6F2CD99F7900C925F4 /* CameraService+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */; };
82D6FB702CD99F7900C925F4 /* ImageResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759892ABCCDE30018D73B /* ImageResizer.swift */; };
82D6FB712CD99F7900C925F4 /* PhotoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */; };
82D6FB722CD99F7900C925F4 /* VideoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */; };
@@ -932,6 +920,7 @@
BA37598A2ABCCDE40018D73B /* ImageResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759892ABCCDE30018D73B /* ImageResizer.swift */; };
BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */; };
BA37598E2ABCCE500018D73B /* VideoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */; };
BA3759922ABCCEBA0018D73B /* CameraService+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */; };
BA3759932ABCCEBA0018D73B /* CameraModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759902ABCCEBA0018D73B /* CameraModel.swift */; };
BA3759942ABCCEBA0018D73B /* CameraService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759912ABCCEBA0018D73B /* CameraService.swift */; };
BA3759972ABCCF360018D73B /* CameraPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759962ABCCF360018D73B /* CameraPreview.swift */; };
@@ -1090,30 +1079,11 @@
D72E127A2BEEEED000F4F781 /* NostrFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */; };
D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */; };
D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */; };
D733F9E12D92C1D900317B11 /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */; };
D733F9E22D92C1D900317B11 /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */; };
D733F9E32D92C1D900317B11 /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */; };
D733F9E52D92C76100317B11 /* UnownedNdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */; };
D733F9E62D92C76100317B11 /* UnownedNdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */; };
D733F9E72D92C76100317B11 /* UnownedNdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */; };
D733F9E82D92C76100317B11 /* UnownedNdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */; };
D734B1452CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = D734B1442CCC19B1000B5C97 /* DamusFullScreenCover.swift */; };
D734B1462CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */ = {isa = PBXBuildFile; fileRef = D734B1442CCC19B1000B5C97 /* DamusFullScreenCover.swift */; };
D7373BA62B688EA300F7783D /* DamusPurpleTranslationSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7373BA52B688EA200F7783D /* DamusPurpleTranslationSetupView.swift */; };
D7373BA82B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7373BA72B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift */; };
D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7373BA92B68A65A00F7783D /* PurpleAccountUpdateNotify.swift */; };
D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73B74E02D8365B40067BDBC /* ExtraFonts.swift */; };
D73B74E22D8365BA0067BDBC /* ExtraFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73B74E02D8365B40067BDBC /* ExtraFonts.swift */; };
D73B74E32D8365BA0067BDBC /* ExtraFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73B74E02D8365B40067BDBC /* ExtraFonts.swift */; };
D73BDB0D2D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */; };
D73BDB0E2D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */; };
D73BDB102D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */; };
D73BDB142D71216500D69970 /* UserRelayListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB132D71215F00D69970 /* UserRelayListManager.swift */; };
D73BDB152D71216500D69970 /* UserRelayListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB132D71215F00D69970 /* UserRelayListManager.swift */; };
D73BDB162D71216500D69970 /* UserRelayListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB132D71215F00D69970 /* UserRelayListManager.swift */; };
D73BDB182D71311900D69970 /* UserRelayListErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */; };
D73BDB192D71311900D69970 /* UserRelayListErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */; };
D73BDB1A2D71311900D69970 /* UserRelayListErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */; };
D73E5E162C6A9619007EB227 /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA327FA577B0006080F /* PostView.swift */; };
D73E5E172C6A962A007EB227 /* ImageUploadModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */; };
D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; };
@@ -1223,6 +1193,7 @@
D73E5E882C6A97F4007EB227 /* StoreObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74F430B2B23FB9B00425B75 /* StoreObserver.swift */; };
D73E5E892C6A97F4007EB227 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; };
D73E5E8A2C6A97F4007EB227 /* PurpleStoreKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C5D2B7709ED00C59298 /* PurpleStoreKitManager.swift */; };
D73E5E8D2C6A97F4007EB227 /* CameraService+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */; };
D73E5E8E2C6A97F4007EB227 /* ImageResizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3759892ABCCDE30018D73B /* ImageResizer.swift */; };
D73E5E8F2C6A97F4007EB227 /* PhotoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */; };
D73E5E902C6A97F4007EB227 /* VideoCaptureProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */; };
@@ -1479,9 +1450,6 @@
D74AAFD22B155E78006CF0F4 /* WalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09612A098D0E00943473 /* WalletConnect.swift */; };
D74AAFD42B155ECB006CF0F4 /* Zaps+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */; };
D74AAFD62B155F0C006CF0F4 /* WalletConnect+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */; };
D74E64132DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; };
D74E64142DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; };
D74E64152DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */; };
D74EA08A2D2BF2A7002290DD /* URLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D767066E2C8BB3CE00F09726 /* URLHandler.swift */; };
D74EA08E2D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; };
D74EA08F2D2E271E002290DD /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D74EA08D2D2E271E002290DD /* ErrorView.swift */; };
@@ -1512,18 +1480,6 @@
D78DB8592C1CE9CA00F0AB12 /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = D78DB8582C1CE9CA00F0AB12 /* SwipeActions */; };
D78DB85B2C20FE5000F0AB12 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; };
D78DB85F2C20FED300F0AB12 /* ChatBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */; };
D78F080C2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; };
D78F080D2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; };
D78F080E2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; };
D78F080F2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; };
D78F08112D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; };
D78F08122D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; };
D78F08132D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; };
D78F08142D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; };
D78F08172D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; };
D78F08182D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; };
D78F08192D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; };
D78F081A2D7F803100FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; };
D798D21A2B0856CC00234419 /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; };
D798D21B2B0856F200234419 /* NdbTagsIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDD1AE12A6B3074001CD4DF /* NdbTagsIterator.swift */; };
D798D21C2B0857E400234419 /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; };
@@ -1652,9 +1608,6 @@
D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; };
D7CE1B482B0BE719002EDAD4 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B93D2A9AD44700DC3548 /* Message.swift */; };
D7CE1B492B0BE729002EDAD4 /* DisplayName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9BB83029C0ED4F00FC4E37 /* DisplayName.swift */; };
D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */; };
D7D2A3812BF815D000E4B42B /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
D7D68FF92C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
D7D68FFA2C9E01BE0015A515 /* KFClickable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D68FF82C9E01B60015A515 /* KFClickable.swift */; };
@@ -1671,9 +1624,6 @@
D7DB93052D66A44100DA1EE5 /* Undistractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */; };
D7DB93062D66A44100DA1EE5 /* Undistractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */; };
D7DB93072D66A44100DA1EE5 /* Undistractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */; };
D7DB930A2D69486700DA1EE5 /* NIP65.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93092D69485A00DA1EE5 /* NIP65.swift */; };
D7DB930B2D69486700DA1EE5 /* NIP65.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93092D69485A00DA1EE5 /* NIP65.swift */; };
D7DB930C2D69486700DA1EE5 /* NIP65.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93092D69485A00DA1EE5 /* NIP65.swift */; };
D7DBD41F2B02F15E002A6197 /* NostrKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */; };
D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */; };
D7EB00B02CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; };
@@ -1706,9 +1656,6 @@
D7F360262CEBBD8B009D34DA /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; };
D7F360272CEBBDC0009D34DA /* DamusVideoControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EFBA362CC322F300F45588 /* DamusVideoControlsView.swift */; };
D7F360292CEBBE34009D34DA /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D7F360282CEBBE34009D34DA /* CodeScanner */; };
D7FA46E52DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */; };
D7FA46E62DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */; };
D7FA46E72DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */; };
D7FB10A72B0C371A00FA8D42 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2B10272A7B0F5C008AA43E /* Log.swift */; };
D7FB14222BE5970000398331 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7FB14212BE5970000398331 /* PrivacyInfo.xcprivacy */; };
D7FB14252BE5A9A800398331 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7FB14242BE5A9A800398331 /* PrivacyInfo.xcprivacy */; };
@@ -2201,7 +2148,7 @@
4C64305B2A945AFF00B0C0E9 /* MusicController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicController.swift; sourceTree = "<group>"; };
4C64987B286D03E000EAE2B3 /* DirectMessagesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesView.swift; sourceTree = "<group>"; };
4C64987D286D082C00EAE2B3 /* DirectMessagesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessagesModel.swift; sourceTree = "<group>"; };
4C684A542A7E91FE005E6031 /* LargeEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeEventTests.swift; sourceTree = "<group>"; };
4C684A542A7E91FE005E6031 /* LongPostTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongPostTests.swift; sourceTree = "<group>"; };
4C684A562A7FFAE6005E6031 /* UrlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlTests.swift; sourceTree = "<group>"; };
4C687C202A5F7ED00092C550 /* DamusBackground.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusBackground.swift; sourceTree = "<group>"; };
4C687C232A5FA86D0092C550 /* SearchHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHeaderView.swift; sourceTree = "<group>"; };
@@ -2420,12 +2367,8 @@
5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = "<group>"; };
5C6E1DAE2A194075008FC15A /* PinkGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinkGradient.swift; sourceTree = "<group>"; };
5C7389B02B6EFA7100781E0A /* ProxyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyView.swift; sourceTree = "<group>"; };
5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapExplainer.swift; sourceTree = "<group>"; };
5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineView.swift; sourceTree = "<group>"; };
5CB017202D2D985800A9ED05 /* CoinosButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinosButton.swift; sourceTree = "<group>"; };
5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsView.swift; sourceTree = "<group>"; };
5CB0172C2D42C76600A9ED05 /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = "<group>"; };
5CB017302D4422D600A9ED05 /* NWCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWCSettings.swift; sourceTree = "<group>"; };
5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightEvent.swift; sourceTree = "<group>"; };
5CC8529E2BD744F60039FFC5 /* HighlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightView.swift; sourceTree = "<group>"; };
5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDescription.swift; sourceTree = "<group>"; };
@@ -2467,6 +2410,7 @@
BA3759892ABCCDE30018D73B /* ImageResizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ImageResizer.swift; sourceTree = "<group>"; };
BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoCaptureProcessor.swift; sourceTree = "<group>"; };
BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoCaptureProcessor.swift; sourceTree = "<group>"; };
BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CameraService+Extensions.swift"; sourceTree = "<group>"; };
BA3759902ABCCEBA0018D73B /* CameraModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraModel.swift; sourceTree = "<group>"; };
BA3759912ABCCEBA0018D73B /* CameraService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraService.swift; sourceTree = "<group>"; };
BA3759962ABCCF360018D73B /* CameraPreview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CameraPreview.swift; sourceTree = "<group>"; };
@@ -2502,16 +2446,10 @@
D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrFilterTests.swift; sourceTree = "<group>"; };
D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = "<group>"; };
D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManagerTests.swift; sourceTree = "<group>"; };
D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionManager.swift; sourceTree = "<group>"; };
D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnownedNdbNote.swift; sourceTree = "<group>"; };
D734B1442CCC19B1000B5C97 /* DamusFullScreenCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusFullScreenCover.swift; sourceTree = "<group>"; };
D7373BA52B688EA200F7783D /* DamusPurpleTranslationSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleTranslationSetupView.swift; sourceTree = "<group>"; };
D7373BA72B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNewUserOnboardingView.swift; sourceTree = "<group>"; };
D7373BA92B68A65A00F7783D /* PurpleAccountUpdateNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleAccountUpdateNotify.swift; sourceTree = "<group>"; };
D73B74E02D8365B40067BDBC /* ExtraFonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraFonts.swift; sourceTree = "<group>"; };
D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrNetworkManager.swift; sourceTree = "<group>"; };
D73BDB132D71215F00D69970 /* UserRelayListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRelayListManager.swift; sourceTree = "<group>"; };
D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRelayListErrors.swift; sourceTree = "<group>"; };
D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAliases.swift; sourceTree = "<group>"; };
D73E5F802C6AA07A007EB227 /* HighlighterExtensionAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlighterExtensionAliases.swift; sourceTree = "<group>"; };
D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = "<group>"; };
@@ -2520,7 +2458,6 @@
D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapDataModel.swift; sourceTree = "<group>"; };
D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Zaps+.swift"; sourceTree = "<group>"; };
D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WalletConnect+.swift"; sourceTree = "<group>"; };
D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HumanReadableErrors.swift; sourceTree = "<group>"; };
D74EA08D2D2E271E002290DD /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
D74EA0922D2E77B9002290DD /* LoadableNostrEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventView.swift; sourceTree = "<group>"; };
D74F43092B23F0BE00425B75 /* DamusPurple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurple.swift; sourceTree = "<group>"; };
@@ -2539,9 +2476,6 @@
D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = "<group>"; };
D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = "<group>"; };
D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = "<group>"; };
D78F080B2D7F78EB00FC6C75 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; };
D78F08102D7F78F600FC6C75 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = "<group>"; };
D78F08162D7F7F6C00FC6C75 /* NIP04.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP04.swift; sourceTree = "<group>"; };
D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = "<group>"; };
D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = "<group>"; };
D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = "<group>"; };
@@ -2564,7 +2498,6 @@
D7CB5D5E2B11770C00AD4105 /* FollowState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowState.swift; sourceTree = "<group>"; };
D7CBD1D32B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNotificationManagement.swift; sourceTree = "<group>"; };
D7CBD1D52B8D509800BFD889 /* DamusPurpleImpendingExpirationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleImpendingExpirationTests.swift; sourceTree = "<group>"; };
D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinosDeterministicAccountClient.swift; sourceTree = "<group>"; };
D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationClient.swift; sourceTree = "<group>"; };
D7D68FF82C9E01B60015A515 /* KFClickable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KFClickable.swift; sourceTree = "<group>"; };
D7DB1FDD2D5A78CE00CF06DA /* NIP44.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP44.swift; sourceTree = "<group>"; };
@@ -2572,7 +2505,6 @@
D7DB1FF02D5AC5D700CF06DA /* nip44.vectors.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = nip44.vectors.json; sourceTree = "<group>"; };
D7DB1FF22D5AC5E400CF06DA /* LICENSES */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSES; sourceTree = "<group>"; };
D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Undistractor.swift; sourceTree = "<group>"; };
D7DB93092D69485A00DA1EE5 /* NIP65.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP65.swift; sourceTree = "<group>"; };
D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = "<group>"; };
D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentFullScreenItemNotify.swift; sourceTree = "<group>"; };
D7EDED1B2B1178FE0018B19C /* NoteContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContent.swift; sourceTree = "<group>"; };
@@ -2581,7 +2513,6 @@
D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtension.swift; sourceTree = "<group>"; };
D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusUserDefaults.swift; sourceTree = "<group>"; };
D7EFBA362CC322F300F45588 /* DamusVideoControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusVideoControlsView.swift; sourceTree = "<group>"; };
D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCacheMigrations.swift; sourceTree = "<group>"; };
D7FB14212BE5970000398331 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
D7FB14242BE5A9A800398331 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
D7FD12252BD345A700CF195B /* FirstAidSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstAidSettingsView.swift; sourceTree = "<group>"; };
@@ -2679,7 +2610,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
4C5726BA2D72C6FA00E7FF82 /* Kingfisher in Frameworks */,
D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */,
D7EDED312B1290B80018B19C /* MarkdownUI in Frameworks */,
D7DB1FEA2D5A9F5A00CF06DA /* CryptoSwift in Frameworks */,
@@ -2778,7 +2708,6 @@
4C0A3F8D280F63FF000448DE /* Models */ = {
isa = PBXGroup;
children = (
D73BDB122D71212600D69970 /* NostrNetworkManager */,
D74F43082B23F09300425B75 /* Purple */,
BA3759882ABCCDE30018D73B /* Camera */,
4C190F1E2A535FC200027FD5 /* Zaps */,
@@ -3291,10 +3220,6 @@
4C7D095A2A098C5C00943473 /* Wallet */ = {
isa = PBXGroup;
children = (
5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */,
5CB017302D4422D600A9ED05 /* NWCSettings.swift */,
5CB0172C2D42C76600A9ED05 /* BalanceView.swift */,
5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */,
4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */,
4C7D095D2A098C5D00943473 /* WalletView.swift */,
4C7D09672A0AE9B200943473 /* NWCScannerView.swift */,
@@ -3320,13 +3245,11 @@
4C7FF7D628233637009601DB /* Util */ = {
isa = PBXGroup;
children = (
D7FA46E42DBDAA75002C9BB0 /* ImageCacheMigrations.swift */,
D73B74E02D8365B40067BDBC /* ExtraFonts.swift */,
D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */,
D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */,
E04A37C52B544F090029650D /* URIParsing.swift */,
4C1D4FB02A7958E60024F453 /* VersionInfo.swift */,
D78F080A2D7F78B000FC6C75 /* WalletConnect */,
4C7D09612A098D0E00943473 /* WalletConnect.swift */,
4C198DF329F88D23004C165C /* Images */,
4C198DEA29F88C6B004C165C /* BlurHash */,
4CE4F0F329D779B5005914DB /* PostBox.swift */,
@@ -3376,7 +3299,7 @@
D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */,
D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */,
D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */,
D7D09AB42DADCA5600AB170D /* CoinosDeterministicAccountClient.swift */,
D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */,
);
path = Util;
sourceTree = "<group>";
@@ -3395,7 +3318,6 @@
4C9054862A6AEB4500811EEC /* nostrdb */ = {
isa = PBXGroup;
children = (
D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */,
4C47928D2A9939BD00489948 /* flatcc */,
4C478E2A2A9935D300489948 /* bindings */,
4CE9FBBB2A6B3D9C007E485C /* Test */,
@@ -3687,10 +3609,8 @@
4CE6DEE527F7A08100C66700 /* damus */ = {
isa = PBXGroup;
children = (
D7DB93082D69478400DA1EE5 /* NIP65 */,
D7DB1FDC2D5A77E500CF06DA /* NIP44 */,
D755B28B2D3E7D6500BBEEFA /* NIP37 */,
D78F08152D7F7F5F00FC6C75 /* NIP04 */,
4C45E5002BED4CE10025A428 /* NIP10 */,
4C1D4FB32A7967990024F453 /* build-git-hash.txt */,
4CA3529C2A76AE47003BB08B /* Notify */,
@@ -3757,7 +3677,7 @@
4C19AE542A5D977400C90DB7 /* HashtagTests.swift */,
3AAC7A012A60FE72002B50DF /* LocalizationUtilTests.swift */,
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */,
4C684A542A7E91FE005E6031 /* LargeEventTests.swift */,
4C684A542A7E91FE005E6031 /* LongPostTests.swift */,
4C684A562A7FFAE6005E6031 /* UrlTests.swift */,
D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */,
D71DC1EB2A9129C3006E207C /* PostViewTests.swift */,
@@ -3931,6 +3851,7 @@
children = (
BA3759902ABCCEBA0018D73B /* CameraModel.swift */,
BA3759912ABCCEBA0018D73B /* CameraService.swift */,
BA37598F2ABCCEBA0018D73B /* CameraService+Extensions.swift */,
BA3759892ABCCDE30018D73B /* ImageResizer.swift */,
BA37598B2ABCCE500018D73B /* PhotoCaptureProcessor.swift */,
BA37598C2ABCCE500018D73B /* VideoCaptureProcessor.swift */,
@@ -3990,17 +3911,6 @@
path = Mocking;
sourceTree = "<group>";
};
D73BDB122D71212600D69970 /* NostrNetworkManager */ = {
isa = PBXGroup;
children = (
D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */,
D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */,
D73BDB132D71215F00D69970 /* UserRelayListManager.swift */,
D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */,
);
path = NostrNetworkManager;
sourceTree = "<group>";
};
D74EA08C2D2E26E6002290DD /* ErrorHandling */ = {
isa = PBXGroup;
children = (
@@ -4041,26 +3951,6 @@
path = Chat;
sourceTree = "<group>";
};
D78F080A2D7F78B000FC6C75 /* WalletConnect */ = {
isa = PBXGroup;
children = (
D74E64112DC95CBE004C7892 /* HumanReadableErrors.swift */,
D78F08102D7F78F600FC6C75 /* Response.swift */,
D78F080B2D7F78EB00FC6C75 /* Request.swift */,
4C7D09612A098D0E00943473 /* WalletConnect.swift */,
D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */,
);
path = WalletConnect;
sourceTree = "<group>";
};
D78F08152D7F7F5F00FC6C75 /* NIP04 */ = {
isa = PBXGroup;
children = (
D78F08162D7F7F6C00FC6C75 /* NIP04.swift */,
);
path = NIP04;
sourceTree = "<group>";
};
D79C4C152AFEB061003A41B4 /* DamusNotificationService */ = {
isa = PBXGroup;
children = (
@@ -4098,14 +3988,6 @@
path = NIP44;
sourceTree = "<group>";
};
D7DB93082D69478400DA1EE5 /* NIP65 */ = {
isa = PBXGroup;
children = (
D7DB93092D69485A00DA1EE5 /* NIP65.swift */,
);
path = NIP65;
sourceTree = "<group>";
};
E06336A72B7582D600A88E6B /* Assets */ = {
isa = PBXGroup;
children = (
@@ -4292,7 +4174,6 @@
D789D11F2AFEFBF20083A7AB /* secp256k1 */,
D7EDED302B1290B80018B19C /* MarkdownUI */,
D7DB1FE92D5A9F5A00CF06DA /* CryptoSwift */,
4C5726B92D72C6FA00E7FF82 /* Kingfisher */,
);
productName = DamusNotificationService;
productReference = D79C4C142AFEB061003A41B4 /* DamusNotificationService.appex */;
@@ -4495,7 +4376,6 @@
4C3DCC762A9FE9EC0091E592 /* NdbTxn.swift in Sources */,
4CEF958D2A9CE650000F901B /* verifier.c in Sources */,
4C32B9342A9AD01A00DC3548 /* NdbProfile.swift in Sources */,
D73BDB0E2D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */,
4C32B9332A99845B00DC3548 /* Ndb.swift in Sources */,
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */,
4C4793082A993E8900489948 /* refmap.c in Sources */,
@@ -4521,7 +4401,6 @@
D7CBD1D42B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift in Sources */,
4C32B9572A9AD44700DC3548 /* Root.swift in Sources */,
4C3EA64428FF558100C48A62 /* sha256.c in Sources */,
5C8498032D5D150000F74FEB /* ZapExplainer.swift in Sources */,
504323A72A34915F006AE6DC /* RelayModel.swift in Sources */,
4CF0ABF62985CD5500D66079 /* UserSearch.swift in Sources */,
4C32B9542A9AD44700DC3548 /* FlatBuffersUtils.swift in Sources */,
@@ -4543,7 +4422,6 @@
F757933A29D7AECD007DEAC1 /* MediaPicker.swift in Sources */,
4CF0ABEE29844B5500D66079 /* AnyEncodable.swift in Sources */,
B57B4C662B312C3700A232C0 /* NostrAuth.swift in Sources */,
D7DB930B2D69486700DA1EE5 /* NIP65.swift in Sources */,
4CB8838D296F710400DC99E7 /* Reposted.swift in Sources */,
4C3EA67728FF7A9800C48A62 /* talstr.c in Sources */,
4CE6DEE927F7A08100C66700 /* ContentView.swift in Sources */,
@@ -4630,12 +4508,12 @@
4C32B9582A9AD44700DC3548 /* VeriferOptions.swift in Sources */,
D74AAFC22B153395006CF0F4 /* HeadlessDamusState.swift in Sources */,
4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */,
D73BDB1A2D71311900D69970 /* UserRelayListErrors.swift in Sources */,
4C30AC7629A5770900E2BD5A /* NotificationItemView.swift in Sources */,
4C86F7C42A76C44C00EC0817 /* ZappingNotify.swift in Sources */,
4C363A8428233689006E126D /* Parser.swift in Sources */,
3AAA95CA298DF87B00F3D526 /* TranslationService.swift in Sources */,
4CE4F9E328528C5200C00DD9 /* AddRelayView.swift in Sources */,
BA3759922ABCCEBA0018D73B /* CameraService+Extensions.swift in Sources */,
D74F430C2B23FB9B00425B75 /* StoreObserver.swift in Sources */,
4C363A9A28283854006E126D /* Reply.swift in Sources */,
BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */,
@@ -4667,7 +4545,6 @@
D78DB85B2C20FE5000F0AB12 /* VectorMath.swift in Sources */,
D7CB5D3E2B116DAD00AD4105 /* NotificationsManager.swift in Sources */,
50A16FFF2AA76A0900DFEC1F /* DamusVideoCoordinator.swift in Sources */,
D733F9E32D92C1D900317B11 /* SubscriptionManager.swift in Sources */,
F7908E97298B1FDF00AB113A /* NIPURLBuilder.swift in Sources */,
4C285C8228385570008A31F1 /* CarouselView.swift in Sources */,
3A3040F129A8FF97008A0F29 /* LocalizationUtil.swift in Sources */,
@@ -4685,7 +4562,6 @@
4CCEB7AE29B53D260078AA28 /* SearchingEventView.swift in Sources */,
4CF0ABE929844AF100D66079 /* AnyCodable.swift in Sources */,
BA3759932ABCCEBA0018D73B /* CameraModel.swift in Sources */,
D7FA46E72DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */,
D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */,
4C0A3F8F280F640A000448DE /* ThreadModel.swift in Sources */,
4C3AC79F2833115300E1F516 /* FollowButtonView.swift in Sources */,
@@ -4699,7 +4575,6 @@
D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */,
D7373BA82B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift in Sources */,
4C7D09682A0AE9B200943473 /* NWCScannerView.swift in Sources */,
D74E64152DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */,
D7CB5D452B116FE800AD4105 /* Contacts+.swift in Sources */,
4CA352A42A76AFF3003BB08B /* UpdateStatsNotify.swift in Sources */,
D798D21E2B0858BB00234419 /* MigratedTypes.swift in Sources */,
@@ -4724,7 +4599,6 @@
4CE879522996B68900F758CC /* RelayType.swift in Sources */,
4CE8795B2996C47A00F758CC /* ZapsModel.swift in Sources */,
4C3A1D3729637E0500558C0F /* PreviewCache.swift in Sources */,
D78F08142D7F78F900FC6C75 /* Response.swift in Sources */,
4C3EA67528FF7A5A00C48A62 /* take.c in Sources */,
4C3AC7A12835A81400E1F516 /* SetupView.swift in Sources */,
4C06670128FC7C5900038D2A /* RelayView.swift in Sources */,
@@ -4750,7 +4624,6 @@
4CAAD8AD298851D000060CEA /* AccountDeletion.swift in Sources */,
4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */,
4C54AA0A29A55429003E4487 /* EventGroup.swift in Sources */,
D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */,
4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */,
4C3EA67928FF7ABF00C48A62 /* list.c in Sources */,
4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */,
@@ -4778,7 +4651,6 @@
4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */,
D73E5F7F2C6AA066007EB227 /* DamusAliases.swift in Sources */,
4C1A9A2A29DDF54400516EAC /* DamusVideoPlayerView.swift in Sources */,
D73BDB152D71216500D69970 /* UserRelayListManager.swift in Sources */,
4CA352A22A76AEC5003BB08B /* LikedNotify.swift in Sources */,
5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */,
BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */,
@@ -4788,7 +4660,6 @@
4C3EA64C28FF59AC00C48A62 /* bech32_util.c in Sources */,
4CA9276C2A2910D10098A105 /* ReplyPart.swift in Sources */,
D7C6787E2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift in Sources */,
5CB017252D42C5C400A9ED05 /* TransactionsView.swift in Sources */,
4CE1399029F0661A00AC6A0B /* RepostAction.swift in Sources */,
4CE1399229F0666100AC6A0B /* ShareActionButton.swift in Sources */,
4C42812C298C848200DBF26F /* TranslateView.swift in Sources */,
@@ -4838,8 +4709,6 @@
4C8EC52529D1FA6C0085D9A8 /* DamusColors.swift in Sources */,
3A4647CF2A413ADC00386AD8 /* CondensedProfilePicturesView.swift in Sources */,
5C14C29B2BBBA29C00079FD2 /* RelaySoftwareDetail.swift in Sources */,
5CB017312D4422DB00A9ED05 /* NWCSettings.swift in Sources */,
D78F080D2D7F78EF00FC6C75 /* Request.swift in Sources */,
D78DB85F2C20FED300F0AB12 /* ChatBubbleView.swift in Sources */,
D2277EEA2A089BD5006C3807 /* Router.swift in Sources */,
4C9D6D162B1AA9C6004E5CD9 /* DisplayTabBarNotify.swift in Sources */,
@@ -4876,7 +4745,6 @@
4CF38C882A9442DC00BE01B6 /* UserStatusView.swift in Sources */,
4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */,
4C1253582A76C9060004F4B8 /* PresentSheetNotify.swift in Sources */,
D7D09AB52DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */,
4C363A962827096D006E126D /* PostBlock.swift in Sources */,
4CA9275F2A2902B20098A105 /* LongformPreview.swift in Sources */,
@@ -4920,7 +4788,6 @@
4C9146FE2A2A87C200DDEA40 /* nostrscript.c in Sources */,
4C5F9118283D88E40052CD1C /* FollowingModel.swift in Sources */,
4C1A9A1A29DCA17E00516EAC /* ReplyCounter.swift in Sources */,
D78F08182D7F7F7500FC6C75 /* NIP04.swift in Sources */,
50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */,
643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */,
F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */,
@@ -4939,7 +4806,6 @@
F7908E92298B0F0700AB113A /* RelayDetailView.swift in Sources */,
4C9147002A2A891E00DDEA40 /* error.c in Sources */,
4CE879552996BAB900F758CC /* RelayPaidDetail.swift in Sources */,
5CB0172F2D42C76A00A9ED05 /* BalanceView.swift in Sources */,
4C1253602A76CF890004F4B8 /* ScrollToTopNotify.swift in Sources */,
4CA3529E2A76AE67003BB08B /* FollowNotify.swift in Sources */,
4CF0ABD42980996B00D66079 /* Report.swift in Sources */,
@@ -4983,7 +4849,6 @@
4C9AA14A2A4587A6003F49FD /* NotificationStatusModel.swift in Sources */,
D7100C5C2B77016700C59298 /* IAPProductStateView.swift in Sources */,
4CB9D4A72992D02B00A9A7E4 /* ProfileNameView.swift in Sources */,
D733F9E82D92C76100317B11 /* UnownedNdbNote.swift in Sources */,
D74EA0902D2E271E002290DD /* ErrorView.swift in Sources */,
4CE4F0F429D779B5005914DB /* PostBox.swift in Sources */,
BA37598E2ABCCE500018D73B /* VideoCaptureProcessor.swift in Sources */,
@@ -5043,7 +4908,7 @@
D753CEAA2BE9DE04001C3A5D /* MutingTests.swift in Sources */,
3A3040F329A91366008A0F29 /* ProfileViewTests.swift in Sources */,
4CF0ABDC2981A19E00D66079 /* ListTests.swift in Sources */,
4C684A552A7E91FE005E6031 /* LargeEventTests.swift in Sources */,
4C684A552A7E91FE005E6031 /* LongPostTests.swift in Sources */,
E02B54182B4DFADA0077FF42 /* Bech32ObjectTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -5085,7 +4950,6 @@
82D6FABC2CD99F7900C925F4 /* refmap.c in Sources */,
82D6FABD2CD99F7900C925F4 /* verifier.c in Sources */,
82D6FABE2CD99F7900C925F4 /* NdbProfile.swift in Sources */,
D78F08112D7F78F900FC6C75 /* Response.swift in Sources */,
82D6FABF2CD99F7900C925F4 /* NdbTagIterator.swift in Sources */,
82D6FAC02CD99F7900C925F4 /* NdbNote.swift in Sources */,
82D6FAC12CD99F7900C925F4 /* AsciiCharacter.swift in Sources */,
@@ -5122,7 +4986,6 @@
82D6FAE02CD99F7900C925F4 /* DisplayTabBarNotify.swift in Sources */,
82D6FAE12CD99F7900C925F4 /* BroadcastNotify.swift in Sources */,
82D6FAE22CD99F7900C925F4 /* ComposeNotify.swift in Sources */,
D73B74E22D8365BA0067BDBC /* ExtraFonts.swift in Sources */,
82D6FAE32CD99F7900C925F4 /* FollowedNotify.swift in Sources */,
82D6FAE42CD99F7900C925F4 /* FollowNotify.swift in Sources */,
82D6FAE52CD99F7900C925F4 /* LikedNotify.swift in Sources */,
@@ -5137,7 +5000,6 @@
82D6FAED2CD99F7900C925F4 /* PostNotify.swift in Sources */,
82D6FAEE2CD99F7900C925F4 /* PresentSheetNotify.swift in Sources */,
D74EA0932D2E77B9002290DD /* LoadableNostrEventView.swift in Sources */,
D74E64142DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */,
82D6FAEF2CD99F7900C925F4 /* ProfileUpdatedNotify.swift in Sources */,
82D6FAF02CD99F7900C925F4 /* ReportNotify.swift in Sources */,
82D6FAF12CD99F7900C925F4 /* ScrollToTopNotify.swift in Sources */,
@@ -5156,7 +5018,6 @@
82D6FAFE2CD99F7900C925F4 /* Pubkey.swift in Sources */,
82D6FAFF2CD99F7900C925F4 /* NoteId.swift in Sources */,
82D6FB002CD99F7900C925F4 /* Referenced.swift in Sources */,
5CB0172D2D42C76A00A9ED05 /* BalanceView.swift in Sources */,
82D6FB012CD99F7900C925F4 /* Block.swift in Sources */,
82D6FB022CD99F7900C925F4 /* MigratedTypes.swift in Sources */,
82D6FB032CD99F7900C925F4 /* DamusDuration.swift in Sources */,
@@ -5164,7 +5025,6 @@
82D6FB052CD99F7900C925F4 /* MusicController.swift in Sources */,
82D6FB062CD99F7900C925F4 /* UserStatusView.swift in Sources */,
82D6FB072CD99F7900C925F4 /* UserStatus.swift in Sources */,
5CB017262D42C5C400A9ED05 /* TransactionsView.swift in Sources */,
82D6FB082CD99F7900C925F4 /* UserStatusSheet.swift in Sources */,
82D6FB092CD99F7900C925F4 /* SearchHeaderView.swift in Sources */,
82D6FB0A2CD99F7900C925F4 /* DamusGradient.swift in Sources */,
@@ -5193,7 +5053,6 @@
82D6FB212CD99F7900C925F4 /* SelectableText.swift in Sources */,
82D6FB222CD99F7900C925F4 /* DamusColors.swift in Sources */,
82D6FB232CD99F7900C925F4 /* ThiccDivider.swift in Sources */,
D733F9E22D92C1D900317B11 /* SubscriptionManager.swift in Sources */,
82D6FB242CD99F7900C925F4 /* IconLabel.swift in Sources */,
82D6FB252CD99F7900C925F4 /* TruncatedText.swift in Sources */,
82D6FB262CD99F7900C925F4 /* SupporterBadge.swift in Sources */,
@@ -5208,11 +5067,9 @@
82D6FB2F2CD99F7900C925F4 /* BlurHashDecode.swift in Sources */,
82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */,
82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */,
D73BDB162D71216500D69970 /* UserRelayListManager.swift in Sources */,
82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */,
82D6FB332CD99F7900C925F4 /* Array.swift in Sources */,
82D6FB342CD99F7900C925F4 /* VectorMath.swift in Sources */,
5C8498022D5D150000F74FEB /* ZapExplainer.swift in Sources */,
82D6FB352CD99F7900C925F4 /* OffsetExtension.swift in Sources */,
82D6FB362CD99F7900C925F4 /* RelayFilters.swift in Sources */,
82D6FB372CD99F7900C925F4 /* RelayModelCache.swift in Sources */,
@@ -5230,7 +5087,6 @@
82D6FB432CD99F7900C925F4 /* KeychainStorage.swift in Sources */,
82D6FB442CD99F7900C925F4 /* Bech32.swift in Sources */,
82D6FB452CD99F7900C925F4 /* InputDismissKeyboard.swift in Sources */,
D7FA46E62DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */,
82D6FB462CD99F7900C925F4 /* Constants.swift in Sources */,
82D6FB472CD99F7900C925F4 /* LinkView.swift in Sources */,
D7DB1FDF2D5A78CE00CF06DA /* NIP44.swift in Sources */,
@@ -5259,7 +5115,6 @@
82D6FB5E2CD99F7900C925F4 /* CredentialHandler.swift in Sources */,
82D6FB5F2CD99F7900C925F4 /* KeyboardVisible.swift in Sources */,
82D6FB602CD99F7900C925F4 /* StringUtil.swift in Sources */,
D78F08172D7F7F7500FC6C75 /* NIP04.swift in Sources */,
82D6FB612CD99F7900C925F4 /* Router.swift in Sources */,
82D6FB622CD99F7900C925F4 /* Log.swift in Sources */,
82D6FB632CD99F7900C925F4 /* AVPlayer+Additions.swift in Sources */,
@@ -5267,7 +5122,6 @@
82D6FB652CD99F7900C925F4 /* CollectionExtension.swift in Sources */,
82D6FB662CD99F7900C925F4 /* ZapDataModel.swift in Sources */,
82D6FB672CD99F7900C925F4 /* Zaps+.swift in Sources */,
D7D09AB72DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
82D6FB682CD99F7900C925F4 /* WalletConnect+.swift in Sources */,
82D6FB692CD99F7900C925F4 /* DamusPurpleNotificationManagement.swift in Sources */,
82D6FB6A2CD99F7900C925F4 /* DamusPurple.swift in Sources */,
@@ -5275,6 +5129,7 @@
82D6FB6C2CD99F7900C925F4 /* DamusPurpleURL.swift in Sources */,
82D6FB6D2CD99F7900C925F4 /* DamusPurpleEnvironment.swift in Sources */,
82D6FB6E2CD99F7900C925F4 /* PurpleStoreKitManager.swift in Sources */,
82D6FB6F2CD99F7900C925F4 /* CameraService+Extensions.swift in Sources */,
82D6FB702CD99F7900C925F4 /* ImageResizer.swift in Sources */,
82D6FB712CD99F7900C925F4 /* PhotoCaptureProcessor.swift in Sources */,
82D6FB722CD99F7900C925F4 /* VideoCaptureProcessor.swift in Sources */,
@@ -5354,7 +5209,6 @@
82D6FBBA2CD99F7900C925F4 /* NostrRequest.swift in Sources */,
82D6FBBB2CD99F7900C925F4 /* Profiles.swift in Sources */,
82D6FBBC2CD99F7900C925F4 /* NostrKind.swift in Sources */,
D733F9E62D92C76100317B11 /* UnownedNdbNote.swift in Sources */,
82D6FBBD2CD99F7900C925F4 /* NostrLink.swift in Sources */,
82D6FBBE2CD99F7900C925F4 /* WebSocket.swift in Sources */,
82D6FBBF2CD99F7900C925F4 /* ReferencedId.swift in Sources */,
@@ -5388,7 +5242,6 @@
82D6FBE02CD99F7900C925F4 /* ReactionsSettingsView.swift in Sources */,
82D6FBE12CD99F7900C925F4 /* NotificationSettingsView.swift in Sources */,
82D6FBE22CD99F7900C925F4 /* AppearanceSettingsView.swift in Sources */,
D7DB930A2D69486700DA1EE5 /* NIP65.swift in Sources */,
82D6FBE32CD99F7900C925F4 /* KeySettingsView.swift in Sources */,
82D6FBE42CD99F7900C925F4 /* ZapSettingsView.swift in Sources */,
82D6FBE52CD99F7900C925F4 /* TranslationSettingsView.swift in Sources */,
@@ -5443,7 +5296,6 @@
82D6FC132CD99F7900C925F4 /* FriendIcon.swift in Sources */,
82D6FC142CD99F7900C925F4 /* CondensedProfilePicturesView.swift in Sources */,
82D6FC152CD99F7900C925F4 /* ProfileEditButton.swift in Sources */,
D73BDB102D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */,
82D6FC162CD99F7900C925F4 /* RelayPaidDetail.swift in Sources */,
82D6FC172CD99F7900C925F4 /* RelayAuthenticationDetail.swift in Sources */,
82D6FC182CD99F7900C925F4 /* RelaySoftwareDetail.swift in Sources */,
@@ -5492,12 +5344,10 @@
82D6FC432CD99F7900C925F4 /* ReactionView.swift in Sources */,
82D6FC442CD99F7900C925F4 /* EventActionBar.swift in Sources */,
82D6FC452CD99F7900C925F4 /* EventDetailBar.swift in Sources */,
D78F080C2D7F78EF00FC6C75 /* Request.swift in Sources */,
82D6FC462CD99F7900C925F4 /* ShareAction.swift in Sources */,
82D6FC472CD99F7900C925F4 /* RepostAction.swift in Sources */,
82D6FC482CD99F7900C925F4 /* ShareActionButton.swift in Sources */,
82D6FC492CD99F7900C925F4 /* BigButton.swift in Sources */,
D73BDB182D71311900D69970 /* UserRelayListErrors.swift in Sources */,
82D6FC4A2CD99F7900C925F4 /* AddRelayView.swift in Sources */,
82D6FC4B2CD99F7900C925F4 /* BlocksView.swift in Sources */,
D74EA0912D2E3464002290DD /* URLHandler.swift in Sources */,
@@ -5519,7 +5369,6 @@
82D6FC5A2CD99F7900C925F4 /* QRScanNSECView.swift in Sources */,
82D6FC5B2CD99F7900C925F4 /* NoteContentView.swift in Sources */,
82D6FC5C2CD99F7900C925F4 /* PostButton.swift in Sources */,
5CB017322D4422DB00A9ED05 /* NWCSettings.swift in Sources */,
82D6FC5D2CD99F7900C925F4 /* PostView.swift in Sources */,
82D6FC5E2CD99F7900C925F4 /* AttachMediaUtility.swift in Sources */,
82D6FC5F2CD99F7900C925F4 /* MediaPicker.swift in Sources */,
@@ -5669,9 +5518,8 @@
D73E5E882C6A97F4007EB227 /* StoreObserver.swift in Sources */,
D73E5E892C6A97F4007EB227 /* DamusPurpleURL.swift in Sources */,
D73E5E8A2C6A97F4007EB227 /* PurpleStoreKitManager.swift in Sources */,
D733F9E72D92C76100317B11 /* UnownedNdbNote.swift in Sources */,
D73E5E8D2C6A97F4007EB227 /* CameraService+Extensions.swift in Sources */,
D73E5E8E2C6A97F4007EB227 /* ImageResizer.swift in Sources */,
D78F080E2D7F78EF00FC6C75 /* Request.swift in Sources */,
D73E5E8F2C6A97F4007EB227 /* PhotoCaptureProcessor.swift in Sources */,
D773BC602C6D538500349F0A /* CommentItem.swift in Sources */,
D73E5E902C6A97F4007EB227 /* VideoCaptureProcessor.swift in Sources */,
@@ -5686,7 +5534,6 @@
D73E5E992C6A97F4007EB227 /* Liked.swift in Sources */,
D73E5E9A2C6A97F4007EB227 /* ProfileUpdate.swift in Sources */,
D73E5E9B2C6A97F4007EB227 /* PostBlock.swift in Sources */,
5CB017332D4422DB00A9ED05 /* NWCSettings.swift in Sources */,
D73E5E9C2C6A97F4007EB227 /* Reply.swift in Sources */,
D73E5E9D2C6A97F4007EB227 /* SearchModel.swift in Sources */,
D73E5E9E2C6A97F4007EB227 /* NostrFilter+Hashable.swift in Sources */,
@@ -5694,8 +5541,6 @@
D73E5F912C6AA71B007EB227 /* InputDismissKeyboard.swift in Sources */,
D73E5E9F2C6A97F4007EB227 /* CreateAccountModel.swift in Sources */,
D73E5EA12C6A97F4007EB227 /* SignalModel.swift in Sources */,
5CB017272D42C5C400A9ED05 /* TransactionsView.swift in Sources */,
D7FA46E52DBDAA7E002C9BB0 /* ImageCacheMigrations.swift in Sources */,
D73E5EA22C6A97F4007EB227 /* FollowTarget.swift in Sources */,
D73E5EA32C6A97F4007EB227 /* BookmarksManager.swift in Sources */,
D73E5EA42C6A97F4007EB227 /* EventsModel.swift in Sources */,
@@ -5703,7 +5548,6 @@
D73E5EA62C6A97F4007EB227 /* FollowersModel.swift in Sources */,
D73E5EA72C6A97F4007EB227 /* SearchHomeModel.swift in Sources */,
D73E5EA82C6A97F4007EB227 /* DirectMessageModel.swift in Sources */,
D78F08132D7F78F900FC6C75 /* Response.swift in Sources */,
D73E5EA92C6A97F4007EB227 /* Report.swift in Sources */,
D73E5EAA2C6A97F4007EB227 /* ZapsModel.swift in Sources */,
D73E5EAB2C6A97F4007EB227 /* DraftsModel.swift in Sources */,
@@ -5746,7 +5590,6 @@
D73E5ECC2C6A97F4007EB227 /* SuggestedUsersViewModel.swift in Sources */,
D73E5ED22C6A97F4007EB227 /* WalletView.swift in Sources */,
D73E5ED32C6A97F4007EB227 /* NWCScannerView.swift in Sources */,
D74E64132DC95CC7004C7892 /* HumanReadableErrors.swift in Sources */,
D73E5ED42C6A97F4007EB227 /* FriendsButton.swift in Sources */,
D73E5ED52C6A97F4007EB227 /* GradientFollowButton.swift in Sources */,
D73E5ED62C6A97F4007EB227 /* AlbyButton.swift in Sources */,
@@ -5780,12 +5623,10 @@
D73E5EF22C6A97F4007EB227 /* DamusPurpleURLSheetView.swift in Sources */,
D73E5EF32C6A97F4007EB227 /* DamusPurpleVerifyNpubView.swift in Sources */,
D73E5EF42C6A97F4007EB227 /* DamusPurpleAccountView.swift in Sources */,
5CB0172E2D42C76A00A9ED05 /* BalanceView.swift in Sources */,
D73E5EF52C6A97F4007EB227 /* DamusPurpleNewUserOnboardingView.swift in Sources */,
D73E5EF62C6A97F4007EB227 /* SearchingEventView.swift in Sources */,
D73E5EF72C6A97F4007EB227 /* PullDownSearch.swift in Sources */,
D73E5EF82C6A97F4007EB227 /* NotificationsView.swift in Sources */,
D73B74E32D8365BA0067BDBC /* ExtraFonts.swift in Sources */,
D73E5EF92C6A97F4007EB227 /* EventGroupView.swift in Sources */,
D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */,
D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */,
@@ -5798,7 +5639,6 @@
D73E5F012C6A97F4007EB227 /* ZapTypePicker.swift in Sources */,
D73E5F022C6A97F4007EB227 /* ZapUserView.swift in Sources */,
D73E5F032C6A97F4007EB227 /* ProfileZapLinkView.swift in Sources */,
D7DB930C2D69486700DA1EE5 /* NIP65.swift in Sources */,
D73E5F042C6A97F4007EB227 /* AboutView.swift in Sources */,
D73E5F052C6A97F4007EB227 /* ProfileName.swift in Sources */,
D73E5F062C6A97F4007EB227 /* ProfilePictureSelector.swift in Sources */,
@@ -5839,10 +5679,8 @@
D73E5F272C6A97F4007EB227 /* TimeDot.swift in Sources */,
D73E5F282C6A97F4007EB227 /* EventTop.swift in Sources */,
D73E5F292C6A97F4007EB227 /* ReplyDescription.swift in Sources */,
D73BDB0D2D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */,
D73E5F2A2C6A97F4007EB227 /* RelativeTime.swift in Sources */,
D73E5F732C6A9885007EB227 /* TestData.swift in Sources */,
D78F08192D7F7F7500FC6C75 /* NIP04.swift in Sources */,
D73E5F2B2C6A97F4007EB227 /* ReplyPart.swift in Sources */,
D73E5F2C2C6A97F4007EB227 /* ProxyView.swift in Sources */,
D73E5F2D2C6A97F4007EB227 /* SelectedEventView.swift in Sources */,
@@ -5878,7 +5716,6 @@
D73E5F472C6A97F5007EB227 /* BookmarksView.swift in Sources */,
D73E5F482C6A97F5007EB227 /* CarouselView.swift in Sources */,
D73E5F492C6A97F5007EB227 /* ConfigView.swift in Sources */,
D733F9E12D92C1D900317B11 /* SubscriptionManager.swift in Sources */,
D73E5F4A2C6A97F5007EB227 /* CreateAccountView.swift in Sources */,
D73E5F7A2C6A9C55007EB227 /* NotificationFormatter.swift in Sources */,
D73E5F4B2C6A97F5007EB227 /* DirectMessagesView.swift in Sources */,
@@ -5914,7 +5751,6 @@
D73E5F6A2C6A97F5007EB227 /* ReportView.swift in Sources */,
D73E5F6C2C6A97F5007EB227 /* RepostsView.swift in Sources */,
D734B1462CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */,
D73BDB142D71216500D69970 /* UserRelayListManager.swift in Sources */,
D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */,
D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */,
D703D78A2C670C8A00A400EA /* LibreTranslateServer.swift in Sources */,
@@ -5934,7 +5770,6 @@
D703D7A32C670E1D00A400EA /* nostr_bech32.c in Sources */,
D703D7992C670DF900A400EA /* sha256.c in Sources */,
D703D7972C670DED00A400EA /* wasm.c in Sources */,
5C8498042D5D150000F74FEB /* ZapExplainer.swift in Sources */,
D703D7842C670C4700A400EA /* SequenceUtils.swift in Sources */,
D703D7912C670D1E00A400EA /* DisplayName.swift in Sources */,
D703D7B02C6710A500A400EA /* Root.swift in Sources */,
@@ -5966,7 +5801,6 @@
D703D7A52C670E3E00A400EA /* mdb.c in Sources */,
D703D76B2C670B3100A400EA /* Referenced.swift in Sources */,
D703D7952C670DE600A400EA /* hash_u5.c in Sources */,
D73BDB192D71311900D69970 /* UserRelayListErrors.swift in Sources */,
D703D7582C670A6000A400EA /* Id.swift in Sources */,
5C05675A2C8FBDE70073F23A /* NDBSearchView.swift in Sources */,
D703D76E2C670B4900A400EA /* NdbTagsIterator.swift in Sources */,
@@ -6019,7 +5853,6 @@
D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */,
D73E5F852C6AA628007EB227 /* LoadScript.swift in Sources */,
D703D74E2C6709DA00A400EA /* Pubkey.swift in Sources */,
D7D09AB62DADCA5C00AB170D /* CoinosDeterministicAccountClient.swift in Sources */,
D703D7802C670C2500A400EA /* NIP05.swift in Sources */,
D703D7AA2C670E5D00A400EA /* verifier.c in Sources */,
D73E5E1D2C6A9680007EB227 /* PreviewCache.swift in Sources */,
@@ -6062,7 +5895,6 @@
buildActionMask = 2147483647;
files = (
4C8FA7242BED58A900798A6A /* ThreadReply.swift in Sources */,
D733F9E52D92C76100317B11 /* UnownedNdbNote.swift in Sources */,
D798D21F2B0858D600234419 /* MigratedTypes.swift in Sources */,
D7CE1B472B0BE719002EDAD4 /* NativeObject.swift in Sources */,
D71AD9002CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */,
@@ -6150,7 +5982,6 @@
D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */,
D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */,
D7CE1B272B0BE224002EDAD4 /* bech32_util.c in Sources */,
D78F08122D7F78F900FC6C75 /* Response.swift in Sources */,
D7CCFC102B05880F00323D86 /* Id.swift in Sources */,
D7CB5D532B1174E900AD4105 /* DeepLPlan.swift in Sources */,
D7EDED282B1180940018B19C /* ImageUploadModel.swift in Sources */,
@@ -6160,11 +5991,9 @@
D7CE1B332B0BE6DE002EDAD4 /* Nostr.swift in Sources */,
D7CE1B3D2B0BE719002EDAD4 /* Verifiable.swift in Sources */,
D7CE1B382B0BE719002EDAD4 /* VeriferOptions.swift in Sources */,
D78F080F2D7F78EF00FC6C75 /* Request.swift in Sources */,
D7CCFC152B05891000323D86 /* Referenced.swift in Sources */,
D7CE1B2B2B0BE243002EDAD4 /* hex.c in Sources */,
D798D2222B08598A00234419 /* ReferencedId.swift in Sources */,
D78F081A2D7F803100FC6C75 /* NIP04.swift in Sources */,
D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */,
D7CE1B492B0BE729002EDAD4 /* DisplayName.swift in Sources */,
D7CE1B192B0BE132002EDAD4 /* builder.c in Sources */,
@@ -6396,7 +6225,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -6419,7 +6248,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MACOSX_DEPLOYMENT_TARGET = 12.3;
MARKETING_VERSION = 1.15;
MARKETING_VERSION = 1.10;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -6465,7 +6294,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 5;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -6484,7 +6313,7 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MACOSX_DEPLOYMENT_TARGET = 12.3;
MARKETING_VERSION = 1.15;
MARKETING_VERSION = 1.10;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
@@ -6503,8 +6332,8 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_PREVIEWS = YES;
@@ -6531,9 +6360,9 @@
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
@@ -6555,8 +6384,8 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\"";
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_PREVIEWS = YES;
@@ -6583,9 +6412,9 @@
"$(inherited)",
"$(PROJECT_DIR)",
);
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
@@ -6671,6 +6500,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "share extension/share extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -6689,6 +6519,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.share-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6707,6 +6538,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "share extension/share extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -6721,6 +6553,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.share-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6739,6 +6572,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "highlighter action extension/highlighter action extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -6753,6 +6587,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.highlighter-action-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6772,6 +6607,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "highlighter action extension/highlighter action extension.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -6786,6 +6622,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.jb55.damus2.highlighter-action-extension";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6804,6 +6641,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_ENTITLEMENTS = DamusNotificationService/DamusNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -6818,6 +6656,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6837,6 +6676,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_ENTITLEMENTS = DamusNotificationService/DamusNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = XK7H4JAB3D;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -6851,6 +6691,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.13;
PRODUCT_BUNDLE_IDENTIFIER = com.jb55.damus2.DamusNotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -6936,7 +6777,7 @@
repositoryURL = "https://github.com/tyiu/EmojiPicker.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.2.0;
minimumVersion = 0.1.1;
};
};
4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
@@ -6944,7 +6785,7 @@
repositoryURL = "https://github.com/onevcat/Kingfisher";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 8.3.1;
minimumVersion = 7.0.0;
};
};
4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
@@ -7029,11 +6870,6 @@
package = 4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
4C5726B92D72C6FA00E7FF82 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 4C06670228FC7EC500038D2A /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
4C649880286E0EE300EAE2B3 /* secp256k1 */ = {
isa = XCSwiftPackageProductDependency;
package = 4C64987F286E0EE300EAE2B3 /* XCRemoteSwiftPackageReference "secp256k1" */;
@@ -1,5 +1,5 @@
{
"originHash" : "06318d35ee2e6bd681b95591e67da33a9461b48a3c652e58bd9d1a6f0d82bdac",
"originHash" : "085cf0f645323bf77edb52886489bf77b309a0a2d2b78a54beaf8520b540d596",
"pins" : [
{
"identity" : "codescanner",
@@ -22,8 +22,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/EmojiKit",
"state" : {
"revision" : "47a4b1402de26be0299dcb4d667c1faaf21a7874",
"version" : "0.2.0"
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
"version" : "0.1.2"
}
},
{
@@ -31,8 +31,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/EmojiPicker.git",
"state" : {
"revision" : "3f48903721eae223238ff0af17c22d6373d33813",
"version" : "0.2.0"
"revision" : "0c28b4a1a6b8840cf2580bda59517f6d0a733dc8",
"version" : "0.1.1"
}
},
{
@@ -49,8 +49,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher",
"state" : {
"revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3",
"version" : "8.3.1"
"revision" : "415b1d97fb38bda1e5a6b2dde63354720832110b",
"version" : "7.6.1"
}
},
{

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 216 B

+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 {
+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 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 = nwc_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))
+45 -32
View File
@@ -199,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()
}
@@ -317,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()
@@ -356,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
@@ -367,6 +367,8 @@ struct ContentView: View {
self.confirm_mute = true
}
.onReceive(handle_notify(.attached_wallet)) { nwc in
try? damus_state.pool.add_relay(.nwc(url: nwc.relay))
// update the lightning address on our profile when we attach a
// wallet with an associated
guard let ds = self.damus_state,
@@ -387,12 +389,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 }
@@ -414,7 +416,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
}
}
@@ -458,7 +460,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")
@@ -504,7 +506,7 @@ struct ContentView: View {
break
case .active:
print("txn: 📙 DAMUS ACTIVE")
damus_state.nostrNetwork.pool.ping()
damus_state.pool.ping()
@unknown default:
break
}
@@ -523,7 +525,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.")) {
@@ -555,7 +557,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
@@ -587,7 +589,7 @@ struct ContentView: View {
}
ds.mutelist_manager.set_mutelist(ev)
ds.nostrNetwork.postbox.send(ev)
ds.postbox.send(ev)
}
}
}, message: {
@@ -628,7 +630,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)
@@ -656,14 +658,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),
@@ -679,6 +695,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,
@@ -702,8 +720,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()
pool.connect()
}
func music_changed(_ state: MusicState) {
@@ -726,7 +743,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)
}
}
@@ -742,8 +759,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
@@ -760,8 +775,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
}
@@ -979,7 +992,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
}
@@ -993,7 +1006,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:
@@ -1006,11 +1019,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:
@@ -1029,15 +1042,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
}
@@ -1045,14 +1058,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])
}
}
@@ -1100,7 +1113,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
}
@@ -1126,7 +1139,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
}
@@ -1201,7 +1214,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(
-4
View File
@@ -2,10 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -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)
}
-7
View File
@@ -40,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]
@@ -72,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
+27 -32
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,9 +39,9 @@ class DamusState: HeadlessDamusState {
var purple: DamusPurple
var push_notification_client: PushNotificationClient
let emoji_provider: EmojiProvider
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) {
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
@@ -55,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
@@ -68,9 +73,6 @@ class DamusState: HeadlessDamusState {
self.quote_reposts = quote_reposts
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
self.emoji_provider = emoji_provider
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate)
}
@MainActor
@@ -96,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),
@@ -119,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,
@@ -161,7 +179,7 @@ class DamusState: HeadlessDamusState {
try await self.push_notification_client.revoke_token()
}
wallet.disconnect()
nostrNetwork.pool.close()
pool.close()
ndb.close()
}
@@ -171,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),
@@ -187,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(),
@@ -198,29 +219,3 @@ class DamusState: HeadlessDamusState {
)
}
}
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)
}
}
}
+2 -2
View File
@@ -115,7 +115,7 @@ class DraftArtifacts: Equatable {
if case .pubkey(let pubkey) = mention.ref {
// A profile reference, format things properly.
let profile = damus_state.ndb.lookup_profile(pubkey)?.unsafeUnownedValue?.profile
let profile_name = DisplayName(profile: profile, pubkey: pubkey).username
let profile_name = parse_display_name(profile: profile, pubkey: pubkey).username
guard let url_address = URL(string: block.asString) else {
rich_text_content.append(.init(string: block.asString))
continue
@@ -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) {
+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) {
+94 -55
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,8 +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`
}
}
@@ -261,58 +259,27 @@ 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 {
return
}
guard nwc.relay == relay else { return } // Don't process NWC responses coming from relays other than our designated one
guard ev.referenced_pubkeys.first == nwc.keypair.pubkey else {
return // This message is not for us. Ignore it.
}
var resp: WalletConnect.FullWalletResponse? = nil
do {
resp = try await WalletConnect.FullWalletResponse(from: ev, nwc: nwc)
} catch {
Log.error("HomeModel: Error on NWC wallet response handling: %s", for: .nwc, error.localizedDescription)
if let initError = error as? WalletConnect.FullWalletResponse.InitializationError,
let humanReadableError = initError.humanReadableError {
present_sheet(.error(humanReadableError))
}
}
guard let resp else { return }
// since command results are not returned for ephemeral events,
// 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)
}
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 let humanReadableError = resp.response.error?.humanReadableError {
present_sheet(.error(humanReadableError))
}
return
}
if resp.response.result_type == .list_transactions {
Log.info("Received NWC transaction list from %s", for: .nwc, relay.absoluteString)
damus_state.wallet.handle_nwc_response(response: resp)
return
}
if resp.response.result_type == .get_balance {
Log.info("Received NWC balance information from %s", for: .nwc, relay.absoluteString)
damus_state.wallet.handle_nwc_response(response: resp)
let nwc = WalletConnectURL(str: nwc_str),
let resp = await 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)")
nwc_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
return
}
print("nwc success: \(resp.response.result.debugDescription) [\(relay)]")
WalletConnect.handle_zap_success(state: self.damus_state, resp: resp)
nwc_success(state: self.damus_state, resp: resp)
}
}
@@ -486,7 +453,7 @@ class HomeModel: ContactsDelegate {
let nwc = WalletConnectURL(str: nwc_str),
nwc.relay == relay_id
{
WalletConnect.subscribe(url: nwc, pool: pool)
subscribe_to_nwc(url: nwc, pool: pool)
}
case .error(let merr):
let desc = String(describing: merr)
@@ -499,7 +466,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):
@@ -969,6 +936,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) {
@@ -976,6 +944,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://")
@@ -1198,4 +1238,3 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale:
}
}
}
+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))
}
@@ -1,95 +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()
}
}
// 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)
}
}
}
}
+60 -104
View File
@@ -73,130 +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 {
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.
if ind >= hide_text_index {
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
@@ -206,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) {
@@ -238,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)
}
}()
+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
}
+12 -26
View File
@@ -10,18 +10,8 @@ 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
@@ -69,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]
@@ -88,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()
}
@@ -105,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) {
@@ -120,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) {
@@ -131,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)
}
@@ -206,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)
}
}
@@ -214,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(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
)
}
+5 -5
View File
@@ -41,13 +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)
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_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.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
}
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
@@ -140,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 {
@@ -156,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:
+4 -4
View File
@@ -41,13 +41,13 @@ class SearchModel: ObservableObject {
//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)
}
+13 -44
View File
@@ -29,18 +29,7 @@ class ThreadModel: ObservableObject {
///
/// This is a computed property because we then don't need to worry about keeping things in sync
var parent_events: [NostrEvent] {
// This block of code helps ensure `ThreadEventMap` stays in sync with `EventCache`
let parent_events_from_cache = damus_state.events.parent_events(event: selected_event, keypair: damus_state.keypair)
for parent_event in parent_events_from_cache {
add_event(
parent_event,
keypair: damus_state.keypair,
look_for_parent_events: false, // We have all parents we need for now
publish_changes: false // Publishing changes during a view render is problematic
)
}
return parent_events_from_cache
return event_map.parent_events(of: selected_event)
}
/// All of the direct and indirect replies of `selected_event` in the thread. sorted chronologically
///
@@ -88,12 +77,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,20 +118,14 @@ 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.
/// Normally this does not need to be called externally because it is the responsibility of this class to load the events, not the view's.
/// However, this can be called externally for testing purposes (e.g. injecting events for testing)
///
/// - Parameters:
/// - ev: The event to add into the thread event map
/// - keypair: The user's keypair
/// - look_for_parent_events: Whether to search for parent events of the input event in NostrDB
/// - publish_changes: Whether to publish changes at the end
func add_event(_ ev: NostrEvent, keypair: Keypair, look_for_parent_events: Bool = true, publish_changes: Bool = true) {
func add_event(_ ev: NostrEvent, keypair: Keypair) {
if event_map.contains(id: ev.id) {
return
}
@@ -153,22 +136,8 @@ class ThreadModel: ObservableObject {
event_map.add(event: ev)
if look_for_parent_events {
// Add all parent events that we have on EventCache (and subsequently on NostrDB)
// This helps ensure we include as many locally-stored notes as possible even on poor networking conditions
damus_state.events.parent_events(event: ev, keypair: damus_state.keypair).forEach {
add_event(
$0, // The `lookup` function in `parent_events` turns the event into an "owned" object, so we do not need to clone here
keypair: damus_state.keypair,
look_for_parent_events: false, // We do not need deep recursion
publish_changes: false // Do not publish changes multiple times
)
}
}
if publish_changes {
objectWillChange.send()
}
// Publish changes
objectWillChange.send()
}
/// Handles an incoming event from a relay pool
@@ -176,7 +145,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)
}
}
+3 -20
View File
@@ -113,9 +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: "left_handed", default_value: false)
var left_handed: Bool
@@ -163,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
@@ -177,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
@@ -349,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 {
-30
View File
@@ -13,17 +13,10 @@ enum WalletConnectState {
case none
}
/// Models and manages the user's NWC wallet based on the app's settings
class WalletModel: ObservableObject {
var settings: UserSettingsStore
private(set) var previous_state: WalletConnectState
var initial_percent: Int
/// The wallet's balance, in sats.
/// Starts with `nil` to signify it is not loaded yet
@Published private(set) var balance: Int64? = nil
/// The list of NWC transactions made in the wallet
/// Starts with `nil` to signify it is not loaded yet
@Published private(set) var transactions: [WalletConnect.Transaction]? = nil
@Published private(set) var connect_state: WalletConnectState
@@ -68,27 +61,4 @@ class WalletModel: ObservableObject {
self.connect_state = .existing(nwc)
self.previous_state = .existing(nwc)
}
/// Handles an NWC response event and updates the model.
///
/// This takes a response received from the NWC relay and updates the internal state of this model.
///
/// - Parameter response: The NWC response received from the network
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
switch response.response.result {
case .get_balance(let balanceResp):
self.balance = balanceResp.balance / 1000
case .none:
return
case .some(.pay_invoice(_)):
return
case .list_transactions(let transactionsResp):
self.transactions = transactionsResp.transactions
}
}
func resetWalletStateInformation() {
self.transactions = nil
self.balance = nil
}
}
+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
-79
View File
@@ -1,79 +0,0 @@
//
// NIP04.swift
// damus
//
// Created by Daniel DAquino on 2025-03-10.
//
import Foundation
/// Functions and utilities for the NIP-04 spec
struct NIP04 {}
extension NIP04 {
/// Encrypts a message using NIP-04.
static func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? {
let iv = random_bytes(count: 16).bytes
guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else {
return nil
}
let utf8_message = Data(message.utf8).bytes
guard let enc_message = aes_encrypt(data: utf8_message, iv: iv, shared_sec: shared_sec) else {
return nil
}
switch encoding {
case .base64:
return encode_dm_base64(content: enc_message.bytes, iv: iv)
case .bech32:
return encode_dm_bech32(content: enc_message.bytes, iv: iv)
}
}
/// Creates an event with encrypted `contents` field, using NIP-04
static func create_encrypted_event(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: FullKeypair, created_at: UInt32, kind: UInt32) -> NostrEvent? {
let privkey = keypair.privkey
guard let enc_content = encrypt_message(message: message, privkey: privkey, to_pk: to_pk) else {
return nil
}
return NostrEvent(content: enc_content, keypair: keypair.to_keypair(), kind: kind, tags: tags, createdAt: created_at)
}
/// Creates a NIP-04 style direct message event
static func create_dm(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: Keypair, created_at: UInt32? = nil) -> NostrEvent?
{
let created = created_at ?? UInt32(Date().timeIntervalSince1970)
guard let keypair = keypair.to_full() else {
return nil
}
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
}
}
-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
@@ -58,7 +58,7 @@ extension NdbProfile {
}
static func displayName(profile: Profile?, pubkey: Pubkey) -> DisplayName {
return DisplayName(name: profile?.name, display_name: profile?.display_name, pubkey: pubkey)
return parse_display_name(profile: profile, pubkey: pubkey)
}
var damus_donation: Int? {
+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
+4 -4
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 })
@@ -68,7 +68,7 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair,
guard let note = NostrEvent(content: message, keypair: identity.to_keypair(), kind: 9733, tags: tags),
let note_json = encode_json(note),
let enc = NIP04.encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
let enc = encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
else {
return nil
}
@@ -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
-17
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 {
@@ -543,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
-2
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,7 +18,6 @@ enum NostrKind: UInt32, Codable {
case like = 7
case chat = 42
case mute_list = 10000
case relay_list = 10002
case list_deprecated = 30000
case draft = 31234
case longform = 30023
+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 -1
View File
@@ -45,7 +45,7 @@ enum NostrResponse {
static func owned_from_json(json: String) -> NostrResponse? {
return json.withCString{ cstr in
let bufsize: Int = max(Int(Double(json.utf8.count) * 8.0), Int(getpagesize()))
let bufsize: Int = max(Int(Double(json.utf8.count) * 4.0), Int(getpagesize()))
let data = malloc(bufsize)
if data == nil {
+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
}
+9 -69
View File
@@ -24,15 +24,13 @@ struct SeenEvent: Hashable {
let evid: NoteId
}
/// Establishes and manages connections and subscriptions to a list of relays.
class RelayPool {
private(set) var relays: [Relay] = []
var relays: [Relay] = []
var handlers: [RelayHandler] = []
var request_queue: [QueuedRequest] = []
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)?
@@ -124,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
@@ -202,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)
@@ -303,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 {
@@ -414,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))
}
+4 -1
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(),
+1 -17
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)
@@ -1,340 +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))
}
/// A deterministic password for a Coinos account
private var password: String? {
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
return sha256Hex(text: "coinos_password:" + self.userKeypair.privkey.hex())
}
/// A deterministic NWC app connection name
private var nwcConnectionName: String { return "Damus" }
// MARK: - Initialization
/// Initializes the client with the user's keypair
init(userKeypair: FullKeypair) {
self.userKeypair = userKeypair
}
// MARK: - Authentication and registration
/// Tries to login to the user's deterministic account. If it cannot be found, it will register for one and log into that.
func loginOrRegister() async throws {
do {
// Check if client has an account
try await self.login()
}
catch {
guard let error = error as? CoinosDeterministicAccountClient.ClientError, error == .unauthorized else { throw error }
// Client does not seem to have an account, create one
try await self.register()
try await self.login()
}
}
/// Registers for a Coinos account using deterministic account details.
///
/// It succeeds if it returns without throwing errors.
func register() async throws {
guard let username, let password else { throw ClientError.errorFormingRequest }
let registerPayload = RegisterRequest(user: UserCredentials(username: username, password: password))
let jsonData = try JSONEncoder().encode(registerPayload)
let url = URL(string: "https://coinos.io/api/register")!
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
return
} else {
throw ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
}
}
/// Logs into the deterministic account, if an auth token is not present
func loginIfNeeded() async throws {
if self.jwtAuthToken == nil { try await self.login() }
}
/// Logs into to our deterministic account.
///
/// Succeeds if it returns without returning errors.
///
/// Mutating function, will update the client's internal state.
func login() async throws {
self.jwtAuthToken = try await sendLoginRequest().token
}
/// Sends the login request and return the response
///
/// Does NOT update the internal login state.
private func sendLoginRequest() async throws -> AuthResponse {
guard let url = URL(string: "https://coinos.io/api/login") else { throw ClientError.errorFormingRequest }
guard let username, let password else { throw ClientError.errorFormingRequest }
let credentials = UserCredentials(username: username, password: password)
let jsonData = try JSONEncoder().encode(credentials)
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
// MARK: - Managing NWC connections
/// Creates a new NWC connection
///
/// Note: Account must exist before calling this endpoint
func createNWCConnection() async throws -> WalletConnectURL {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
try await self.loginIfNeeded()
let config = try defaultWalletConnectionConfig()
let configData = try encode_json_data(config)
let (data, response) = try await self.makeAuthenticatedRequest(
method: .post,
url: urlEndpoint,
payload: configData,
payload_type: .json
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
return nwc
case 401: throw ClientError.unauthorized
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
/// Returns the default wallet connection config
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
return NewWalletConnectionConfig(
name: self.nwcConnectionName,
secret: nwcKeypair.privkey.hex(),
pubkey: nwcKeypair.pubkey.hex(),
max_amount: 30000, // 30K sats per week maximum
budget_renewal: .weekly
)
}
/// Gets the NWC URL for the deterministic NWC app connection
///
/// Account must already exist before calling this
///
/// Returns `nil` if no NWC url is found, (e.g. if app connection has not been configured yet)
func getNWCUrl() async throws -> WalletConnectURL? {
guard let connectionConfig = try await self.getNWCAppConnectionConfig(), let nwc = connectionConfig.nwc else { return nil }
return WalletConnectURL(str: nwc)
}
/// Gets the deterministic NWC app connection configuration details, if it exists
///
/// Account must already exist before calling this
///
/// Returns `nil` if no connection is found, (e.g. if app connection has not been configured yet)
func getNWCAppConnectionConfig() async throws -> WalletConnectionConfig? {
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
guard let url = URL(string: "https://coinos.io/api/app/" + nwcKeypair.pubkey.hex()) else { throw ClientError.errorFormingRequest }
try await self.loginIfNeeded()
let (data, response) = try await self.makeAuthenticatedRequest(
method: .get,
url: url,
payload: nil,
payload_type: nil
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200: return try JSONDecoder().decode(WalletConnectionConfig.self, from: data)
case 401: throw ClientError.unauthorized
case 404: return nil
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.errorProcessingResponse
}
// MARK: - Lower level request convenience functions
/// Makes a request without any authorization
func makeRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.httpBody = payload
if let payload_type {
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
}
return try await URLSession.shared.data(for: request)
}
/// Makes an authenticated request with our JWT auth token.
///
/// Client must be logged-in before calling this, otherwise an error will be thrown.
func makeAuthenticatedRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
guard let jwtAuthToken else { throw ClientError.errorFormingRequest }
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.httpBody = payload
request.setValue("Bearer " + jwtAuthToken, forHTTPHeaderField: "Authorization")
if let payload_type {
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
}
return try await URLSession.shared.data(for: request)
}
// MARK: - Helper structures
/// Payload for registering for a new Coinos account
struct RegisterRequest: Codable {
/// New user credentials
let user: UserCredentials
}
/// Payload for user credentials (sign-up and login)
struct UserCredentials: Codable {
/// The username
let username: String
/// The user password
let password: String
}
/// A successful response to a login auth endpoint
struct AuthResponse: Codable {
/// The JWT token to be applied to any authenticated API calls
let token: String
}
/// Used by the client to define new NWC configurations
struct NewWalletConnectionConfig: Codable {
/// The name of the connection
let name: String
/// 32 Hex-encoded bytes containing a shared private key secret
let secret: String
/// 32 Hex-encoded bytes containing the pubkey for the secret
let pubkey: String
/// Max amount that can be spent in each renewal period (measured in sats)
let max_amount: UInt64
/// The period of time it takes for the budget limits to reset
let budget_renewal: BudgetRenewalPeriod
}
/// The NWC connection configuration details
///
/// ## Implementation notes
///
/// - All items defined as optionals because the Coinos API may change in the future, so this may help increase future compatibility.
struct WalletConnectionConfig: Codable {
/// The name of the connection
let name: String?
/// 32 Hex-encoded bytes containing a shared private key secret
let secret: String?
/// 32 Hex-encoded bytes containing the pubkey for the secret
let pubkey: String?
/// Max amount that can be spent in every renewal period (measured in sats)
let max_amount: UInt64?
/// The NWC url generated by the server
let nwc: String?
/// Budget renewal information
let budget_renewal: BudgetRenewalPeriod?
}
/// A period of time it takes for budget limits to be reset
enum BudgetRenewalPeriod: String, Codable {
/// Resets once a week
case weekly
}
/// A client error occured
enum ClientError: Error, Equatable {
/// Received an unexpected HTTP response
///
/// Could be for a variety of reasons.
case unexpectedHTTPResponse(status_code: Int, response: Data)
/// Error forming the request, generally due to missing or inconsistent internal data
///
/// Probably caused by a programming error.
case errorFormingRequest
/// The client could not process the response from the server
///
/// Might be a sign of an incompatibility bug
case errorProcessingResponse
/// The action performed is not authorized
/// Generally thrown if user does not exist, credentials do not match what Coinos has on file, or programming error
case unauthorized
/// Client not logged in on a call that expected login
case notLoggedIn
}
}
/// Computes a SHA256 hash digest from a piece of UTF-8 text, and returns the result as a "hex" string
///
/// When working only with strings, this can be more convenient than transforming text to data, and data back to text
fileprivate func sha256Hex(text: String) -> String? {
guard let data = text.data(using: .utf8) else { return nil }
return sha256(data).toHexString()
}
-1
View File
@@ -14,7 +14,6 @@ import Foundation
class Constants {
//static let EXAMPLE_DEMOS: DamusState = .empty
static let DAMUS_APP_GROUP_IDENTIFIER: String = "group.com.damus"
static let IMAGE_CACHE_DIRNAME: String = "ImageCache"
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
+9 -34
View File
@@ -10,15 +10,7 @@ import Foundation
enum DisplayName: Equatable {
case both(username: String, displayName: String)
case one(String)
init (profile: Profile?, pubkey: Pubkey) {
self = parse_display_name(name: profile?.name, display_name: profile?.display_name, pubkey: pubkey)
}
init (name: String?, display_name: String?, pubkey: Pubkey) {
self = parse_display_name(name: name, display_name: display_name, pubkey: pubkey)
}
var displayName: String {
switch self {
case .one(let one):
@@ -36,37 +28,20 @@ enum DisplayName: Equatable {
return username
}
}
func nameComponents() -> PersonNameComponents {
var components = PersonNameComponents()
switch self {
case .one(let one):
components.nickname = one
return components
case .both(username: let username, displayName: let displayName):
components.nickname = username
let names = displayName.split(separator: " ")
if let name = names.first {
components.givenName = String(name)
components.familyName = names.dropFirst().joined(separator: " ")
}
return components
}
}
}
func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) -> DisplayName {
func parse_display_name(profile: Profile?, pubkey: Pubkey) -> DisplayName {
if pubkey == ANON_PUBKEY {
return .one(NSLocalizedString("Anonymous", comment: "Placeholder display name of anonymous user."))
}
if name == nil && display_name == nil {
guard let profile else {
return .one(abbrev_bech32_pubkey(pubkey: pubkey))
}
let name = name?.isEmpty == false ? name : nil
let disp_name = display_name?.isEmpty == false ? display_name : nil
let name = profile.name?.isEmpty == false ? profile.name : nil
let disp_name = profile.display_name?.isEmpty == false ? profile.display_name : nil
if let name, let disp_name, name != disp_name {
return .both(username: name, displayName: disp_name)
@@ -80,9 +55,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)")
}
}
+6 -11
View File
@@ -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]
@@ -159,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")
-15
View File
@@ -1,15 +0,0 @@
//
// ExtraFonts.swift
// damus
//
// Created by Daniel DAquino on 2025-03-13.
//
import SwiftUI
extension Font {
// Note: When changing the font size accessibility setting, these styles only update after an app restart. It's a current limitation of this.
static let veryLargeTitle: Font = .system(size: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize * 1.5, weight: .bold) // Makes a bigger title while allowing for iOS dynamic font sizing to take effect
static let veryVeryLargeTitle: Font = .system(size: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize * 2.1, weight: .bold) // Makes a bigger title while allowing for iOS dynamic font sizing to take effect
}
-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)
}
}
-2
View File
@@ -15,8 +15,6 @@ enum LogCategory: String {
case storage
case networking
case timeline
/// Logs related to Nostr Wallet Connect components
case nwc
case push_notifications
case damus_purple
case image_uploading
+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 -2
View File
@@ -126,7 +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)
LoadScript(pool: damusState.pool, model: load_model)
}
}
@@ -209,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")
+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
}
+118
View File
@@ -0,0 +1,118 @@
//
// WalletConnect+.swift
// damus
//
// Created by Daniel DAquino on 2023-11-27.
//
import Foundation
func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest<PayInvoiceRequest> {
let data = PayInvoiceRequest(invoice: invoice)
return WalletRequest(method: "pay_invoice", params: data)
}
func make_wallet_balance_request() -> WalletRequest<EmptyRequest> {
return WalletRequest(method: "get_balance", params: nil)
}
struct EmptyRequest: Codable {
}
struct PayInvoiceRequest: Codable {
let invoice: String
}
func make_wallet_connect_request<T>(req: WalletRequest<T>, to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? {
let tags = [to_pk.tag]
let created_at = UInt32(Date().timeIntervalSince1970)
guard let content = encode_json(req) else {
return nil
}
return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194)
}
func subscribe_to_nwc(url: WalletConnectURL, pool: RelayPool) {
var filter = NostrFilter(kinds: [.nwc_response])
filter.authors = [url.pubkey]
filter.limit = 0
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false)
}
@discardableResult
func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
let req = make_wallet_pay_invoice_request(invoice: invoice)
guard let ev = make_wallet_connect_request(req: req, to_pk: url.pubkey, keypair: url.keypair) else {
return nil
}
try? pool.add_relay(.nwc(url: url.relay))
subscribe_to_nwc(url: url, pool: pool)
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
return ev
}
func nwc_success(state: DamusState, resp: FullWalletResponse) {
// find the pending zap and mark it as pending-confirmed
for kv in state.zaps.our_zaps {
let zaps = kv.value
for zap in zaps {
guard case .pending(let pzap) = zap,
case .nwc(let nwc_state) = pzap.state,
case .postbox_pending(let nwc_req) = nwc_state.state,
nwc_req.id == resp.req_id
else {
continue
}
if nwc_state.update_state(state: .confirmed) {
// notify the zaps model of an update so it can mark them as paid
state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send()
print("NWC success confirmed")
}
return
}
}
}
func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async {
let percent_f = Double(percent) / 100.0
let donations_msats = Int64(percent_f * Double(base_msats))
let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus")
guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else {
// we failed... oh well. no donation for us.
print("damus-donation failed to fetch invoice")
return
}
print("damus-donation donating...")
nwc_pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil)
}
func nwc_error(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) {
// find a pending zap with the nwc request id associated with this response and remove it
for kv in zapcache.our_zaps {
let zaps = kv.value
for zap in zaps {
guard case .pending(let pzap) = zap,
case .nwc(let nwc_state) = pzap.state,
case .postbox_pending(let req) = nwc_state.state,
req.id == resp.req_id
else {
continue
}
// remove the pending zap if there was an error
let reqid = ZapRequestId(from_pending: pzap)
remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache)
return
}
}
}
+155
View File
@@ -0,0 +1,155 @@
//
// WalletConnect.swift
// damus
//
// Created by William Casarin on 2023-03-22.
//
import Foundation
struct WalletConnectURL: Equatable {
static func == (lhs: WalletConnectURL, rhs: WalletConnectURL) -> Bool {
return lhs.keypair == rhs.keypair &&
lhs.pubkey == rhs.pubkey &&
lhs.relay == rhs.relay
}
let relay: RelayURL
let keypair: FullKeypair
let pubkey: Pubkey
let lud16: String?
func to_url() -> URL {
var urlComponents = URLComponents()
urlComponents.scheme = "nostrwalletconnect"
urlComponents.host = pubkey.hex()
urlComponents.queryItems = [
URLQueryItem(name: "relay", value: relay.absoluteString),
URLQueryItem(name: "secret", value: keypair.privkey.hex())
]
if let lud16 {
urlComponents.queryItems?.append(URLQueryItem(name: "lud16", value: lud16))
}
return urlComponents.url!
}
init?(str: String) {
guard let components = URLComponents(string: str),
components.scheme == "nostrwalletconnect" || components.scheme == "nostr+walletconnect",
// The line below provides flexibility for both `nostrwalletconnect://` (non-compliant, but commonly used) and `nostrwalletconnect:` (NIP-47 compliant) formats
let encoded_pubkey = components.path == "" ? components.host : components.path,
let pubkey = hex_decode_pubkey(encoded_pubkey),
let items = components.queryItems,
let relay = items.first(where: { qi in qi.name == "relay" })?.value,
let relay_url = RelayURL(relay),
let secret = items.first(where: { qi in qi.name == "secret" })?.value,
secret.utf8.count == 64,
let decoded = hex_decode(secret)
else {
return nil
}
let privkey = Privkey(Data(decoded))
guard let our_pk = privkey_to_pubkey(privkey: privkey) else { return nil }
let lud16 = items.first(where: { qi in qi.name == "lud16" })?.value
let keypair = FullKeypair(pubkey: our_pk, privkey: privkey)
self = WalletConnectURL(pubkey: pubkey, relay: relay_url, keypair: keypair, lud16: lud16)
}
init(pubkey: Pubkey, relay: RelayURL, keypair: FullKeypair, lud16: String?) {
self.pubkey = pubkey
self.relay = relay
self.keypair = keypair
self.lud16 = lud16
}
}
struct WalletRequest<T: Codable>: Codable {
let method: String
let params: T?
}
struct WalletResponseErr: Codable {
let code: String?
let message: String?
}
struct PayInvoiceResponse: Decodable {
let preimage: String
}
enum WalletResponseResultType: String {
case pay_invoice
}
enum WalletResponseResult {
case pay_invoice(PayInvoiceResponse)
}
struct FullWalletResponse {
let req_id: NoteId
let response: WalletResponse
init?(from: NostrEvent, nwc: WalletConnectURL) async {
guard let note_id = from.referenced_ids.first else {
return nil
}
self.req_id = note_id
let ares = Task {
guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64),
let resp: WalletResponse = decode_json(json)
else {
let resp: WalletResponse? = nil
return resp
}
return resp
}
guard let res = await ares.value else {
return nil
}
self.response = res
}
}
struct WalletResponse: Decodable {
let result_type: WalletResponseResultType
let error: WalletResponseErr?
let result: WalletResponseResult?
private enum CodingKeys: CodingKey {
case result_type, error, result
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let result_type_str = try container.decode(String.self, forKey: .result_type)
guard let result_type = WalletResponseResultType(rawValue: result_type_str) else {
throw DecodingError.typeMismatch(WalletResponseResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown"))
}
self.result_type = result_type
self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error)
guard self.error == nil else {
self.result = nil
return
}
switch result_type {
case .pay_invoice:
let res = try container.decode(PayInvoiceResponse.self, forKey: .result)
self.result = .pay_invoice(res)
}
}
}
@@ -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")"
)
}
}
}
-177
View File
@@ -1,177 +0,0 @@
//
// Request.swift
// damus
//
// Created by Daniel DAquino on 2025-03-10.
//
import Foundation
extension WalletConnect {
/// Models a request to an NWC wallet provider
enum Request: Codable {
/// 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?
)
/// Get the current wallet balance
case getBalance
/// Get the current wallet transaction history
case getTransactionList(
/// Starting timestamp in seconds since epoch (inclusive), optional.
from: UInt64?,
/// Ending timestamp in seconds since epoch (inclusive), optional.
until: UInt64?,
/// Maximum number of invoices to return, optional.
limit: Int?,
/// Offset of the first invoice to return, optional.
offset: Int?,
/// Include unpaid invoices, optional, default false.
unpaid: Bool?,
/// "incoming" for invoices, "outgoing" for payments, undefined for both.
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
/// Converts the NWC request into a raw Nostr event to be sent in the network
///
/// - Parameters:
/// - to_pk: The destination pubkey (used for encryption)
/// - keypair: The requester's pubkey (used for encryption and signing)
/// - Returns: The NWC request in a raw Nostr Event format, or nil if it cannot be encoded
func to_nostr_event(to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? {
let tags = [to_pk.tag]
let created_at = UInt32(Date().timeIntervalSince1970)
guard let content = encode_json(self) else {
return nil
}
return NIP04.create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: NostrKind.nwc_request.rawValue)
}
// MARK: - Encoding and decoding
/// Keys for top-level JSON
private enum CodingKeys: String, CodingKey {
case method
case params
}
/// Keys for the JSON inside the "params" object
private enum ParamKeys: String, CodingKey {
case invoice, description, metadata
case from, until, limit, offset, unpaid, type
}
/// Constants for possible request "method" verbs
private enum Method: String {
case payInvoice = "pay_invoice"
case getBalance = "get_balance"
case listTransactions = "list_transactions"
}
/// Decodes a payload into this request structure
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let method = try container.decode(String.self, forKey: .method)
switch method {
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)
case Method.getBalance.rawValue:
// No params to decode
self = .getBalance
case Method.listTransactions.rawValue:
let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
let from = try paramsContainer.decodeIfPresent(UInt64.self, forKey: .from)
let until = try paramsContainer.decodeIfPresent(UInt64.self, forKey: .until)
let limit = try paramsContainer.decodeIfPresent(Int.self, forKey: .limit)
let offset = try paramsContainer.decodeIfPresent(Int.self, forKey: .offset)
let unpaid = try paramsContainer.decodeIfPresent(Bool.self, forKey: .unpaid)
let type = try paramsContainer.decodeIfPresent(String.self, forKey: .type)
self = .getTransactionList(from: from, until: until, limit: limit, offset: offset, unpaid: unpaid, type: type)
default:
throw DecodingError.dataCorruptedError(
forKey: .method,
in: container,
debugDescription: "Unknown wallet method \"\(method)\""
)
}
}
/// Encodes this request structure into a payload
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .payInvoice(let invoice, let description, let metadata):
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)
// "params": null
try container.encodeNil(forKey: .params)
case .getTransactionList(let from, let until, let limit, let offset, let unpaid, let type):
try container.encode(Method.listTransactions.rawValue, forKey: .method)
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
try paramsContainer.encodeIfPresent(from, forKey: .from)
try paramsContainer.encodeIfPresent(until, forKey: .until)
try paramsContainer.encodeIfPresent(limit, forKey: .limit)
try paramsContainer.encodeIfPresent(offset, forKey: .offset)
try paramsContainer.encodeIfPresent(unpaid, forKey: .unpaid)
try paramsContainer.encodeIfPresent(type, forKey: .type)
}
}
}
}
-157
View File
@@ -1,157 +0,0 @@
//
// Response.swift
// damus
//
// Created by Daniel DAquino on 2025-03-10.
//
import Combine
extension WalletConnect {
/// Models a response from the NWC provider
struct Response: Decodable {
let result_type: Response.Result.ResultType
let error: WalletResponseErr?
let result: Response.Result?
private enum CodingKeys: CodingKey {
case result_type, error, result
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let result_type_str = try container.decode(String.self, forKey: .result_type)
guard let result_type = Response.Result.ResultType(rawValue: result_type_str) else {
throw DecodingError.typeMismatch(Response.Result.ResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown"))
}
self.result_type = result_type
self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error)
guard self.error == nil else {
self.result = nil
return
}
switch result_type {
case .pay_invoice:
let res = try container.decode(Result.PayInvoiceResponse.self, forKey: .result)
self.result = .pay_invoice(res)
case .get_balance:
let res = try container.decode(Result.GetBalanceResponse.self, forKey: .result)
self.result = .get_balance(res)
case .list_transactions:
let res = try container.decode(Result.ListTransactionsResponse.self, forKey: .result)
self.result = .list_transactions(res)
}
}
}
struct FullWalletResponse {
let req_id: NoteId
let response: Response
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) async throws(InitializationError) {
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
self.req_id = referencedNoteId
var json = ""
do {
json = try NIP04.decryptContent(
recipientPrivateKey: nwc.keypair.privkey,
senderPubkey: nwc.pubkey,
content: event.content,
encoding: .base64
)
}
catch { throw .failedToDecrypt(error) }
do {
let response: WalletConnect.Response = try decode_json_throwing(json)
self.response = response
}
catch { throw .failedToDecodeJSON(error) }
}
enum InitializationError: Error {
case incorrectAuthorPubkey
case missingRequestIdReference
case failedToDecodeJSON(any Error)
case failedToDecrypt(any Error)
}
}
struct WalletResponseErr: Codable {
let code: Code?
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)
}
}
}
extension WalletConnect.Response {
/// The response data resulting from an NWC request
enum Result {
case pay_invoice(PayInvoiceResponse)
case get_balance(GetBalanceResponse)
case list_transactions(ListTransactionsResponse)
enum ResultType: String {
case pay_invoice
case get_balance
case list_transactions
}
struct PayInvoiceResponse: Decodable {
let preimage: String
}
struct GetBalanceResponse: Decodable {
let balance: Int64
}
struct ListTransactionsResponse: Decodable {
let transactions: [WalletConnect.Transaction]
}
}
}
@@ -1,172 +0,0 @@
//
// WalletConnect+.swift
// damus
//
// Created by Daniel DAquino on 2023-11-27.
//
import Foundation
// TODO: Eventually we should move these convenience functions into structured classes responsible for managing this type of functionality, such as `WalletModel`
extension WalletConnect {
/// Creates and sends a subscription to an NWC relay requesting NWC responses to be sent back.
///
/// Notes: This assumes there is already a listener somewhere else
///
/// - Parameters:
/// - url: The Nostr Wallet Connect URL containing connection info to the NWC wallet
/// - pool: The RelayPool to send the subscription request through
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")
pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false)
}
/// Sends out a request to pay an invoice to the NWC relay, and ensures that:
/// 1. the NWC relay is connected and we are listening to NWC events
/// 2. the NWC relay is connected and we are listening to NWC
///
/// Note: This does not return information about whether the payment is succesful or not. The actual confirmation is handled elsewhere around `HomeModel` and `WalletModel`
///
/// - Parameters:
/// - url: The NWC wallet connection URL
/// - pool: The relay pool to connect to
/// - post: The postbox to send events in
/// - delay: The delay before actually sending the request to the network _(this makes it possible to cancel a zap)_
/// - 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)
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
return nil
}
try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected
WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
return ev
}
/// Sends out a wallet balance request to the NWC relay, and ensures that:
/// 1. the NWC relay is connected and we are listening to NWC events
/// 2. the NWC relay is connected and we are listening to NWC
///
/// Note: This does not return the actual balance information. The actual balance is handled elsewhere around `HomeModel` and `WalletModel`
///
/// - Parameters:
/// - url: The NWC wallet connection URL
/// - pool: The relay pool to connect to
/// - post: The postbox to send events in
/// - delay: The delay before actually sending the request to the network
/// - on_flush: A callback to call after the event has been flushed to the network
/// - Returns: The Nostr Event that was sent to the network, representing the request that was made
@discardableResult
static func request_balance_information(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? {
let req = WalletConnect.Request.getBalance
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
return nil
}
try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected
WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
return ev
}
/// Sends out a wallet transaction list request to the NWC relay, and ensures that:
/// 1. the NWC relay is connected and we are listening to NWC events
/// 2. the NWC relay is connected and we are listening to NWC
///
/// Note: This does not return the actual transaction list. The actual transaction list is handled elsewhere around `HomeModel` and `WalletModel`
///
/// - Parameters:
/// - url: The NWC wallet connection URL
/// - pool: The relay pool to connect to
/// - post: The postbox to send events in
/// - delay: The delay before actually sending the request to the network
/// - on_flush: A callback to call after the event has been flushed to the network
/// - Returns: The Nostr Event that was sent to the network, representing the request that was made
@discardableResult
static func request_transaction_list(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? {
let req = WalletConnect.Request.getTransactionList(from: nil, until: nil, limit: 10, offset: 0, unpaid: false, type: "")
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
return nil
}
try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected
WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
return ev
}
static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) {
// find the pending zap and mark it as pending-confirmed
for kv in state.zaps.our_zaps {
let zaps = kv.value
for zap in zaps {
guard case .pending(let pzap) = zap,
case .nwc(let nwc_state) = pzap.state,
case .postbox_pending(let nwc_req) = nwc_state.state,
nwc_req.id == resp.req_id
else {
continue
}
if nwc_state.update_state(state: .confirmed) {
// notify the zaps model of an update so it can mark them as paid
state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send()
print("NWC success confirmed")
}
return
}
}
}
/// Send a donation zap to the Damus team
static func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async {
let percent_f = Double(percent) / 100.0
let donations_msats = Int64(percent_f * Double(base_msats))
let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus")
guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else {
// we failed... oh well. no donation for us.
print("damus-donation failed to fetch invoice")
return
}
print("damus-donation donating...")
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, zap_request: nil, delay: nil)
}
/// Handles a received Nostr Wallet Connect error
static func handle_error(zapcache: Zaps, evcache: EventCache, resp: WalletConnect.FullWalletResponse) {
// find a pending zap with the nwc request id associated with this response and remove it
for kv in zapcache.our_zaps {
let zaps = kv.value
for zap in zaps {
guard case .pending(let pzap) = zap,
case .nwc(let nwc_state) = pzap.state,
case .postbox_pending(let req) = nwc_state.state,
req.id == resp.req_id
else {
continue
}
// remove the pending zap if there was an error
let reqid = ZapRequestId(from_pending: pzap)
remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache)
return
}
}
}
}
@@ -1,92 +0,0 @@
//
// WalletConnect.swift
// damus
//
// Created by William Casarin on 2023-03-22.
//
import Foundation
struct WalletConnect {}
typealias WalletConnectURL = WalletConnect.ConnectURL // Declared to facilitate refactor
extension WalletConnect {
/// Models a decoded NWC URL, containing information to connect to an NWC wallet.
struct ConnectURL: Equatable {
let relay: RelayURL
let keypair: FullKeypair
let pubkey: Pubkey
let lud16: String?
static func == (lhs: ConnectURL, rhs: ConnectURL) -> Bool {
return lhs.keypair == rhs.keypair &&
lhs.pubkey == rhs.pubkey &&
lhs.relay == rhs.relay
}
func to_url() -> URL {
var urlComponents = URLComponents()
urlComponents.scheme = "nostrwalletconnect"
urlComponents.host = pubkey.hex()
urlComponents.queryItems = [
URLQueryItem(name: "relay", value: relay.absoluteString),
URLQueryItem(name: "secret", value: keypair.privkey.hex())
]
if let lud16 {
urlComponents.queryItems?.append(URLQueryItem(name: "lud16", value: lud16))
}
return urlComponents.url!
}
init?(str: String) {
guard let components = URLComponents(string: str),
components.scheme == "nostrwalletconnect" || components.scheme == "nostr+walletconnect",
// The line below provides flexibility for both `nostrwalletconnect://` (non-compliant, but commonly used) and `nostrwalletconnect:` (NIP-47 compliant) formats
let encoded_pubkey = components.path == "" ? components.host : components.path,
let pubkey = hex_decode_pubkey(encoded_pubkey),
let items = components.queryItems,
let relay = items.first(where: { qi in qi.name == "relay" })?.value,
let relay_url = RelayURL(relay),
let secret = items.first(where: { qi in qi.name == "secret" })?.value,
secret.utf8.count == 64,
let decoded = hex_decode(secret)
else {
return nil
}
let privkey = Privkey(Data(decoded))
guard let our_pk = privkey_to_pubkey(privkey: privkey) else { return nil }
let lud16 = items.first(where: { qi in qi.name == "lud16" })?.value
let keypair = FullKeypair(pubkey: our_pk, privkey: privkey)
self = ConnectURL(pubkey: pubkey, relay: relay_url, keypair: keypair, lud16: lud16)
}
init(pubkey: Pubkey, relay: RelayURL, keypair: FullKeypair, lud16: String?) {
self.pubkey = pubkey
self.relay = relay
self.keypair = keypair
self.lud16 = lud16
}
}
/// Models an NWC wallet transaction
struct Transaction: Decodable, Equatable, Hashable {
let type: String
let invoice: String?
let description: String?
let description_hash: String?
let preimage: String?
let payment_hash: String?
let amount: Int64
let fees_paid: Int64?
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.
}
}
+1 -1
View File
@@ -270,7 +270,7 @@ struct EventActionBar: View {
generator.impactOccurred()
damus_state.nostrNetwork.postbox.send(like_ev)
damus_state.postbox.send(like_ev)
}
// MARK: Helper structures
+1 -1
View File
@@ -25,7 +25,7 @@ struct RepostAction: View {
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)
+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
+1 -1
View File
@@ -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 {
+64 -109
View File
@@ -18,26 +18,11 @@ struct ConfigView: View {
@State var delete_account_warning: Bool = false
@State var confirm_delete_account: Bool = false
@State var delete_text: String = ""
@State private var searchText: String = ""
@ObservedObject var settings: UserSettingsStore
// String constants
private let DELETE_KEYWORD = "DELETE"
private let keysTitle = NSLocalizedString("Keys", comment: "Settings section for managing keys")
private let appearanceTitle = NSLocalizedString("Appearance and filters", comment: "Section header for text, appearance, and content filter settings")
private let searchUniverseTitle = NSLocalizedString("Search / Universe", comment: "Section header for search/universe settings")
private let notificationsTitle = NSLocalizedString("Notifications", comment: "Section header for Damus notifications")
private let zapsTitle = NSLocalizedString("Zaps", comment: "Section header for zap settings")
private let translationTitle = NSLocalizedString("Translation", comment: "Section header for text and appearance settings")
private let reactionsTitle = NSLocalizedString("Reactions", comment: "Section header for reactions settings")
private let developerTitle = NSLocalizedString("Developer", comment: "Section header for developer settings")
private let firstAidTitle = NSLocalizedString("First Aid", comment: "Section header for first aid tools and settings")
private let signOutTitle = NSLocalizedString("Sign out", comment: "Sidebar menu label to sign out of the account.")
private let deleteAccountTitle = NSLocalizedString("Delete Account", comment: "Button to delete the user's account.")
private let versionTitle = NSLocalizedString("Version", comment: "Section title for displaying the version number of the Damus app.")
private let copyString = NSLocalizedString("Copy", comment: "Context menu option for copying the version of damus.")
init(state: DamusState) {
self.state = state
_settings = ObservedObject(initialValue: state.settings)
@@ -46,122 +31,91 @@ struct ConfigView: View {
func textColor() -> Color {
colorScheme == .light ? DamusColors.black : DamusColors.white
}
func showSettingsButton(title : String)->Bool{
return searchText.isEmpty || title.lowercased().contains(searchText.lowercased())
}
var body: some View {
ZStack(alignment: .leading) {
Form {
Section {
// Keys
if showSettingsButton(title: keysTitle){
NavigationLink(value:Route.KeySettings(keypair: state.keypair)){
IconLabel(keysTitle,img_name:"Key",color:.purple)
}
}
// Appearance and filters
if showSettingsButton(title: appearanceTitle){
NavigationLink(value:Route.AppearanceSettings(settings: settings)){
IconLabel(appearanceTitle,img_name:"eye",color:.red)
}
}
// Search/Universe
if showSettingsButton(title: searchUniverseTitle){
NavigationLink(value: Route.SearchSettings(settings: settings)){
IconLabel(searchUniverseTitle,img_name:"search",color:.red)
}
NavigationLink(value: Route.KeySettings(keypair: state.keypair)) {
IconLabel(NSLocalizedString("Keys", comment: "Settings section for managing keys"), img_name: "Key", color: .purple)
}
//Notifications
if showSettingsButton(title: notificationsTitle){
NavigationLink(value: Route.NotificationSettings(settings: settings)){
IconLabel(notificationsTitle,img_name:"notification-bell-on",color:.blue)
}
NavigationLink(value: Route.AppearanceSettings(settings: settings)) {
IconLabel(NSLocalizedString("Appearance and filters", comment: "Section header for text, appearance, and content filter settings"), img_name: "eye", color: .red)
}
//Zaps
if showSettingsButton(title: zapsTitle){
NavigationLink(value: Route.ZapSettings(settings: settings)){
IconLabel(zapsTitle,img_name:"zap.fill",color:.orange)
}
NavigationLink(value: Route.SearchSettings(settings: settings)) {
IconLabel(NSLocalizedString("Search/Universe", comment: "Section header for search/universe settings"), img_name: "search", color: .red)
}
//Translation
if showSettingsButton(title: translationTitle){
NavigationLink(value: Route.TranslationSettings(settings: settings)){
IconLabel(translationTitle,img_name:"globe",color:.green)
}
NavigationLink(value: Route.NotificationSettings(settings: settings)) {
IconLabel(NSLocalizedString("Notifications", comment: "Section header for Damus notifications"), img_name: "notification-bell-on", color: .blue)
}
//Reactions
if showSettingsButton(title: reactionsTitle){
NavigationLink(value: Route.ReactionsSettings(settings: settings)){
IconLabel(reactionsTitle,img_name:"shaka.fill",color:.purple)
}
NavigationLink(value: Route.ZapSettings(settings: settings)) {
IconLabel(NSLocalizedString("Zaps", comment: "Section header for zap settings"), img_name: "zap.fill", color: .orange)
}
//Developer
if showSettingsButton(title: developerTitle){
NavigationLink(value: Route.DeveloperSettings(settings: settings)){
IconLabel(developerTitle,img_name:"magic-stick2.fill",color:DamusColors.adaptableBlack)
}
NavigationLink(value: Route.TranslationSettings(settings: settings)) {
IconLabel(NSLocalizedString("Translation", comment: "Section header for text and appearance settings"), img_name: "globe", color: .green)
}
//First Aid
if showSettingsButton(title: firstAidTitle){
NavigationLink(value: Route.FirstAidSettings(settings: settings)){
IconLabel(firstAidTitle,img_name:"help2",color: .red)
}
NavigationLink(value: Route.ReactionsSettings(settings: settings)) {
IconLabel(NSLocalizedString("Reactions", comment: "Section header for reactions settings"), img_name: "shaka.fill", color: .purple)
}
NavigationLink(value: Route.DeveloperSettings(settings: settings)) {
IconLabel(NSLocalizedString("Developer", comment: "Section header for developer settings"), img_name: "magic-stick2.fill", color: DamusColors.adaptableBlack)
}
NavigationLink(value: Route.FirstAidSettings(settings: settings)) {
IconLabel(NSLocalizedString("First Aid", comment: "Section header for first aid tools and settings"), img_name: "help2", color: .red)
}
}
//Sign out Section
if showSettingsButton(title: signOutTitle){
Section(signOutTitle){
Section(NSLocalizedString("Sign Out", comment: "Section title for signing out")) {
Button(action: {
if state.keypair.privkey == nil {
logout(state)
} else {
confirm_logout = true
}
}, label: {
Label(NSLocalizedString("Sign out", comment: "Sidebar menu label to sign out of the account."), image: "logout")
.foregroundColor(textColor())
.frame(maxWidth: .infinity, alignment: .leading)
})
}
if state.is_privkey_user {
Section(header: Text("Permanently Delete Account", comment: "Section title for deleting the user")) {
Button(action: {
if state.keypair.privkey == nil {
logout(state)
} else {
confirm_logout = true
}
delete_account_warning = true
}, label: {
Label(signOutTitle, image: "logout")
.foregroundColor(textColor())
Label(NSLocalizedString("Delete Account", comment: "Button to delete the user's account."), image: "delete")
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundColor(.red)
})
}
}
// Delete Account
if showSettingsButton(title: deleteAccountTitle){
if state.is_privkey_user {
Section(header: Text("Permanently Delete Account", comment: "Section title for deleting the user")) {
Button(action: {
delete_account_warning = true
}, label: {
Label(deleteAccountTitle, image: "delete")
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundColor(.red)
})
}
}
}
// Version info
if showSettingsButton(title: versionTitle) {
Section(
header: Text(versionTitle),
footer: Text("").padding(.bottom, tabHeight + getSafeAreaBottom())
) {
Text(verbatim: VersionInfo.version)
.contextMenu {
Button {
UIPasteboard.general.string = VersionInfo.version
} label: {
Label(copyString, image: "copy2")
}
Section(
header: Text(NSLocalizedString("Version", comment: "Section title for displaying the version number of the Damus app.")),
footer: Text("").padding(.bottom, tabHeight + getSafeAreaBottom())
) {
Text(verbatim: VersionInfo.version)
.contextMenu {
Button {
UIPasteboard.general.string = VersionInfo.version
} label: {
Label(NSLocalizedString("Copy", comment: "Context menu option for copying the version of damus."), image: "copy2")
}
}
}
}
}
}
.navigationTitle(NSLocalizedString("Settings", comment: "Navigation title for Settings view."))
.navigationBarTitleDisplayMode(.large)
.searchable(text: $searchText, prompt: NSLocalizedString("Search within settings", comment: "Text to prompt the user to search settings."))
.alert(NSLocalizedString("WARNING:\n\nTHIS WILL SIGN AN EVENT THAT DELETES THIS ACCOUNT.\n\nYOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.\n\n ARE YOU SURE YOU WANT TO CONTINUE?", comment: "Alert for deleting the users account."), isPresented: $delete_account_warning) {
Button(NSLocalizedString("Cancel", comment: "Cancel deleting the user."), role: .cancel) {
@@ -182,7 +136,7 @@ struct ConfigView: View {
let ev = created_deleted_account_profile(keypair: keypair) else {
return
}
state.nostrNetwork.postbox.send(ev)
state.postbox.send(ev)
logout(state)
}
}
@@ -200,6 +154,7 @@ struct ConfigView: View {
dismiss()
}
}
}
struct ConfigView_Previews: PreviewProvider {
+42 -2
View File
@@ -131,14 +131,14 @@ struct DMChatView: View, KeyboardReadable {
.map(\.asString)
.joined(separator: "")
guard let dm = NIP04.create_dm(content, to_pk: pubkey, tags: tags, keypair: damus_state.keypair) else {
guard let dm = create_dm(content, to_pk: pubkey, tags: tags, keypair: damus_state.keypair) else {
print("error creating dm")
return
}
dms.draft = ""
damus_state.nostrNetwork.postbox.send(dm)
damus_state.postbox.send(dm)
handle_incoming_dm(ev: dm, our_pubkey: damus_state.pubkey, dms: damus_state.dms, prev_events: NewEventsBits())
@@ -176,6 +176,46 @@ struct DMChatView_Previews: PreviewProvider {
}
}
func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? {
let iv = random_bytes(count: 16).bytes
guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else {
return nil
}
let utf8_message = Data(message.utf8).bytes
guard let enc_message = aes_encrypt(data: utf8_message, iv: iv, shared_sec: shared_sec) else {
return nil
}
switch encoding {
case .base64:
return encode_dm_base64(content: enc_message.bytes, iv: iv)
case .bech32:
return encode_dm_bech32(content: enc_message.bytes, iv: iv)
}
}
func create_encrypted_event(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: FullKeypair, created_at: UInt32, kind: UInt32) -> NostrEvent? {
let privkey = keypair.privkey
guard let enc_content = encrypt_message(message: message, privkey: privkey, to_pk: to_pk) else {
return nil
}
return NostrEvent(content: enc_content, keypair: keypair.to_keypair(), kind: kind, tags: tags, createdAt: created_at)
}
func create_dm(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: Keypair, created_at: UInt32? = nil) -> NostrEvent?
{
let created = created_at ?? UInt32(Date().timeIntervalSince1970)
guard let keypair = keypair.to_full() else {
return nil
}
return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4)
}
extension View {
/// Layers the given views behind this ``TextEditor``.
func textEditorBackground<V>(@ViewBuilder _ content: () -> V) -> some View where V : View {
+1 -38
View File
@@ -50,10 +50,6 @@ struct ErrorView: View {
.cornerRadius(10)
.padding(.vertical, 30)
if let technical_info = error.technical_info {
ErrorTechInfoCopyButton(errorInfo: technical_info)
}
Spacer()
if let damus_state, damus_state.is_privkey_user {
@@ -73,39 +69,6 @@ struct ErrorView: View {
.padding(.top, 20)
}
struct ErrorTechInfoCopyButton: View {
let errorInfo: String
@State var copied: Bool = false
var body: some View {
VStack {
if !copied {
Button(action: {
UIPasteboard.general.string = errorInfo
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
copied = false
}
}, label: {
HStack {
Image(systemName: "square.on.square.dashed")
Text("Copy technical information", comment: "Button label to allow user to copy technical information from an error screen (usually to provide our support team for further troubleshooting)")
}
})
}
else {
HStack {
Image(systemName: "checkmark.circle")
Text("Copied!", comment: "Label indicating that the error technical information was successfully copied to the clipboard, which shows up as soon as the user clicks the copy button.")
}
.foregroundStyle(.damusGreen)
}
}
.padding(.vertical, 20)
}
}
/// An error that is displayed to the user, and can be sent to the Developers as well.
struct UserPresentableError {
/// The description of the error to be shown to the user
@@ -150,7 +113,7 @@ struct ErrorView: View {
error: .init(
user_visible_description: "We are still too early",
tip: "Stay humble, keep building, stack sats",
technical_info: "UTXOs too small, must stack more sats"
technical_info: nil
)
)
}
+3 -3
View File
@@ -24,12 +24,12 @@ struct EventLoaderView<Content: View>: View {
}
func unsubscribe() {
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subscription_uuid)
damus_state.pool.unsubscribe(sub_id: subscription_uuid)
}
func subscribe(filters: [NostrFilter]) {
damus_state.nostrNetwork.pool.register_handler(sub_id: subscription_uuid, handler: handle_event)
damus_state.nostrNetwork.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid)))
damus_state.pool.register_handler(sub_id: subscription_uuid, handler: handle_event)
damus_state.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid)))
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
+1 -1
View File
@@ -113,7 +113,7 @@ struct MenuItems: View {
if let full_keypair = self.damus_state.keypair.to_full(),
let new_mutelist_ev = toggle_from_mutelist(keypair: full_keypair, prev: damus_state.mutelist_manager.event, to_toggle: .thread(event.thread_id(), duration?.date_from_now)) {
damus_state.mutelist_manager.set_mutelist(new_mutelist_ev)
damus_state.nostrNetwork.postbox.send(new_mutelist_ev)
damus_state.postbox.send(new_mutelist_ev)
}
let muted = damus_state.mutelist_manager.is_event_muted(event)
isMutedThread = muted
@@ -122,7 +122,10 @@ struct LongformPreviewBody: View {
} else if blur_images || (blur_images && !state.settings.media_previews) {
ZStack {
titleImage(url: url)
BlurOverlayView(blur_images: $blur_images, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView)
Blur()
.onTapGesture {
blur_images = false
}
}
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject {
case .zap, .zap_request:
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
return .loaded(route: Route.Zaps(target: zap.target))
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list:
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .zap, .zap_request, .nwc_request, .nwc_response, .http_auth, .status:
return .unknown_or_unsupported_kind
}
case .naddr(let naddr):
+1 -1
View File
@@ -87,7 +87,7 @@ struct AddMuteItemView: View {
}
state.mutelist_manager.set_mutelist(mutelist)
state.nostrNetwork.postbox.send(mutelist)
state.postbox.send(mutelist)
}
new_text = ""
+1 -1
View File
@@ -30,7 +30,7 @@ struct MutelistView: View {
}
damus_state.mutelist_manager.set_mutelist(new_ev)
damus_state.nostrNetwork.postbox.send(new_ev)
damus_state.postbox.send(new_ev)
updateMuteItems()
} label: {
Label(NSLocalizedString("Delete", comment: "Button to remove a user from their mutelist."), image: "delete")
+6 -61
View File
@@ -23,7 +23,6 @@ struct Blur: UIViewRepresentable {
}
}
struct NoteContentView: View {
let damus_state: DamusState
@@ -167,7 +166,10 @@ struct NoteContentView: View {
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
fullscreen_preview(dismiss: dismiss)
}
BlurOverlayView(blur_images: $blur_images, artifacts: artifacts, size: size, damus_state: damus_state, parentView: .noteContentView)
Blur()
.onTapGesture {
blur_images = false
}
}
}
}
@@ -382,64 +384,6 @@ func lookup_cached_preview_size(previews: PreviewCache, evid: NoteId) -> CGFloat
return height
}
struct BlurOverlayView: View {
@Binding var blur_images: Bool
let artifacts: NoteArtifactsSeparated?
let size: EventViewKind?
let damus_state: DamusState?
let parentView: ParentViewType
var body: some View {
ZStack {
Color.black
.opacity(0.54)
Blur()
VStack(alignment: .center) {
Image(systemName: "eye.slash")
.foregroundStyle(.white)
.bold()
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
Text(NSLocalizedString("Media from someone you don't follow", comment: "Label on the image blur mask"))
.multilineTextAlignment(.center)
.foregroundStyle(Color.white)
.font(.title2)
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
Button(NSLocalizedString("Tap to load", comment: "Label for button that allows user to dismiss media content warning and unblur the image")) {
blur_images = false
}
.buttonStyle(.bordered)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
if parentView == .noteContentView,
let artifacts = artifacts,
let size = size,
let damus_state = damus_state
{
switch artifacts.media[0] {
case .image(let url), .video(let url):
Text(abbreviateURL(url, maxLength: 30))
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size * 0.8))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.padding(EdgeInsets(top: 20, leading: 10, bottom: 5, trailing: 10))
}
}
}
}
.onTapGesture {
blur_images = false
}
}
enum ParentViewType {
case noteContentView, longFormView
}
}
struct NoteContentView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
@@ -457,7 +401,7 @@ struct NoteContentView_Previews: PreviewProvider {
.previewDisplayName("Super short note")
VStack {
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: true, size: .normal, options: [])
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: [])
}
.previewDisplayName("Note with image")
@@ -490,3 +434,4 @@ func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
return mediaUrls.isEmpty ? nil : mediaUrls
}
@@ -86,10 +86,10 @@ struct DamusAppNotificationView: View {
Task {
do {
let url = try await damus_state.purple.generate_verified_ln_checkout_link(product_template_name: product_template_name)
self.open_url(url: url)
await self.open_url(url: url)
}
catch {
self.open_url(url: damus_state.purple.environment.purple_landing_page_url().appendingPathComponent("checkout"))
await self.open_url(url: damus_state.purple.environment.purple_landing_page_url().appendingPathComponent("checkout"))
}
}
}
@@ -9,27 +9,15 @@ import SwiftUI
class NotificationFilter: ObservableObject, Equatable {
@Published var state: NotificationFilterState
@Published var friend_filter: FriendFilter
@Published var hellthread_notifications_disabled: Bool
@Published var hellthread_notification_max_pubkeys: Int
@Published var fine_filter: FriendFilter
static func == (lhs: NotificationFilter, rhs: NotificationFilter) -> Bool {
return lhs.state == rhs.state
&& lhs.friend_filter == rhs.friend_filter
&& lhs.hellthread_notifications_disabled == rhs.hellthread_notifications_disabled
&& lhs.hellthread_notification_max_pubkeys == rhs.hellthread_notification_max_pubkeys
return lhs.state == rhs.state && lhs.fine_filter == rhs.fine_filter
}
init(
state: NotificationFilterState = .all,
friend_filter: FriendFilter = .all,
hellthread_notifications_disabled: Bool = false,
hellthread_notification_max_pubkeys: Int = DEFAULT_HELLTHREAD_MAX_PUBKEYS
) {
init(state: NotificationFilterState = .all, fine_filter: FriendFilter = .all) {
self.state = state
self.friend_filter = friend_filter
self.hellthread_notifications_disabled = hellthread_notifications_disabled
self.hellthread_notification_max_pubkeys = hellthread_notification_max_pubkeys
self.fine_filter = fine_filter
}
func filter(contacts: Contacts, items: [NotificationItem]) -> [NotificationItem] {
@@ -38,14 +26,8 @@ class NotificationFilter: ObservableObject, Equatable {
if !self.state.filter(item) {
return
}
if let item = item.filter({ ev in
self.friend_filter.filter(contacts: contacts, pubkey: ev.pubkey) &&
(!hellthread_notifications_disabled || !ev.is_hellthread(max_pubkeys: hellthread_notification_max_pubkeys)) &&
// Allow notes that are created no more than 3 seconds in the future
// to account for natural clock skew between sender and receiver.
ev.age >= -3
}) {
if let item = item.filter({ self.fine_filter.filter(contacts: contacts, pubkey: $0.pubkey) }) {
acc.append(item)
}
}
@@ -83,9 +65,7 @@ struct NotificationsView: View {
NotificationTab(
NotificationFilter(
state: .all,
friend_filter: filter.friend_filter,
hellthread_notifications_disabled: state.settings.hellthread_notifications_disabled,
hellthread_notification_max_pubkeys: state.settings.hellthread_notification_max_pubkeys
fine_filter: filter.fine_filter
)
)
.tag(NotificationFilterState.all)
@@ -93,9 +73,7 @@ struct NotificationsView: View {
NotificationTab(
NotificationFilter(
state: .zaps,
friend_filter: filter.friend_filter,
hellthread_notifications_disabled: state.settings.hellthread_notifications_disabled,
hellthread_notification_max_pubkeys: state.settings.hellthread_notification_max_pubkeys
fine_filter: filter.fine_filter
)
)
.tag(NotificationFilterState.zaps)
@@ -103,9 +81,7 @@ struct NotificationsView: View {
NotificationTab(
NotificationFilter(
state: .replies,
friend_filter: filter.friend_filter,
hellthread_notifications_disabled: state.settings.hellthread_notifications_disabled,
hellthread_notification_max_pubkeys: state.settings.hellthread_notification_max_pubkeys
fine_filter: filter.fine_filter
)
)
.tag(NotificationFilterState.replies)
@@ -122,20 +98,20 @@ struct NotificationsView: View {
}
ToolbarItem(placement: .navigationBarTrailing) {
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
FriendsButton(filter: $filter.friend_filter)
FriendsButton(filter: $filter.fine_filter)
}
}
}
.onChange(of: filter.friend_filter) { val in
.onChange(of: filter.fine_filter) { val in
state.settings.friend_filter = val
self.subtitle = filter.friend_filter.description()
self.subtitle = filter.fine_filter.description()
}
.onChange(of: filter_state) { val in
filter.state = val
}
.onAppear {
self.filter.friend_filter = state.settings.friend_filter
self.subtitle = filter.friend_filter.description()
self.filter.fine_filter = state.settings.friend_filter
self.subtitle = filter.fine_filter.description()
filter.state = filter_state
}
.safeAreaInset(edge: .top, spacing: 0) {
@@ -77,7 +77,7 @@ class SuggestedUsersViewModel: ObservableObject {
private func subscribeToSuggestedProfiles(pubkeys: [Pubkey]) {
let filter = NostrFilter(kinds: [.metadata], authors: pubkeys)
damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event)
damus_state.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event)
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
+28 -48
View File
@@ -79,7 +79,6 @@ struct PostView: View {
var autoSaveModel: AutoSaveIndicatorView.AutoSaveViewModel
@State var preUploadedMedia: [PreUploadedMedia] = []
@State var mediaUploadUnderProgress: MediaUpload? = nil
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
@StateObject var tagModel: TagModel = TagModel()
@@ -331,6 +330,11 @@ struct PostView: View {
PostButton
}
if let progress = image_upload.progress {
ProgressView(value: progress, total: 1.0)
.progressViewStyle(.linear)
}
Divider()
.foregroundColor(DamusColors.neutral3)
.padding(.top, 5)
@@ -342,7 +346,6 @@ struct PostView: View {
@discardableResult
func handle_upload(media: MediaUpload) async -> Bool {
mediaUploadUnderProgress = media
let uploader = damus_state.settings.default_media_uploader
let img = getImage(media: media)
@@ -351,7 +354,6 @@ struct PostView: View {
async let blurhash = calculate_blurhash(img: img)
let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair)
mediaUploadUnderProgress = nil
switch res {
case .success(let url):
guard let url = URL(string: url) else {
@@ -399,13 +401,10 @@ struct PostView: View {
}
.id("post")
PVImageCarouselView(media: $uploadedMedias,
mediaUnderProgress: $mediaUploadUnderProgress,
imageUploadModel: image_upload,
deviceWidth: deviceSize.size.width)
.onChange(of: uploadedMedias) { media in
post_changed(post: post, media: media)
}
PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width)
.onChange(of: uploadedMedias) { media in
post_changed(post: post, media: media)
}
if case .quoting(let ev) = action {
BuilderEventView(damus: damus_state, event: ev)
@@ -621,8 +620,6 @@ struct PostView_Previews: PreviewProvider {
struct PVImageCarouselView: View {
@Binding var media: [UploadedMedia]
@Binding var mediaUnderProgress: MediaUpload?
@ObservedObject var imageUploadModel: ImageUploadModel
let deviceWidth: CGFloat
@@ -670,25 +667,6 @@ struct PVImageCarouselView: View {
.padding(.bottom, 35)
}
}
if let mediaUP = mediaUnderProgress, let progress = imageUploadModel.progress {
ZStack {
// Media under upload-progress
Image(uiImage: getImage(media: mediaUP))
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: media.count == 0 ? deviceWidth * 0.8 : 250, height: media.count == 0 ? 400 : 250)
.cornerRadius(10)
.opacity(0.3)
.padding()
// Circle showing progress on top of media
Circle()
.trim(from: 0, to: CGFloat(progress))
.stroke(Color.damusPurple, lineWidth: 5.0)
.rotationEffect(.degrees(-90))
.frame(width: 30, height: 30)
.padding()
}
}
}
.padding()
}
@@ -785,7 +763,7 @@ func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts?
}
// If there are no exact matches to the highlight, try to load a draft for the same highlight source
// We do this to improve UX, because we don't want to leave the post view blank if they only selected a slightly different piece of text from before.
let other_matches = drafts.highlights
var other_matches = drafts.highlights
.filter { $0.key.source == highlight.source }
// It's not an exact match, so there is no way of telling which one is the preferred draft. So just load the first one we found.
return other_matches.first?.value
@@ -887,31 +865,33 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
var content = post.string
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: "\n")
let imagesString = uploadedMedias.map { $0.uploadedURL.absoluteString }.joined(separator: " ")
if !imagesString.isEmpty {
content.append("\n\n" + imagesString)
content.append(" " + imagesString + " ")
}
var tags: [[String]] = []
switch action {
case .replying_to(let replying_to):
// start off with the reply tags
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
case .replying_to(let replying_to):
// start off with the reply tags
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
case .quoting(let ev):
content.append("\n\nnostr:" + bech32_note_id(ev.id))
case .quoting(let ev):
content.append(" nostr:" + bech32_note_id(ev.id))
tags.append(["q", ev.id.hex()]);
if let quoted_ev = state.events.lookup(ev.id) {
tags.append(["p", quoted_ev.pubkey.hex()])
}
case .posting, .highlighting, .sharing:
break
if let quoted_ev = state.events.lookup(ev.id) {
tags.append(["p", quoted_ev.pubkey.hex()])
}
case .posting(let postTarget):
break
case .highlighting(let draft):
break
case .sharing(_):
break
}
// append additional tags
@@ -933,7 +913,7 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
}
}
return NostrPost(content: content.trimmingCharacters(in: .whitespacesAndNewlines), kind: .text, tags: tags)
return NostrPost(content: content, kind: .text, tags: tags)
}
func isSupportedVideo(url: URL?) -> Bool {
+1 -1
View File
@@ -65,7 +65,7 @@ struct EditMetadataView: View {
return
}
damus_state.nostrNetwork.postbox.send(metadata_ev)
damus_state.postbox.send(metadata_ev)
}
func is_ln_valid(ln: String) -> Bool {
+1 -1
View File
@@ -287,7 +287,7 @@ struct EditPictureControl: View {
var accessibility_value: String? {
if style.first_time_setup {
if model.current_image_url != nil {
if let current_image_url = model.current_image_url {
switch self.model.context {
case .normal:
return NSLocalizedString("Image is setup", comment: "Accessibility value on image control")
+1 -3
View File
@@ -67,9 +67,7 @@ struct ProfileName: View {
}
func name_choice(profile: Profile?) -> String {
let displayName = current_display_name(profile: profile)
let untruncatedName = prefix == "@" ? displayName.username : displayName.displayName
return untruncatedName.truncate(maxLength: 50)
return prefix == "@" ? current_display_name(profile: profile).username.truncate(maxLength: 50) : current_display_name(profile: profile).displayName.truncate(maxLength: 50)
}
func onlyzapper(profile: Profile?) -> Bool {
+5 -5
View File
@@ -219,7 +219,7 @@ struct ProfileView: View {
}
damus_state.mutelist_manager.set_mutelist(new_ev)
damus_state.nostrNetwork.postbox.send(new_ev)
damus_state.postbox.send(new_ev)
}
} else {
Button(NSLocalizedString("Mute", comment: "Button to mute a profile"), role: .destructive) {
@@ -396,18 +396,18 @@ struct ProfileView: View {
}
}
if let relays = profile.relay_urls {
if let relays = profile.relays {
// Only open relay config view if the user is logged in with private key and they are looking at their own profile.
let noun_string = pluralizedString(key: "relays_count", count: relays.count)
let noun_string = pluralizedString(key: "relays_count", count: relays.keys.count)
let noun_text = Text(noun_string).font(.subheadline).foregroundColor(.gray)
let relay_text = Text("\(Text(verbatim: relays.count.formatted()).font(.subheadline.weight(.medium))) \(noun_text)", comment: "Sentence composed of 2 variables to describe how many relay servers a user is connected. In source English, the first variable is the number of relay servers, and the second variable is 'Relay' or 'Relays'.")
let relay_text = Text("\(Text(verbatim: relays.keys.count.formatted()).font(.subheadline.weight(.medium))) \(noun_text)", comment: "Sentence composed of 2 variables to describe how many relay servers a user is connected. In source English, the first variable is the number of relay servers, and the second variable is 'Relay' or 'Relays'.")
if profile.pubkey == damus_state.pubkey && damus_state.is_privkey_user {
NavigationLink(value: Route.RelayConfig) {
relay_text
}
.buttonStyle(PlainButtonStyle())
} else {
NavigationLink(value: Route.UserRelays(relays: relays.sorted())) {
NavigationLink(value: Route.UserRelays(relays: Array(relays.keys).sorted())) {
relay_text
}
.buttonStyle(PlainButtonStyle())

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