Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ac39454a6e
|
|||
|
9eeb00c897
|
|||
|
140da5ba09
|
|||
| f436291209 | |||
| a9196a39df | |||
| 6a8ee9c360 | |||
|
947e24864e
|
|||
|
b9198d6bd7
|
|||
| 14bf187a6e | |||
| c996e5f8b3 | |||
|
b6dad349c9
|
|||
|
56dde30cf6
|
|||
|
95bfbae131
|
|||
|
3da0ff7ecc
|
|||
|
b8f846ded8
|
|||
|
e74c45ad39
|
|||
|
e6a03522c6
|
|||
|
dbc7d79ecd
|
|||
|
d2b5a65eca
|
|||
|
16b19d3a96
|
|||
|
70edb8d7c5
|
|||
|
ea04ebe95c
|
|||
|
44cf47faa4
|
|||
|
612abfd862
|
|||
|
20af086273
|
|||
| e9c1671d06 | |||
| d02847d466 | |||
| 580fa954b2 | |||
| aef516ae9f | |||
| eb4e3b692b | |||
| fe52381d63 | |||
| ab8d52e685 | |||
| 1d32200ae3 | |||
| 309b00380d | |||
| 7fa2118480 | |||
| 1a6c17e308 | |||
| 82a6046620 | |||
| 241755c8c4 | |||
| b26f66f15c | |||
| 28bd0c81e8 | |||
| 0bd1814877 | |||
| ee94f67b94 | |||
| 3a25075473 | |||
| d16ff8f78f | |||
| 38dc90cb33 | |||
| 52bbc698b2 | |||
| 496a11f597 | |||
| 4a8a0ea1bd | |||
| c424d4da99 | |||
| 69d5fc1553 | |||
| bcb59896db | |||
| e1e6d9eb3d | |||
| f1fdae5957 | |||
| f96647fa40 | |||
| 5ea522d306 | |||
| 54d6161acd | |||
| b1fd84fd75 | |||
| 9dbdf7928a | |||
| 67f0e3d296 | |||
| e498418c2d | |||
| 33150a42c5 | |||
| e7fe4ab9b4 | |||
| c146bab08a | |||
| d1cced8d54 | |||
| 8849b6105c | |||
| 3a0acfaba1 | |||
| 0ec2b05070 | |||
| 130bbfafb4 | |||
| ffc75772f9 | |||
| 5b3fac70ed | |||
| 53e3f6d86b | |||
| c28ab7a57c | |||
| 09ce3af11e | |||
| e42c09883a | |||
| 77e3924809 | |||
| 3511b1ee91 | |||
| 78a62c8ef0 | |||
| 8b96b9f4e6 | |||
| 649a857c3a | |||
| cdae2c7558 | |||
| 3639110c51 | |||
|
186668512e
|
|||
|
f63666fae2
|
|||
|
68d25059b1
|
|||
|
9aef6b7f5b
|
|||
|
d2e712575f
|
|||
|
bf9674e6e4
|
|||
|
4815390cbe
|
|||
|
6ce903f1f6
|
|||
|
b2c91ffce4
|
|||
|
ae335b18bf
|
|||
|
6391819fb2
|
|||
|
5d0e56b7c7
|
|||
|
50ccc7bd7f
|
|||
|
b3a6bcf3b2
|
|||
|
38b2988bbe
|
|||
|
446c541dcb
|
|||
|
31fd48ee52
|
|||
| b35cc33c32 | |||
| 9510290c29 | |||
| 3b1238b9c7 | |||
| 3bec23ecac | |||
| 7b678228b6 | |||
| b1292d4562 | |||
| a62d782fe5 | |||
| 81b07eb339 | |||
| 02f88398b9 | |||
| e80961cc09 | |||
| bd7721dc26 | |||
| 6d974bf71c | |||
| aeeb817735 | |||
| b5e7033958 | |||
| bdc843f30f | |||
| a823fa8e14 | |||
| 9232386c15 | |||
| 3c1547718c | |||
| b67a7f3e9e | |||
| 92850d4f64 | |||
| 6323eafd7e | |||
| b8fe826b58 | |||
| 841c49238f | |||
| 7ab612e3d9 | |||
| 6d8a27688f | |||
| 765385319a | |||
| 342c49a3e5 | |||
| 25860e7bb2 | |||
| 6962f2b462 | |||
|
e48ce4c6c5
|
|||
|
1cb311cc2c
|
|||
|
401846abe4
|
|||
|
16ef393350
|
|||
|
d5742f8e4c
|
|||
|
319063f823
|
|||
|
5b13cf5634
|
|||
|
da10b908b3
|
|||
|
4568935bc5
|
|||
|
467404a55e
|
|||
|
fcfe1e4558
|
|||
|
c9d87a1b9a
|
|||
|
35ebf4dfc2
|
|||
|
bc3c256d22
|
|||
|
0e10e74496
|
|||
|
ebe9097f73
|
|||
|
d61a11b647
|
|||
|
d980cc1f8e
|
|||
|
bd6056ce2e
|
|||
|
cf48fda8d0
|
|||
|
dc344cd28c
|
|||
|
358610575f
|
|||
|
a7869fccbb
|
|||
|
a50903f90a
|
|||
|
9243705995
|
|||
|
db4dd9eee9
|
|||
| 22f2aba969 | |||
| 98f2777fda | |||
| 102ce43216 | |||
| 0c148c8a1f | |||
| 3cccb2eb6b | |||
| af4949e26a | |||
| 5bb7e95624 | |||
| 814bcf694f | |||
| b0382c61b1 | |||
| e2650a8bfc | |||
| ac39a53b33 | |||
| fb356cdf0b | |||
| 238e89ce16 | |||
| 6e041c79f7 | |||
| 6ef4b60d14 | |||
|
054bec2d9a
|
|||
|
943a46a343
|
|||
|
17381f6b94
|
|||
|
18c88de407
|
|||
|
99d21fc89b
|
|||
|
db5c86a0d1
|
|||
|
736ec6fb9e
|
|||
|
fa2327325a
|
|||
|
4fdf048040
|
|||
|
273538bd36
|
|||
|
0980c8c040
|
|||
|
f0bfdeaa5a
|
|||
|
ab7c5c18e3
|
|||
|
6ae95ab5ec
|
|||
|
eec630b2b0
|
|||
| 2b3d86968d | |||
| 935a6cae7a | |||
| d4940d8386 | |||
|
71ec18f6c6
|
|||
|
caa4bfe864
|
@@ -6,6 +6,7 @@ _[Please provide a summary of the changes in this PR.]_
|
||||
|
||||
- [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md)
|
||||
- [ ] I have tested the changes in this PR
|
||||
- [ ] I have opened or referred to an existing github issue related to this change.
|
||||
- [ ] My PR is either small, or I have split it into smaller logical commits that are easier to review
|
||||
- [ ] I have added the signoff line to all my commits. See [Signing off your work](../docs/CONTRIBUTING.md#sign-your-work---the-developers-certificate-of-origin)
|
||||
- [ ] I have added appropriate changelog entries for the changes in this PR. See [Adding changelog entries](../docs/CONTRIBUTING.md#add-changelog-changed-changelog-fixed-etc)
|
||||
|
||||
+107
@@ -1,3 +1,110 @@
|
||||
## [1.14] - 2025-05-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added safety reminder to wallets with higher balance (Daniel D’Aquino)
|
||||
- Added one-click Coinos wallet setup (Daniel D’Aquino)
|
||||
- Add notification setting to hide hellthreads (Terry Yiu)
|
||||
- Added separated first aid option for relay lists that does not need a contact list reset (Daniel D’Aquino)
|
||||
- Added NIP-65 relay list support (Daniel D’Aquino)
|
||||
- Added Unicode 16 emoji reactions for iOS 18.4+ by upgrading EmojiPicker (Terry Yiu)
|
||||
- Added a search interface to the settings screen (SanjaySiddharth)
|
||||
- Added view introducing users to Zaps (ericholguin)
|
||||
- Added new wallet view with balance and transactions list (ericholguin)
|
||||
- Added copy technical info button to user visible errors, so that users can more easily share errors with developers (Daniel D’Aquino)
|
||||
- Add dismiss button to wallet high balance reminders (Daniel D’Aquino)
|
||||
- Zap receiver information now included for outgoing zaps (Daniel D’Aquino)
|
||||
- Added inline note rendering of invoices to pull up wallet selector sheet (Terry Yiu)
|
||||
- Added route to profile page from wallet tx list (ericholguin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Added additional information on top of blurred images (SanjaySiddharth)
|
||||
- Improved robustness of relay list handling (Daniel D’Aquino)
|
||||
- Updated image cache for better stability (Daniel D’Aquino)
|
||||
- Improved integration with Nostr Wallet Connect wallets (ericholguin)
|
||||
- Added relay connectivity information to NWC settings (Daniel D’Aquino)
|
||||
- Improved handling around NWC responses (Daniel D’Aquino)
|
||||
- Added more human visible errors on NWC wallets to aid with troubleshooting (Daniel D’Aquino)
|
||||
- Re-enabled note zaps as permitted by the new App Store guidelines (Daniel D’Aquino)
|
||||
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hide future notes from timeline (Terry Yiu)
|
||||
- Fixed issue where profiles with a NIP-65 relay list would not display on Damus (Daniel D’Aquino)
|
||||
- Fix quote notes to include missing q tag (Terry Yiu)
|
||||
- Fixed issue where the side menu would close when copying the npub (SanjaySiddharth)
|
||||
- Fixed issue where cached images would be backed up to iCloud (Daniel D’Aquino)
|
||||
- Optimized classify_url function (Terry Yiu)
|
||||
- Fixed note rendering for those that contain previewable items or leading and trailing whitespaces (Terry Yiu)
|
||||
- Fixed issue where some videos would become unplayable after some time using the app (Daniel D’Aquino)
|
||||
|
||||
|
||||
[1.14]: https://github.com/damus-io/damus/releases/tag/v1.14
|
||||
|
||||
|
||||
## [1.13.1] - 2025-03-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue where threads would not load properly (Daniel D’Aquino)
|
||||
|
||||
|
||||
[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 D’Aquino)
|
||||
- Added user-friendly error view for errors around the app that would not fit in other places (Daniel D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- Profile image cropping tools (Daniel D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- Improved reliability of picture selector (Daniel D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- Fixed issue where app would need a restart for new NWC wallets to work (Daniel D’Aquino)
|
||||
- Fixed overly sensitive horizontal swipe on thread chat view (Daniel D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- Fixed an issue where events on a thread view would occasionally disappear (Daniel D’Aquino)
|
||||
- Improved robustness of the URL handler (Daniel D’Aquino)
|
||||
- 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,6 +2,8 @@
|
||||
<!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 = try? Ndb(owns_db_file: false) else { return nil }
|
||||
guard let ndb = Ndb(owns_db_file: false) else { return nil }
|
||||
self.ndb = ndb
|
||||
|
||||
guard let keypair = get_saved_keypair() else { return nil }
|
||||
|
||||
@@ -5,15 +5,32 @@
|
||||
// Created by Daniel D’Aquino 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,
|
||||
@@ -40,9 +57,16 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
// 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
|
||||
@@ -56,7 +80,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.
|
||||
@@ -65,7 +89,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.
|
||||
@@ -74,15 +98,58 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
Task {
|
||||
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
|
||||
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 {
|
||||
|
||||
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
|
||||
return
|
||||
}
|
||||
|
||||
contentHandler(improvedContent)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,3 +162,162 @@ 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()
|
||||
}
|
||||
|
||||
|
||||
|
||||
+32
-3
@@ -1,3 +1,32 @@
|
||||
dependencies: [
|
||||
.Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
|
||||
]
|
||||
// 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"),
|
||||
]
|
||||
)
|
||||
|
||||
+321
-44
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "085cf0f645323bf77edb52886489bf77b309a0a2d2b78a54beaf8520b540d596",
|
||||
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codescanner",
|
||||
@@ -22,8 +22,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/EmojiKit",
|
||||
"state" : {
|
||||
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
|
||||
"version" : "0.1.2"
|
||||
"revision" : "47a4b1402de26be0299dcb4d667c1faaf21a7874",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -31,8 +31,17 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/EmojiPicker.git",
|
||||
"state" : {
|
||||
"revision" : "0c28b4a1a6b8840cf2580bda59517f6d0a733dc8",
|
||||
"version" : "0.1.1"
|
||||
"revision" : "3f48903721eae223238ff0af17c22d6373d33813",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "faviconfinder",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/will-lumley/FaviconFinder.git",
|
||||
"state" : {
|
||||
"revision" : "9279f4371f4877ca302ba3bf1015f3f58ae4a56c",
|
||||
"version" : "5.1.4"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -49,8 +58,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/onevcat/Kingfisher",
|
||||
"state" : {
|
||||
"revision" : "415b1d97fb38bda1e5a6b2dde63354720832110b",
|
||||
"version" : "7.6.1"
|
||||
"revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3",
|
||||
"version" : "8.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -105,6 +114,15 @@
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftsoup",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||
"state" : {
|
||||
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
|
||||
"version" : "2.8.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftycrop",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "bbw.jpg",
|
||||
"filename" : "blink.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 216 B After Width: | Height: | Size: 216 B |
@@ -94,26 +94,30 @@ enum OpenWalletError: Error {
|
||||
}
|
||||
|
||||
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
||||
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) {
|
||||
this_app.open(url)
|
||||
return url
|
||||
} else {
|
||||
guard let store_link = wallet.appStoreLink else {
|
||||
throw OpenWalletError.no_wallet_to_open
|
||||
throw .no_wallet_to_open
|
||||
}
|
||||
|
||||
guard let url = URL(string: store_link) else {
|
||||
throw OpenWalletError.store_link_invalid
|
||||
throw .store_link_invalid
|
||||
}
|
||||
|
||||
guard this_app.canOpenURL(url) else {
|
||||
throw OpenWalletError.system_cannot_open_store_link
|
||||
throw .system_cannot_open_store_link
|
||||
}
|
||||
|
||||
this_app.open(url)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
|
||||
|
||||
struct InvoiceView_Previews: PreviewProvider {
|
||||
|
||||
@@ -5,27 +5,27 @@
|
||||
// Created by William Casarin on 2023-01-11.
|
||||
//
|
||||
|
||||
import FaviconFinder
|
||||
import Kingfisher
|
||||
import SwiftUI
|
||||
|
||||
struct NIP05Badge: View {
|
||||
let nip05: NIP05
|
||||
let pubkey: Pubkey
|
||||
let contacts: Contacts
|
||||
let damus_state: DamusState
|
||||
let show_domain: Bool
|
||||
let profiles: Profiles
|
||||
let nip05_domain_favicon: FaviconURL?
|
||||
|
||||
@Environment(\.openURL) var openURL
|
||||
|
||||
init(nip05: NIP05, pubkey: Pubkey, contacts: Contacts, show_domain: Bool, profiles: Profiles) {
|
||||
init(nip05: NIP05, pubkey: Pubkey, damus_state: DamusState, show_domain: Bool, nip05_domain_favicon: FaviconURL?) {
|
||||
self.nip05 = nip05
|
||||
self.pubkey = pubkey
|
||||
self.contacts = contacts
|
||||
self.damus_state = damus_state
|
||||
self.show_domain = show_domain
|
||||
self.profiles = profiles
|
||||
self.nip05_domain_favicon = nip05_domain_favicon
|
||||
}
|
||||
|
||||
var nip05_color: Bool {
|
||||
return use_nip05_color(pubkey: pubkey, contacts: contacts)
|
||||
return use_nip05_color(pubkey: pubkey, contacts: damus_state.contacts)
|
||||
}
|
||||
|
||||
var Seal: some View {
|
||||
@@ -44,8 +44,23 @@ struct NIP05Badge: View {
|
||||
}
|
||||
}
|
||||
|
||||
var domainBadge: some View {
|
||||
Group {
|
||||
if let nip05_domain_favicon {
|
||||
KFImage(nip05_domain_favicon.source)
|
||||
.imageContext(.favicon, disable_animation: true)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 18, height: 18)
|
||||
.clipped()
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var username_matches_nip05: Bool {
|
||||
guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
|
||||
guard let name = damus_state.profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -65,14 +80,18 @@ struct NIP05Badge: View {
|
||||
HStack(spacing: 2) {
|
||||
Seal
|
||||
|
||||
if show_domain {
|
||||
Text(nip05_string)
|
||||
.nip05_colorized(gradient: nip05_color)
|
||||
.onTapGesture {
|
||||
if let nip5url = nip05.siteUrl {
|
||||
openURL(nip5url)
|
||||
}
|
||||
}
|
||||
Group {
|
||||
if show_domain {
|
||||
Text(nip05_string)
|
||||
.nip05_colorized(gradient: nip05_color)
|
||||
}
|
||||
|
||||
if nip05_domain_favicon != nil {
|
||||
domainBadge
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
damus_state.nav.push(route: Route.NIP05DomainEvents(events: NIP05DomainEventsModel(state: damus_state, domain: nip05.host), nip05_domain_favicon: nip05_domain_favicon))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,13 +117,9 @@ struct NIP05Badge_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let test_state = test_damus_state
|
||||
VStack {
|
||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
|
||||
|
||||
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
||||
|
||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
||||
|
||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: Contacts(our_pubkey: test_pubkey), show_domain: true, profiles: test_state.profiles)
|
||||
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
|
||||
guard let res = cancel_zap(zap: pzap, box: damus_state.nostrNetwork.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
|
||||
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
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.pool.our_descriptors.prefix(10))
|
||||
let relays = Array(damus_state.nostrNetwork.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 send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
|
||||
await WalletConnect.send_donation_zap(pool: damus_state.nostrNetwork.pool, postbox: damus_state.nostrNetwork.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
||||
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
|
||||
let delay = damus_state.settings.nozaps ? nil : 5.0
|
||||
|
||||
let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher)
|
||||
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, zap_request: zapreq, delay: delay, on_flush: flusher)
|
||||
|
||||
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
|
||||
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
|
||||
|
||||
@@ -94,12 +94,12 @@ struct SelectableText: View {
|
||||
case show_mute_word_view(highlighted_text: String)
|
||||
|
||||
func should_show_highlight_post_view() -> Bool {
|
||||
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
|
||||
guard case .show_highlight_post_view = self else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
func should_show_mute_word_view() -> Bool {
|
||||
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
|
||||
guard case .show_mute_word_view = self else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -119,16 +119,23 @@ 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) {
|
||||
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void, enableHighlighting: Bool) {
|
||||
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(_:)) {
|
||||
@@ -142,23 +149,44 @@ fileprivate class TextView: UITextView {
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
}
|
||||
|
||||
func getSelectedText() -> String? {
|
||||
private func getSelectedText() -> String? {
|
||||
guard let selectedRange = self.selectedTextRange else { return nil }
|
||||
return self.text(in: selectedRange)
|
||||
}
|
||||
|
||||
@objc public func highlightText(_ sender: Any?) {
|
||||
@objc private func highlightText(_ sender: Any?) {
|
||||
guard let selectedText = self.getSelectedText() else { return }
|
||||
self.postHighlight(selectedText)
|
||||
}
|
||||
|
||||
@objc public func muteText(_ sender: Any?) {
|
||||
@objc private 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
|
||||
@@ -172,7 +200,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)
|
||||
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord, enableHighlighting: enableHighlighting)
|
||||
view.isEditable = false
|
||||
view.dataDetectorTypes = .all
|
||||
view.isSelectable = true
|
||||
@@ -183,11 +211,6 @@ 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.postbox, keypair: test_keypair, status: .init())
|
||||
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.nostrNetwork.postbox, keypair: test_keypair, status: .init())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, set
|
||||
|
||||
// Render translated note
|
||||
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
|
||||
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles)
|
||||
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles, can_hide_last_previewable_refs: true)
|
||||
|
||||
// and cache it
|
||||
return .translated(Translated(artifacts: artifacts, language: note_lang))
|
||||
|
||||
+51
-47
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import AVKit
|
||||
import MediaPlayer
|
||||
import EmojiPicker
|
||||
import TipKit
|
||||
|
||||
struct ZapSheet {
|
||||
let target: ZapTarget
|
||||
@@ -178,7 +179,7 @@ struct ContentView: View {
|
||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||
|
||||
case .dms:
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
|
||||
}
|
||||
}
|
||||
.background(DamusColors.adaptableWhite)
|
||||
@@ -199,7 +200,7 @@ struct ContentView: View {
|
||||
func MaybeReportView(target: ReportTarget) -> some View {
|
||||
Group {
|
||||
if let keypair = damus_state.keypair.to_full() {
|
||||
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
|
||||
ReportView(postbox: damus_state.nostrNetwork.postbox, target: target, keypair: keypair)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -317,7 +318,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!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
|
||||
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.nostrNetwork.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
|
||||
.presentationDragIndicator(.visible)
|
||||
case .event:
|
||||
EventDetailView()
|
||||
@@ -356,7 +357,7 @@ struct ContentView: View {
|
||||
self.hide_bar = !show
|
||||
}
|
||||
.onReceive(timer) { n in
|
||||
self.damus_state?.postbox.try_flushing_events()
|
||||
self.damus_state?.nostrNetwork.postbox.try_flushing_events()
|
||||
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
|
||||
}
|
||||
.onReceive(handle_notify(.report)) { target in
|
||||
@@ -367,8 +368,6 @@ 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,
|
||||
@@ -389,12 +388,12 @@ struct ContentView: View {
|
||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
|
||||
|
||||
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||
ds.postbox.send(ev)
|
||||
ds.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
.onReceive(handle_notify(.broadcast)) { ev in
|
||||
guard let ds = self.damus_state else { return }
|
||||
|
||||
ds.postbox.send(ev)
|
||||
ds.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
.onReceive(handle_notify(.unfollow)) { target in
|
||||
guard let state = self.damus_state else { return }
|
||||
@@ -416,7 +415,7 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
|
||||
if !handle_post_notification(keypair: keypair, postbox: state.postbox, events: state.events, post: post) {
|
||||
if !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) {
|
||||
self.active_sheet = nil
|
||||
}
|
||||
}
|
||||
@@ -460,7 +459,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.disconnect_relays)) { () in
|
||||
damus_state.pool.disconnect()
|
||||
damus_state.nostrNetwork.pool.disconnect()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
|
||||
print("txn: 📙 DAMUS ACTIVE NOTIFY")
|
||||
@@ -506,7 +505,7 @@ struct ContentView: View {
|
||||
break
|
||||
case .active:
|
||||
print("txn: 📙 DAMUS ACTIVE")
|
||||
damus_state.pool.ping()
|
||||
damus_state.nostrNetwork.pool.ping()
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
@@ -525,7 +524,7 @@ struct ContentView: View {
|
||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
|
||||
|
||||
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||
ds.postbox.send(profile_ev)
|
||||
ds.nostrNetwork.postbox.send(profile_ev)
|
||||
}
|
||||
.alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
|
||||
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
|
||||
@@ -557,7 +556,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
ds.mutelist_manager.set_mutelist(mutelist)
|
||||
ds.postbox.send(mutelist)
|
||||
ds.nostrNetwork.postbox.send(mutelist)
|
||||
|
||||
confirm_overwrite_mutelist = false
|
||||
confirm_mute = false
|
||||
@@ -589,7 +588,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
ds.mutelist_manager.set_mutelist(ev)
|
||||
ds.postbox.send(ev)
|
||||
ds.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
}
|
||||
}, message: {
|
||||
@@ -630,7 +629,7 @@ struct ContentView: View {
|
||||
|
||||
func handleNotification(notification: LossyLocalNotification) {
|
||||
Log.info("ContentView is handling a notification", for: .push_notifications)
|
||||
guard let damus_state else {
|
||||
guard damus_state != nil else {
|
||||
// This should never happen because `listenAndHandleLocalNotifications` is called after damus state is initialized in `onAppear`
|
||||
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)
|
||||
@@ -658,28 +657,14 @@ 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)
|
||||
}
|
||||
|
||||
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,
|
||||
self.damus_state = DamusState(keypair: keypair,
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
boosts: EventCounter(our_pubkey: pubkey),
|
||||
contacts: Contacts(our_pubkey: pubkey),
|
||||
@@ -695,8 +680,6 @@ 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,
|
||||
@@ -704,7 +687,8 @@ struct ContentView: View {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: FaviconCache()
|
||||
)
|
||||
|
||||
home.damus_state = self.damus_state!
|
||||
@@ -720,7 +704,23 @@ struct ContentView: View {
|
||||
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
||||
}
|
||||
|
||||
pool.connect()
|
||||
damus_state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||
damus_state.nostrNetwork.connect()
|
||||
|
||||
if #available(iOS 17, *) {
|
||||
if damus_state.settings.developer_mode && damus_state.settings.reset_tips_on_launch {
|
||||
do {
|
||||
try Tips.resetDatastore()
|
||||
} catch {
|
||||
Log.error("Failed to reset tips datastore: %s", for: .tips, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
do {
|
||||
try Tips.configure()
|
||||
} catch {
|
||||
Log.error("Failed to configure tips: %s", for: .tips, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func music_changed(_ state: MusicState) {
|
||||
@@ -743,7 +743,7 @@ struct ContentView: View {
|
||||
pdata.status.music = music
|
||||
|
||||
guard let ev = music.to_note(keypair: kp) else { return }
|
||||
damus_state.postbox.send(ev)
|
||||
damus_state.nostrNetwork.postbox.send(ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,6 +759,8 @@ struct ContentView: View {
|
||||
case route(Route)
|
||||
/// Open a sheet
|
||||
case sheet(Sheets)
|
||||
/// Open an external URL
|
||||
case external_url(URL)
|
||||
/// Do nothing.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
@@ -775,6 +777,8 @@ 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
|
||||
}
|
||||
@@ -992,7 +996,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
||||
var has_event = false
|
||||
guard let filter else { return }
|
||||
|
||||
state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
|
||||
state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
|
||||
guard case .nostr_event(let ev) = res else {
|
||||
return
|
||||
}
|
||||
@@ -1006,7 +1010,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
||||
break
|
||||
case .event(_, let ev):
|
||||
has_event = true
|
||||
state.pool.unsubscribe(sub_id: subid)
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: subid)
|
||||
|
||||
switch query {
|
||||
case .profile:
|
||||
@@ -1019,11 +1023,11 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
||||
case .eose:
|
||||
if !has_event {
|
||||
attempts += 1
|
||||
if attempts >= state.pool.our_descriptors.count {
|
||||
if attempts >= state.nostrNetwork.pool.our_descriptors.count {
|
||||
callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil
|
||||
}
|
||||
}
|
||||
state.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
|
||||
case .notice:
|
||||
break
|
||||
case .auth:
|
||||
@@ -1042,15 +1046,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?) -> ()) {
|
||||
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
||||
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
||||
|
||||
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
|
||||
|
||||
let subid = UUID().description
|
||||
|
||||
damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
|
||||
damus_state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
|
||||
guard case .nostr_event(let ev) = res else {
|
||||
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1058,14 +1062,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.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
callback(ev)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,7 +1117,7 @@ func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
|
||||
|
||||
let old_contacts = state.contacts.event
|
||||
|
||||
guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
|
||||
guard let ev = unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -1139,7 +1143,7 @@ func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
|
||||
guard let ev = follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -1214,7 +1218,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(let string):
|
||||
case .nrelay:
|
||||
// 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(
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!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>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// CameraService+Extensions.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Suhail Saqan on 8/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
extension AVCaptureVideoOrientation {
|
||||
init?(deviceOrientation: UIDeviceOrientation) {
|
||||
switch deviceOrientation {
|
||||
case .portrait: self = .portrait
|
||||
case .portraitUpsideDown: self = .portraitUpsideDown
|
||||
case .landscapeLeft: self = .landscapeRight
|
||||
case .landscapeRight: self = .landscapeLeft
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
init?(interfaceOrientation: UIInterfaceOrientation) {
|
||||
switch interfaceOrientation {
|
||||
case .portrait: self = .portrait
|
||||
case .portraitUpsideDown: self = .portraitUpsideDown
|
||||
case .landscapeLeft: self = .landscapeLeft
|
||||
case .landscapeRight: self = .landscapeRight
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,44 +63,10 @@ func follow_user_event(our_contacts: NostrEvent?, keypair: FullKeypair, follow:
|
||||
}
|
||||
|
||||
|
||||
func decode_json_relays(_ content: String) -> [RelayURL: RelayInfo]? {
|
||||
func decode_json_relays(_ content: String) -> [RelayURL: LegacyKind3RelayRWConfiguration]? {
|
||||
return decode_json(content)
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -128,22 +94,3 @@ func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEven
|
||||
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: tags)
|
||||
}
|
||||
|
||||
func make_contact_relays(_ relays: [RelayDescriptor]) -> [RelayURL: RelayInfo] {
|
||||
return relays.reduce(into: [:]) { acc, relay in
|
||||
acc[relay.url] = relay.info
|
||||
}
|
||||
}
|
||||
|
||||
func make_relay_metadata(relays: [RelayDescriptor], keypair: FullKeypair) -> NostrEvent? {
|
||||
let tags = relays.compactMap { r -> [String]? in
|
||||
var tag = ["r", r.url.absoluteString]
|
||||
if (r.info.read ?? true) != (r.info.write ?? true) {
|
||||
tag += r.info.read == true ? ["read"] : ["write"]
|
||||
}
|
||||
if ((r.info.read ?? true) || (r.info.write ?? true)) && r.variant == .regular {
|
||||
return tag;
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 10_002, tags: tags)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ class Contacts {
|
||||
return friends
|
||||
}
|
||||
|
||||
func get_friend_of_friends_list() -> Set<Pubkey> {
|
||||
return friend_of_friends
|
||||
}
|
||||
|
||||
func get_followed_hashtags() -> Set<String> {
|
||||
guard let ev = self.event else { return Set() }
|
||||
return Set(ev.referenced_hashtags.map({ $0.hashtag }))
|
||||
|
||||
@@ -10,8 +10,9 @@ import Foundation
|
||||
|
||||
/// Simple filter to determine whether to show posts or all posts and replies.
|
||||
enum FilterState : Int {
|
||||
case posts_and_replies = 1
|
||||
case posts = 0
|
||||
case posts_and_replies = 1
|
||||
case conversations = 2
|
||||
|
||||
func filter(ev: NostrEvent) -> Bool {
|
||||
switch self {
|
||||
@@ -19,6 +20,8 @@ enum FilterState : Int {
|
||||
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply()
|
||||
case .posts_and_replies:
|
||||
return true
|
||||
case .conversations:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +40,12 @@ func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEv
|
||||
}
|
||||
}
|
||||
|
||||
func timestamp_filter(ev: NostrEvent) -> Bool {
|
||||
// Allow notes that are created no more than 3 seconds in the future
|
||||
// to account for natural clock skew between sender and receiver.
|
||||
ev.age >= -3
|
||||
}
|
||||
|
||||
/// Generic filter with various tweakable settings
|
||||
struct ContentFilters {
|
||||
var filters: [(NostrEvent) -> Bool]
|
||||
@@ -63,6 +72,7 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ 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
|
||||
|
||||
@@ -10,7 +10,6 @@ import LinkPresentation
|
||||
import EmojiPicker
|
||||
|
||||
class DamusState: HeadlessDamusState {
|
||||
let pool: RelayPool
|
||||
let keypair: Keypair
|
||||
let likes: EventCounter
|
||||
let boosts: EventCounter
|
||||
@@ -28,8 +27,6 @@ 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
|
||||
@@ -39,9 +36,10 @@ class DamusState: HeadlessDamusState {
|
||||
var purple: DamusPurple
|
||||
var push_notification_client: PushNotificationClient
|
||||
let emoji_provider: EmojiProvider
|
||||
let favicon_cache: FaviconCache
|
||||
private(set) var nostrNetwork: NostrNetworkManager
|
||||
|
||||
init(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
|
||||
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache) {
|
||||
self.keypair = keypair
|
||||
self.likes = likes
|
||||
self.boosts = boosts
|
||||
@@ -58,8 +56,6 @@ 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
|
||||
@@ -73,6 +69,10 @@ class DamusState: HeadlessDamusState {
|
||||
self.quote_reposts = quote_reposts
|
||||
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
||||
self.emoji_provider = emoji_provider
|
||||
self.favicon_cache = FaviconCache()
|
||||
|
||||
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
|
||||
self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -98,27 +98,13 @@ 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),
|
||||
@@ -135,8 +121,6 @@ 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,
|
||||
@@ -144,7 +128,8 @@ class DamusState: HeadlessDamusState {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: FaviconCache()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,7 +164,7 @@ class DamusState: HeadlessDamusState {
|
||||
try await self.push_notification_client.revoke_token()
|
||||
}
|
||||
wallet.disconnect()
|
||||
pool.close()
|
||||
nostrNetwork.pool.close()
|
||||
ndb.close()
|
||||
}
|
||||
|
||||
@@ -189,7 +174,6 @@ 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),
|
||||
@@ -206,8 +190,6 @@ 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(),
|
||||
@@ -215,7 +197,34 @@ class DamusState: HeadlessDamusState {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: .empty,
|
||||
quote_reposts: .init(our_pubkey: empty_pub),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: FaviconCache()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension DamusState {
|
||||
struct NostrNetworkManagerDelegate: NostrNetworkManager.Delegate {
|
||||
let settings: UserSettingsStore
|
||||
let contacts: Contacts
|
||||
|
||||
var ndb: Ndb
|
||||
var keypair: Keypair
|
||||
|
||||
var latestRelayListEventIdHex: String? {
|
||||
get { self.settings.latestRelayListEventIdHex }
|
||||
set { self.settings.latestRelayListEventIdHex = newValue }
|
||||
}
|
||||
|
||||
var latestContactListEvent: NostrEvent? { self.contacts.event }
|
||||
var bootstrapRelays: [RelayURL] { get_default_bootstrap_relays() }
|
||||
var developerMode: Bool { self.settings.developer_mode }
|
||||
var relayModelCache: RelayModelCache
|
||||
var relayFilters: RelayFilters
|
||||
|
||||
var nwcWallet: WalletConnectURL? {
|
||||
guard let nwcString = self.settings.nostr_wallet_connect else { return nil }
|
||||
return WalletConnectURL(str: nwcString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = parse_display_name(profile: profile, pubkey: pubkey).username
|
||||
let profile_name = DisplayName(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.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
|
||||
damus_state.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
|
||||
}
|
||||
|
||||
damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() })
|
||||
|
||||
@@ -68,13 +68,13 @@ class EventsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
state.pool.subscribe(sub_id: sub_id,
|
||||
state.nostrNetwork.pool.subscribe(sub_id: sub_id,
|
||||
filters: [get_filter()],
|
||||
handler: handle_nostr_event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.pool.unsubscribe(sub_id: sub_id)
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
}
|
||||
|
||||
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
|
||||
|
||||
@@ -37,11 +37,11 @@ class FollowersModel: ObservableObject {
|
||||
let filter = get_filter()
|
||||
let filters = [filter]
|
||||
//print_filters(relay_id: "following", filters: [filters])
|
||||
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
self.damus_state.pool.unsubscribe(sub_id: sub_id)
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
}
|
||||
|
||||
func handle_contact_event(_ ev: NostrEvent) {
|
||||
@@ -61,7 +61,7 @@ class FollowersModel: ObservableObject {
|
||||
|
||||
let filter = NostrFilter(kinds: [.metadata],
|
||||
authors: authors)
|
||||
damus_state.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
|
||||
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
@@ -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.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
||||
}
|
||||
|
||||
case .ok:
|
||||
|
||||
@@ -42,7 +42,7 @@ class FollowingModel {
|
||||
}
|
||||
let filters = [filter]
|
||||
//print_filters(relay_id: "following", filters: [filters])
|
||||
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
@@ -50,7 +50,7 @@ class FollowingModel {
|
||||
return
|
||||
}
|
||||
print("unsubscribing from following \(sub_id)")
|
||||
self.damus_state.pool.unsubscribe(sub_id: sub_id)
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
|
||||
@@ -35,9 +35,9 @@ enum FriendFilter: String, StringCodable {
|
||||
func description() -> String {
|
||||
switch self {
|
||||
case .all:
|
||||
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
|
||||
return NSLocalizedString("All", comment: "Human-readable short description of the 'trusted network filter' when it is disabled, and therefore is showing all content.")
|
||||
case .friends_of_friends:
|
||||
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
|
||||
return NSLocalizedString("Trusted Network", comment: "Human-readable short description of the 'trusted network filter' when it is enabled, and therefore showing content from only the trusted network.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,13 +95,13 @@ class HomeModel: ContactsDelegate {
|
||||
}
|
||||
|
||||
var pool: RelayPool {
|
||||
return damus_state.pool
|
||||
self.damus_state.nostrNetwork.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,6 +225,8 @@ 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`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,27 +261,58 @@ 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),
|
||||
let resp = await FullWalletResponse(from: ev, nwc: nwc) else {
|
||||
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.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
|
||||
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
|
||||
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 {
|
||||
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
|
||||
Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
print("nwc success: \(resp.response.result.debugDescription) [\(relay)]")
|
||||
nwc_success(state: self.damus_state, resp: resp)
|
||||
WalletConnect.handle_zap_success(state: self.damus_state, resp: resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +486,7 @@ class HomeModel: ContactsDelegate {
|
||||
let nwc = WalletConnectURL(str: nwc_str),
|
||||
nwc.relay == relay_id
|
||||
{
|
||||
subscribe_to_nwc(url: nwc, pool: pool)
|
||||
WalletConnect.subscribe(url: nwc, pool: pool)
|
||||
}
|
||||
case .error(let merr):
|
||||
let desc = String(describing: merr)
|
||||
@@ -466,7 +499,7 @@ class HomeModel: ContactsDelegate {
|
||||
break
|
||||
}
|
||||
|
||||
update_signal_from_pool(signal: self.signal, pool: damus_state.pool)
|
||||
update_signal_from_pool(signal: self.signal, pool: damus_state.nostrNetwork.pool)
|
||||
case .nostr_event(let ev):
|
||||
switch ev {
|
||||
case .event(let sub_id, let ev):
|
||||
@@ -936,7 +969,6 @@ 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) {
|
||||
@@ -944,78 +976,6 @@ 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://")
|
||||
@@ -1238,3 +1198,4 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-10
@@ -47,16 +47,16 @@ enum MuteItem: Hashable, Equatable {
|
||||
// rhs is the item we want to check against (ie. the item in the mute list)
|
||||
|
||||
switch (lhs, rhs) {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.postbox.send(new_mutelist_event)
|
||||
damus_state.nostrNetwork.postbox.send(new_mutelist_event)
|
||||
// Set existing muted threads to an empty array
|
||||
UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// NIP05DomainEventsModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 4/11/25.
|
||||
//
|
||||
|
||||
import FaviconFinder
|
||||
import Foundation
|
||||
|
||||
class NIP05DomainEventsModel: ObservableObject {
|
||||
let state: DamusState
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool = false
|
||||
|
||||
let domain: String
|
||||
var filter: NostrFilter
|
||||
let sub_id = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
|
||||
init(state: DamusState, domain: String) {
|
||||
self.state = state
|
||||
self.domain = domain
|
||||
self.events = EventHolder(on_queue: { ev in
|
||||
preload_events(state: state, events: [ev])
|
||||
})
|
||||
self.filter = NostrFilter()
|
||||
}
|
||||
|
||||
@MainActor func subscribe() {
|
||||
filter.limit = self.limit
|
||||
filter.kinds = [.text, .longform, .highlight]
|
||||
|
||||
var authors = Set<Pubkey>()
|
||||
for pubkey in state.contacts.get_friend_of_friends_list() {
|
||||
let profile_txn = state.profiles.lookup(id: pubkey)
|
||||
|
||||
guard let profile = profile_txn?.unsafeUnownedValue,
|
||||
let nip05_str = profile.nip05,
|
||||
let nip05 = NIP05.parse(nip05_str),
|
||||
nip05.host.caseInsensitiveCompare(domain) == .orderedSame else {
|
||||
continue
|
||||
}
|
||||
|
||||
authors.insert(pubkey)
|
||||
}
|
||||
if authors.isEmpty {
|
||||
return
|
||||
}
|
||||
filter.authors = Array(authors)
|
||||
|
||||
print("subscribing to notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
|
||||
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
loading = true
|
||||
state.nostrNetwork.pool.send(.subscribe(.init(filters: [filter], sub_id: sub_id)))
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
loading = false
|
||||
print("unsubscribing from notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
|
||||
}
|
||||
|
||||
func add_event(_ ev: NostrEvent) {
|
||||
if !event_matches_filter(ev, filter: filter) {
|
||||
return
|
||||
}
|
||||
|
||||
guard should_show_event(state: state, ev: ev) else {
|
||||
return
|
||||
}
|
||||
|
||||
if self.events.insert(ev) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
if sub_id == self.sub_id && ev.is_textlike && ev.should_show_event {
|
||||
self.add_event(ev)
|
||||
}
|
||||
}
|
||||
|
||||
guard done else {
|
||||
return
|
||||
}
|
||||
|
||||
self.loading = false
|
||||
|
||||
if sub_id == self.sub_id {
|
||||
guard let txn = NdbTxn(ndb: state.ndb) else { return }
|
||||
load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// NostrNetworkManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-02-26.
|
||||
//
|
||||
import Foundation
|
||||
|
||||
/// Manages interactions with the Nostr Network.
|
||||
///
|
||||
/// This delineates a layer that is responsible for doing mid-level management of interactions with the Nostr network, controlling lower-level classes that perform more network/DB specific code, and providing an easier to use and more semantic interfaces for the rest of the app.
|
||||
///
|
||||
/// This is responsible for:
|
||||
/// - Managing the user's relay list
|
||||
/// - Establishing a `RelayPool` and maintaining it in sync with the user's relay list as it changes
|
||||
/// - Abstracting away complexities of interacting with the nostr network, providing an easier-to-use interface to fetch and send content related to the Nostr network
|
||||
///
|
||||
/// This is **NOT** responsible for:
|
||||
/// - Doing actual storage of relay list (delegated via the delegate
|
||||
/// - Handling low-level relay logic (this will be delegated to lower level classes used in RelayPool/RelayConnection)
|
||||
class NostrNetworkManager {
|
||||
/// The relay pool that we manage
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This will be marked `private` in the future to prevent other code from accessing the relay pool directly. Code outside this layer should use a higher level interface
|
||||
let pool: RelayPool // TODO: Make this private and make higher level interface for classes outside the NostrNetworkManager
|
||||
/// A delegate that allows us to interact with the rest of app without introducing hard or circular dependencies
|
||||
private var delegate: Delegate
|
||||
/// Manages the user's relay list, controls RelayPool's connected relays
|
||||
let userRelayList: UserRelayListManager
|
||||
/// Handles sending out notes to the network
|
||||
let postbox: PostBox
|
||||
/// Handles subscriptions and functions to read or consume data from the Nostr network
|
||||
let reader: SubscriptionManager
|
||||
|
||||
init(delegate: Delegate) {
|
||||
self.delegate = delegate
|
||||
let pool = RelayPool(ndb: delegate.ndb, keypair: delegate.keypair)
|
||||
self.pool = pool
|
||||
let reader = SubscriptionManager(pool: pool, ndb: delegate.ndb)
|
||||
let userRelayList = UserRelayListManager(delegate: delegate, pool: pool, reader: reader)
|
||||
self.reader = reader
|
||||
self.userRelayList = userRelayList
|
||||
self.postbox = PostBox(pool: pool)
|
||||
}
|
||||
|
||||
// MARK: - Control functions
|
||||
|
||||
/// Connects the app to the Nostr network
|
||||
func connect() {
|
||||
self.userRelayList.connect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper types
|
||||
|
||||
extension NostrNetworkManager {
|
||||
/// The delegate that provides information and structure for the `NostrNetworkManager` to function.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// This is needed to prevent a circular reference between `DamusState` and `NostrNetworkManager`, and reduce coupling.
|
||||
protocol Delegate: Sendable {
|
||||
/// NostrDB instance, used with `RelayPool` to send events for ingestion.
|
||||
var ndb: Ndb { get }
|
||||
|
||||
/// The keypair to use for relay authentication and updating relay lists
|
||||
var keypair: Keypair { get }
|
||||
|
||||
/// The latest relay list event id hex
|
||||
var latestRelayListEventIdHex: String? { get set } // TODO: Update this once we have full NostrDB query support
|
||||
|
||||
/// The latest contact list `NostrEvent`
|
||||
///
|
||||
/// Note: Read-only access, because `NostrNetworkManager` does not manage contact lists.
|
||||
var latestContactListEvent: NostrEvent? { get }
|
||||
|
||||
/// Default bootstrap relays to start with when a user relay list is not present
|
||||
var bootstrapRelays: [RelayURL] { get }
|
||||
|
||||
/// Whether the app is in developer mode
|
||||
var developerMode: Bool { get }
|
||||
|
||||
/// The cache of relay model information
|
||||
var relayModelCache: RelayModelCache { get }
|
||||
|
||||
/// Relay filters
|
||||
var relayFilters: RelayFilters { get }
|
||||
|
||||
/// The user's connected NWC wallet
|
||||
var nwcWallet: WalletConnectURL? { get }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//
|
||||
// SubscriptionManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-03-25.
|
||||
//
|
||||
|
||||
extension NostrNetworkManager {
|
||||
/// Reads or fetches information from RelayPool and NostrDB, and provides an easier and unified higher-level interface.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This class will be a key part of the local relay model migration. Most higher-level code should fetch content from this class, which will properly setup the correct relay pool subscriptions, and provide a stream from NostrDB for higher performance and reliability.
|
||||
class SubscriptionManager {
|
||||
private let pool: RelayPool
|
||||
private var ndb: Ndb
|
||||
|
||||
init(pool: RelayPool, ndb: Ndb) {
|
||||
self.pool = pool
|
||||
self.ndb = ndb
|
||||
}
|
||||
|
||||
// MARK: - Reading data from Nostr
|
||||
|
||||
/// Subscribes to data from the user's relays
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - When we migrate to the local relay model, we should modify this function to stream directly from NostrDB
|
||||
///
|
||||
/// - Parameter filters: The nostr filters to specify what kind of data to subscribe to
|
||||
/// - Returns: An async stream of nostr data
|
||||
func subscribe(filters: [NostrFilter]) -> AsyncStream<StreamItem> {
|
||||
return AsyncStream<StreamItem> { continuation in
|
||||
let streamTask = Task {
|
||||
for await item in self.pool.subscribe(filters: filters) {
|
||||
switch item {
|
||||
case .eose: continuation.yield(.eose)
|
||||
case .event(let nostrEvent):
|
||||
// At this point of the pipeline, if the note is valid it should have been processed and verified by NostrDB,
|
||||
// in which case we should pull the note from NostrDB to ensure validity.
|
||||
// However, NdbNotes are unowned, so we return a function where our callers can temporarily borrow the NostrDB note
|
||||
let noteId = nostrEvent.id
|
||||
let lender: NdbNoteLender = { lend in
|
||||
guard let ndbNoteTxn = self.ndb.lookup_note(noteId) else {
|
||||
throw NdbNoteLenderError.errorLoadingNote
|
||||
}
|
||||
guard let unownedNote = UnownedNdbNote(ndbNoteTxn) else {
|
||||
throw NdbNoteLenderError.errorLoadingNote
|
||||
}
|
||||
lend(unownedNote)
|
||||
}
|
||||
continuation.yield(.event(borrow: lender))
|
||||
}
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
streamTask.cancel() // Close the RelayPool stream when caller stops streaming
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StreamItem {
|
||||
/// An event which can be borrowed from NostrDB
|
||||
case event(borrow: NdbNoteLender)
|
||||
/// The end of stored events
|
||||
case eose
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// UserRelayListErrors.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-02-27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension NostrNetworkManager.UserRelayListManager {
|
||||
/// Models an error that may occur when performing operations that change the user's relay list.
|
||||
///
|
||||
/// Callers to functions that throw this error SHOULD handle them in order to provide a better user experience.
|
||||
enum UpdateError: Error {
|
||||
/// The user is not authorized to change relay list, usually because the private key is missing.
|
||||
case notAuthorizedToChangeRelayList
|
||||
/// An error occurred when forming the relay list Nostr event.
|
||||
case cannotFormRelayListEvent
|
||||
/// Cannot add item to the relay list because the relay is already present in the list.
|
||||
case relayAlreadyExists
|
||||
/// Cannot update the relay list because we do not have the user's previous relay list.
|
||||
///
|
||||
/// Implementers must be careful not to overwrite the user's existing relay list if it exists somewhere else.
|
||||
case noInitialRelayList
|
||||
/// Cannot remove or update a specific relay because it is not on the relay list
|
||||
case noSuchRelay
|
||||
|
||||
/// Convert `RelayPool.RelayError` into `UserRelayListUpdateError`
|
||||
static func from(_ relayPoolError: RelayPool.RelayError) -> Self {
|
||||
switch relayPoolError {
|
||||
case .RelayAlreadyExists: return .relayAlreadyExists
|
||||
}
|
||||
}
|
||||
|
||||
var humanReadableError: ErrorView.UserPresentableError {
|
||||
switch self {
|
||||
case .notAuthorizedToChangeRelayList:
|
||||
ErrorView.UserPresentableError(
|
||||
user_visible_description: NSLocalizedString("You do not have permission to alter this relay list.", comment: "Human readable error description"),
|
||||
tip: NSLocalizedString("Please make sure you have logged-in with your private key.", comment: "Human readable tip for error"),
|
||||
technical_info: nil
|
||||
)
|
||||
case .cannotFormRelayListEvent:
|
||||
ErrorView.UserPresentableError(
|
||||
user_visible_description: NSLocalizedString("There was a problem creating the relay list event.", comment: "Human readable error description"),
|
||||
tip: NSLocalizedString("Please try again later or contact support if the issue persists.", comment: "Human readable tip for error"),
|
||||
technical_info: "Failed forming Nostr event for the relay list update."
|
||||
)
|
||||
case .relayAlreadyExists:
|
||||
ErrorView.UserPresentableError(
|
||||
user_visible_description: NSLocalizedString("This relay is already in your list.", comment: "Human readable tip for error"),
|
||||
tip: NSLocalizedString("Check the address and/or the relay list.", comment: "Human readable tip for error"),
|
||||
technical_info: nil
|
||||
)
|
||||
case .noInitialRelayList:
|
||||
ErrorView.UserPresentableError(
|
||||
user_visible_description: NSLocalizedString("No initial relay list available to update.", comment: "Human readable error description"),
|
||||
tip: NSLocalizedString("Please go to Settings > First Aid > Repair relay list, or contact support.", comment: "Human readable tip for error"),
|
||||
technical_info: "Missing initial relay list data for reference during update."
|
||||
)
|
||||
case .noSuchRelay:
|
||||
ErrorView.UserPresentableError(
|
||||
user_visible_description: NSLocalizedString("The specified relay that you are trying to udpate was not found in your relay list.", comment: "Human readable error description"),
|
||||
tip: NSLocalizedString("This is an unexpected error, please contact support.", comment: "Human readable tip for error"),
|
||||
technical_info: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LoadingError: Error {
|
||||
case relayListParseError
|
||||
|
||||
var humanReadableError: ErrorView.UserPresentableError {
|
||||
switch self {
|
||||
case .relayListParseError:
|
||||
return ErrorView.UserPresentableError(
|
||||
user_visible_description: NSLocalizedString("Your relay list appears to be broken, so we cannot connect you to your Nostr network.", comment: "Human readable error description for a failure to parse the relay list due to a bad relay list"),
|
||||
tip: NSLocalizedString("Please contact support for further help.", comment: "Human readable tips for what to do for a failure to find the relay list"),
|
||||
technical_info: "Relay list could not be parsed."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//
|
||||
// UserRelayListManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-02-27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
extension NostrNetworkManager {
|
||||
/// Manages the user's relay list
|
||||
///
|
||||
/// - It can compute the user's current relay list
|
||||
/// - It can compute the best relay list to connect to
|
||||
/// - It can edit the user's relay list
|
||||
class UserRelayListManager {
|
||||
private var delegate: Delegate
|
||||
private let pool: RelayPool
|
||||
private let reader: SubscriptionManager
|
||||
|
||||
private var relayListObserverTask: Task<Void, Never>? = nil
|
||||
private var walletUpdatesObserverTask: AnyCancellable? = nil
|
||||
|
||||
init(delegate: Delegate, pool: RelayPool, reader: SubscriptionManager) {
|
||||
self.delegate = delegate
|
||||
self.pool = pool
|
||||
self.reader = reader
|
||||
}
|
||||
|
||||
// MARK: - Computing the relays to connect to
|
||||
|
||||
private func relaysToConnectTo() -> [RelayPool.RelayDescriptor] {
|
||||
return self.computeRelaysToConnectTo(with: self.getBestEffortRelayList())
|
||||
}
|
||||
|
||||
private func computeRelaysToConnectTo(with relayList: NIP65.RelayList) -> [RelayPool.RelayDescriptor] {
|
||||
let regularRelayDescriptorList = relayList.toRelayDescriptors()
|
||||
if let nwcWallet = delegate.nwcWallet {
|
||||
return regularRelayDescriptorList + [.nwc(url: nwcWallet.relay)]
|
||||
}
|
||||
return regularRelayDescriptorList
|
||||
}
|
||||
|
||||
// MARK: - Getting the user's relay list
|
||||
|
||||
/// Gets the "best effort" relay list.
|
||||
///
|
||||
/// It attempts to get a relay list from the user. If one is not available, it uses the default bootstrap list.
|
||||
///
|
||||
/// This is always guaranteed to return a relay list.
|
||||
func getBestEffortRelayList() -> NIP65.RelayList {
|
||||
guard let userCurrentRelayList = self.getUserCurrentRelayList() else {
|
||||
return NIP65.RelayList(relays: delegate.bootstrapRelays)
|
||||
}
|
||||
return userCurrentRelayList
|
||||
}
|
||||
|
||||
/// Gets the user's current relay list.
|
||||
///
|
||||
/// It attempts to get a NIP-65 relay list from the local database, or falls back to a legacy list.
|
||||
func getUserCurrentRelayList() -> NIP65.RelayList? {
|
||||
if let latestRelayListEvent = try? self.getLatestNIP65RelayList() { return latestRelayListEvent }
|
||||
if let latestRelayListEvent = try? self.getLatestKind3RelayList() { return latestRelayListEvent }
|
||||
if let latestRelayListEvent = try? self.getLatestUserDefaultsRelayList() { return latestRelayListEvent }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Gets the latest NIP-65 relay list from NostrDB.
|
||||
///
|
||||
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
|
||||
///
|
||||
/// - Returns: The latest NIP-65 relay list object
|
||||
private func getLatestNIP65RelayList() throws(LoadingError) -> NIP65.RelayList? {
|
||||
guard let latestRelayListEvent = self.getLatestNIP65RelayListEvent() else { return nil }
|
||||
guard let list = try? NIP65.RelayList(event: latestRelayListEvent) else { throw .relayListParseError }
|
||||
return list
|
||||
}
|
||||
|
||||
/// Gets the latest NIP-65 relay list event from NostrDB.
|
||||
///
|
||||
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
|
||||
///
|
||||
/// It is recommended to use this function only if the NostrEvent metadata is needed. For cases where only the relay list info is needed, use `getLatestNIP65RelayList` instead.
|
||||
///
|
||||
/// - Returns: The latest NIP-65 relay list NdbNote
|
||||
private func getLatestNIP65RelayListEvent() -> NdbNote? {
|
||||
guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil }
|
||||
guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil }
|
||||
return delegate.ndb.lookup_note(latestRelayListEventId)?.unsafeUnownedValue?.to_owned()
|
||||
}
|
||||
|
||||
/// Gets the latest `kind:3` relay list from NostrDB.
|
||||
///
|
||||
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
|
||||
private func getLatestKind3RelayList() throws(LoadingError) -> NIP65.RelayList? {
|
||||
guard let latestContactListEvent = delegate.latestContactListEvent else { return nil }
|
||||
guard let legacyContactList = try? NIP65.RelayList.fromLegacyContactList(latestContactListEvent) else { throw .relayListParseError }
|
||||
return legacyContactList
|
||||
}
|
||||
|
||||
/// Gets the latest relay list from `UserDefaults`
|
||||
///
|
||||
/// This is `private` because it is part of internal logic. Callers should use the higher level functions.
|
||||
private func getLatestUserDefaultsRelayList() throws(LoadingError) -> NIP65.RelayList? {
|
||||
let key = bootstrap_relays_setting_key(pubkey: delegate.keypair.pubkey)
|
||||
guard let relays = UserDefaults.standard.stringArray(forKey: key) else { return nil }
|
||||
let relayUrls = relays.compactMap({ RelayURL($0) })
|
||||
if relayUrls.count == 0 { return nil }
|
||||
return NIP65.RelayList(relays: relayUrls)
|
||||
}
|
||||
|
||||
// MARK: - Getting metadata from the user's relay list
|
||||
|
||||
/// Gets the creation date of the user's current relay list, with preference to NIP-65 relay lists
|
||||
/// - Returns: The current relay list's creation date
|
||||
private func getUserCurrentRelayListCreationDate() -> UInt32? {
|
||||
if let latestNIP65RelayListEvent = self.getLatestNIP65RelayListEvent() { return latestNIP65RelayListEvent.created_at }
|
||||
if let latestKind3RelayListEvent = delegate.latestContactListEvent { return latestKind3RelayListEvent.created_at }
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Listening to and handling relay updates from the network
|
||||
|
||||
func connect() {
|
||||
self.load()
|
||||
|
||||
self.relayListObserverTask?.cancel()
|
||||
self.relayListObserverTask = Task { await self.listenAndHandleRelayUpdates() }
|
||||
self.walletUpdatesObserverTask?.cancel()
|
||||
self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in self.load() }
|
||||
}
|
||||
|
||||
func listenAndHandleRelayUpdates() async {
|
||||
let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey])
|
||||
for await item in self.reader.subscribe(filters: [filter]) {
|
||||
switch item {
|
||||
case .event(borrow: let borrow): // Signature validity already ensured at this point
|
||||
let currentRelayListCreationDate = self.getUserCurrentRelayListCreationDate()
|
||||
try? borrow { note in
|
||||
guard note.pubkey == self.delegate.keypair.pubkey else { return } // Ensure this new list was ours
|
||||
guard note.createdAt > (currentRelayListCreationDate ?? 0) else { return } // Ensure this is a newer list
|
||||
guard let relayList = try? NIP65.RelayList(event: note) else { return } // Ensure it is a valid NIP-65 list
|
||||
|
||||
try? self.set(userRelayList: relayList) // Set the validated list
|
||||
}
|
||||
case .eose: continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Editing the user's relay list
|
||||
|
||||
func upsert(relay: NIP65.RelayList.RelayItem, force: Bool = false, overwriteExisting: Bool = false) throws(UpdateError) {
|
||||
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||
guard !currentUserRelayList.relays.keys.contains(relay.url) || overwriteExisting else { throw .relayAlreadyExists }
|
||||
var newList = currentUserRelayList.relays
|
||||
newList[relay.url] = relay
|
||||
try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
|
||||
}
|
||||
|
||||
func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) throws(UpdateError) {
|
||||
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||
guard currentUserRelayList.relays[relay.url] == nil else { throw .relayAlreadyExists }
|
||||
try self.upsert(relay: relay, force: force)
|
||||
}
|
||||
|
||||
func remove(relayURL: RelayURL, force: Bool = false) throws(UpdateError) {
|
||||
guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList }
|
||||
guard currentUserRelayList.relays.keys.contains(relayURL) || force else { throw .noSuchRelay }
|
||||
var newList = currentUserRelayList.relays
|
||||
newList[relayURL] = nil
|
||||
try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values)))
|
||||
}
|
||||
|
||||
func set(userRelayList: NIP65.RelayList) throws(UpdateError) {
|
||||
guard let fullKeypair = delegate.keypair.to_full() else { throw .notAuthorizedToChangeRelayList }
|
||||
guard let relayListEvent = userRelayList.toNostrEvent(keypair: fullKeypair) else { throw .cannotFormRelayListEvent }
|
||||
|
||||
self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList))
|
||||
|
||||
self.pool.send(.event(relayListEvent)) // This will send to NostrDB as well, which will locally save that NIP-65 event
|
||||
self.delegate.latestRelayListEventIdHex = relayListEvent.id.hex() // Make sure we are able to recall this event from NostrDB
|
||||
}
|
||||
|
||||
// MARK: - Syncing our saved user relay list with the active `RelayPool`
|
||||
|
||||
/// Loads the current user relay list
|
||||
func load() {
|
||||
self.apply(newRelayList: self.relaysToConnectTo())
|
||||
}
|
||||
|
||||
/// Loads a new relay list into the active relay pool, making sure it matches the specified relay list.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - state: The state of the app
|
||||
/// - newRelayList: The new relay list to be applied
|
||||
///
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This is `private` because syncing the user's saved relay list with the relay pool is `NostrNetworkManager`'s responsibility,
|
||||
/// so we do not want other classes to forcibly load this.
|
||||
private func apply(newRelayList: [RelayPool.RelayDescriptor]) {
|
||||
let currentRelayList = self.pool.relays.map({ $0.descriptor })
|
||||
|
||||
var changed = false
|
||||
let new_relay_filters = load_relay_filters(delegate.keypair.pubkey) == nil
|
||||
|
||||
for index in self.pool.relays.indices {
|
||||
guard let newDescriptor = newRelayList.first(where: { $0.url == self.pool.relays[index].descriptor.url }) else { continue }
|
||||
self.pool.relays[index].descriptor.info = newDescriptor.info
|
||||
// Relay read-write configuration change does not need reconnection to the relay, so we do not set the `changed` flag.
|
||||
}
|
||||
|
||||
// Working with URL Sets for difference analysis
|
||||
let currentRelayURLs = Set(currentRelayList.map { $0.url })
|
||||
let newRelayURLs = Set(newRelayList.map { $0.url })
|
||||
|
||||
// Analyzing which relays to add or remove
|
||||
let relaysToRemove = currentRelayURLs.subtracting(newRelayURLs)
|
||||
let relaysToAdd = newRelayURLs.subtracting(currentRelayURLs)
|
||||
|
||||
// Remove relays not in the new list
|
||||
relaysToRemove.forEach { url in
|
||||
pool.remove_relay(url)
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Add new relays from the new list
|
||||
relaysToAdd.forEach { url in
|
||||
guard let descriptor = newRelayList.first(where: { $0.url == url }) else { return }
|
||||
add_new_relay(
|
||||
model_cache: delegate.relayModelCache,
|
||||
relay_filters: delegate.relayFilters,
|
||||
pool: pool,
|
||||
descriptor: descriptor,
|
||||
new_relay_filters: new_relay_filters,
|
||||
logging_enabled: delegate.developerMode
|
||||
)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed {
|
||||
pool.connect()
|
||||
notify(.relays_changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper extensions
|
||||
|
||||
fileprivate extension NIP65.RelayList.RelayItem {
|
||||
func toRelayDescriptor() -> RelayPool.RelayDescriptor {
|
||||
return RelayPool.RelayDescriptor(url: self.url, info: self.rwConfiguration, variant: .regular) // NIP-65 relays are regular by definition.
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension NIP65.RelayList {
|
||||
func toRelayDescriptors() -> [RelayPool.RelayDescriptor] {
|
||||
return self.relays.values.map({ $0.toRelayDescriptor() })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper functions
|
||||
|
||||
|
||||
/// Adds a new relay, taking care of other tangential concerns, such as updating the relay model cache, configuring logging, etc
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// 1. This function used to be in `HomeModel.swift` and moved here when `UserRelayListManager` was first implemented
|
||||
/// 2. This is `fileprivate` because only `UserRelayListManager` should be able to manage the user's relay list and apply them to the `RelayPool`
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - model_cache: The relay model cache, that keeps metadata cached
|
||||
/// - relay_filters: Relay filters
|
||||
/// - pool: The relay pool to add this in
|
||||
/// - descriptor: The description of the relay being added
|
||||
/// - new_relay_filters: Whether to insert new relay filters
|
||||
/// - logging_enabled: Whether logging is enabled
|
||||
fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayPool.RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
|
||||
try? pool.add_relay(descriptor)
|
||||
let url = descriptor.url
|
||||
|
||||
let relay_id = url
|
||||
guard model_cache.model(withURL: url) == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
Task.detached(priority: .background) {
|
||||
guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else {
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
let model = RelayModel(url, metadata: meta)
|
||||
model_cache.insert(model: model)
|
||||
|
||||
if logging_enabled {
|
||||
pool.setLog(model.log, for: relay_id)
|
||||
}
|
||||
|
||||
// if this is the first time adding filters, we should filter non-paid relays
|
||||
if new_relay_filters && !meta.is_paid {
|
||||
relay_filters.insert(timeline: .search, relay_id: relay_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+125
-60
@@ -73,85 +73,143 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -
|
||||
return .longform(LongformContent(ev.content))
|
||||
}
|
||||
|
||||
return .separated(render_blocks(blocks: blocks, profiles: profiles))
|
||||
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
|
||||
}
|
||||
|
||||
func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated {
|
||||
func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
|
||||
var invoices: [Invoice] = []
|
||||
var urls: [UrlType] = []
|
||||
let blocks = bs.blocks
|
||||
|
||||
let one_note_ref = blocks
|
||||
.filter({
|
||||
if case .mention(let mention) = $0,
|
||||
case .note = mention.ref {
|
||||
return true
|
||||
|
||||
var end_mention_count = 0
|
||||
var end_url_count = 0
|
||||
|
||||
// Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
|
||||
var hide_text_index = blocks.endIndex
|
||||
if can_hide_last_previewable_refs {
|
||||
outerLoop: for (i, block) in blocks.enumerated().reversed() {
|
||||
if block.is_previewable {
|
||||
switch block {
|
||||
case .mention:
|
||||
end_mention_count += 1
|
||||
|
||||
// If there is more than one previewable mention,
|
||||
// do not hide anything because we allow rich rendering of only one mention currently.
|
||||
// This should be fixed in the future to show events inline instead.
|
||||
if end_mention_count > 1 {
|
||||
hide_text_index = blocks.endIndex
|
||||
break outerLoop
|
||||
}
|
||||
case .url(let url):
|
||||
let url_type = classify_url(url)
|
||||
if case .link = url_type {
|
||||
end_url_count += 1
|
||||
|
||||
// If there is more than one link, do not hide anything because we allow rich rendering of only
|
||||
// one link.
|
||||
if end_url_count > 1 {
|
||||
hide_text_index = blocks.endIndex
|
||||
break outerLoop
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
hide_text_index = i
|
||||
} else if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
// We should hide whitespace at the end sequence.
|
||||
hide_text_index = i
|
||||
} else if case .hashtag = block {
|
||||
// We should keep hashtags at the end sequence but hide all the other previewables around it.
|
||||
hide_text_index = i
|
||||
} else {
|
||||
break
|
||||
}
|
||||
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 .mention(let m):
|
||||
if case .note = m.ref, one_note_ref {
|
||||
case .invoice(let invoice):
|
||||
invoices.append(invoice)
|
||||
case .url(let url):
|
||||
let url_type = classify_url(url)
|
||||
urls.append(url_type)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if can_hide_last_previewable_refs {
|
||||
// If there are previewable blocks that occur before the consecutive sequence of them at the end of the content,
|
||||
// we should not hide the text representation of any previewable block to avoid altering the format of the note.
|
||||
if ind < hide_text_index && block.is_previewable {
|
||||
hide_text_index = blocks.endIndex
|
||||
}
|
||||
|
||||
// No need to show the text representation of the block if the only previewables are the sequence of them
|
||||
// found at the end of the content.
|
||||
// This is to save unnecessary use of screen space.
|
||||
// The only exception is that if there are hashtags embedded in the end sequence, which is not uncommon,
|
||||
// then we still want to show those hashtags but hide everything else that is previewable in the end sequence.
|
||||
if ind >= hide_text_index {
|
||||
if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if case .hashtag = blocks[safe: ind+1] {
|
||||
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt))
|
||||
}
|
||||
} else if case .hashtag(let htag) = block {
|
||||
return str + hashtag_str(htag)
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
return str + mention_str(m, profiles: profiles)
|
||||
case .text(let txt):
|
||||
return str + CompatibleText(stringLiteral: reduce_text_block(blocks: blocks, ind: ind, txt: txt, one_note_ref: one_note_ref))
|
||||
|
||||
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
|
||||
case .relay(let relay):
|
||||
return str + CompatibleText(stringLiteral: relay)
|
||||
|
||||
case .hashtag(let htag):
|
||||
return str + hashtag_str(htag)
|
||||
case .invoice(let invoice):
|
||||
invoices.append(invoice)
|
||||
return str
|
||||
return str + invoice_str(invoice)
|
||||
case .url(let 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 str + url_str(url)
|
||||
}
|
||||
}
|
||||
|
||||
return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
|
||||
}
|
||||
|
||||
func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String {
|
||||
func reduce_text_block(ind: Int, hide_text_index: Int, txt: String) -> String {
|
||||
var trimmed = txt
|
||||
|
||||
if let prev = blocks[safe: ind-1],
|
||||
case .url(let u) = prev,
|
||||
classify_url(u).is_media != nil {
|
||||
trimmed = " " + trim_prefix(trimmed)
|
||||
|
||||
// Trim leading whitespaces.
|
||||
if ind == 0 {
|
||||
trimmed = trim_prefix(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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@@ -161,17 +219,16 @@ func url_str(_ url: URL) -> CompatibleText {
|
||||
}
|
||||
|
||||
func classify_url(_ url: URL) -> UrlType {
|
||||
let str = url.lastPathComponent.lowercased()
|
||||
|
||||
if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") {
|
||||
let fileExtension = url.lastPathComponent.lowercased().components(separatedBy: ".").last
|
||||
|
||||
switch fileExtension {
|
||||
case "png", "jpg", "jpeg", "gif", "webp":
|
||||
return .media(.image(url))
|
||||
}
|
||||
|
||||
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
|
||||
case "mp4", "mov", "m3u8":
|
||||
return .media(.video(url))
|
||||
default:
|
||||
return .link(url)
|
||||
}
|
||||
|
||||
return .link(url)
|
||||
}
|
||||
|
||||
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
|
||||
@@ -194,11 +251,11 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
|
||||
let display_str: String = {
|
||||
switch m.ref {
|
||||
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
|
||||
case .note: return abbrev_pubkey(bech32String)
|
||||
case .nevent: return abbrev_pubkey(bech32String)
|
||||
case .note: return abbrev_identifier(bech32String)
|
||||
case .nevent: return abbrev_identifier(bech32String)
|
||||
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
|
||||
case .nrelay(let url): return url
|
||||
case .naddr: return abbrev_pubkey(bech32String)
|
||||
case .naddr: return abbrev_identifier(bech32String)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -213,12 +270,20 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
|
||||
|
||||
// trim suffix whitespace and newlines
|
||||
func trim_suffix(_ str: String) -> String {
|
||||
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
|
||||
var result = str
|
||||
while result.last?.isWhitespace == true {
|
||||
result.removeLast()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// trim prefix whitespace and newlines
|
||||
func trim_prefix(_ str: String) -> String {
|
||||
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
|
||||
var result = str
|
||||
while result.first?.isWhitespace == true {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
struct LongformContent {
|
||||
|
||||
@@ -41,6 +41,10 @@ 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
|
||||
@@ -50,7 +54,14 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,18 @@ import Foundation
|
||||
class ProfileModel: ObservableObject, Equatable {
|
||||
@Published var contacts: NostrEvent? = nil
|
||||
@Published var following: Int = 0
|
||||
@Published var relays: [RelayURL: RelayInfo]? = nil
|
||||
@Published var relay_list: NIP65.RelayList? = nil
|
||||
@Published var legacy_relay_list: [RelayURL: LegacyKind3RelayRWConfiguration]? = nil
|
||||
@Published var progress: Int = 0
|
||||
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
|
||||
|
||||
@@ -22,8 +32,10 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
var seen_event: Set<NoteId> = Set()
|
||||
var sub_id = UUID().description
|
||||
var prof_subid = UUID().description
|
||||
var conversations_subid = UUID().description
|
||||
var findRelay_subid = UUID().description
|
||||
|
||||
var conversation_events: Set<NoteId> = Set()
|
||||
|
||||
init(pubkey: Pubkey, damus: DamusState) {
|
||||
self.pubkey = pubkey
|
||||
self.damus = damus
|
||||
@@ -57,25 +69,45 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
|
||||
func unsubscribe() {
|
||||
print("unsubscribing from profile \(pubkey) with sub_id \(sub_id)")
|
||||
damus.pool.unsubscribe(sub_id: sub_id)
|
||||
damus.pool.unsubscribe(sub_id: prof_subid)
|
||||
damus.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
damus.nostrNetwork.pool.unsubscribe(sub_id: prof_subid)
|
||||
if pubkey != damus.pubkey {
|
||||
damus.nostrNetwork.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]
|
||||
|
||||
text_filter.authors = [pubkey]
|
||||
text_filter.limit = 500
|
||||
|
||||
print("subscribing to profile \(pubkey) with sub_id \(sub_id)")
|
||||
|
||||
print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)")
|
||||
//print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]])
|
||||
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)
|
||||
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)
|
||||
|
||||
subscribe_to_conversations()
|
||||
}
|
||||
|
||||
|
||||
private func subscribe_to_conversations() {
|
||||
// Only subscribe to conversation events if the profile is not us.
|
||||
guard pubkey != damus.pubkey else {
|
||||
return
|
||||
}
|
||||
|
||||
let conversation_kinds: [NostrKind] = [.text, .longform, .highlight]
|
||||
let limit: UInt32 = 500
|
||||
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)
|
||||
}
|
||||
|
||||
func handle_profile_contact_event(_ ev: NostrEvent) {
|
||||
process_contact_event(state: damus, ev: ev)
|
||||
|
||||
@@ -88,17 +120,10 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
|
||||
self.contacts = ev
|
||||
self.following = count_pubkeys(ev.tags)
|
||||
self.relays = decode_json_relays(ev.content)
|
||||
self.legacy_relay_list = decode_json_relays(ev.content)
|
||||
}
|
||||
|
||||
func add_event(_ ev: NostrEvent) {
|
||||
guard ev.should_show_event else {
|
||||
return
|
||||
}
|
||||
|
||||
if seen_event.contains(ev.id) {
|
||||
return
|
||||
}
|
||||
private func add_event(_ ev: NostrEvent) {
|
||||
if ev.is_textlike || ev.known_kind == .boost {
|
||||
if self.events.insert(ev) {
|
||||
self.objectWillChange.send()
|
||||
@@ -106,27 +131,63 @@ 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)
|
||||
}
|
||||
|
||||
// Ensure the event public key matches the public key(s) we are querying.
|
||||
// This is done to protect against a relay not properly filtering events by the pubkey
|
||||
// See https://github.com/damus-io/damus/issues/1846 for more information
|
||||
private func relay_filtered_correctly(_ ev: NostrEvent, subid: String?) -> Bool {
|
||||
if subid == self.conversations_subid {
|
||||
switch ev.pubkey {
|
||||
case self.pubkey:
|
||||
return ev.referenced_pubkeys.contains(damus.pubkey)
|
||||
case damus.pubkey:
|
||||
return ev.referenced_pubkeys.contains(self.pubkey)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return self.pubkey == ev.pubkey
|
||||
}
|
||||
|
||||
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
switch ev {
|
||||
case .ws_event:
|
||||
return
|
||||
case .nostr_event(let resp):
|
||||
guard resp.subid == self.sub_id || resp.subid == self.prof_subid else {
|
||||
guard resp.subid == self.sub_id || resp.subid == self.prof_subid || resp.subid == self.conversations_subid else {
|
||||
return
|
||||
}
|
||||
switch resp {
|
||||
case .ok:
|
||||
break
|
||||
case .event(_, let ev):
|
||||
// Ensure the event public key matches this profiles public key
|
||||
// This is done to protect against a relay not properly filtering events by the pubkey
|
||||
// See https://github.com/damus-io/damus/issues/1846 for more information
|
||||
guard self.pubkey == ev.pubkey else { break }
|
||||
guard ev.should_show_event else {
|
||||
break
|
||||
}
|
||||
|
||||
add_event(ev)
|
||||
if !seen_event.contains(ev.id) {
|
||||
guard relay_filtered_correctly(ev, subid: resp.subid) else {
|
||||
break
|
||||
}
|
||||
|
||||
add_event(ev)
|
||||
|
||||
if resp.subid == self.conversations_subid {
|
||||
conversation_events.insert(ev.id)
|
||||
}
|
||||
} else if resp.subid == self.conversations_subid && !conversation_events.contains(ev.id) {
|
||||
guard relay_filtered_correctly(ev, subid: resp.subid) else {
|
||||
break
|
||||
}
|
||||
|
||||
conversation_events.insert(ev.id)
|
||||
}
|
||||
case .notice:
|
||||
break
|
||||
//notify(.notice, notice)
|
||||
@@ -145,7 +206,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.relays = decode_json_relays(event.content)
|
||||
self.legacy_relay_list = decode_json_relays(event.content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,15 +214,15 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
var profile_filter = NostrFilter(kinds: [.contacts])
|
||||
profile_filter.authors = [pubkey]
|
||||
|
||||
damus.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
|
||||
damus.nostrNetwork.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
|
||||
}
|
||||
|
||||
func unsubscribeFindRelays() {
|
||||
damus.pool.unsubscribe(sub_id: findRelay_subid)
|
||||
damus.nostrNetwork.pool.unsubscribe(sub_id: findRelay_subid)
|
||||
}
|
||||
|
||||
func getCappedRelayStrings() -> [String] {
|
||||
return relays?.keys.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
||||
return self.relay_urls?.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
// 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
|
||||
@@ -175,15 +181,33 @@ 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 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?
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -194,7 +218,9 @@ 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
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,13 +41,13 @@ class SearchHomeModel: ObservableObject {
|
||||
|
||||
func subscribe() {
|
||||
loading = true
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
loading = false
|
||||
damus_state.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
||||
}
|
||||
|
||||
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.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
|
||||
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
|
||||
|
||||
let now = UInt64(Date.now.timeIntervalSince1970)
|
||||
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.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
|
||||
case .ok:
|
||||
break
|
||||
case .notice:
|
||||
|
||||
@@ -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.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
loading = true
|
||||
state.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||
state.nostrNetwork.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.pool.unsubscribe(sub_id: sub_id)
|
||||
state.nostrNetwork.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.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
if ev.is_textlike && ev.should_show_event {
|
||||
self.add_event(ev)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,18 @@ 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] {
|
||||
return event_map.parent_events(of: selected_event)
|
||||
// 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
|
||||
}
|
||||
/// All of the direct and indirect replies of `selected_event` in the thread. sorted chronologically
|
||||
///
|
||||
@@ -77,12 +88,12 @@ class ThreadModel: ObservableObject {
|
||||
|
||||
/// Unsubscribe from events in the relay pool. Call this when unloading the view
|
||||
func unsubscribe() {
|
||||
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)
|
||||
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)
|
||||
Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
|
||||
}
|
||||
|
||||
@@ -118,14 +129,20 @@ 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.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
|
||||
damus_state.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
|
||||
}
|
||||
|
||||
/// Adds an event to this thread.
|
||||
/// 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)
|
||||
func add_event(_ ev: NostrEvent, keypair: Keypair) {
|
||||
///
|
||||
/// - 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) {
|
||||
if event_map.contains(id: ev.id) {
|
||||
return
|
||||
}
|
||||
@@ -136,8 +153,22 @@ class ThreadModel: ObservableObject {
|
||||
|
||||
event_map.add(event: ev)
|
||||
|
||||
// Publish changes
|
||||
objectWillChange.send()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles an incoming event from a relay pool
|
||||
@@ -145,7 +176,7 @@ class ThreadModel: ObservableObject {
|
||||
/// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface
|
||||
@MainActor
|
||||
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in
|
||||
let (sub_id, done) = handle_subid_event(pool: damus_state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sid, ev in
|
||||
guard subids.contains(sid) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,6 +43,15 @@ 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
|
||||
}
|
||||
@@ -91,6 +100,11 @@ 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
|
||||
}
|
||||
@@ -103,5 +117,6 @@ struct DamusURLHandler {
|
||||
case wallet_connect(WalletConnectURL)
|
||||
case script([UInt8])
|
||||
case purple(DamusPurpleURL)
|
||||
case invoice(Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,12 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "show_wallet_selector", default_value: false)
|
||||
var show_wallet_selector: Bool
|
||||
|
||||
@Setting(key: "dismiss_wallet_high_balance_warning", default_value: false)
|
||||
var dismiss_wallet_high_balance_warning: Bool
|
||||
|
||||
@Setting(key: "hide_wallet_balance", default_value: false)
|
||||
var hide_wallet_balance: Bool
|
||||
|
||||
@Setting(key: "left_handed", default_value: false)
|
||||
var left_handed: Bool
|
||||
|
||||
@@ -121,7 +127,13 @@ class UserSettingsStore: ObservableObject {
|
||||
|
||||
@Setting(key: "media_previews", default_value: true)
|
||||
var media_previews: Bool
|
||||
|
||||
|
||||
@Setting(key: "show_trusted_replies_first", default_value: true)
|
||||
var show_trusted_replies_first: Bool
|
||||
|
||||
@Setting(key: "reset_tips_on_launch", default_value: false)
|
||||
var reset_tips_on_launch: Bool
|
||||
|
||||
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
|
||||
var hide_nsfw_tagged_content: Bool
|
||||
|
||||
@@ -160,7 +172,13 @@ 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
|
||||
|
||||
@@ -168,8 +186,12 @@ 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
|
||||
@Setting(key: "nozaps", default_value: true)
|
||||
var nozaps: Bool
|
||||
///
|
||||
/// Update 2025-05-12: This can be re-enabled 🥳. See https://github.com/damus-io/damus/issues/3016
|
||||
// @Setting(key: "nozaps", default_value: true)
|
||||
var nozaps: Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@Setting(key: "truncate_mention_text", default_value: true)
|
||||
var truncate_mention_text: Bool
|
||||
@@ -336,6 +358,10 @@ 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 {
|
||||
|
||||
@@ -83,8 +83,10 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
|
||||
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:",
|
||||
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
|
||||
case .bitcoinbeach:
|
||||
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://",
|
||||
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
|
||||
// Blink used to be called Bitcoin Beach.
|
||||
// We have to keep the tag called "bitcoinbeach" for backwards compatibility.
|
||||
return .init(index: 10, tag: "bitcoinbeach", displayName: "Blink", link: "blink://",
|
||||
appStoreLink: "https://apps.apple.com/app/blink-bitcoin-wallet/id1531383905", image: "blink")
|
||||
case .blixtwallet:
|
||||
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
|
||||
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
|
||||
|
||||
@@ -13,10 +13,17 @@ 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
|
||||
|
||||
@@ -61,4 +68,27 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ class ZapsModel: ObservableObject {
|
||||
case .note(let note_target):
|
||||
filter.referenced_ids = [note_target.note_id]
|
||||
}
|
||||
state.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
|
||||
state.nostrNetwork.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.pool.unsubscribe(sub_id: zaps_subid)
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: zaps_subid)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// NIP04.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//
|
||||
// NIP65.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-02-21.
|
||||
//
|
||||
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
|
||||
|
||||
import OrderedCollections
|
||||
import Foundation
|
||||
|
||||
/// Includes models and functions for working with NIP-65
|
||||
struct NIP65: Sendable {}
|
||||
|
||||
extension NIP65 {
|
||||
/// Models a NIP-65 relay list
|
||||
struct RelayList: NostrEventConvertible, Sendable {
|
||||
let relays: OrderedDictionary<RelayURL, RelayItem>
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(event: NdbNote) throws(NIP65DecodingError) {
|
||||
try self.init(event: UnownedNdbNote(event))
|
||||
}
|
||||
|
||||
init(event: borrowing UnownedNdbNote) throws(NIP65DecodingError) {
|
||||
guard event.known_kind == .relay_list else { throw .notRelayList }
|
||||
var relays: [RelayItem] = []
|
||||
for tag in event.tags {
|
||||
guard let relay = try RelayItem.fromTag(tag: tag) else { continue }
|
||||
relays.append(relay)
|
||||
}
|
||||
self.relays = Self.relayOrderedDictionary(from: relays)
|
||||
}
|
||||
|
||||
init?(event: NdbNote?) throws(NIP65DecodingError) {
|
||||
guard let event else { return nil }
|
||||
try self.init(event: event)
|
||||
}
|
||||
|
||||
init(relays: [RelayItem]) {
|
||||
self.relays = Self.relayOrderedDictionary(from: relays)
|
||||
}
|
||||
|
||||
init(relays: [RelayURL]) {
|
||||
let relayItemList = relays.map({ RelayItem(url: $0, rwConfiguration: .readWrite) })
|
||||
self.relays = Self.relayOrderedDictionary(from: relayItemList)
|
||||
}
|
||||
|
||||
private static func relayOrderedDictionary(from relayList: [RelayItem]) -> OrderedDictionary<RelayURL, RelayItem> {
|
||||
var seenUrls: Set<RelayURL> = []
|
||||
return OrderedDictionary(uniqueKeysWithValues: relayList.compactMap({
|
||||
// We need to ensure the keys are unique to avoid assertion errors from OrderedDictionary
|
||||
guard !seenUrls.contains($0.url) else { return nil }
|
||||
seenUrls.insert($0.url)
|
||||
return ($0.url, $0)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Conversion to a Nostr Event
|
||||
|
||||
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
|
||||
return NdbNote(
|
||||
content: "",
|
||||
keypair: keypair.to_keypair(),
|
||||
kind: NostrKind.relay_list.rawValue,
|
||||
tags: self.relays.values.map({ $0.tag }),
|
||||
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NIP65 {
|
||||
/// An error thrown when decoding an item into a NIP-65 relay list
|
||||
enum NIP65DecodingError: Error {
|
||||
/// The Nostr event being converted is not a NIP-65 relay list
|
||||
case notRelayList
|
||||
/// The relay URL is invalid
|
||||
case invalidRelayURL
|
||||
///The relay RW marker is invalid
|
||||
case invalidRelayMarker
|
||||
}
|
||||
}
|
||||
|
||||
extension NIP65.RelayList {
|
||||
/// An item referencing a relay and its configuration inside a relay list
|
||||
struct RelayItem: ThrowingTagConvertible, Sendable {
|
||||
typealias E = NIP65.NIP65DecodingError
|
||||
|
||||
let url: RelayURL
|
||||
let rwConfiguration: RWConfiguration
|
||||
|
||||
/// The raw tag sequence in a Nostr event
|
||||
var tag: [String] {
|
||||
var tag = ["r", url.absoluteString]
|
||||
if let rwMarker = rwConfiguration.tagItem { tag.append(rwMarker) }
|
||||
return tag
|
||||
}
|
||||
|
||||
/// Initialize a new relay item from a Nostr event's tag sequence
|
||||
static func fromTag(tag: TagSequence) throws(E) -> NIP65.RelayList.RelayItem? {
|
||||
var i = tag.makeIterator()
|
||||
|
||||
guard tag.count >= 2,
|
||||
let t0 = i.next(),
|
||||
let key = t0.single_char,
|
||||
let rkey = RefId.RefKey(rawValue: key),
|
||||
let t1 = i.next()
|
||||
else { return nil }
|
||||
|
||||
let t2 = i.next()
|
||||
|
||||
switch rkey {
|
||||
case .r: return try self.fromRawInfo(urlString: t1.string(), rwMarker: t2?.string())
|
||||
// Keep options explicit to make compiler prompt developer on whether to ignore or handle new future options
|
||||
case .e, .p, .q, .t, .d, .a: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Initializes a Relay Item based on raw information
|
||||
static func fromRawInfo(urlString: String, rwMarker: String?) throws(NIP65.NIP65DecodingError) -> NIP65.RelayList.RelayItem? {
|
||||
guard let relayUrl = RelayURL(urlString) else { throw .invalidRelayURL }
|
||||
guard let rwConfiguration = RWConfiguration.fromTagItem(rwMarker) else { throw .invalidRelayMarker }
|
||||
return NIP65.RelayList.RelayItem(url: relayUrl, rwConfiguration: rwConfiguration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NIP65.RelayList.RelayItem {
|
||||
/// The read/write configuration for a relay item
|
||||
enum RWConfiguration: TagItemConvertible {
|
||||
case read
|
||||
case write
|
||||
case readWrite
|
||||
|
||||
static let READ_MARKER: String = "read"
|
||||
static let WRITE_MARKER: String = "write"
|
||||
|
||||
var canRead: Bool {
|
||||
switch self {
|
||||
case .read, .readWrite: return true
|
||||
case .write: return false
|
||||
}
|
||||
}
|
||||
|
||||
var canWrite: Bool {
|
||||
switch self {
|
||||
case .write, .readWrite: return true
|
||||
case .read: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw Nostr Event tag item
|
||||
var tagItem: String? {
|
||||
switch self {
|
||||
case .read: Self.READ_MARKER
|
||||
case .write: Self.WRITE_MARKER
|
||||
case .readWrite: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize this from a raw Nostr Event tag item
|
||||
static func fromTagItem(_ item: String?) -> Self? {
|
||||
if item == READ_MARKER { return .read }
|
||||
if item == WRITE_MARKER { return .write }
|
||||
return .readWrite
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -34,6 +34,19 @@ protocol TagConvertible {
|
||||
static func from_tag(tag: TagSequence) -> Self?
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -130,8 +143,16 @@ struct ReplaceableParam: TagConvertible {
|
||||
var keychar: AsciiCharacter { "d" }
|
||||
}
|
||||
|
||||
struct Signature: Hashable, Equatable {
|
||||
struct Signature: Codable, 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
|
||||
|
||||
@@ -58,7 +58,7 @@ extension NdbProfile {
|
||||
}
|
||||
|
||||
static func displayName(profile: Profile?, pubkey: Pubkey) -> DisplayName {
|
||||
return parse_display_name(profile: profile, pubkey: pubkey)
|
||||
return DisplayName(name: profile?.name, display_name: profile?.display_name, pubkey: pubkey)
|
||||
}
|
||||
|
||||
var damus_donation: Int? {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: Relay) -> NostrEvent? {
|
||||
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: RelayPool.Relay) -> NostrEvent? {
|
||||
let tags: [[String]] = [["relay", relay.descriptor.url.absoluteString],["challenge", challenge_string]]
|
||||
let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags)
|
||||
return event
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
|
||||
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayPool.RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
|
||||
var tags = zap_target_to_tags(target)
|
||||
var 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 = encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
|
||||
let enc = NIP04.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 = RelayInfo(read: true, write: true)
|
||||
var relays: [RelayURL: RelayInfo] = [:]
|
||||
let rw_relay_info = LegacyKind3RelayRWConfiguration(read: true, write: true)
|
||||
var relays: [RelayURL: LegacyKind3RelayRWConfiguration] = [:]
|
||||
|
||||
for relay in bootstrap_relays {
|
||||
relays[relay] = rw_relay_info
|
||||
|
||||
@@ -13,6 +13,18 @@ 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
|
||||
@@ -367,6 +379,10 @@ 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 {
|
||||
@@ -527,6 +543,7 @@ 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
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
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
|
||||
@@ -18,6 +19,7 @@ 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
|
||||
|
||||
@@ -12,6 +12,7 @@ enum NostrLink: Equatable {
|
||||
case ref(RefId)
|
||||
case filter(NostrFilter)
|
||||
case script([UInt8])
|
||||
case invoice(String)
|
||||
}
|
||||
|
||||
func encode_pubkey_uri(_ pubkey: Pubkey) -> String {
|
||||
@@ -93,8 +94,15 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
|
||||
return
|
||||
}
|
||||
|
||||
if parts.count >= 2 && parts[0] == "t" {
|
||||
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
|
||||
if parts.count >= 2 {
|
||||
switch parts[0] {
|
||||
case "t":
|
||||
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
|
||||
case "lightning":
|
||||
return .invoice(parts[1])
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard parts.count == 1 else {
|
||||
|
||||
@@ -12,11 +12,14 @@ 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
|
||||
@@ -25,6 +28,7 @@ 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
|
||||
@@ -34,12 +38,18 @@ 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:
|
||||
@@ -53,6 +63,7 @@ enum NostrRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this request is meant to read data from a relay
|
||||
var is_read: Bool {
|
||||
return !is_write
|
||||
}
|
||||
|
||||
@@ -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) * 4.0), Int(getpagesize()))
|
||||
let bufsize: Int = max(Int(Double(json.utf8.count) * 8.0), Int(getpagesize()))
|
||||
let data = malloc(bufsize)
|
||||
|
||||
if data == nil {
|
||||
|
||||
@@ -35,6 +35,7 @@ class Profiles {
|
||||
@MainActor
|
||||
private var profiles: [Pubkey: ProfileData] = [:]
|
||||
|
||||
// Map of validated NIP-05 address to pubkey.
|
||||
@MainActor
|
||||
var nip05_pubkey: [String: Pubkey] = [:]
|
||||
|
||||
|
||||
@@ -115,6 +115,19 @@ enum FollowRef: TagKeys, Hashable, TagConvertible, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Models common tag references defined by the Nostr protocol, and their associated values.
|
||||
///
|
||||
/// For example, this raw JSON tag sequence:
|
||||
/// ```json
|
||||
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
|
||||
/// ```
|
||||
///
|
||||
/// would be parsed into something equivalent to `.pubkey(Pubkey(hex: "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"))`
|
||||
///
|
||||
/// ## Notes
|
||||
///
|
||||
/// - Not all tag information from all NIPs can be modelled using this alone, as some NIPs may define extra associated values for specific event types. You may need to use a specialized type for some event kinds
|
||||
///
|
||||
enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case event(NoteId)
|
||||
case pubkey(Pubkey)
|
||||
@@ -124,6 +137,7 @@ 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
|
||||
@@ -136,6 +150,14 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines the type of reference being made on a Nostr event tag
|
||||
///
|
||||
/// Example:
|
||||
/// ```json
|
||||
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
|
||||
/// ```
|
||||
///
|
||||
/// The `RefKey` is "p"
|
||||
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
|
||||
case e, p, t, d, q, a, r
|
||||
|
||||
@@ -148,10 +170,12 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw nostr-style tag sequence representation of this object
|
||||
var tag: [String] {
|
||||
[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()
|
||||
@@ -166,6 +190,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a raw tag sequence
|
||||
static func from_tag(tag: TagSequence) -> RefId? {
|
||||
var i = tag.makeIterator()
|
||||
|
||||
|
||||
+88
-50
@@ -7,16 +7,25 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct RelayInfo: Codable {
|
||||
let read: Bool?
|
||||
let write: Bool?
|
||||
public struct LegacyKind3RelayRWConfiguration: Codable, Sendable {
|
||||
public let read: Bool?
|
||||
public let write: Bool?
|
||||
|
||||
init(read: Bool, write: Bool) {
|
||||
self.read = read
|
||||
self.write = write
|
||||
}
|
||||
|
||||
static let rw = RelayInfo(read: true, write: true)
|
||||
static let rw = LegacyKind3RelayRWConfiguration(read: true, write: true)
|
||||
|
||||
func toNIP65RWConfiguration() -> NIP65.RelayList.RelayItem.RWConfiguration? {
|
||||
switch (self.read, self.write) {
|
||||
case (false, true): return .write
|
||||
case (true, false): return .read
|
||||
case (true, true): return .readWrite
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RelayVariant {
|
||||
@@ -25,30 +34,33 @@ enum RelayVariant {
|
||||
case 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
static func nwc(url: RelayURL) -> RelayDescriptor {
|
||||
return RelayDescriptor(url: url, info: .rw, variant: .nwc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,30 +141,56 @@ struct RelayMetadata: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
class Relay: Identifiable {
|
||||
let 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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
var is_broken: Bool {
|
||||
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
|
||||
}
|
||||
|
||||
var id: RelayURL {
|
||||
return descriptor.url
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum RelayError: Error {
|
||||
case RelayAlreadyExists
|
||||
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)
|
||||
}
|
||||
|
||||
static func fromLegacyContactList(_ contactList: NdbNote?) throws(BridgeError) -> Self? {
|
||||
guard let contactList = contactList else { return nil }
|
||||
return try fromLegacyContactList(contactList)
|
||||
}
|
||||
|
||||
enum BridgeError: Error {
|
||||
case couldNotDecodeRelayListInfo
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,15 @@ struct SeenEvent: Hashable {
|
||||
let evid: NoteId
|
||||
}
|
||||
|
||||
/// Establishes and manages connections and subscriptions to a list of relays.
|
||||
class RelayPool {
|
||||
var relays: [Relay] = []
|
||||
private(set) 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)?
|
||||
@@ -122,7 +124,7 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
|
||||
func add_relay(_ desc: RelayDescriptor) throws {
|
||||
func add_relay(_ desc: RelayDescriptor) throws(RelayError) {
|
||||
let relay_id = desc.url
|
||||
if get_relay(relay_id) != nil {
|
||||
throw RelayError.RelayAlreadyExists
|
||||
@@ -200,6 +202,64 @@ 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)
|
||||
@@ -243,19 +303,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)
|
||||
self.send_raw_to_local_ndb(req) // Always send Nostr events and data to NostrDB for a local copy
|
||||
|
||||
for relay in relays {
|
||||
if req.is_read && !(relay.descriptor.info.read ?? true) {
|
||||
continue
|
||||
if req.is_read && !(relay.descriptor.info.canRead) {
|
||||
continue // Do not send read requests to relays that are not READ relays
|
||||
}
|
||||
|
||||
if req.is_write && !(relay.descriptor.info.write ?? true) {
|
||||
continue
|
||||
if req.is_write && !(relay.descriptor.info.canWrite) {
|
||||
continue // Do not send write requests to relays that are not WRITE relays
|
||||
}
|
||||
|
||||
if relay.descriptor.ephemeral && skip_ephemeral {
|
||||
continue
|
||||
continue // Do not send requests to ephemeral relays if we want to skip them
|
||||
}
|
||||
|
||||
guard relay.connection.isConnected else {
|
||||
@@ -354,7 +414,7 @@ class RelayPool {
|
||||
}
|
||||
|
||||
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
|
||||
try? pool.add_relay(RelayDescriptor(url: url, info: .rw))
|
||||
try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -83,8 +83,7 @@ var test_damus_state: DamusState = ({
|
||||
let our_pubkey = test_pubkey
|
||||
let pool = RelayPool(ndb: ndb)
|
||||
let settings = UserSettingsStore()
|
||||
let damus = DamusState(pool: pool,
|
||||
keypair: test_keypair,
|
||||
let damus = DamusState(keypair: test_keypair,
|
||||
likes: .init(our_pubkey: our_pubkey),
|
||||
boosts: .init(our_pubkey: our_pubkey),
|
||||
contacts: .init(our_pubkey: our_pubkey),
|
||||
@@ -100,8 +99,6 @@ 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(),
|
||||
@@ -109,7 +106,8 @@ var test_damus_state: DamusState = ({
|
||||
video: .init(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: our_pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: .init()
|
||||
)
|
||||
|
||||
/*
|
||||
|
||||
+17
-1
@@ -37,7 +37,23 @@ 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)
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//
|
||||
// CoinosDeterministicClient.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-04-14.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
|
||||
///
|
||||
/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
|
||||
class CoinosDeterministicAccountClient {
|
||||
// MARK: - State
|
||||
|
||||
/// The user's normal keypair for using Nostr
|
||||
private let userKeypair: FullKeypair
|
||||
/// The JWT authentication token with Coinos
|
||||
private var jwtAuthToken: String? = nil
|
||||
|
||||
|
||||
// MARK: - Computed properties for a deterministic wallet
|
||||
|
||||
/// A deterministic keypair for the NWC connection derived from the user's private key
|
||||
private var nwcKeypair: FullKeypair? {
|
||||
let nwcPrivateKey: Privkey = Privkey(sha256(self.userKeypair.privkey.id)) // SHA256 is an irreversible operation, user's nsec should not be deriveable from this new private key
|
||||
return FullKeypair(privkey: nwcPrivateKey)
|
||||
}
|
||||
|
||||
/// A deterministic username for a Coinos account
|
||||
private var username: String? {
|
||||
// Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
|
||||
// Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
|
||||
guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
|
||||
// There is very little risk of a birthday attack on getting only the first 16 characters, because:
|
||||
// 1. before this user creates an account, no one else knows the private key in order to know the expected username and create an account before them
|
||||
// 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
|
||||
//
|
||||
// In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
|
||||
// According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
|
||||
// even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
|
||||
return String(fullText.prefix(16))
|
||||
}
|
||||
|
||||
/// A deterministic password for a Coinos account
|
||||
private var password: String? {
|
||||
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
|
||||
return sha256Hex(text: "coinos_password:" + self.userKeypair.privkey.hex())
|
||||
}
|
||||
|
||||
/// A deterministic NWC app connection name
|
||||
private var nwcConnectionName: String { return "Damus" }
|
||||
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Initializes the client with the user's keypair
|
||||
init(userKeypair: FullKeypair) {
|
||||
self.userKeypair = userKeypair
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Authentication and registration
|
||||
|
||||
/// Tries to login to the user's deterministic account. If it cannot be found, it will register for one and log into that.
|
||||
func loginOrRegister() async throws {
|
||||
do {
|
||||
// Check if client has an account
|
||||
try await self.login()
|
||||
}
|
||||
catch {
|
||||
guard let error = error as? CoinosDeterministicAccountClient.ClientError, error == .unauthorized else { throw error }
|
||||
// Client does not seem to have an account, create one
|
||||
try await self.register()
|
||||
try await self.login()
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers for a Coinos account using deterministic account details.
|
||||
///
|
||||
/// It succeeds if it returns without throwing errors.
|
||||
func register() async throws {
|
||||
guard let username, let password else { throw ClientError.errorFormingRequest }
|
||||
let registerPayload = RegisterRequest(user: UserCredentials(username: username, password: password))
|
||||
let jsonData = try JSONEncoder().encode(registerPayload)
|
||||
|
||||
let url = URL(string: "https://coinos.io/api/register")!
|
||||
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
|
||||
return
|
||||
} else {
|
||||
throw ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs into the deterministic account, if an auth token is not present
|
||||
func loginIfNeeded() async throws {
|
||||
if self.jwtAuthToken == nil { try await self.login() }
|
||||
}
|
||||
|
||||
/// Logs into to our deterministic account.
|
||||
///
|
||||
/// Succeeds if it returns without returning errors.
|
||||
///
|
||||
/// Mutating function, will update the client's internal state.
|
||||
func login() async throws {
|
||||
self.jwtAuthToken = try await sendLoginRequest().token
|
||||
}
|
||||
|
||||
/// Sends the login request and return the response
|
||||
///
|
||||
/// Does NOT update the internal login state.
|
||||
private func sendLoginRequest() async throws -> AuthResponse {
|
||||
guard let url = URL(string: "https://coinos.io/api/login") else { throw ClientError.errorFormingRequest }
|
||||
guard let username, let password else { throw ClientError.errorFormingRequest }
|
||||
let credentials = UserCredentials(username: username, password: password)
|
||||
let jsonData = try JSONEncoder().encode(credentials)
|
||||
|
||||
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
|
||||
case 401: throw ClientError.unauthorized
|
||||
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Managing NWC connections
|
||||
|
||||
/// Creates a new NWC connection
|
||||
///
|
||||
/// Note: Account must exist before calling this endpoint
|
||||
func createNWCConnection() async throws -> WalletConnectURL {
|
||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
|
||||
|
||||
try await self.loginIfNeeded()
|
||||
|
||||
let config = try defaultWalletConnectionConfig()
|
||||
let configData = try encode_json_data(config)
|
||||
|
||||
let (data, response) = try await self.makeAuthenticatedRequest(
|
||||
method: .post,
|
||||
url: urlEndpoint,
|
||||
payload: configData,
|
||||
payload_type: .json
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
|
||||
return nwc
|
||||
case 401: throw ClientError.unauthorized
|
||||
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
/// Returns the default wallet connection config
|
||||
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
|
||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||
return NewWalletConnectionConfig(
|
||||
name: self.nwcConnectionName,
|
||||
secret: nwcKeypair.privkey.hex(),
|
||||
pubkey: nwcKeypair.pubkey.hex(),
|
||||
max_amount: 30000, // 30K sats per week maximum
|
||||
budget_renewal: .weekly
|
||||
)
|
||||
}
|
||||
|
||||
/// Gets the NWC URL for the deterministic NWC app connection
|
||||
///
|
||||
/// Account must already exist before calling this
|
||||
///
|
||||
/// Returns `nil` if no NWC url is found, (e.g. if app connection has not been configured yet)
|
||||
func getNWCUrl() async throws -> WalletConnectURL? {
|
||||
guard let connectionConfig = try await self.getNWCAppConnectionConfig(), let nwc = connectionConfig.nwc else { return nil }
|
||||
return WalletConnectURL(str: nwc)
|
||||
}
|
||||
|
||||
/// Gets the deterministic NWC app connection configuration details, if it exists
|
||||
///
|
||||
/// Account must already exist before calling this
|
||||
///
|
||||
/// Returns `nil` if no connection is found, (e.g. if app connection has not been configured yet)
|
||||
func getNWCAppConnectionConfig() async throws -> WalletConnectionConfig? {
|
||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||
guard let url = URL(string: "https://coinos.io/api/app/" + nwcKeypair.pubkey.hex()) else { throw ClientError.errorFormingRequest }
|
||||
|
||||
try await self.loginIfNeeded()
|
||||
|
||||
let (data, response) = try await self.makeAuthenticatedRequest(
|
||||
method: .get,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: nil
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200: return try JSONDecoder().decode(WalletConnectionConfig.self, from: data)
|
||||
case 401: throw ClientError.unauthorized
|
||||
case 404: return nil
|
||||
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Lower level request convenience functions
|
||||
|
||||
/// Makes a request without any authorization
|
||||
func makeRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.httpBody = payload
|
||||
|
||||
if let payload_type {
|
||||
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
return try await URLSession.shared.data(for: request)
|
||||
}
|
||||
|
||||
/// Makes an authenticated request with our JWT auth token.
|
||||
///
|
||||
/// Client must be logged-in before calling this, otherwise an error will be thrown.
|
||||
func makeAuthenticatedRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
|
||||
guard let jwtAuthToken else { throw ClientError.errorFormingRequest }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.httpBody = payload
|
||||
|
||||
request.setValue("Bearer " + jwtAuthToken, forHTTPHeaderField: "Authorization")
|
||||
if let payload_type {
|
||||
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
return try await URLSession.shared.data(for: request)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper structures
|
||||
|
||||
/// Payload for registering for a new Coinos account
|
||||
struct RegisterRequest: Codable {
|
||||
/// New user credentials
|
||||
let user: UserCredentials
|
||||
}
|
||||
|
||||
/// Payload for user credentials (sign-up and login)
|
||||
struct UserCredentials: Codable {
|
||||
/// The username
|
||||
let username: String
|
||||
/// The user password
|
||||
let password: String
|
||||
}
|
||||
|
||||
/// A successful response to a login auth endpoint
|
||||
struct AuthResponse: Codable {
|
||||
/// The JWT token to be applied to any authenticated API calls
|
||||
let token: String
|
||||
}
|
||||
|
||||
/// Used by the client to define new NWC configurations
|
||||
struct NewWalletConnectionConfig: Codable {
|
||||
/// The name of the connection
|
||||
let name: String
|
||||
/// 32 Hex-encoded bytes containing a shared private key secret
|
||||
let secret: String
|
||||
/// 32 Hex-encoded bytes containing the pubkey for the secret
|
||||
let pubkey: String
|
||||
/// Max amount that can be spent in each renewal period (measured in sats)
|
||||
let max_amount: UInt64
|
||||
/// The period of time it takes for the budget limits to reset
|
||||
let budget_renewal: BudgetRenewalPeriod
|
||||
}
|
||||
|
||||
/// The NWC connection configuration details
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - All items defined as optionals because the Coinos API may change in the future, so this may help increase future compatibility.
|
||||
struct WalletConnectionConfig: Codable {
|
||||
/// The name of the connection
|
||||
let name: String?
|
||||
/// 32 Hex-encoded bytes containing a shared private key secret
|
||||
let secret: String?
|
||||
/// 32 Hex-encoded bytes containing the pubkey for the secret
|
||||
let pubkey: String?
|
||||
/// Max amount that can be spent in every renewal period (measured in sats)
|
||||
let max_amount: UInt64?
|
||||
/// The NWC url generated by the server
|
||||
let nwc: String?
|
||||
/// Budget renewal information
|
||||
let budget_renewal: BudgetRenewalPeriod?
|
||||
}
|
||||
|
||||
/// A period of time it takes for budget limits to be reset
|
||||
enum BudgetRenewalPeriod: String, Codable {
|
||||
/// Resets once a week
|
||||
case weekly
|
||||
}
|
||||
|
||||
/// A client error occured
|
||||
enum ClientError: Error, Equatable {
|
||||
/// Received an unexpected HTTP response
|
||||
///
|
||||
/// Could be for a variety of reasons.
|
||||
case unexpectedHTTPResponse(status_code: Int, response: Data)
|
||||
/// Error forming the request, generally due to missing or inconsistent internal data
|
||||
///
|
||||
/// Probably caused by a programming error.
|
||||
case errorFormingRequest
|
||||
/// The client could not process the response from the server
|
||||
///
|
||||
/// Might be a sign of an incompatibility bug
|
||||
case errorProcessingResponse
|
||||
/// The action performed is not authorized
|
||||
/// Generally thrown if user does not exist, credentials do not match what Coinos has on file, or programming error
|
||||
case unauthorized
|
||||
/// Client not logged in on a call that expected login
|
||||
case notLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes a SHA256 hash digest from a piece of UTF-8 text, and returns the result as a "hex" string
|
||||
///
|
||||
/// When working only with strings, this can be more convenient than transforming text to data, and data back to text
|
||||
fileprivate func sha256Hex(text: String) -> String? {
|
||||
guard let data = text.data(using: .utf8) else { return nil }
|
||||
return sha256(data).toHexString()
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
|
||||
|
||||
@@ -10,7 +10,15 @@ 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):
|
||||
@@ -28,20 +36,37 @@ 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(profile: Profile?, pubkey: Pubkey) -> DisplayName {
|
||||
func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) -> DisplayName {
|
||||
if pubkey == ANON_PUBKEY {
|
||||
return .one(NSLocalizedString("Anonymous", comment: "Placeholder display name of anonymous user."))
|
||||
}
|
||||
|
||||
guard let profile else {
|
||||
|
||||
if name == nil && display_name == nil {
|
||||
return .one(abbrev_bech32_pubkey(pubkey: pubkey))
|
||||
}
|
||||
|
||||
let name = profile.name?.isEmpty == false ? profile.name : nil
|
||||
let disp_name = profile.display_name?.isEmpty == false ? profile.display_name : nil
|
||||
|
||||
let name = name?.isEmpty == false ? name : nil
|
||||
let disp_name = display_name?.isEmpty == false ? display_name : nil
|
||||
|
||||
if let name, let disp_name, name != disp_name {
|
||||
return .both(username: name, displayName: disp_name)
|
||||
@@ -55,9 +80,9 @@ func parse_display_name(profile: Profile?, pubkey: Pubkey) -> DisplayName {
|
||||
}
|
||||
|
||||
func abbrev_bech32_pubkey(pubkey: Pubkey) -> String {
|
||||
return abbrev_pubkey(String(pubkey.npub.dropFirst(4)))
|
||||
return abbrev_identifier(String(pubkey.npub.dropFirst(4)))
|
||||
}
|
||||
|
||||
func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String {
|
||||
func abbrev_identifier(_ pubkey: String, amount: Int = 8) -> String {
|
||||
return pubkey.prefix(amount) + ":" + pubkey.suffix(amount)
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ func preload_image(url: URL) {
|
||||
|
||||
//print("Preloading image \(url.absoluteString)")
|
||||
|
||||
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: url)) { val in
|
||||
KingfisherManager.shared.retrieveImage(with: Kingfisher.KF.ImageResource(downloadURL: url)) { val in
|
||||
//print("Preloaded image \(url.absoluteString)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,15 @@ extension KFOptionSetter {
|
||||
options.onlyLoadFirstFrame = disable_animation
|
||||
|
||||
switch imageContext {
|
||||
case .pfp:
|
||||
options.diskCacheExpiration = .days(60)
|
||||
break
|
||||
case .banner:
|
||||
options.diskCacheExpiration = .days(5)
|
||||
break
|
||||
case .note:
|
||||
options.diskCacheExpiration = .days(1)
|
||||
break
|
||||
case .pfp, .favicon:
|
||||
options.diskCacheExpiration = .days(60)
|
||||
break
|
||||
case .banner:
|
||||
options.diskCacheExpiration = .days(5)
|
||||
break
|
||||
case .note:
|
||||
options.diskCacheExpiration = .days(1)
|
||||
break
|
||||
}
|
||||
|
||||
return self
|
||||
@@ -52,7 +52,7 @@ extension KFOptionSetter {
|
||||
|
||||
func onFailure(fallbackUrl: URL?, cacheKey: String?) -> Self {
|
||||
guard let url = fallbackUrl, let key = cacheKey else { return self }
|
||||
let imageResource = Kingfisher.ImageResource(downloadURL: url, cacheKey: key)
|
||||
let imageResource = Kingfisher.KF.ImageResource(downloadURL: url, cacheKey: key)
|
||||
let source = imageResource.convertToSource()
|
||||
options.alternativeSources = [source]
|
||||
|
||||
@@ -82,11 +82,14 @@ enum ImageContext {
|
||||
case pfp
|
||||
case banner
|
||||
case note
|
||||
|
||||
case favicon
|
||||
|
||||
func maxMebibyteSize() -> Int {
|
||||
switch self {
|
||||
case .favicon:
|
||||
return 512_000 // 500KiB
|
||||
case .pfp:
|
||||
return 5_242_880 // 5Mib
|
||||
return 5_242_880 // 5MiB
|
||||
case .banner, .note:
|
||||
return 20_971_520 // 20MiB
|
||||
}
|
||||
@@ -94,6 +97,8 @@ enum ImageContext {
|
||||
|
||||
func downsampleSize() -> CGSize {
|
||||
switch self {
|
||||
case .favicon:
|
||||
return CGSize(width: 18, height: 18)
|
||||
case .pfp:
|
||||
return CGSize(width: 200, height: 200)
|
||||
case .banner:
|
||||
@@ -159,20 +164,25 @@ struct CustomCacheSerializer: CacheSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
class CustomSessionDelegate: SessionDelegate {
|
||||
override func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||
class CustomSessionDelegate: SessionDelegate, @unchecked Sendable {
|
||||
override func urlSession(
|
||||
_ session: URLSession,
|
||||
dataTask: URLSessionDataTask,
|
||||
didReceive response: URLResponse
|
||||
) async -> URLSession.ResponseDisposition {
|
||||
let contentLength = response.expectedContentLength
|
||||
|
||||
// Content-Length header is optional (-1 when missing)
|
||||
if (contentLength != -1 && contentLength > MAX_FILE_SIZE) {
|
||||
return super.urlSession(session, dataTask: dataTask, didReceive: URLResponse(), completionHandler: completionHandler)
|
||||
return await super.urlSession(session, dataTask: dataTask, didReceive: URLResponse())
|
||||
}
|
||||
|
||||
super.urlSession(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler)
|
||||
return await super.urlSession(session, dataTask: dataTask, didReceive: response)
|
||||
}
|
||||
}
|
||||
|
||||
class CustomImageDownloader: ImageDownloader {
|
||||
|
||||
class CustomImageDownloader: ImageDownloader, @unchecked Sendable {
|
||||
|
||||
static let shared = CustomImageDownloader(name: "shared")
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// ExtraFonts.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// FaviconCache.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 5/23/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import FaviconFinder
|
||||
|
||||
class FaviconCache {
|
||||
private var nip05DomainFavicons: [String: [FaviconURL]] = [:]
|
||||
|
||||
@MainActor
|
||||
func lookup(_ domain: String) async -> [FaviconURL] {
|
||||
let lowercasedDomain = domain.lowercased()
|
||||
if let faviconURLs = nip05DomainFavicons[lowercasedDomain] {
|
||||
return faviconURLs
|
||||
}
|
||||
|
||||
guard let siteURL = URL(string: "https://\(lowercasedDomain)"),
|
||||
let faviconURLs = try? await FaviconFinder(
|
||||
url: siteURL,
|
||||
configuration: .init(
|
||||
preferredSource: .ico, // Prefer using common favicon .ico filenames at root level to avoid scraping HTML when possible.
|
||||
preferences: [
|
||||
.html: FaviconFormatType.appleTouchIcon.rawValue,
|
||||
.ico: "favicon.ico",
|
||||
.webApplicationManifestFile: FaviconFormatType.launcherIcon4x.rawValue
|
||||
]
|
||||
)
|
||||
).fetchFaviconURLs()
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
nip05DomainFavicons[lowercasedDomain] = faviconURLs
|
||||
|
||||
return faviconURLs
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//
|
||||
// ImageCacheMigrations.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-04-26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Kingfisher
|
||||
|
||||
struct ImageCacheMigrations {
|
||||
static func migrateKingfisherCacheIfNeeded() {
|
||||
let fileManager = FileManager.default
|
||||
let defaults = UserDefaults.standard
|
||||
let migration1Key = "KingfisherCacheMigrated" // Never ever changes
|
||||
let migration2Key = "KingfisherCacheMigratedV2" // Never ever changes
|
||||
|
||||
let migration1Done = defaults.bool(forKey: migration1Key)
|
||||
let migration2Done = defaults.bool(forKey: migration2Key)
|
||||
|
||||
guard !migration1Done || !migration2Done else {
|
||||
// All migrations are already done. Skip.
|
||||
return
|
||||
}
|
||||
|
||||
let oldCachePath = migration1Done ? migration1KingfisherCachePath() : migration0KingfisherCachePath()
|
||||
|
||||
// New shared cache location
|
||||
let newCachePath = kingfisherCachePath().path
|
||||
|
||||
if fileManager.fileExists(atPath: oldCachePath) {
|
||||
do {
|
||||
// Move the old cache to the new location
|
||||
try fileManager.moveItem(atPath: oldCachePath, toPath: newCachePath)
|
||||
Log.info("Successfully migrated Kingfisher cache to %s", for: .storage, newCachePath)
|
||||
} catch {
|
||||
do {
|
||||
// Cache data is not essential, fallback to deleting the cache and starting all over
|
||||
// It's better than leaving significant garbage data stuck indefinitely on the user's phone
|
||||
try fileManager.removeItem(atPath: newCachePath)
|
||||
try fileManager.removeItem(atPath: oldCachePath)
|
||||
}
|
||||
catch {
|
||||
Log.error("Failed to migrate cache: %s", for: .storage, error.localizedDescription)
|
||||
return // Do not mark them as complete, we can try again next time the user reloads the app
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark migrations as complete
|
||||
defaults.set(true, forKey: migration1Key)
|
||||
defaults.set(true, forKey: migration2Key)
|
||||
}
|
||||
|
||||
static private func migration0KingfisherCachePath() -> String {
|
||||
// Implementation note: These are old, so they should not be changed
|
||||
let defaultCache = ImageCache.default
|
||||
return defaultCache.diskStorage.directoryURL.path
|
||||
}
|
||||
|
||||
static private func migration1KingfisherCachePath() -> String {
|
||||
// Implementation note: These are old, so they are hard-coded on purpose, because we can't change these values from the past.
|
||||
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.damus")!
|
||||
return groupURL.appendingPathComponent("ImageCache").path
|
||||
}
|
||||
|
||||
/// The latest path for kingfisher to store cached images on.
|
||||
///
|
||||
/// Documentation references:
|
||||
/// - https://developer.apple.com/documentation/foundation/filemanager/containerurl(forsecurityapplicationgroupidentifier:)#:~:text=The%20system%20creates%20only%20the%20Library/Caches%20subdirectory%20automatically
|
||||
/// - https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#:~:text=Put%20data%20cache,files%20as%20needed.
|
||||
static func kingfisherCachePath() -> URL {
|
||||
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER)!
|
||||
return groupURL
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Caches")
|
||||
.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,13 @@ 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
|
||||
case video_coordination
|
||||
case tips
|
||||
}
|
||||
|
||||
/// Damus structured logger
|
||||
|
||||
@@ -54,7 +54,7 @@ enum CancelSendErr {
|
||||
}
|
||||
|
||||
class PostBox {
|
||||
let pool: RelayPool
|
||||
private let pool: RelayPool
|
||||
var events: [NoteId: PostedEvent]
|
||||
|
||||
init(pool: RelayPool) {
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
|
||||
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]()
|
||||
|
||||
|
||||
+15
-2
@@ -5,6 +5,7 @@
|
||||
// Created by Scott Penrose on 5/7/23.
|
||||
//
|
||||
|
||||
import FaviconFinder
|
||||
import SwiftUI
|
||||
|
||||
enum Route: Hashable {
|
||||
@@ -46,6 +47,8 @@ enum Route: Hashable {
|
||||
case Wallet(wallet: WalletModel)
|
||||
case WalletScanner(result: Binding<WalletScanResult>)
|
||||
case FollowersYouKnow(friendedFollowers: [Pubkey], followers: FollowersModel)
|
||||
case NIP05DomainEvents(events: NIP05DomainEventsModel, nip05_domain_favicon: FaviconURL?)
|
||||
case NIP05DomainPubkeys(domain: String, nip05_domain_favicon: FaviconURL?, pubkeys: [Pubkey])
|
||||
|
||||
@ViewBuilder
|
||||
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
|
||||
@@ -126,7 +129,11 @@ 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.pool, model: load_model)
|
||||
LoadScript(pool: damusState.nostrNetwork.pool, model: load_model)
|
||||
case .NIP05DomainEvents(let events, let nip05_domain_favicon):
|
||||
NIP05DomainTimelineView(damus_state: damusState, model: events, nip05_domain_favicon: nip05_domain_favicon)
|
||||
case .NIP05DomainPubkeys(let domain, let nip05_domain_favicon, let pubkeys):
|
||||
NIP05DomainPubkeysView(damus_state: damusState, domain: domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: pubkeys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +216,7 @@ enum Route: Hashable {
|
||||
case .Search(let search):
|
||||
hasher.combine("search")
|
||||
hasher.combine(search.search)
|
||||
case .NDBSearch(let results):
|
||||
case .NDBSearch:
|
||||
hasher.combine("results")
|
||||
case .EULA:
|
||||
hasher.combine("eula")
|
||||
@@ -231,6 +238,12 @@ enum Route: Hashable {
|
||||
case .Script(let model):
|
||||
hasher.combine("script")
|
||||
hasher.combine(model.data.count)
|
||||
case .NIP05DomainEvents(let events, _):
|
||||
hasher.combine("nip05DomainEvents")
|
||||
hasher.combine(events.domain)
|
||||
case .NIP05DomainPubkeys(let domain, _, _):
|
||||
hasher.combine("nip05DomainPubkeys")
|
||||
hasher.combine(domain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +35,11 @@ func remove_nostr_uri_prefix(_ s: String) -> String {
|
||||
return uri
|
||||
}
|
||||
|
||||
func abbreviateURL(_ url: URL) -> String {
|
||||
func abbreviateURL(_ url: URL, maxLength: Int = MAX_CHAR_URL) -> String {
|
||||
let urlString = url.absoluteString
|
||||
|
||||
if urlString.count > MAX_CHAR_URL {
|
||||
return String(urlString.prefix(MAX_CHAR_URL)) + "..."
|
||||
if urlString.count > maxLength {
|
||||
return String(urlString.prefix(maxLength)) + "…"
|
||||
}
|
||||
return urlString
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
//
|
||||
// WalletConnect+.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//
|
||||
// HumanReadableErrors.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-05-05.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension WalletConnect.FullWalletResponse.InitializationError {
|
||||
var humanReadableError: ErrorView.UserPresentableError? {
|
||||
switch self {
|
||||
case .incorrectAuthorPubkey:
|
||||
nil // Anyone can send a response event with an incorrect author pubkey, it is not really an "error". We should silently ignore it.
|
||||
case .missingRequestIdReference:
|
||||
.init(
|
||||
user_visible_description: NSLocalizedString("Wallet provider returned an invalid response.", comment: "Error description shown to the user when a response from the wallet provider is invalid"),
|
||||
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
|
||||
technical_info: "Wallet response does not make a reference to any request; No request ID `e` tag was found."
|
||||
)
|
||||
case .failedToDecodeJSON(let error):
|
||||
.init(
|
||||
user_visible_description: NSLocalizedString("Wallet provider returned a response that we do not understand.", comment: "Error description shown to the user when a response from the wallet provider contains data the app does not understand"),
|
||||
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
|
||||
technical_info: "Failed to decode NWC Wallet response JSON. Error: \(error)"
|
||||
)
|
||||
case .failedToDecrypt(let error):
|
||||
.init(
|
||||
user_visible_description: NSLocalizedString("Wallet provider returned a response that we could not decrypt.", comment: "Error description shown to the user when a response from the wallet provider contains data the app could not decrypt."),
|
||||
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
|
||||
technical_info: "Failed to decrypt NWC Wallet response. Error: \(error)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WalletConnect.WalletResponseErr {
|
||||
var humanReadableError: ErrorView.UserPresentableError? {
|
||||
guard let code = self.code else {
|
||||
return .init(
|
||||
user_visible_description: String(format: NSLocalizedString("Your connected wallet raised an unknown error. Message: %s", comment: "Human readable error description for unknown error"), self.message ?? NSLocalizedString("Empty error message", comment: "A human readable placeholder to indicate that the error message is empty")),
|
||||
tip: NSLocalizedString("Please contact the developer of your wallet provider for help.", comment: "Human readable error description for an unknown error raised by a wallet provider."),
|
||||
technical_info: "NWC wallet provider returned an error response without a valid reason code. Message: \(self.message ?? "Empty error message")"
|
||||
)
|
||||
}
|
||||
switch code {
|
||||
case .rateLimited:
|
||||
return .init(
|
||||
user_visible_description: NSLocalizedString("Your wallet is temporarily being rate limited.", comment: "Error description for rate limit error"),
|
||||
tip: NSLocalizedString("Wait a few moments, and then try again.", comment: "Tip for rate limit error"),
|
||||
technical_info: "Wallet returned a rate limit error with message: \(self.message ?? "No further details provided")"
|
||||
)
|
||||
case .notImplemented:
|
||||
return .init(
|
||||
user_visible_description: NSLocalizedString("This feature is not implemented by your wallet.", comment: "Error description for not implemented feature"),
|
||||
tip: NSLocalizedString("Please check for updates or contact your wallet provider.", comment: "Tip for not implemented error"),
|
||||
technical_info: "Wallet reported a not implemented error. Message: \(self.message ?? "No further details provided")"
|
||||
)
|
||||
case .insufficientBalance:
|
||||
return .init(
|
||||
user_visible_description: NSLocalizedString("Your wallet does not have sufficient balance for this transaction.", comment: "Error description for insufficient balance"),
|
||||
tip: NSLocalizedString("Please deposit more funds and try again.", comment: "Tip for insufficient balance errors"),
|
||||
technical_info: "Wallet returned an insufficient balance error. Message: \(self.message ?? "No further details provided")"
|
||||
)
|
||||
case .quotaExceeded:
|
||||
return .init(
|
||||
user_visible_description: NSLocalizedString("Your transaction quota has been exceeded.", comment: "Error description for quota exceeded"),
|
||||
tip: NSLocalizedString("Wait for the quota to reset, or configure your wallet provider to allow a higher limit.", comment: "Tip for quota exceeded"),
|
||||
technical_info: "Wallet reported a quota exceeded error. Message: \(self.message ?? "No further details provided")"
|
||||
)
|
||||
case .restricted:
|
||||
return .init(
|
||||
user_visible_description: NSLocalizedString("This operation is restricted by your wallet.", comment: "Error description for restricted operation"),
|
||||
tip: NSLocalizedString("Check your account permissions or contact support.", comment: "Tip for restricted operation"),
|
||||
technical_info: "Wallet returned a restricted error. Message: \(self.message ?? "No further details provided")"
|
||||
)
|
||||
case .unauthorized:
|
||||
return .init(
|
||||
user_visible_description: NSLocalizedString("You are not authorized to perform this action with your wallet.", comment: "Error description for unauthorized access"),
|
||||
tip: NSLocalizedString("Please verify your credentials or permissions.", comment: "Tip for unauthorized access"),
|
||||
technical_info: "Wallet returned an unauthorized error. Message: \(self.message ?? "No further details provided")"
|
||||
)
|
||||
case .internalError:
|
||||
return .init(
|
||||
user_visible_description: NSLocalizedString("An internal error occurred in your wallet.", comment: "Error description for an internal error"),
|
||||
tip: NSLocalizedString("Try restarting your wallet or contacting support if the problem persists.", comment: "Tip for internal error"),
|
||||
technical_info: "Wallet reported an internal error. Message: \(self.message ?? "No further details provided")"
|
||||
)
|
||||
case .other:
|
||||
return .init(
|
||||
user_visible_description: NSLocalizedString("An unspecified error occurred in your wallet.", comment: "Error description for an unspecified error"),
|
||||
tip: NSLocalizedString("Please try again or contact your wallet provider for further assistance.", comment: "Tip for unspecified error"),
|
||||
technical_info: "Wallet returned an error: \(self.message ?? "No further details provided")"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//
|
||||
// Request.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// Response.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// WalletConnect+.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ struct EventActionBar: View {
|
||||
|
||||
generator.impactOccurred()
|
||||
|
||||
damus_state.postbox.send(like_ev)
|
||||
damus_state.nostrNetwork.postbox.send(like_ev)
|
||||
}
|
||||
|
||||
// MARK: Helper structures
|
||||
|
||||
@@ -25,7 +25,7 @@ struct RepostAction: View {
|
||||
return
|
||||
}
|
||||
|
||||
damus_state.postbox.send(boost)
|
||||
damus_state.nostrNetwork.postbox.send(boost)
|
||||
} label: {
|
||||
Label(NSLocalizedString("Repost", comment: "Button to repost a note"), image: "repost")
|
||||
.frame(maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .leading)
|
||||
|
||||
@@ -15,6 +15,8 @@ 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.")
|
||||
@@ -82,38 +84,21 @@ struct AddRelayView: View {
|
||||
new_relay = "wss://" + new_relay
|
||||
}
|
||||
|
||||
guard let url = RelayURL(new_relay),
|
||||
let ev = state.contacts.event,
|
||||
let keypair = state.keypair.to_full() else {
|
||||
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")
|
||||
return
|
||||
}
|
||||
|
||||
let info = RelayInfo.rw
|
||||
let descriptor = RelayDescriptor(url: url, info: info)
|
||||
|
||||
do {
|
||||
try state.pool.add_relay(descriptor)
|
||||
try state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: url, rwConfiguration: .readWrite))
|
||||
relayAddErrorTitle = nil // Clear error title
|
||||
relayAddErrorMessage = nil // Clear error message
|
||||
} 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
|
||||
}
|
||||
catch {
|
||||
present_sheet(.error(self.humanReadableError(for: error)))
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -134,6 +119,17 @@ 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,44 +0,0 @@
|
||||
//
|
||||
// FriendsButton.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FriendsButton: View {
|
||||
@Binding var filter: FriendFilter
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
switch self.filter {
|
||||
case .all:
|
||||
self.filter = .friends_of_friends
|
||||
case .friends_of_friends:
|
||||
self.filter = .all
|
||||
}
|
||||
}) {
|
||||
if filter == .friends_of_friends {
|
||||
LINEAR_GRADIENT
|
||||
.mask(Image("user-added")
|
||||
.resizable()
|
||||
).frame(width: 28, height: 28)
|
||||
} else {
|
||||
Image("user-added")
|
||||
.resizable()
|
||||
.frame(width: 28, height: 28)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct FriendsButton_Previews: PreviewProvider {
|
||||
@State static var enabled: FriendFilter = .all
|
||||
|
||||
static var previews: some View {
|
||||
FriendsButton(filter: $enabled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// TrustedNetworkButton.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-04-21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TrustedNetworkButton: View {
|
||||
@Binding var filter: FriendFilter
|
||||
var action: (@MainActor () -> Void)? = nil
|
||||
|
||||
var MainButton: some View {
|
||||
Button(action: {
|
||||
switch self.filter {
|
||||
case .all:
|
||||
self.filter = .friends_of_friends
|
||||
case .friends_of_friends:
|
||||
self.filter = .all
|
||||
}
|
||||
|
||||
if let action {
|
||||
action()
|
||||
}
|
||||
}) {
|
||||
if filter == .friends_of_friends {
|
||||
LINEAR_GRADIENT
|
||||
.mask(Image(systemName: "network.badge.shield.half.filled")
|
||||
.frame(width: 24, height: 24)
|
||||
)
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
} else {
|
||||
Image(systemName: "network.slash")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MainButton
|
||||
}
|
||||
}
|
||||
|
||||
struct TrustedNetworkButton_Previews: PreviewProvider {
|
||||
@State static var enabled: FriendFilter = .all
|
||||
|
||||
static var previews: some View {
|
||||
TrustedNetworkButton(filter: $enabled)
|
||||
}
|
||||
}
|
||||
@@ -244,7 +244,7 @@ struct ChatEventView: View {
|
||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||
generator.impactOccurred()
|
||||
|
||||
damus_state.postbox.send(like_ev)
|
||||
damus_state.nostrNetwork.postbox.send(like_ev)
|
||||
}
|
||||
|
||||
var action_bar: some View {
|
||||
@@ -337,12 +337,6 @@ struct ChatEventView: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static var toggle_thread_view: Notification.Name {
|
||||
return Notification.Name("convert_to_thread")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwipeActions
|
||||
import TipKit
|
||||
|
||||
struct ChatroomThreadView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -15,11 +16,20 @@ struct ChatroomThreadView: View {
|
||||
@ObservedObject var thread: ThreadModel
|
||||
@State var highlighted_note_id: NoteId? = nil
|
||||
@State var user_just_posted_flag: Bool = false
|
||||
@State var untrusted_network_expanded: Bool = true
|
||||
@Namespace private var animation
|
||||
|
||||
|
||||
|
||||
// Add state for sticky header
|
||||
@State var showStickyHeader: Bool = false
|
||||
@State var untrustedSectionOffset: CGFloat = 0
|
||||
|
||||
private static let untrusted_network_section_id = "untrusted-network-section"
|
||||
private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2)
|
||||
|
||||
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
|
||||
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top)
|
||||
let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top
|
||||
|
||||
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: adjustedAnchor)
|
||||
highlighted_note_id = note_id
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
|
||||
withAnimation {
|
||||
@@ -27,7 +37,7 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) {
|
||||
withAnimation {
|
||||
self.thread.select(event: ev)
|
||||
@@ -35,93 +45,202 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func trusted_event_filter(_ event: NostrEvent) -> Bool {
|
||||
!damus.settings.show_trusted_replies_first || damus.contacts.is_in_friendosphere(event.pubkey)
|
||||
}
|
||||
|
||||
func ThreadedSwipeViewGroup(scroller: ScrollViewProxy, events: [NostrEvent]) -> some View {
|
||||
SwipeViewGroup {
|
||||
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
|
||||
ChatEventView(event: events[ind],
|
||||
selected_event: self.thread.selected_event,
|
||||
prev_ev: ind > 0 ? events[ind-1] : nil,
|
||||
next_ev: ind == events.count-1 ? nil : events[ind+1],
|
||||
damus_state: damus,
|
||||
thread: thread,
|
||||
scroll_to_event: { note_id in
|
||||
self.go_to_event(scroller: scroller, note_id: note_id)
|
||||
},
|
||||
focus_event: {
|
||||
self.set_active_event(scroller: scroller, ev: ev)
|
||||
},
|
||||
highlight_bubble: highlighted_note_id == ev.id,
|
||||
bar: make_actionbar_model(ev: ev.id, damus: damus)
|
||||
)
|
||||
.id(ev.id)
|
||||
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var OutsideTrustedNetworkLabel: some View {
|
||||
HStack {
|
||||
Label(
|
||||
NSLocalizedString(
|
||||
"Replies outside your trusted network",
|
||||
comment: "Section title in thread for replies from outside of the current user's trusted network, which is their follows and follows of follows."),
|
||||
systemImage: "network.slash"
|
||||
)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.rotationEffect(.degrees(untrusted_network_expanded ? 90 : 0))
|
||||
.animation(.easeInOut(duration: 0.1), value: untrusted_network_expanded)
|
||||
}
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
var StickyHeaderView: some View {
|
||||
OutsideTrustedNetworkLabel
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
Color(UIColor.systemBackground)
|
||||
.shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { scroller in
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
// MARK: - Parents events view
|
||||
ForEach(thread.parent_events, id: \.id) { parent_event in
|
||||
EventMutingContainerView(damus_state: damus, event: parent_event) {
|
||||
EventView(damus: damus, event: parent_event)
|
||||
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.onTapGesture {
|
||||
self.set_active_event(scroller: scroller, ev: parent_event)
|
||||
}
|
||||
.id(parent_event.id)
|
||||
|
||||
Divider()
|
||||
.padding(.top, 4)
|
||||
.padding(.leading, 25 * 2)
|
||||
|
||||
}.background(GeometryReader { geometry in
|
||||
// get the height and width of the EventView view
|
||||
let eventHeight = geometry.frame(in: .global).height
|
||||
// let eventWidth = geometry.frame(in: .global).width
|
||||
|
||||
// vertical gray line in the background
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.25))
|
||||
.frame(width: 2, height: eventHeight)
|
||||
.offset(x: 40, y: 40)
|
||||
})
|
||||
|
||||
// MARK: - Actual event view
|
||||
EventMutingContainerView(
|
||||
damus_state: damus,
|
||||
event: self.thread.selected_event,
|
||||
muteBox: { event_shown, muted_reason in
|
||||
AnyView(
|
||||
EventMutedBoxView(shown: event_shown, reason: muted_reason)
|
||||
.padding(5)
|
||||
)
|
||||
}
|
||||
) {
|
||||
SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected)
|
||||
.matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center)
|
||||
}
|
||||
.id(self.thread.selected_event.id)
|
||||
|
||||
|
||||
// MARK: - Children view
|
||||
let events = thread.sorted_child_events
|
||||
let count = events.count
|
||||
SwipeViewGroup {
|
||||
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
|
||||
ChatEventView(event: events[ind],
|
||||
selected_event: self.thread.selected_event,
|
||||
prev_ev: ind > 0 ? events[ind-1] : nil,
|
||||
next_ev: ind == count-1 ? nil : events[ind+1],
|
||||
damus_state: damus,
|
||||
thread: thread,
|
||||
scroll_to_event: { note_id in
|
||||
self.go_to_event(scroller: scroller, note_id: note_id)
|
||||
},
|
||||
focus_event: {
|
||||
self.set_active_event(scroller: scroller, ev: ev)
|
||||
},
|
||||
highlight_bubble: highlighted_note_id == ev.id,
|
||||
bar: make_actionbar_model(ev: ev.id, damus: damus)
|
||||
)
|
||||
let sorted_child_events = thread.sorted_child_events
|
||||
|
||||
let untrusted_events = sorted_child_events.filter { !trusted_event_filter($0) }
|
||||
let trusted_events = sorted_child_events.filter { trusted_event_filter($0) }
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
ScrollView(.vertical) {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
// MARK: - Parents events view
|
||||
ForEach(thread.parent_events, id: \.id) { parent_event in
|
||||
EventMutingContainerView(damus_state: damus, event: parent_event) {
|
||||
EventView(damus: damus, event: parent_event)
|
||||
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.id(ev.id)
|
||||
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
|
||||
.onTapGesture {
|
||||
self.set_active_event(scroller: scroller, ev: parent_event)
|
||||
}
|
||||
.id(parent_event.id)
|
||||
|
||||
Divider()
|
||||
.padding(.top, 4)
|
||||
.padding(.leading, 25 * 2)
|
||||
|
||||
}.background(GeometryReader { geometry in
|
||||
let eventHeight = geometry.frame(in: .global).height
|
||||
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.25))
|
||||
.frame(width: 2, height: eventHeight)
|
||||
.offset(x: 40, y: 40)
|
||||
})
|
||||
|
||||
// MARK: - Actual event view
|
||||
EventMutingContainerView(
|
||||
damus_state: damus,
|
||||
event: self.thread.selected_event,
|
||||
muteBox: { event_shown, muted_reason in
|
||||
AnyView(
|
||||
EventMutedBoxView(shown: event_shown, reason: muted_reason)
|
||||
.padding(5)
|
||||
)
|
||||
}
|
||||
) {
|
||||
SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected)
|
||||
.matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center)
|
||||
}
|
||||
.id(self.thread.selected_event.id)
|
||||
|
||||
// MARK: - Children view - inside trusted network
|
||||
if !trusted_events.isEmpty {
|
||||
ThreadedSwipeViewGroup(scroller: scroller, events: trusted_events)
|
||||
}
|
||||
}
|
||||
.padding(.top)
|
||||
|
||||
// MARK: - Children view - outside trusted network
|
||||
if !untrusted_events.isEmpty {
|
||||
if #available(iOS 17, *) {
|
||||
TipView(TrustedNetworkRepliesTip.shared, arrowEdge: .bottom)
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Track this section's position
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.background(
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
untrustedSectionOffset = proxy.frame(in: .global).minY
|
||||
}
|
||||
.onChange(of: proxy.frame(in: .global).minY) { newY in
|
||||
let shouldShow = newY <= 100 // Adjust this threshold as needed
|
||||
if shouldShow != showStickyHeader {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
showStickyHeader = shouldShow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
untrusted_network_expanded.toggle()
|
||||
|
||||
if #available(iOS 17, *) {
|
||||
TrustedNetworkRepliesTip.shared.invalidate(reason: .actionPerformed)
|
||||
}
|
||||
|
||||
scroll_to_event(scroller: scroller, id: ChatroomThreadView.untrusted_network_section_id, delay: 0.1, animate: true, anchor: ChatroomThreadView.sticky_header_adjusted_anchor)
|
||||
}
|
||||
}) {
|
||||
OutsideTrustedNetworkLabel
|
||||
}
|
||||
.id(ChatroomThreadView.untrusted_network_section_id)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.padding(.horizontal)
|
||||
|
||||
if untrusted_network_expanded {
|
||||
withAnimation {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
ThreadedSwipeViewGroup(scroller: scroller, events: untrusted_events)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EndBlock()
|
||||
|
||||
HStack {}
|
||||
.frame(height: tabHeight + getSafeAreaBottom())
|
||||
}
|
||||
|
||||
if showStickyHeader && !untrusted_events.isEmpty {
|
||||
VStack {
|
||||
StickyHeaderView
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
untrusted_network_expanded.toggle()
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.zIndex(1)
|
||||
}
|
||||
.padding(.top)
|
||||
EndBlock()
|
||||
|
||||
HStack {}
|
||||
.frame(height: tabHeight + getSafeAreaBottom())
|
||||
}
|
||||
.onReceive(handle_notify(.post), perform: { notify in
|
||||
switch notify {
|
||||
case .post(_):
|
||||
user_just_posted_flag = true
|
||||
case .cancel:
|
||||
return
|
||||
case .post(_):
|
||||
user_just_posted_flag = true
|
||||
case .cancel:
|
||||
return
|
||||
}
|
||||
})
|
||||
.onReceive(thread.objectWillChange) {
|
||||
@@ -139,15 +258,8 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggle_thread_view() {
|
||||
NotificationCenter.default.post(name: .toggle_thread_view, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
struct ChatroomView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
@@ -167,8 +279,3 @@ struct ChatroomView_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) {
|
||||
scroll_to_event(scroller: proxy, id: thread.selected_event.id, delay: 0.1, animate: false)
|
||||
}
|
||||
|
||||
+116
-71
@@ -18,11 +18,26 @@ 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)
|
||||
@@ -31,91 +46,122 @@ 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 {
|
||||
NavigationLink(value: Route.KeySettings(keypair: state.keypair)) {
|
||||
IconLabel(NSLocalizedString("Keys", comment: "Settings section for managing keys"), img_name: "Key", color: .purple)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
NavigationLink(value: Route.SearchSettings(settings: settings)) {
|
||||
IconLabel(NSLocalizedString("Search/Universe", comment: "Section header for search/universe settings"), img_name: "search", color: .red)
|
||||
}
|
||||
|
||||
NavigationLink(value: Route.NotificationSettings(settings: settings)) {
|
||||
IconLabel(NSLocalizedString("Notifications", comment: "Section header for Damus notifications"), img_name: "notification-bell-on", color: .blue)
|
||||
}
|
||||
|
||||
NavigationLink(value: Route.ZapSettings(settings: settings)) {
|
||||
IconLabel(NSLocalizedString("Zaps", comment: "Section header for zap settings"), img_name: "zap.fill", color: .orange)
|
||||
}
|
||||
|
||||
NavigationLink(value: Route.TranslationSettings(settings: settings)) {
|
||||
IconLabel(NSLocalizedString("Translation", comment: "Section header for text and appearance settings"), img_name: "globe", color: .green)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Section(NSLocalizedString("Sign Out", comment: "Section title for signing out")) {
|
||||
Button(action: {
|
||||
if state.keypair.privkey == nil {
|
||||
logout(state)
|
||||
} else {
|
||||
confirm_logout = true
|
||||
// Keys
|
||||
if showSettingsButton(title: keysTitle){
|
||||
NavigationLink(value:Route.KeySettings(keypair: state.keypair)){
|
||||
IconLabel(keysTitle,img_name:"Key",color:.purple)
|
||||
}
|
||||
}, label: {
|
||||
Label(NSLocalizedString("Sign out", comment: "Sidebar menu label to sign out of the account."), image: "logout")
|
||||
.foregroundColor(textColor())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
})
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if state.is_privkey_user {
|
||||
Section(header: Text("Permanently Delete Account", comment: "Section title for deleting the user")) {
|
||||
//Notifications
|
||||
if showSettingsButton(title: notificationsTitle){
|
||||
NavigationLink(value: Route.NotificationSettings(settings: settings)){
|
||||
IconLabel(notificationsTitle,img_name:"notification-bell-on",color:.blue)
|
||||
}
|
||||
}
|
||||
//Zaps
|
||||
if showSettingsButton(title: zapsTitle){
|
||||
NavigationLink(value: Route.ZapSettings(settings: settings)){
|
||||
IconLabel(zapsTitle,img_name:"zap.fill",color:.orange)
|
||||
}
|
||||
}
|
||||
//Translation
|
||||
if showSettingsButton(title: translationTitle){
|
||||
NavigationLink(value: Route.TranslationSettings(settings: settings)){
|
||||
IconLabel(translationTitle,img_name:"globe",color:.green)
|
||||
}
|
||||
}
|
||||
//Reactions
|
||||
if showSettingsButton(title: reactionsTitle){
|
||||
NavigationLink(value: Route.ReactionsSettings(settings: settings)){
|
||||
IconLabel(reactionsTitle,img_name:"shaka.fill",color:.purple)
|
||||
}
|
||||
}
|
||||
//Developer
|
||||
if showSettingsButton(title: developerTitle){
|
||||
NavigationLink(value: Route.DeveloperSettings(settings: settings)){
|
||||
IconLabel(developerTitle,img_name:"magic-stick2.fill",color:DamusColors.adaptableBlack)
|
||||
}
|
||||
}
|
||||
//First Aid
|
||||
if showSettingsButton(title: firstAidTitle){
|
||||
NavigationLink(value: Route.FirstAidSettings(settings: settings)){
|
||||
IconLabel(firstAidTitle,img_name:"help2",color: .red)
|
||||
}
|
||||
}
|
||||
}
|
||||
//Sign out Section
|
||||
if showSettingsButton(title: signOutTitle){
|
||||
Section(signOutTitle){
|
||||
Button(action: {
|
||||
delete_account_warning = true
|
||||
if state.keypair.privkey == nil {
|
||||
logout(state)
|
||||
} else {
|
||||
confirm_logout = true
|
||||
}
|
||||
}, label: {
|
||||
Label(NSLocalizedString("Delete Account", comment: "Button to delete the user's account."), image: "delete")
|
||||
Label(signOutTitle, image: "logout")
|
||||
.foregroundColor(textColor())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundColor(.red)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
@@ -136,7 +182,7 @@ struct ConfigView: View {
|
||||
let ev = created_deleted_account_profile(keypair: keypair) else {
|
||||
return
|
||||
}
|
||||
state.postbox.send(ev)
|
||||
state.nostrNetwork.postbox.send(ev)
|
||||
logout(state)
|
||||
}
|
||||
}
|
||||
@@ -154,7 +200,6 @@ struct ConfigView: View {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ConfigView_Previews: PreviewProvider {
|
||||
|
||||
@@ -131,14 +131,14 @@ struct DMChatView: View, KeyboardReadable {
|
||||
.map(\.asString)
|
||||
.joined(separator: "")
|
||||
|
||||
guard let dm = create_dm(content, to_pk: pubkey, tags: tags, keypair: damus_state.keypair) else {
|
||||
guard let dm = NIP04.create_dm(content, to_pk: pubkey, tags: tags, keypair: damus_state.keypair) else {
|
||||
print("error creating dm")
|
||||
return
|
||||
}
|
||||
|
||||
dms.draft = ""
|
||||
|
||||
damus_state.postbox.send(dm)
|
||||
damus_state.nostrNetwork.postbox.send(dm)
|
||||
|
||||
handle_incoming_dm(ev: dm, our_pubkey: damus_state.pubkey, dms: damus_state.dms, prev_events: NewEventsBits())
|
||||
|
||||
@@ -176,46 +176,6 @@ 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 {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
enum DMType: Hashable {
|
||||
case rando
|
||||
@@ -18,6 +19,7 @@ struct DirectMessagesView: View {
|
||||
@State var dm_type: DMType = .friend
|
||||
@ObservedObject var model: DirectMessagesModel
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@Binding var subtitle: String?
|
||||
|
||||
func MainContent(requests: Bool) -> some View {
|
||||
ScrollView {
|
||||
@@ -72,7 +74,15 @@ struct DirectMessagesView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let showTrustedButton = would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms)
|
||||
VStack(spacing: 0) {
|
||||
if #available(iOS 17, *), showTrustedButton {
|
||||
TipView(TrustedNetworkButtonTip.shared)
|
||||
.tipBackground(.clear)
|
||||
.tipViewStyle(TrustedNetworkButtonTipViewStyle())
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message."), DMType.friend),
|
||||
(NSLocalizedString("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet"), DMType.rando),
|
||||
@@ -92,12 +102,22 @@ struct DirectMessagesView: View {
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms) {
|
||||
|
||||
FriendsButton(filter: $settings.friend_filter)
|
||||
if showTrustedButton {
|
||||
TrustedNetworkButton(filter: $settings.friend_filter) {
|
||||
if #available(iOS 17, *) {
|
||||
TrustedNetworkButtonTip.shared.invalidate(reason: .actionPerformed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.subtitle = settings.friend_filter.description()
|
||||
|
||||
}
|
||||
.onChange(of: settings.friend_filter) { val in
|
||||
self.subtitle = val.description()
|
||||
}
|
||||
.navigationTitle(NSLocalizedString("DMs", comment: "Navigation title for view of DMs, where DM is an English abbreviation for Direct Message."))
|
||||
}
|
||||
}
|
||||
@@ -115,6 +135,6 @@ func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageMo
|
||||
struct DirectMessagesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let ds = test_damus_state
|
||||
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings)
|
||||
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings, subtitle: .constant(nil))
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user