Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f7a7e7ed8a
|
@@ -6,7 +6,6 @@ _[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)
|
||||
|
||||
@@ -6,4 +6,3 @@ damus.xcodeproj/xcshareddata/xcbaselines
|
||||
TODO.bak
|
||||
tags
|
||||
build-git-hash.txt
|
||||
.build
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
### Acknowledgements and licenses
|
||||
|
||||
1. This product contains code derived from [Nostr SDK iOS](https://github.com/nostr-sdk/nostr-sdk-ios). [License](https://github.com/nostr-sdk/nostr-sdk-ios/blob/40df800c6749d7ce0b6fd7328e76cbc0dc71c87b/LICENSE)
|
||||
2. This product includes software developed by the "Marcin Krzyzanowski" (http://krzyzanowskim.com/). [License](https://github.com/krzyzanowskim/CryptoSwift/blob/e74bbbfbef939224b242ae7c342a90e60b88b5ce/LICENSE)
|
||||
|
||||
-107
@@ -1,110 +1,3 @@
|
||||
## [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,8 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.usernotifications.communication</key>
|
||||
<true/>
|
||||
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
|
||||
@@ -18,7 +18,7 @@ struct NotificationExtensionState: HeadlessDamusState {
|
||||
let lnurls: LNUrls
|
||||
|
||||
init?() {
|
||||
guard let ndb = Ndb(owns_db_file: false) else { return nil }
|
||||
guard let ndb = try? Ndb(owns_db_file: false) else { return nil }
|
||||
self.ndb = ndb
|
||||
|
||||
guard let keypair = get_saved_keypair() else { return nil }
|
||||
|
||||
@@ -5,32 +5,15 @@
|
||||
// Created by Daniel 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,
|
||||
@@ -57,16 +40,9 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
return
|
||||
}
|
||||
|
||||
let sender_profile = {
|
||||
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
|
||||
let profile = txn?.unsafeUnownedValue?.profile
|
||||
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
|
||||
return ProfileBuf(picture: picture,
|
||||
name: profile?.name,
|
||||
display_name: profile?.display_name,
|
||||
nip05: profile?.nip05)
|
||||
}()
|
||||
let sender_pubkey = nostr_event.pubkey
|
||||
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
|
||||
let profile = txn?.unsafeUnownedValue?.profile
|
||||
let name = Profile.displayName(profile: profile, pubkey: nostr_event.pubkey).displayName
|
||||
|
||||
// Don't show notification details that match mute list.
|
||||
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
|
||||
@@ -80,7 +56,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
contentHandler(content)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
|
||||
Log.debug("should_display_notification failed", for: .push_notifications)
|
||||
// We should not display notification for this event. Suppress notification.
|
||||
@@ -89,7 +65,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
|
||||
Log.debug("generate_local_notification_object failed", for: .push_notifications)
|
||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||
@@ -98,58 +74,15 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
Task {
|
||||
let sender_dn = DisplayName(name: sender_profile.name, display_name: sender_profile.display_name, pubkey: sender_pubkey)
|
||||
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: sender_dn.displayName, notify: notification_object, state: state) else {
|
||||
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
|
||||
|
||||
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
var options: [AnyHashable: Any] = [:]
|
||||
if let imageSource = CGImageSourceCreateWithURL(sender_profile.picture as CFURL, nil),
|
||||
let uti = CGImageSourceGetType(imageSource) {
|
||||
options[UNNotificationAttachmentOptionsTypeHintKey] = uti
|
||||
}
|
||||
|
||||
let attachment = try UNNotificationAttachment(identifier: sender_profile.picture.absoluteString, url: sender_profile.picture, options: options)
|
||||
improvedContent.attachments = [attachment]
|
||||
} catch {
|
||||
Log.error("failed to get notification attachment: %s", for: .push_notifications, error.localizedDescription)
|
||||
}
|
||||
|
||||
let kind = nostr_event.known_kind
|
||||
|
||||
// these aren't supported yet
|
||||
if !(kind == .text || kind == .dm) {
|
||||
contentHandler(improvedContent)
|
||||
return
|
||||
}
|
||||
|
||||
// rich communication notifications for kind1, dms, etc
|
||||
|
||||
let message_intent = await message_intent_from_note(ndb: state.ndb,
|
||||
sender_profile: sender_profile,
|
||||
content: improvedContent.body,
|
||||
note: nostr_event,
|
||||
our_pubkey: state.keypair.pubkey)
|
||||
|
||||
improvedContent.threadIdentifier = nostr_event.thread_id().hex()
|
||||
improvedContent.categoryIdentifier = "COMMUNICATION"
|
||||
|
||||
let interaction = INInteraction(intent: message_intent, response: nil)
|
||||
interaction.direction = .incoming
|
||||
do {
|
||||
try await interaction.donate()
|
||||
let updated = try improvedContent.updating(from: message_intent)
|
||||
contentHandler(updated)
|
||||
} catch {
|
||||
Log.error("failed to donate interaction: %s", for: .push_notifications, error.localizedDescription)
|
||||
contentHandler(improvedContent)
|
||||
}
|
||||
contentHandler(improvedContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,162 +95,3 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct ProfileBuf {
|
||||
let picture: URL
|
||||
let name: String?
|
||||
let display_name: String?
|
||||
let nip05: String?
|
||||
}
|
||||
|
||||
func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: String, note: NdbNote, our_pubkey: Pubkey) async -> INSendMessageIntent {
|
||||
let sender_pk = note.pubkey
|
||||
let sender = await profile_to_inperson(name: sender_profile.name,
|
||||
display_name: sender_profile.display_name,
|
||||
picture: sender_profile.picture.absoluteString,
|
||||
nip05: sender_profile.nip05,
|
||||
pubkey: sender_pk,
|
||||
our_pubkey: our_pubkey)
|
||||
|
||||
let conversationIdentifier = note.thread_id().hex()
|
||||
var recipients: [INPerson] = []
|
||||
var pks: [Pubkey] = []
|
||||
let meta = INSendMessageIntentDonationMetadata()
|
||||
|
||||
// gather recipients
|
||||
if let recipient_note_id = note.direct_replies() {
|
||||
let replying_to = ndb.lookup_note(recipient_note_id)
|
||||
if let replying_to_pk = replying_to?.unsafeUnownedValue?.pubkey {
|
||||
meta.isReplyToCurrentUser = replying_to_pk == our_pubkey
|
||||
|
||||
if replying_to_pk != sender_pk {
|
||||
// we push the actual person being replied to first
|
||||
pks.append(replying_to_pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pubkeys = Array(note.referenced_pubkeys)
|
||||
meta.recipientCount = pubkeys.count
|
||||
if pubkeys.contains(sender_pk) {
|
||||
meta.recipientCount -= 1
|
||||
}
|
||||
|
||||
for pk in pubkeys.prefix(3) {
|
||||
if pk == sender_pk || pks.contains(pk) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !meta.isReplyToCurrentUser && pk == our_pubkey {
|
||||
meta.mentionsCurrentUser = true
|
||||
}
|
||||
|
||||
pks.append(pk)
|
||||
}
|
||||
|
||||
for pk in pks {
|
||||
let recipient = await pubkey_to_inperson(ndb: ndb, pubkey: pk, our_pubkey: our_pubkey)
|
||||
recipients.append(recipient)
|
||||
}
|
||||
|
||||
// we enable default formatting this way
|
||||
var groupName = INSpeakableString(spokenPhrase: "")
|
||||
|
||||
// otherwise we just say its a DM
|
||||
if note.known_kind == .dm {
|
||||
groupName = INSpeakableString(spokenPhrase: "DM")
|
||||
}
|
||||
|
||||
let intent = INSendMessageIntent(recipients: recipients,
|
||||
outgoingMessageType: .outgoingMessageText,
|
||||
content: content,
|
||||
speakableGroupName: groupName,
|
||||
conversationIdentifier: conversationIdentifier,
|
||||
serviceName: "kind\(note.kind)",
|
||||
sender: sender,
|
||||
attachments: nil)
|
||||
intent.donationMetadata = meta
|
||||
|
||||
// this is needed for recipients > 0
|
||||
if let img = sender.image {
|
||||
intent.setImage(img, forParameterNamed: \.speakableGroupName)
|
||||
}
|
||||
|
||||
return intent
|
||||
}
|
||||
|
||||
func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
|
||||
let profile_txn = ndb.lookup_profile(pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue?.profile
|
||||
let name = profile?.name
|
||||
let display_name = profile?.display_name
|
||||
let nip05 = profile?.nip05
|
||||
let picture = profile?.picture
|
||||
|
||||
return await profile_to_inperson(name: name,
|
||||
display_name: display_name,
|
||||
picture: picture,
|
||||
nip05: nip05,
|
||||
pubkey: pubkey,
|
||||
our_pubkey: our_pubkey)
|
||||
}
|
||||
|
||||
func fetch_pfp(picture: URL) async throws -> RetrieveImageResult {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: picture)) { result in
|
||||
switch result {
|
||||
case .success(let img):
|
||||
continuation.resume(returning: img)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func profile_to_inperson(name: String?, display_name: String?, picture: String?, nip05: String?, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
|
||||
let npub = pubkey.npub
|
||||
let handle = INPersonHandle(value: npub, type: .unknown)
|
||||
var aliases: [INPersonHandle] = []
|
||||
|
||||
if let nip05 {
|
||||
aliases.append(INPersonHandle(value: nip05, type: .emailAddress))
|
||||
}
|
||||
|
||||
let nostrName = DisplayName(name: name, display_name: display_name, pubkey: pubkey)
|
||||
let nameComponents = nostrName.nameComponents()
|
||||
let displayName = nostrName.displayName
|
||||
let contactIdentifier = npub
|
||||
let customIdentifier = npub
|
||||
let suggestionType = INPersonSuggestionType.socialProfile
|
||||
|
||||
var image: INImage? = nil
|
||||
|
||||
if let picture,
|
||||
let url = URL(string: picture),
|
||||
let img = try? await fetch_pfp(picture: url),
|
||||
let imgdata = img.data()
|
||||
{
|
||||
image = INImage(imageData: imgdata)
|
||||
} else {
|
||||
Log.error("Failed to fetch pfp (%s) for %s", for: .push_notifications, picture ?? "nil", displayName)
|
||||
}
|
||||
|
||||
let person = INPerson(personHandle: handle,
|
||||
nameComponents: nameComponents,
|
||||
displayName: displayName,
|
||||
image: image,
|
||||
contactIdentifier: contactIdentifier,
|
||||
customIdentifier: customIdentifier,
|
||||
isMe: pubkey == our_pubkey,
|
||||
suggestionType: suggestionType
|
||||
)
|
||||
|
||||
return person
|
||||
}
|
||||
|
||||
func robohash(_ pk: Pubkey) -> String {
|
||||
return "https://robohash.org/" + pk.hex()
|
||||
}
|
||||
|
||||
|
||||
|
||||
+3
-32
@@ -1,32 +1,3 @@
|
||||
// swift-tools-version: 6.0
|
||||
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "damus",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v12)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "damus",
|
||||
targets: ["damus"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "damus",
|
||||
dependencies: [
|
||||
.product(name: "secp256k1", package: "secp256k1.swift")
|
||||
],
|
||||
path: "damus"),
|
||||
.testTarget(
|
||||
name: "damusTests",
|
||||
dependencies: ["damus"],
|
||||
path: "damusTests"),
|
||||
]
|
||||
)
|
||||
dependencies: [
|
||||
.Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
|
||||
]
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
<div align="center">
|
||||
[](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
|
||||
|
||||
<img src="./damus/Assets.xcassets/damus-home.imageset/damus-home@2x.png" alt="Damus Logo" title="Damus logo" width=""/>
|
||||
|
||||
# Damus
|
||||
|
||||
The social network you control
|
||||
# damus
|
||||
|
||||
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
|
||||
|
||||
[](/LICENSE)
|
||||
|
||||
## Download and Install
|
||||
|
||||
[](https://apps.apple.com/us/app/damus/id1628663131)
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
iOS 16.0+ • macOS 13.0+
|
||||
|
||||
<img src="./demo1.png" width="70%" height="50%" />
|
||||
|
||||
</div>
|
||||
<img src="./ss.png" width="50%" height="50%" />
|
||||
|
||||
[nostr]: https://github.com/fiatjaf/nostr
|
||||
|
||||
## How is Damus better than X/Twitter?
|
||||
## How is Damus better than twitter?
|
||||
There are no toxic algorithms.\
|
||||
You can send or receive zaps (satoshis) without asking for permission.\
|
||||
[There is no central database](https://fiatjaf.com/nostr.html). Therefore, Damus is censorship resistant.\
|
||||
|
||||
+93
-546
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc",
|
||||
"originHash" : "fa2b0ad84b4bd1a962ffbe49810548db7c9d7131f4a1fd4b4af06ff4c6de0a44",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codescanner",
|
||||
@@ -9,21 +9,13 @@
|
||||
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "cryptoswift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/krzyzanowskim/CryptoSwift.git",
|
||||
"state" : {
|
||||
"revision" : "e74bbbfbef939224b242ae7c342a90e60b88b5ce"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "emojikit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/EmojiKit",
|
||||
"state" : {
|
||||
"revision" : "47a4b1402de26be0299dcb4d667c1faaf21a7874",
|
||||
"version" : "0.2.0"
|
||||
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -31,17 +23,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/tyiu/EmojiPicker.git",
|
||||
"state" : {
|
||||
"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"
|
||||
"revision" : "0c28b4a1a6b8840cf2580bda59517f6d0a733dc8",
|
||||
"version" : "0.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -58,8 +41,16 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/onevcat/Kingfisher",
|
||||
"state" : {
|
||||
"revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3",
|
||||
"version" : "8.3.1"
|
||||
"revision" : "415b1d97fb38bda1e5a6b2dde63354720832110b",
|
||||
"version" : "7.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "nostr-sdk-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/rust-nostr/nostr-sdk-swift",
|
||||
"state" : {
|
||||
"revision" : "27711a03ea7d977162595eea1d9b2d5a45f0b628"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -114,15 +105,6 @@
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftsoup",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||
"state" : {
|
||||
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
|
||||
"version" : "2.8.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftycrop",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "blink.png",
|
||||
"filename" : "bbw.jpg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 216 B After Width: | Height: | Size: 216 B |
@@ -162,7 +162,6 @@ class CarouselModel: ObservableObject {
|
||||
// Upon updating information, update the carousel fill size if the size for the current url has changed
|
||||
if oldValue[current_url] != media_size_information[current_url] {
|
||||
self.refresh_current_item_fill()
|
||||
self.refresh_first_item_height()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,13 +186,6 @@ class CarouselModel: ObservableObject {
|
||||
/// and is automatically updated upon changes to these properties.
|
||||
@Published private(set) var current_item_fill: ImageFill?
|
||||
|
||||
/// Holds the ideal fill dimensions for the first item in the carousel.
|
||||
/// This is used to maintain a consistent height for the carousel when swiping between images.
|
||||
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly.
|
||||
/// **Implementation note:** This property ensures the carousel maintains a consistent height based on the first image,
|
||||
/// preventing the UI from "jumping" when swiping between images of different aspect ratios.
|
||||
@Published private(set) var first_image_fill: ImageFill?
|
||||
|
||||
|
||||
// MARK: Initialization and de-initialization
|
||||
|
||||
@@ -215,7 +207,6 @@ class CarouselModel: ObservableObject {
|
||||
self.observe_video_sizes()
|
||||
Task {
|
||||
self.refresh_current_item_fill()
|
||||
self.refresh_first_item_height()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,17 +241,10 @@ class CarouselModel: ObservableObject {
|
||||
/// **Usage note:** This is private, do not call this directly from outside the class.
|
||||
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
|
||||
private func refresh_current_item_fill() {
|
||||
self.current_item_fill = self.compute_item_fill(url: current_url)
|
||||
}
|
||||
|
||||
/// Computes the image fill properties for a given URL without side effects.
|
||||
/// This is a pure function that calculates the appropriate fill dimensions based on image size and container constraints.
|
||||
/// **Usage note:** This is a helper method used by both `refresh_current_item_fill` and `refresh_first_item_height`.
|
||||
private func compute_item_fill(url: URL?) -> ImageFill? {
|
||||
if let url,
|
||||
let item_size = self.media_size_information[url],
|
||||
if let current_url,
|
||||
let item_size = self.media_size_information[current_url],
|
||||
let geo_size {
|
||||
return ImageFill.calculate_image_fill(
|
||||
self.current_item_fill = ImageFill.calculate_image_fill(
|
||||
geo_size: geo_size,
|
||||
img_size: item_size,
|
||||
maxHeight: self.max_height,
|
||||
@@ -268,26 +252,9 @@ class CarouselModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
else {
|
||||
return nil // Not enough information to compute the proper fill. Default to nil
|
||||
self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
|
||||
}
|
||||
}
|
||||
|
||||
/// This function refreshes the first item height based on the current state of the model
|
||||
/// **Usage note:** This is private, do not call this directly from outside the class.
|
||||
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the height.
|
||||
/// When the first image dimensions change, this ensures the carousel maintains consistent dimensions.
|
||||
private func refresh_first_item_height() {
|
||||
self.first_image_fill = self.compute_first_item_fill()
|
||||
}
|
||||
|
||||
/// Computes the first item fill with no side-effects.
|
||||
/// **Usage note:** Not to be used outside the class. Use the `first_image_fill` property instead.
|
||||
/// **Implementation note:** This retrieves the first URL from the carousel and computes its fill properties
|
||||
/// to establish a consistent height for the entire carousel.
|
||||
private func compute_first_item_fill() -> ImageFill? {
|
||||
guard let first_url = urls[safe: 0] else { return nil }
|
||||
return self.compute_item_fill(url: first_url.url)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Carousel
|
||||
@@ -319,15 +286,13 @@ struct ImageCarousel<Content: View>: View {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
/// Determines if the image should fill its container.
|
||||
/// Always returns true to ensure images consistently fill the width of the container.
|
||||
/// This simplifies the layout behavior and prevents inconsistent sizing between carousel items.
|
||||
var filling: Bool { true }
|
||||
var filling: Bool {
|
||||
model.current_item_fill?.filling == true
|
||||
}
|
||||
|
||||
var height: CGFloat {
|
||||
// Use the first image height (to prevent height from jumping when swiping), then default to the default fill height
|
||||
// This prioritization ensures consistent carousel height regardless of which image is currently displayed
|
||||
model.first_image_fill?.height ?? model.default_fill_height
|
||||
// Use the calculated fill height if available, otherwise use the default fill height
|
||||
model.current_item_fill?.height ?? model.default_fill_height
|
||||
}
|
||||
|
||||
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
|
||||
@@ -411,7 +376,6 @@ struct ImageCarousel<Content: View>: View {
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||
.frame(height: height)
|
||||
.clipped() // Prevents content from overflowing the frame, ensuring clean edges in the carousel
|
||||
.onChange(of: model.selectedIndex) { value in
|
||||
model.selectedIndex = value
|
||||
}
|
||||
|
||||
@@ -94,30 +94,26 @@ enum OpenWalletError: Error {
|
||||
}
|
||||
|
||||
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
||||
let url = try getUrlToOpen(invoice: invoice, with: wallet)
|
||||
this_app.open(url)
|
||||
}
|
||||
|
||||
func getUrlToOpen(invoice: String, with wallet: Wallet.Model) throws(OpenWalletError) -> URL {
|
||||
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
|
||||
return url
|
||||
this_app.open(url)
|
||||
} else {
|
||||
guard let store_link = wallet.appStoreLink else {
|
||||
throw .no_wallet_to_open
|
||||
throw OpenWalletError.no_wallet_to_open
|
||||
}
|
||||
|
||||
guard let url = URL(string: store_link) else {
|
||||
throw .store_link_invalid
|
||||
throw OpenWalletError.store_link_invalid
|
||||
}
|
||||
|
||||
guard this_app.canOpenURL(url) else {
|
||||
throw .system_cannot_open_store_link
|
||||
throw OpenWalletError.system_cannot_open_store_link
|
||||
}
|
||||
|
||||
return url
|
||||
this_app.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
|
||||
|
||||
struct InvoiceView_Previews: PreviewProvider {
|
||||
|
||||
@@ -5,27 +5,27 @@
|
||||
// Created by William Casarin on 2023-01-11.
|
||||
//
|
||||
|
||||
import FaviconFinder
|
||||
import Kingfisher
|
||||
import SwiftUI
|
||||
|
||||
struct NIP05Badge: View {
|
||||
let nip05: NIP05
|
||||
let pubkey: Pubkey
|
||||
let damus_state: DamusState
|
||||
let contacts: Contacts
|
||||
let show_domain: Bool
|
||||
let nip05_domain_favicon: FaviconURL?
|
||||
let profiles: Profiles
|
||||
|
||||
init(nip05: NIP05, pubkey: Pubkey, damus_state: DamusState, show_domain: Bool, nip05_domain_favicon: FaviconURL?) {
|
||||
@Environment(\.openURL) var openURL
|
||||
|
||||
init(nip05: NIP05, pubkey: Pubkey, contacts: Contacts, show_domain: Bool, profiles: Profiles) {
|
||||
self.nip05 = nip05
|
||||
self.pubkey = pubkey
|
||||
self.damus_state = damus_state
|
||||
self.contacts = contacts
|
||||
self.show_domain = show_domain
|
||||
self.nip05_domain_favicon = nip05_domain_favicon
|
||||
self.profiles = profiles
|
||||
}
|
||||
|
||||
var nip05_color: Bool {
|
||||
return use_nip05_color(pubkey: pubkey, contacts: damus_state.contacts)
|
||||
return use_nip05_color(pubkey: pubkey, contacts: contacts)
|
||||
}
|
||||
|
||||
var Seal: some View {
|
||||
@@ -44,23 +44,8 @@ struct NIP05Badge: View {
|
||||
}
|
||||
}
|
||||
|
||||
var domainBadge: some View {
|
||||
Group {
|
||||
if let nip05_domain_favicon {
|
||||
KFImage(nip05_domain_favicon.source)
|
||||
.imageContext(.favicon, disable_animation: true)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 18, height: 18)
|
||||
.clipped()
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var username_matches_nip05: Bool {
|
||||
guard let name = damus_state.profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
|
||||
guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -80,18 +65,14 @@ struct NIP05Badge: View {
|
||||
HStack(spacing: 2) {
|
||||
Seal
|
||||
|
||||
Group {
|
||||
if show_domain {
|
||||
Text(nip05_string)
|
||||
.nip05_colorized(gradient: nip05_color)
|
||||
}
|
||||
|
||||
if nip05_domain_favicon != nil {
|
||||
domainBadge
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
damus_state.nav.push(route: Route.NIP05DomainEvents(events: NIP05DomainEventsModel(state: damus_state, domain: nip05.host), nip05_domain_favicon: nip05_domain_favicon))
|
||||
if show_domain {
|
||||
Text(nip05_string)
|
||||
.nip05_colorized(gradient: nip05_color)
|
||||
.onTapGesture {
|
||||
if let nip5url = nip05.siteUrl {
|
||||
openURL(nip5url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +98,13 @@ struct NIP05Badge_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let test_state = test_damus_state
|
||||
VStack {
|
||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
|
||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
||||
|
||||
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
|
||||
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
||||
|
||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
||||
|
||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: Contacts(our_pubkey: test_pubkey), show_domain: true, profiles: test_state.profiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ struct NoteZapButton: View {
|
||||
print("cancel_zap: we already have a real zap, can't cancel")
|
||||
break
|
||||
case .pending(let pzap):
|
||||
guard let res = cancel_zap(zap: pzap, box: damus_state.nostrNetwork.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
|
||||
guard let res = cancel_zap(zap: pzap, box: damus_state.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
|
||||
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
return
|
||||
@@ -179,7 +179,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
||||
}
|
||||
|
||||
// Only take the first 10 because reasons
|
||||
let relays = Array(damus_state.nostrNetwork.pool.our_descriptors.prefix(10))
|
||||
let relays = Array(damus_state.pool.our_descriptors.prefix(10))
|
||||
let content = comment ?? ""
|
||||
|
||||
guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else {
|
||||
@@ -232,7 +232,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
||||
flusher = .once({ pe in
|
||||
// send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation
|
||||
Task { @MainActor in
|
||||
await WalletConnect.send_donation_zap(pool: damus_state.nostrNetwork.pool, postbox: damus_state.nostrNetwork.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
|
||||
await send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
||||
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
|
||||
let delay = damus_state.settings.nozaps ? nil : 5.0
|
||||
|
||||
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, zap_request: zapreq, delay: delay, on_flush: flusher)
|
||||
let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher)
|
||||
|
||||
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
|
||||
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
|
||||
|
||||
@@ -10,68 +10,22 @@ import SwiftUI
|
||||
struct Reposted: View {
|
||||
let damus: DamusState
|
||||
let pubkey: Pubkey
|
||||
let target: NostrEvent
|
||||
@State var reposts: Int
|
||||
|
||||
init(damus: DamusState, pubkey: Pubkey, target: NostrEvent) {
|
||||
self.damus = damus
|
||||
self.pubkey = pubkey
|
||||
self.target = target
|
||||
self.reposts = damus.boosts.counts[target.id] ?? 1
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center) {
|
||||
Image("repost")
|
||||
.foregroundColor(Color.gray)
|
||||
|
||||
// Show profile picture of the reposter only if the reposter is not the author of the reposted note.
|
||||
if pubkey != target.pubkey {
|
||||
ProfilePicView(pubkey: pubkey, size: eventview_pfp_size(.small), highlight: .none, profiles: damus.profiles, disable_animation: damus.settings.disable_animation)
|
||||
.onTapGesture {
|
||||
show_profile_action_sheet_if_enabled(damus_state: damus, pubkey: pubkey)
|
||||
}
|
||||
.onLongPressGesture(minimumDuration: 0.1) {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
damus.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink(value: Route.Reposts(reposts: .reposts(state: damus, target: target.id))) {
|
||||
Text(people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
ProfileName(pubkey: pubkey, damus: damus, show_nip5_domain: false)
|
||||
.foregroundColor(Color.gray)
|
||||
Text("Reposted", comment: "Text indicating that the note was reposted (i.e. re-shared).")
|
||||
.foregroundColor(Color.gray)
|
||||
}
|
||||
.onReceive(handle_notify(.update_stats), perform: { note_id in
|
||||
guard note_id == target.id else { return }
|
||||
let repost_count = damus.boosts.counts[target.id]
|
||||
if let repost_count, reposts != repost_count {
|
||||
reposts = repost_count
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func people_reposted_text(profiles: Profiles, pubkey: Pubkey, reposts: Int, locale: Locale = Locale.current) -> String {
|
||||
guard reposts > 0 else {
|
||||
return ""
|
||||
}
|
||||
|
||||
let bundle = bundleForLocale(locale: locale)
|
||||
let other_reposts = reposts - 1
|
||||
let display_name = event_author_name(profiles: profiles, pubkey: pubkey)
|
||||
|
||||
if other_reposts == 0 {
|
||||
return String(format: NSLocalizedString("%@ reposted", bundle: bundle, comment: "Text indicating that the note was reposted (i.e. re-shared)."), locale: locale, display_name)
|
||||
} else {
|
||||
return String(format: localizedStringFormat(key: "people_reposted_count", locale: locale), locale: locale, other_reposts, display_name)
|
||||
}
|
||||
}
|
||||
|
||||
struct Reposted_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let test_state = test_damus_state
|
||||
Reposted(damus: test_state, pubkey: test_state.pubkey, target: test_note)
|
||||
Reposted(damus: test_state, pubkey: test_state.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,12 +94,12 @@ struct SelectableText: View {
|
||||
case show_mute_word_view(highlighted_text: String)
|
||||
|
||||
func should_show_highlight_post_view() -> Bool {
|
||||
guard case .show_highlight_post_view = self else { return false }
|
||||
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
func should_show_mute_word_view() -> Bool {
|
||||
guard case .show_mute_word_view = self else { return false }
|
||||
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -119,23 +119,16 @@ struct SelectableText: View {
|
||||
fileprivate class TextView: UITextView {
|
||||
var postHighlight: (String) -> Void
|
||||
var muteWord: (String) -> Void
|
||||
private let enableHighlighting: Bool
|
||||
|
||||
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void, enableHighlighting: Bool) {
|
||||
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
|
||||
self.postHighlight = postHighlight
|
||||
self.muteWord = muteWord
|
||||
self.enableHighlighting = enableHighlighting
|
||||
|
||||
super.init(frame: frame, textContainer: textContainer)
|
||||
|
||||
if enableHighlighting {
|
||||
self.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
if action == #selector(highlightText(_:)) {
|
||||
@@ -149,44 +142,23 @@ fileprivate class TextView: UITextView {
|
||||
return super.canPerformAction(action, withSender: sender)
|
||||
}
|
||||
|
||||
private func getSelectedText() -> String? {
|
||||
func getSelectedText() -> String? {
|
||||
guard let selectedRange = self.selectedTextRange else { return nil }
|
||||
return self.text(in: selectedRange)
|
||||
}
|
||||
|
||||
@objc private func highlightText(_ sender: Any?) {
|
||||
@objc public func highlightText(_ sender: Any?) {
|
||||
guard let selectedText = self.getSelectedText() else { return }
|
||||
self.postHighlight(selectedText)
|
||||
}
|
||||
|
||||
@objc private func muteText(_ sender: Any?) {
|
||||
@objc public func muteText(_ sender: Any?) {
|
||||
guard let selectedText = self.getSelectedText() else { return }
|
||||
self.muteWord(selectedText)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TextView: UITextViewDelegate {
|
||||
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
|
||||
guard enableHighlighting,
|
||||
let selectedTextRange = self.selectedTextRange,
|
||||
let selectedText = self.text(in: selectedTextRange),
|
||||
!selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let highlightAction = UIAction(title: NSLocalizedString("Highlight", comment: "Context menu action to highlight the selected text as context to draft a new note."), image: UIImage(systemName: "highlighter")) { [weak self] _ in
|
||||
self?.postHighlight(selectedText)
|
||||
}
|
||||
|
||||
let muteAction = UIAction(title: NSLocalizedString("Mute", comment: "Context menu action to mute the selected word."), image: UIImage(systemName: "speaker.slash")) { [weak self] _ in
|
||||
self?.muteWord(selectedText)
|
||||
}
|
||||
|
||||
return UIMenu(children: suggestedActions + [highlightAction, muteAction])
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
||||
|
||||
let attributedString: AttributedString
|
||||
@@ -200,7 +172,7 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
||||
@Binding var height: CGFloat
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
|
||||
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord, enableHighlighting: enableHighlighting)
|
||||
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
|
||||
view.isEditable = false
|
||||
view.dataDetectorTypes = .all
|
||||
view.isSelectable = true
|
||||
@@ -211,6 +183,11 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
||||
view.textContainerInset.right = 1.0
|
||||
view.textAlignment = textAlignment
|
||||
|
||||
let menuController = UIMenuController.shared
|
||||
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
|
||||
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
|
||||
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
|
||||
@@ -213,6 +213,6 @@ struct UserStatusSheet: View {
|
||||
|
||||
struct UserStatusSheet_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.nostrNetwork.postbox, keypair: test_keypair, status: .init())
|
||||
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.postbox, keypair: test_keypair, status: .init())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ struct SupporterBadge_Previews: PreviewProvider {
|
||||
.frame(width: 100)
|
||||
}
|
||||
|
||||
Text(verbatim: "Double star (just shape itself, with alt background color, to show it adapts to background well)")
|
||||
Text("Double star (just shape itself, with alt background color, to show it adapts to background well)")
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if #available(iOS 17.0, *) {
|
||||
@@ -257,13 +257,13 @@ struct SupporterBadge_Previews: PreviewProvider {
|
||||
.background(Color.blue)
|
||||
}
|
||||
|
||||
Text(verbatim: "Double star (fallback for iOS 16 and below)")
|
||||
|
||||
Text("Double star (fallback for iOS 16 and below)")
|
||||
|
||||
HStack(alignment: .center) {
|
||||
DoubleStar.Fallback(size: 17, starOffset: 5)
|
||||
}
|
||||
|
||||
Text(verbatim: "Double star (fallback for iOS 16 and below, with alt color limitation shown)")
|
||||
Text("Double star (fallback for iOS 16 and below, with alt color limitation shown)")
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
HStack(alignment: .center) {
|
||||
|
||||
@@ -160,7 +160,7 @@ func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, set
|
||||
|
||||
// Render translated note
|
||||
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
|
||||
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles, can_hide_last_previewable_refs: true)
|
||||
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles)
|
||||
|
||||
// and cache it
|
||||
return .translated(Translated(artifacts: artifacts, language: note_lang))
|
||||
|
||||
+88
-117
@@ -9,7 +9,6 @@ import SwiftUI
|
||||
import AVKit
|
||||
import MediaPlayer
|
||||
import EmojiPicker
|
||||
import TipKit
|
||||
|
||||
struct ZapSheet {
|
||||
let target: ZapTarget
|
||||
@@ -179,7 +178,7 @@ struct ContentView: View {
|
||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||
|
||||
case .dms:
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||
}
|
||||
}
|
||||
.background(DamusColors.adaptableWhite)
|
||||
@@ -200,7 +199,7 @@ struct ContentView: View {
|
||||
func MaybeReportView(target: ReportTarget) -> some View {
|
||||
Group {
|
||||
if let keypair = damus_state.keypair.to_full() {
|
||||
ReportView(postbox: damus_state.nostrNetwork.postbox, target: target, keypair: keypair)
|
||||
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -223,6 +222,12 @@ struct ContentView: View {
|
||||
navigationCoordinator.push(route: Route.Script(script: model))
|
||||
}
|
||||
|
||||
func open_profile(pubkey: Pubkey) {
|
||||
let profile_model = ProfileModel(pubkey: pubkey, damus: damus_state!)
|
||||
let followers = FollowersModel(damus_state: damus_state!, target: pubkey)
|
||||
navigationCoordinator.push(route: Route.Profile(profile: profile_model, followers: followers))
|
||||
}
|
||||
|
||||
func open_search(filt: NostrFilter) {
|
||||
let search = SearchModel(state: damus_state!, search: filt)
|
||||
navigationCoordinator.push(route: Route.Search(search: search))
|
||||
@@ -307,9 +312,6 @@ struct ContentView: View {
|
||||
hasSeenOnboardingSuggestions = true
|
||||
}
|
||||
self.appDelegate?.state = damus_state
|
||||
Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread.
|
||||
await self.listenAndHandleLocalNotifications()
|
||||
}
|
||||
}
|
||||
.sheet(item: $active_sheet) { item in
|
||||
switch item {
|
||||
@@ -318,7 +320,7 @@ struct ContentView: View {
|
||||
case .post(let action):
|
||||
PostView(action: action, damus_state: damus_state!)
|
||||
case .user_status:
|
||||
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.nostrNetwork.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
|
||||
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
|
||||
.presentationDragIndicator(.visible)
|
||||
case .event:
|
||||
EventDetailView()
|
||||
@@ -334,20 +336,7 @@ struct ContentView: View {
|
||||
.presentationDetents([.height(550)])
|
||||
.presentationDragIndicator(.visible)
|
||||
case .onboardingSuggestions:
|
||||
if let model = try? SuggestedUsersViewModel(damus_state: damus_state!) {
|
||||
OnboardingSuggestionsView(model: model)
|
||||
.interactiveDismissDisabled(true)
|
||||
}
|
||||
else {
|
||||
ErrorView(
|
||||
damus_state: damus_state,
|
||||
error: .init(
|
||||
user_visible_description: NSLocalizedString("Unexpected error loading user suggestions", comment: "Human readable error label"),
|
||||
tip: NSLocalizedString("Please contact support", comment: "Human readable error tip"),
|
||||
technical_info: "Error inializing SuggestedUsersViewModel"
|
||||
)
|
||||
)
|
||||
}
|
||||
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
|
||||
case .purple(let purple_url):
|
||||
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
|
||||
case .purple_onboarding:
|
||||
@@ -370,7 +359,7 @@ struct ContentView: View {
|
||||
self.hide_bar = !show
|
||||
}
|
||||
.onReceive(timer) { n in
|
||||
self.damus_state?.nostrNetwork.postbox.try_flushing_events()
|
||||
self.damus_state?.postbox.try_flushing_events()
|
||||
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
|
||||
}
|
||||
.onReceive(handle_notify(.report)) { target in
|
||||
@@ -401,12 +390,12 @@ struct ContentView: View {
|
||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions)
|
||||
|
||||
guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||
ds.nostrNetwork.postbox.send(ev)
|
||||
ds.postbox.send(ev)
|
||||
}
|
||||
.onReceive(handle_notify(.broadcast)) { ev in
|
||||
guard let ds = self.damus_state else { return }
|
||||
|
||||
ds.nostrNetwork.postbox.send(ev)
|
||||
ds.postbox.send(ev)
|
||||
}
|
||||
.onReceive(handle_notify(.unfollow)) { target in
|
||||
guard let state = self.damus_state else { return }
|
||||
@@ -428,7 +417,7 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
|
||||
if !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) {
|
||||
if !handle_post_notification(keypair: keypair, postbox: state.postbox, events: state.events, post: post) {
|
||||
self.active_sheet = nil
|
||||
}
|
||||
}
|
||||
@@ -472,7 +461,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.disconnect_relays)) { () in
|
||||
damus_state.nostrNetwork.pool.disconnect()
|
||||
damus_state.pool.disconnect()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
|
||||
print("txn: 📙 DAMUS ACTIVE NOTIFY")
|
||||
@@ -518,10 +507,31 @@ struct ContentView: View {
|
||||
break
|
||||
case .active:
|
||||
print("txn: 📙 DAMUS ACTIVE")
|
||||
damus_state.nostrNetwork.pool.ping()
|
||||
damus_state.pool.ping()
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.local_notification)) { local in
|
||||
guard let damus_state else { return }
|
||||
|
||||
switch local.mention {
|
||||
case .pubkey(let pubkey):
|
||||
open_profile(pubkey: pubkey)
|
||||
|
||||
case .note(let noteId):
|
||||
openEvent(noteId: noteId, notificationType: local.type)
|
||||
case .nevent(let nevent):
|
||||
openEvent(noteId: nevent.noteid, notificationType: local.type)
|
||||
case .nprofile(let nprofile):
|
||||
open_profile(pubkey: nprofile.author)
|
||||
case .nrelay(_):
|
||||
break
|
||||
case .naddr(let naddr):
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
.onReceive(handle_notify(.onlyzaps_mode)) { hide in
|
||||
home.filter_events()
|
||||
@@ -537,7 +547,7 @@ struct ContentView: View {
|
||||
let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide)
|
||||
|
||||
guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return }
|
||||
ds.nostrNetwork.postbox.send(profile_ev)
|
||||
ds.postbox.send(profile_ev)
|
||||
}
|
||||
.alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: {
|
||||
Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) {
|
||||
@@ -569,7 +579,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
ds.mutelist_manager.set_mutelist(mutelist)
|
||||
ds.nostrNetwork.postbox.send(mutelist)
|
||||
ds.postbox.send(mutelist)
|
||||
|
||||
confirm_overwrite_mutelist = false
|
||||
confirm_mute = false
|
||||
@@ -601,7 +611,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
ds.mutelist_manager.set_mutelist(ev)
|
||||
ds.nostrNetwork.postbox.send(ev)
|
||||
ds.postbox.send(ev)
|
||||
}
|
||||
}
|
||||
}, message: {
|
||||
@@ -631,28 +641,6 @@ struct ContentView: View {
|
||||
self.selected_timeline = timeline
|
||||
}
|
||||
|
||||
/// Listens to requests to open a push/local user notification
|
||||
///
|
||||
/// This function never returns, it just keeps streaming
|
||||
func listenAndHandleLocalNotifications() async {
|
||||
for await notification in await QueueableNotify<LossyLocalNotification>.shared.stream {
|
||||
self.handleNotification(notification: notification)
|
||||
}
|
||||
}
|
||||
|
||||
func handleNotification(notification: LossyLocalNotification) {
|
||||
Log.info("ContentView is handling a notification", for: .push_notifications)
|
||||
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)
|
||||
return
|
||||
}
|
||||
let local = notification
|
||||
let openAction = local.toViewOpenAction()
|
||||
self.execute_open_action(openAction)
|
||||
}
|
||||
|
||||
func connect() {
|
||||
// nostrdb
|
||||
var mndb = Ndb()
|
||||
@@ -670,14 +658,28 @@ struct ContentView: View {
|
||||
|
||||
guard let ndb = mndb else { return }
|
||||
|
||||
let pool = RelayPool(ndb: ndb, keypair: keypair)
|
||||
let model_cache = RelayModelCache()
|
||||
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
||||
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
|
||||
|
||||
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
||||
|
||||
let new_relay_filters = load_relay_filters(pubkey) == nil
|
||||
for relay in bootstrap_relays {
|
||||
let descriptor = RelayDescriptor(url: relay, info: .rw)
|
||||
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
|
||||
}
|
||||
|
||||
self.damus_state = DamusState(keypair: keypair,
|
||||
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||
|
||||
if let nwc_str = settings.nostr_wallet_connect,
|
||||
let nwc = WalletConnectURL(str: nwc_str) {
|
||||
try? pool.add_relay(.nwc(url: nwc.relay))
|
||||
}
|
||||
|
||||
self.damus_state = DamusState(pool: pool,
|
||||
keypair: keypair,
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
boosts: EventCounter(our_pubkey: pubkey),
|
||||
contacts: Contacts(our_pubkey: pubkey),
|
||||
@@ -693,6 +695,8 @@ struct ContentView: View {
|
||||
drafts: Drafts(),
|
||||
events: EventCache(ndb: ndb),
|
||||
bookmarks: BookmarksManager(pubkey: pubkey),
|
||||
postbox: PostBox(pool: pool),
|
||||
bootstrap_relays: bootstrap_relays,
|
||||
replies: ReplyCounter(our_pubkey: pubkey),
|
||||
wallet: WalletModel(settings: settings),
|
||||
nav: self.navigationCoordinator,
|
||||
@@ -700,8 +704,7 @@ struct ContentView: View {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: FaviconCache()
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
)
|
||||
|
||||
home.damus_state = self.damus_state!
|
||||
@@ -717,23 +720,7 @@ struct ContentView: View {
|
||||
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
||||
}
|
||||
|
||||
damus_state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||
damus_state.nostrNetwork.connect()
|
||||
|
||||
if #available(iOS 17, *) {
|
||||
if damus_state.settings.developer_mode && damus_state.settings.reset_tips_on_launch {
|
||||
do {
|
||||
try Tips.resetDatastore()
|
||||
} catch {
|
||||
Log.error("Failed to reset tips datastore: %s", for: .tips, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
do {
|
||||
try Tips.configure()
|
||||
} catch {
|
||||
Log.error("Failed to configure tips: %s", for: .tips, error.localizedDescription)
|
||||
}
|
||||
}
|
||||
pool.connect()
|
||||
}
|
||||
|
||||
func music_changed(_ state: MusicState) {
|
||||
@@ -756,7 +743,24 @@ struct ContentView: View {
|
||||
pdata.status.music = music
|
||||
|
||||
guard let ev = music.to_note(keypair: kp) else { return }
|
||||
damus_state.nostrNetwork.postbox.send(ev)
|
||||
damus_state.postbox.send(ev)
|
||||
}
|
||||
}
|
||||
|
||||
private func openEvent(noteId: NoteId, notificationType: LocalNotificationType) {
|
||||
guard let target = damus_state.events.lookup(noteId) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch notificationType {
|
||||
case .dm:
|
||||
selected_timeline = .dms
|
||||
damus_state.dms.set_active_dm(target.pubkey)
|
||||
navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
|
||||
case .like, .zap, .mention, .repost, .reply, .tagged:
|
||||
open_event(ev: target)
|
||||
case .profile_zap:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,8 +776,6 @@ struct ContentView: View {
|
||||
case route(Route)
|
||||
/// Open a sheet
|
||||
case sheet(Sheets)
|
||||
/// Open an external URL
|
||||
case external_url(URL)
|
||||
/// Do nothing.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
@@ -790,8 +792,6 @@ struct ContentView: View {
|
||||
navigationCoordinator.push(route: route)
|
||||
case .sheet(let sheet):
|
||||
self.active_sheet = sheet
|
||||
case .external_url(let url):
|
||||
this_app.open(url)
|
||||
case .no_action:
|
||||
return
|
||||
}
|
||||
@@ -1009,7 +1009,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
||||
var has_event = false
|
||||
guard let filter else { return }
|
||||
|
||||
state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
|
||||
state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in
|
||||
guard case .nostr_event(let ev) = res else {
|
||||
return
|
||||
}
|
||||
@@ -1023,7 +1023,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
||||
break
|
||||
case .event(_, let ev):
|
||||
has_event = true
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: subid)
|
||||
state.pool.unsubscribe(sub_id: subid)
|
||||
|
||||
switch query {
|
||||
case .profile:
|
||||
@@ -1036,11 +1036,11 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
||||
case .eose:
|
||||
if !has_event {
|
||||
attempts += 1
|
||||
if attempts >= state.nostrNetwork.pool.our_descriptors.count {
|
||||
if attempts >= state.pool.our_descriptors.count {
|
||||
callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil
|
||||
}
|
||||
}
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
|
||||
state.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose
|
||||
case .notice:
|
||||
break
|
||||
case .auth:
|
||||
@@ -1059,15 +1059,15 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
||||
/// - naddr: the `naddr` address
|
||||
/// - callback: A function to handle the found event
|
||||
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
|
||||
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
||||
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
|
||||
|
||||
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
|
||||
|
||||
let subid = UUID().description
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
|
||||
damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
|
||||
guard case .nostr_event(let ev) = res else {
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1075,14 +1075,14 @@ func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (Nos
|
||||
for tag in ev.tags {
|
||||
if(tag.count >= 2 && tag[0].string() == "d"){
|
||||
if (tag[1].string() == naddr.identifier){
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
callback(ev)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1130,7 +1130,7 @@ func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool {
|
||||
|
||||
let old_contacts = state.contacts.event
|
||||
|
||||
guard let ev = unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
|
||||
guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -1156,7 +1156,7 @@ func handle_follow(state: DamusState, follow: FollowRef) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let ev = follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
|
||||
guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
@@ -1216,35 +1216,6 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
|
||||
}
|
||||
}
|
||||
|
||||
extension LossyLocalNotification {
|
||||
/// Computes a view open action from a mention reference.
|
||||
/// Use this when opening a user-presentable interface to a specific mention reference.
|
||||
func toViewOpenAction() -> ContentView.ViewOpenAction {
|
||||
switch self.mention {
|
||||
case .pubkey(let pubkey):
|
||||
return .route(.ProfileByKey(pubkey: pubkey))
|
||||
case .note(let noteId):
|
||||
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId)))
|
||||
case .nevent(let nEvent):
|
||||
// TODO: Improve this by implementing a route that handles nevents with their relay hints.
|
||||
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid)))
|
||||
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:
|
||||
// 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(
|
||||
user_visible_description: NSLocalizedString("You opened an invalid link. The link you tried to open refers to \"nrelay\", which has been deprecated and is not supported.", comment: "User-visible error description for a user who tries to open a deprecated \"nrelay\" link."),
|
||||
tip: NSLocalizedString("Please contact the person who provided the link, and ask for another link.", comment: "User-visible tip on what to do if a link contains a deprecated \"nrelay\" reference."),
|
||||
technical_info: "`MentionRef.toViewOpenAction` detected deprecated `nrelay` contents"
|
||||
)))
|
||||
case .naddr(let nAddr):
|
||||
return .route(.LoadableNostrEvent(note_reference: .naddr(nAddr)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func logout(_ state: DamusState?)
|
||||
{
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
//
|
||||
// Interests.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-06-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct DIP06 {
|
||||
/// Standard general interest topics.
|
||||
/// See https://github.com/damus-io/dips/pull/3
|
||||
enum Interest: String, CaseIterable {
|
||||
/// Bitcoin-related topics (e.g. Bitcoin, Lightning, e-cash etc)
|
||||
case bitcoin = "bitcoin"
|
||||
/// Any non-Bitcoin technology-related topic (e.g. Linux, new releases, software development, supersonic flight, etc)
|
||||
case technology = "technology"
|
||||
/// Any science-related topic (e.g. astronomy, biology, physics, etc)
|
||||
case science = "science"
|
||||
/// Lifestyle topics (e.g. Worldschooling, Digital nomading, vagabonding, homesteading, digital minimalism, life hacks, etc)
|
||||
case lifestyle = "lifestyle"
|
||||
/// Travel-related topics (e.g. Information about locations to visit, travel logs, etc)
|
||||
case travel = "travel"
|
||||
/// Any art-related topic (e.g. poetry, painting, sculpting, photography, etc)
|
||||
case art = "art"
|
||||
/// Topics focused on improving human health (e.g. advances in medicine, exercising, nutrition, meditation, sleep, etc)
|
||||
case health = "health"
|
||||
/// Any music-related topic (e.g. Bands, fan pages, instruments, classical music theory, etc)
|
||||
case music = "music"
|
||||
/// Any topic related to food (e.g. Cooking, recipes, meal planning, nutrition)
|
||||
case food = "food"
|
||||
/// Any topic related to sports (e.g. Athlete fan pages, general sports information, sports news, sports equipment, etc)
|
||||
case sports = "sports"
|
||||
/// Any topic related to religion, spirituality, or faith (e.g. Christianity, Judaism, Buddhism, Islamism, Hinduism, Taoism, general meditation practice, etc)
|
||||
case religionSpirituality = "religion-spirituality"
|
||||
/// General humanities topics (e.g. philosophy, sociology, culture, etc)
|
||||
case humanities = "humanities"
|
||||
/// General topics about politics
|
||||
case politics = "politics"
|
||||
/// Other miscellaneous topics that do not fit in any of the previous items of the list
|
||||
case other = "other"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .bitcoin:
|
||||
return NSLocalizedString("₿ Bitcoin", comment: "Interest topic label")
|
||||
case .technology:
|
||||
return NSLocalizedString("💻 Tech", comment: "Interest topic label")
|
||||
case .science:
|
||||
return NSLocalizedString("🔭 Science", comment: "Interest topic label")
|
||||
case .lifestyle:
|
||||
return NSLocalizedString("🏝️ Lifestyle", comment: "Interest topic label")
|
||||
case .travel:
|
||||
return NSLocalizedString("✈️ Travel", comment: "Interest topic label")
|
||||
case .art:
|
||||
return NSLocalizedString("🎨 Art", comment: "Interest topic label")
|
||||
case .health:
|
||||
return NSLocalizedString("🏃 Health", comment: "Interest topic label")
|
||||
case .music:
|
||||
return NSLocalizedString("🎶 Music", comment: "Interest topic label")
|
||||
case .food:
|
||||
return NSLocalizedString("🍱 Food", comment: "Interest topic label")
|
||||
case .sports:
|
||||
return NSLocalizedString("⚾️ Sports", comment: "Interest topic label")
|
||||
case .religionSpirituality:
|
||||
return NSLocalizedString("🛐 Religion", comment: "Interest topic label")
|
||||
case .humanities:
|
||||
return NSLocalizedString("📚 Humanities", comment: "Interest topic label")
|
||||
case .politics:
|
||||
return NSLocalizedString("🏛️ Politics", comment: "Interest topic label")
|
||||
case .other:
|
||||
return NSLocalizedString("♾️ Other", comment: "Interest topic label")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// CameraService+Extensions.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Suhail Saqan on 8/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
extension AVCaptureVideoOrientation {
|
||||
init?(deviceOrientation: UIDeviceOrientation) {
|
||||
switch deviceOrientation {
|
||||
case .portrait: self = .portrait
|
||||
case .portraitUpsideDown: self = .portraitUpsideDown
|
||||
case .landscapeLeft: self = .landscapeRight
|
||||
case .landscapeRight: self = .landscapeLeft
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
init?(interfaceOrientation: UIInterfaceOrientation) {
|
||||
switch interfaceOrientation {
|
||||
case .portrait: self = .portrait
|
||||
case .portraitUpsideDown: self = .portraitUpsideDown
|
||||
case .landscapeLeft: self = .landscapeLeft
|
||||
case .landscapeRight: self = .landscapeRight
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,44 @@ func follow_user_event(our_contacts: NostrEvent?, keypair: FullKeypair, follow:
|
||||
}
|
||||
|
||||
|
||||
func decode_json_relays(_ content: String) -> [RelayURL: LegacyKind3RelayRWConfiguration]? {
|
||||
func decode_json_relays(_ content: String) -> [RelayURL: RelayInfo]? {
|
||||
return decode_json(content)
|
||||
}
|
||||
|
||||
func remove_relay(ev: NostrEvent, current_relays: [RelayDescriptor], keypair: FullKeypair, relay: RelayURL) -> NostrEvent?{
|
||||
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
|
||||
|
||||
relays.removeValue(forKey: relay)
|
||||
|
||||
guard let content = encode_json(relays) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
|
||||
}
|
||||
|
||||
func add_relay(ev: NostrEvent, keypair: FullKeypair, current_relays: [RelayDescriptor], relay: RelayURL, info: RelayInfo) -> NostrEvent? {
|
||||
var relays = ensure_relay_info(relays: current_relays, content: ev.content)
|
||||
|
||||
// If kind:3 content is empty, or if the relay doesn't exist in the list,
|
||||
// we want to create a kind:3 event with the new relay
|
||||
guard ev.content.isEmpty || relays.index(forKey: relay) == nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
relays[relay] = info
|
||||
|
||||
guard let content = encode_json(relays) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings())
|
||||
}
|
||||
|
||||
func ensure_relay_info(relays: [RelayDescriptor], content: String) -> [RelayURL: RelayInfo] {
|
||||
return decode_json_relays(content) ?? make_contact_relays(relays)
|
||||
}
|
||||
|
||||
func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
|
||||
return contacts.references.contains { ref in
|
||||
switch (ref, follow) {
|
||||
@@ -94,3 +128,22 @@ func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEven
|
||||
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: tags)
|
||||
}
|
||||
|
||||
func make_contact_relays(_ relays: [RelayDescriptor]) -> [RelayURL: RelayInfo] {
|
||||
return relays.reduce(into: [:]) { acc, relay in
|
||||
acc[relay.url] = relay.info
|
||||
}
|
||||
}
|
||||
|
||||
func make_relay_metadata(relays: [RelayDescriptor], keypair: FullKeypair) -> NostrEvent? {
|
||||
let tags = relays.compactMap { r -> [String]? in
|
||||
var tag = ["r", r.url.absoluteString]
|
||||
if (r.info.read ?? true) != (r.info.write ?? true) {
|
||||
tag += r.info.read == true ? ["read"] : ["write"]
|
||||
}
|
||||
if ((r.info.read ?? true) || (r.info.write ?? true)) && r.variant == .regular {
|
||||
return tag;
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 10_002, tags: tags)
|
||||
}
|
||||
|
||||
@@ -38,10 +38,6 @@ class Contacts {
|
||||
return friends
|
||||
}
|
||||
|
||||
func get_friend_of_friends_list() -> Set<Pubkey> {
|
||||
return friend_of_friends
|
||||
}
|
||||
|
||||
func get_followed_hashtags() -> Set<String> {
|
||||
guard let ev = self.event else { return Set() }
|
||||
return Set(ev.referenced_hashtags.map({ $0.hashtag }))
|
||||
|
||||
@@ -10,10 +10,8 @@ import Foundation
|
||||
|
||||
/// Simple filter to determine whether to show posts or all posts and replies.
|
||||
enum FilterState : Int {
|
||||
case posts = 0
|
||||
case posts_and_replies = 1
|
||||
case conversations = 2
|
||||
case follow_list = 3
|
||||
case posts = 0
|
||||
|
||||
func filter(ev: NostrEvent) -> Bool {
|
||||
switch self {
|
||||
@@ -21,17 +19,13 @@ 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
|
||||
case .follow_list:
|
||||
return ev.known_kind == .follow_list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple filter to determine whether to show posts with #nsfw tags
|
||||
func nsfw_tag_filter(ev: NostrEvent) -> Bool {
|
||||
return ev.referenced_hashtags.first(where: { t in t.hashtag.caseInsensitiveCompare("nsfw") == .orderedSame }) == nil
|
||||
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
|
||||
}
|
||||
|
||||
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
|
||||
@@ -43,12 +37,6 @@ func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEv
|
||||
}
|
||||
}
|
||||
|
||||
func timestamp_filter(ev: NostrEvent) -> Bool {
|
||||
// Allow notes that are created no more than 3 seconds in the future
|
||||
// to account for natural clock skew between sender and receiver.
|
||||
ev.age >= -3
|
||||
}
|
||||
|
||||
/// Generic filter with various tweakable settings
|
||||
struct ContentFilters {
|
||||
var filters: [(NostrEvent) -> Bool]
|
||||
@@ -75,7 +63,6 @@ extension ContentFilters {
|
||||
filters.append(nsfw_tag_filter)
|
||||
}
|
||||
filters.append(get_repost_of_muted_user_filter(damus_state: damus_state))
|
||||
filters.append(timestamp_filter)
|
||||
return filters
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,6 @@ class CreateAccountModel: ObservableObject {
|
||||
return Keypair(pubkey: self.pubkey, privkey: self.privkey)
|
||||
}
|
||||
|
||||
var full_keypair: FullKeypair {
|
||||
return FullKeypair(pubkey: self.pubkey, privkey: self.privkey)
|
||||
}
|
||||
|
||||
init(display_name: String = "", name: String = "", about: String = "") {
|
||||
let keypair = generate_new_keypair()
|
||||
self.pubkey = keypair.pubkey
|
||||
|
||||
@@ -10,6 +10,7 @@ import LinkPresentation
|
||||
import EmojiPicker
|
||||
|
||||
class DamusState: HeadlessDamusState {
|
||||
let pool: RelayPool
|
||||
let keypair: Keypair
|
||||
let likes: EventCounter
|
||||
let boosts: EventCounter
|
||||
@@ -27,6 +28,8 @@ class DamusState: HeadlessDamusState {
|
||||
let drafts: Drafts
|
||||
let events: EventCache
|
||||
let bookmarks: BookmarksManager
|
||||
let postbox: PostBox
|
||||
let bootstrap_relays: [RelayURL]
|
||||
let replies: ReplyCounter
|
||||
let wallet: WalletModel
|
||||
let nav: NavigationCoordinator
|
||||
@@ -36,10 +39,9 @@ class DamusState: HeadlessDamusState {
|
||||
var purple: DamusPurple
|
||||
var push_notification_client: PushNotificationClient
|
||||
let emoji_provider: EmojiProvider
|
||||
let favicon_cache: FaviconCache
|
||||
private(set) var nostrNetwork: NostrNetworkManager
|
||||
|
||||
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache) {
|
||||
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
|
||||
self.pool = pool
|
||||
self.keypair = keypair
|
||||
self.likes = likes
|
||||
self.boosts = boosts
|
||||
@@ -56,6 +58,8 @@ class DamusState: HeadlessDamusState {
|
||||
self.drafts = drafts
|
||||
self.events = events
|
||||
self.bookmarks = bookmarks
|
||||
self.postbox = postbox
|
||||
self.bootstrap_relays = bootstrap_relays
|
||||
self.replies = replies
|
||||
self.wallet = wallet
|
||||
self.nav = nav
|
||||
@@ -69,10 +73,6 @@ class DamusState: HeadlessDamusState {
|
||||
self.quote_reposts = quote_reposts
|
||||
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
||||
self.emoji_provider = emoji_provider
|
||||
self.favicon_cache = FaviconCache()
|
||||
|
||||
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
|
||||
self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -98,13 +98,27 @@ class DamusState: HeadlessDamusState {
|
||||
guard let ndb = mndb else { return nil }
|
||||
let pubkey = keypair.pubkey
|
||||
|
||||
let pool = RelayPool(ndb: ndb, keypair: keypair)
|
||||
let model_cache = RelayModelCache()
|
||||
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
||||
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
|
||||
|
||||
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
||||
|
||||
let new_relay_filters = load_relay_filters(pubkey) == nil
|
||||
for relay in bootstrap_relays {
|
||||
let descriptor = RelayDescriptor(url: relay, info: .rw)
|
||||
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
|
||||
}
|
||||
|
||||
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||
|
||||
if let nwc_str = settings.nostr_wallet_connect,
|
||||
let nwc = WalletConnectURL(str: nwc_str) {
|
||||
try? pool.add_relay(.nwc(url: nwc.relay))
|
||||
}
|
||||
self.init(
|
||||
pool: pool,
|
||||
keypair: keypair,
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
boosts: EventCounter(our_pubkey: pubkey),
|
||||
@@ -121,6 +135,8 @@ class DamusState: HeadlessDamusState {
|
||||
drafts: Drafts(),
|
||||
events: EventCache(ndb: ndb),
|
||||
bookmarks: BookmarksManager(pubkey: pubkey),
|
||||
postbox: PostBox(pool: pool),
|
||||
bootstrap_relays: bootstrap_relays,
|
||||
replies: ReplyCounter(our_pubkey: pubkey),
|
||||
wallet: WalletModel(settings: settings),
|
||||
nav: navigationCoordinator,
|
||||
@@ -128,8 +144,7 @@ class DamusState: HeadlessDamusState {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: FaviconCache()
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -164,7 +179,7 @@ class DamusState: HeadlessDamusState {
|
||||
try await self.push_notification_client.revoke_token()
|
||||
}
|
||||
wallet.disconnect()
|
||||
nostrNetwork.pool.close()
|
||||
pool.close()
|
||||
ndb.close()
|
||||
}
|
||||
|
||||
@@ -174,6 +189,7 @@ class DamusState: HeadlessDamusState {
|
||||
let kp = Keypair(pubkey: empty_pub, privkey: nil)
|
||||
|
||||
return DamusState.init(
|
||||
pool: RelayPool(ndb: .empty),
|
||||
keypair: Keypair(pubkey: empty_pub, privkey: empty_sec),
|
||||
likes: EventCounter(our_pubkey: empty_pub),
|
||||
boosts: EventCounter(our_pubkey: empty_pub),
|
||||
@@ -190,6 +206,8 @@ class DamusState: HeadlessDamusState {
|
||||
drafts: Drafts(),
|
||||
events: EventCache(ndb: .empty),
|
||||
bookmarks: BookmarksManager(pubkey: empty_pub),
|
||||
postbox: PostBox(pool: RelayPool(ndb: .empty)),
|
||||
bootstrap_relays: [],
|
||||
replies: ReplyCounter(our_pubkey: empty_pub),
|
||||
wallet: WalletModel(settings: UserSettingsStore()),
|
||||
nav: NavigationCoordinator(),
|
||||
@@ -197,34 +215,7 @@ class DamusState: HeadlessDamusState {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: .empty,
|
||||
quote_reposts: .init(our_pubkey: empty_pub),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: FaviconCache()
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension DamusState {
|
||||
struct NostrNetworkManagerDelegate: NostrNetworkManager.Delegate {
|
||||
let settings: UserSettingsStore
|
||||
let contacts: Contacts
|
||||
|
||||
var ndb: Ndb
|
||||
var keypair: Keypair
|
||||
|
||||
var latestRelayListEventIdHex: String? {
|
||||
get { self.settings.latestRelayListEventIdHex }
|
||||
set { self.settings.latestRelayListEventIdHex = newValue }
|
||||
}
|
||||
|
||||
var latestContactListEvent: NostrEvent? { self.contacts.event }
|
||||
var bootstrapRelays: [RelayURL] { get_default_bootstrap_relays() }
|
||||
var developerMode: Bool { self.settings.developer_mode }
|
||||
var relayModelCache: RelayModelCache
|
||||
var relayFilters: RelayFilters
|
||||
|
||||
var nwcWallet: WalletConnectURL? {
|
||||
guard let nwcString = self.settings.nostr_wallet_connect else { return nil }
|
||||
return WalletConnectURL(str: nwcString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ class DraftArtifacts: Equatable {
|
||||
if case .pubkey(let pubkey) = mention.ref {
|
||||
// A profile reference, format things properly.
|
||||
let profile = damus_state.ndb.lookup_profile(pubkey)?.unsafeUnownedValue?.profile
|
||||
let profile_name = DisplayName(profile: profile, pubkey: pubkey).username
|
||||
let profile_name = parse_display_name(profile: profile, pubkey: pubkey).username
|
||||
guard let url_address = URL(string: block.asString) else {
|
||||
rich_text_content.append(.init(string: block.asString))
|
||||
continue
|
||||
@@ -251,7 +251,7 @@ class Drafts: ObservableObject {
|
||||
// TODO: Once it is time to implement draft syncing with relays, please consider the following:
|
||||
// - Privacy: Sending drafts to the network leaks metadata about app activity, and may break user expectations
|
||||
// - Down-sync conflict resolution: Consider how to solve conflicts for different draft versions holding the same ID (e.g. edited in Damus, then another client, then Damus again)
|
||||
damus_state.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
|
||||
damus_state.pool.send_raw_to_local_ndb(.typical(.event(draft_event)))
|
||||
}
|
||||
|
||||
damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() })
|
||||
|
||||
@@ -68,13 +68,13 @@ class EventsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func subscribe() {
|
||||
state.nostrNetwork.pool.subscribe(sub_id: sub_id,
|
||||
state.pool.subscribe(sub_id: sub_id,
|
||||
filters: [get_filter()],
|
||||
handler: handle_nostr_event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
state.pool.unsubscribe(sub_id: sub_id)
|
||||
}
|
||||
|
||||
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
//
|
||||
// FollowPackEvent.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 4/30/25.
|
||||
//
|
||||
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FollowPackEvent: Hashable {
|
||||
let event: NostrEvent
|
||||
var title: String? = nil
|
||||
var uuid: String? = nil
|
||||
var image: URL? = nil
|
||||
var description: String? = nil
|
||||
var publicKeys: [Pubkey] = []
|
||||
var interests: Set<DIP06.Interest> = []
|
||||
|
||||
|
||||
static func parse(from ev: NostrEvent) -> FollowPackEvent {
|
||||
var followlist = FollowPackEvent(event: ev)
|
||||
|
||||
for tag in ev.tags {
|
||||
guard tag.count >= 2 else { continue }
|
||||
switch tag[0].string() {
|
||||
case "title": followlist.title = tag[1].string()
|
||||
case "d": followlist.uuid = tag[1].string()
|
||||
case "image": followlist.image = URL(string: tag[1].string())
|
||||
case "description": followlist.description = tag[1].string()
|
||||
case "p":
|
||||
followlist.publicKeys.append(Pubkey(Data(hex: tag[1].string())))
|
||||
case "t":
|
||||
if let interest = DIP06.Interest(rawValue: tag[1].string()) {
|
||||
followlist.interests.insert(interest)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return followlist
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//
|
||||
// FollowPackModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by eric on 6/5/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
class FollowPackModel: ObservableObject {
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool = false
|
||||
|
||||
let damus_state: DamusState
|
||||
let subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
|
||||
init(damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
self.events = EventHolder(on_queue: { ev in
|
||||
preload_events(state: damus_state, events: [ev])
|
||||
})
|
||||
}
|
||||
|
||||
func subscribe(follow_pack_users: [Pubkey]) {
|
||||
loading = true
|
||||
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||
var filter = NostrFilter(kinds: [.text, .chat])
|
||||
filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||
filter.authors = follow_pack_users
|
||||
filter.limit = 500
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: subid, filters: [filter], handler: handle_event, to: to_relays)
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
loading = false
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: to.map { [$0] })
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let event) = conn_ev else {
|
||||
return
|
||||
}
|
||||
|
||||
switch event {
|
||||
case .event(let sub_id, let ev):
|
||||
guard sub_id == self.subid else {
|
||||
return
|
||||
}
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||
{
|
||||
if self.events.insert(ev) {
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
case .notice(let msg):
|
||||
print("follow pack notice: \(msg)")
|
||||
case .ok:
|
||||
break
|
||||
case .eose(let sub_id):
|
||||
loading = false
|
||||
|
||||
if sub_id == self.subid {
|
||||
unsubscribe(to: relay_id)
|
||||
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||
}
|
||||
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,11 +37,11 @@ class FollowersModel: ObservableObject {
|
||||
let filter = get_filter()
|
||||
let filters = [filter]
|
||||
//print_filters(relay_id: "following", filters: [filters])
|
||||
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
self.damus_state.pool.unsubscribe(sub_id: sub_id)
|
||||
}
|
||||
|
||||
func handle_contact_event(_ ev: NostrEvent) {
|
||||
@@ -61,7 +61,7 @@ class FollowersModel: ObservableObject {
|
||||
|
||||
let filter = NostrFilter(kinds: [.metadata],
|
||||
authors: authors)
|
||||
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
|
||||
damus_state.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event)
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
@@ -86,7 +86,7 @@ class FollowersModel: ObservableObject {
|
||||
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
|
||||
load_profiles(relay_id: relay_id, txn: txn)
|
||||
} else if sub_id == self.profiles_id {
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
||||
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
||||
}
|
||||
|
||||
case .ok:
|
||||
|
||||
@@ -42,7 +42,7 @@ class FollowingModel {
|
||||
}
|
||||
let filters = [filter]
|
||||
//print_filters(relay_id: "following", filters: [filters])
|
||||
self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
@@ -50,7 +50,7 @@ class FollowingModel {
|
||||
return
|
||||
}
|
||||
print("unsubscribing from following \(sub_id)")
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
self.damus_state.pool.unsubscribe(sub_id: sub_id)
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
|
||||
@@ -35,9 +35,9 @@ enum FriendFilter: String, StringCodable {
|
||||
func description() -> String {
|
||||
switch self {
|
||||
case .all:
|
||||
return NSLocalizedString("All", comment: "Human-readable short description of the 'trusted network filter' when it is disabled, and therefore is showing all content.")
|
||||
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
|
||||
case .friends_of_friends:
|
||||
return NSLocalizedString("Trusted Network", comment: "Human-readable short description of the 'trusted network filter' when it is enabled, and therefore showing content from only the trusted network.")
|
||||
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ class HomeModel: ContactsDelegate {
|
||||
var notifications = NotificationsModel()
|
||||
var notification_status = NotificationStatusModel()
|
||||
var events: EventHolder = EventHolder()
|
||||
var already_reposted: Set<NoteId> = Set()
|
||||
var zap_button: ZapButtonModel = ZapButtonModel()
|
||||
|
||||
init() {
|
||||
@@ -95,13 +94,13 @@ class HomeModel: ContactsDelegate {
|
||||
}
|
||||
|
||||
var pool: RelayPool {
|
||||
self.damus_state.nostrNetwork.pool
|
||||
return damus_state.pool
|
||||
}
|
||||
|
||||
var dms: DirectMessagesModel {
|
||||
return damus_state.dms
|
||||
}
|
||||
|
||||
|
||||
func has_sub_id_event(sub_id: String, ev_id: NoteId) -> Bool {
|
||||
if !has_event.keys.contains(sub_id) {
|
||||
has_event[sub_id] = Set()
|
||||
@@ -225,12 +224,6 @@ class HomeModel: ContactsDelegate {
|
||||
// TODO: Implement draft syncing with relays. We intentionally do not support that as of writing. See `DraftsModel.swift` for other details
|
||||
// try? damus_state.drafts.load(wrapped_draft_note: ev, with: damus_state)
|
||||
break
|
||||
case .relay_list:
|
||||
break // This will be handled by `UserRelayListManager`
|
||||
case .follow_list:
|
||||
break
|
||||
case .interest_list:
|
||||
break // Don't care for now
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,46 +258,27 @@ class HomeModel: ContactsDelegate {
|
||||
Task { @MainActor in
|
||||
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
|
||||
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
|
||||
let nwc = WalletConnectURL(str: nwc_str) else {
|
||||
return
|
||||
}
|
||||
|
||||
guard nwc.relay == relay else { return } // Don't process NWC responses coming from relays other than our designated one
|
||||
guard ev.referenced_pubkeys.first == nwc.keypair.pubkey else {
|
||||
return // This message is not for us. Ignore it.
|
||||
}
|
||||
|
||||
var resp: WalletConnect.FullWalletResponse? = nil
|
||||
do {
|
||||
resp = try await WalletConnect.FullWalletResponse(from: ev, nwc: nwc)
|
||||
} catch {
|
||||
Log.error("HomeModel: Error on NWC wallet response handling: %s", for: .nwc, error.localizedDescription)
|
||||
if let initError = error as? WalletConnect.FullWalletResponse.InitializationError,
|
||||
let humanReadableError = initError.humanReadableError {
|
||||
present_sheet(.error(humanReadableError))
|
||||
}
|
||||
}
|
||||
guard let resp else { return }
|
||||
|
||||
// since command results are not returned for ephemeral events,
|
||||
// remove the request from the postbox which is likely failing over and over
|
||||
if damus_state.nostrNetwork.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
|
||||
Log.debug("HomeModel: got NWC response, removed %s from the postbox [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
|
||||
} else {
|
||||
Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
|
||||
}
|
||||
|
||||
damus_state.wallet.handle_nwc_response(response: resp) // This can handle success or error cases
|
||||
|
||||
guard resp.response.error == nil else {
|
||||
Log.error("HomeModel: NWC wallet raised an error: %s", for: .nwc, String(describing: resp.response))
|
||||
WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
|
||||
|
||||
let nwc = WalletConnectURL(str: nwc_str),
|
||||
let resp = await FullWalletResponse(from: ev, nwc: nwc) else {
|
||||
return
|
||||
}
|
||||
|
||||
// since command results are not returned for ephemeral events,
|
||||
// remove the request from the postbox which is likely failing over and over
|
||||
if damus_state.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
|
||||
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
|
||||
} else {
|
||||
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
|
||||
}
|
||||
|
||||
guard resp.response.error == nil else {
|
||||
print("nwc error: \(resp.response)")
|
||||
nwc_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
|
||||
return
|
||||
}
|
||||
|
||||
print("nwc success: \(resp.response.result.debugDescription) [\(relay)]")
|
||||
WalletConnect.handle_zap_success(state: self.damus_state, resp: resp)
|
||||
nwc_success(state: self.damus_state, resp: resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,8 +375,6 @@ class HomeModel: ContactsDelegate {
|
||||
boost_ev_id = inner_ev.id
|
||||
|
||||
Task {
|
||||
// NOTE (jb55): remove this after nostrdb update, since nostrdb
|
||||
// processess reposts when note is ingested
|
||||
guard validate_event(ev: inner_ev) == .ok else {
|
||||
return
|
||||
}
|
||||
@@ -422,7 +394,7 @@ class HomeModel: ContactsDelegate {
|
||||
switch self.damus_state.boosts.add_event(ev, target: e) {
|
||||
case .already_counted:
|
||||
break
|
||||
case .success(_):
|
||||
case .success(let n):
|
||||
notify(.update_stats(note_id: e))
|
||||
}
|
||||
}
|
||||
@@ -431,7 +403,7 @@ class HomeModel: ContactsDelegate {
|
||||
switch damus_state.quote_reposts.add_event(ev, target: target) {
|
||||
case .already_counted:
|
||||
break
|
||||
case .success(_):
|
||||
case .success(let n):
|
||||
notify(.update_stats(note_id: target))
|
||||
}
|
||||
}
|
||||
@@ -478,7 +450,7 @@ class HomeModel: ContactsDelegate {
|
||||
let nwc = WalletConnectURL(str: nwc_str),
|
||||
nwc.relay == relay_id
|
||||
{
|
||||
WalletConnect.subscribe(url: nwc, pool: pool)
|
||||
subscribe_to_nwc(url: nwc, pool: pool)
|
||||
}
|
||||
case .error(let merr):
|
||||
let desc = String(describing: merr)
|
||||
@@ -491,7 +463,7 @@ class HomeModel: ContactsDelegate {
|
||||
break
|
||||
}
|
||||
|
||||
update_signal_from_pool(signal: self.signal, pool: damus_state.nostrNetwork.pool)
|
||||
update_signal_from_pool(signal: self.signal, pool: damus_state.pool)
|
||||
case .nostr_event(let ev):
|
||||
switch ev {
|
||||
case .event(let sub_id, let ev):
|
||||
@@ -760,16 +732,6 @@ class HomeModel: ContactsDelegate {
|
||||
handle_quote_repost_event(ev, target: quoted_event.note_id)
|
||||
}
|
||||
|
||||
// don't add duplicate reposts to home
|
||||
if ev.known_kind == .boost, let target = ev.get_inner_event()?.id {
|
||||
if already_reposted.contains(target) {
|
||||
Log.info("Skipping duplicate repost for event %s", for: .timeline, target.hex())
|
||||
return
|
||||
} else {
|
||||
already_reposted.insert(target)
|
||||
}
|
||||
}
|
||||
|
||||
if sub_id == home_subid {
|
||||
insert_home_event(ev)
|
||||
} else if sub_id == notifications_subid {
|
||||
@@ -961,6 +923,7 @@ func load_our_stuff(state: DamusState, ev: NostrEvent) {
|
||||
state.contacts.event = ev
|
||||
|
||||
load_our_contacts(state: state, m_old_ev: m_old_ev, ev: ev)
|
||||
load_our_relays(state: state, m_old_ev: m_old_ev, ev: ev)
|
||||
}
|
||||
|
||||
func process_contact_event(state: DamusState, ev: NostrEvent) {
|
||||
@@ -968,6 +931,78 @@ func process_contact_event(state: DamusState, ev: NostrEvent) {
|
||||
add_contact_if_friend(contacts: state.contacts, ev: ev)
|
||||
}
|
||||
|
||||
func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
||||
let bootstrap_dict: [RelayURL: RelayInfo] = [:]
|
||||
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? state.bootstrap_relays.reduce(into: bootstrap_dict) { (d, r) in
|
||||
d[r] = .rw
|
||||
}
|
||||
|
||||
guard let decoded: [RelayURL: RelayInfo] = decode_json_relays(ev.content) else {
|
||||
return
|
||||
}
|
||||
|
||||
var changed = false
|
||||
|
||||
var new = Set<RelayURL>()
|
||||
for key in decoded.keys {
|
||||
new.insert(key)
|
||||
}
|
||||
|
||||
var old = Set<RelayURL>()
|
||||
for key in old_decoded.keys {
|
||||
old.insert(key)
|
||||
}
|
||||
|
||||
let diff = old.symmetricDifference(new)
|
||||
|
||||
let new_relay_filters = load_relay_filters(state.pubkey) == nil
|
||||
for d in diff {
|
||||
changed = true
|
||||
if new.contains(d) {
|
||||
let descriptor = RelayDescriptor(url: d, info: decoded[d] ?? .rw)
|
||||
add_new_relay(model_cache: state.relay_model_cache, relay_filters: state.relay_filters, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: state.settings.developer_mode)
|
||||
} else {
|
||||
state.pool.remove_relay(d)
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
save_bootstrap_relays(pubkey: state.pubkey, relays: Array(new))
|
||||
state.pool.connect()
|
||||
notify(.relays_changed)
|
||||
}
|
||||
}
|
||||
|
||||
func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) {
|
||||
try? pool.add_relay(descriptor)
|
||||
let url = descriptor.url
|
||||
|
||||
let relay_id = url
|
||||
guard model_cache.model(withURL: url) == nil else {
|
||||
return
|
||||
}
|
||||
|
||||
Task.detached(priority: .background) {
|
||||
guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else {
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
let model = RelayModel(url, metadata: meta)
|
||||
model_cache.insert(model: model)
|
||||
|
||||
if logging_enabled {
|
||||
pool.setLog(model.log, for: relay_id)
|
||||
}
|
||||
|
||||
// if this is the first time adding filters, we should filter non-paid relays
|
||||
if new_relay_filters && !meta.is_paid {
|
||||
relay_filters.insert(timeline: .search, relay_id: relay_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetch_relay_metadata(relay_id: RelayURL) async throws -> RelayMetadata? {
|
||||
var urlString = relay_id.absoluteString.replacingOccurrences(of: "wss://", with: "https://")
|
||||
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
|
||||
@@ -1190,4 +1225,3 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,10 +163,6 @@ struct LightningInvoice<T> {
|
||||
let payment_hash: Data
|
||||
let created_at: UInt64
|
||||
|
||||
var abbreviated: String {
|
||||
return self.string.prefix(8) + "…" + self.string.suffix(8)
|
||||
}
|
||||
|
||||
var description_string: String {
|
||||
switch description {
|
||||
case .description(let string):
|
||||
@@ -175,17 +171,6 @@ struct LightningInvoice<T> {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
static func from(string: String) -> Invoice? {
|
||||
// This feels a bit hacky at first, but it is actually clean
|
||||
// because it reuses the same well-tested parsing logic as the rest of the app,
|
||||
// avoiding code duplication and utilizing the guarantees acquired from age and testing.
|
||||
// We could also use the C function `parse_invoice`, but it requires extra C bridging logic.
|
||||
// NDBTODO: This may need updating on the nostrdb upgrade.
|
||||
let parsedBlocks = parse_note_content(content: .content(string,nil)).blocks
|
||||
guard parsedBlocks.count == 1 else { return nil }
|
||||
return parsedBlocks[0].asInvoice
|
||||
}
|
||||
}
|
||||
|
||||
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
|
||||
@@ -207,13 +192,6 @@ enum Amount: Equatable {
|
||||
return format_msats(amt)
|
||||
}
|
||||
}
|
||||
|
||||
func amount_sats() -> Int64? {
|
||||
switch self {
|
||||
case .any: nil
|
||||
case .specific(let amount): amount / 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func format_msats_abbrev(_ msats: Int64) -> String {
|
||||
|
||||
+10
-10
@@ -47,16 +47,16 @@ enum MuteItem: Hashable, Equatable {
|
||||
// rhs is the item we want to check against (ie. the item in the mute list)
|
||||
|
||||
switch (lhs, rhs) {
|
||||
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, _)):
|
||||
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
|
||||
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, _)):
|
||||
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
|
||||
case (.word(let lhs_word, _), .word(let rhs_word, _)):
|
||||
return lhs_word == rhs_word && !rhs.is_expired()
|
||||
case (.thread(let lhs_thread, _), .thread(let rhs_thread, _)):
|
||||
return lhs_thread == rhs_thread && !rhs.is_expired()
|
||||
default:
|
||||
return false
|
||||
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, let rhs_expiration_date)):
|
||||
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
|
||||
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, let rhs_expiration_date)):
|
||||
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
|
||||
case (.word(let lhs_word, _), .word(let rhs_word, let rhs_expiration_date)):
|
||||
return lhs_word == rhs_word && !rhs.is_expired()
|
||||
case (.thread(let lhs_thread, _), .thread(let rhs_thread, let rhs_expiration_date)):
|
||||
return lhs_thread == rhs_thread && !rhs.is_expired()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ func migrate_old_muted_threads_to_new_mutelist(keypair: Keypair, damus_state: Da
|
||||
let previous_mute_list_event = damus_state.mutelist_manager.event
|
||||
guard let new_mutelist_event = create_or_update_mutelist(keypair: fullKeypair, mprev: previous_mute_list_event, to_add: Set(mutedThreads.map { MuteItem.thread($0, nil) })) else { return }
|
||||
damus_state.mutelist_manager.set_mutelist(new_mutelist_event)
|
||||
damus_state.nostrNetwork.postbox.send(new_mutelist_event)
|
||||
damus_state.postbox.send(new_mutelist_event)
|
||||
// Set existing muted threads to an empty array
|
||||
UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey))
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
//
|
||||
// NIP05DomainEventsModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 4/11/25.
|
||||
//
|
||||
|
||||
import FaviconFinder
|
||||
import Foundation
|
||||
|
||||
class NIP05DomainEventsModel: ObservableObject {
|
||||
let state: DamusState
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool = false
|
||||
|
||||
let domain: String
|
||||
var filter: NostrFilter
|
||||
let sub_id = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
|
||||
init(state: DamusState, domain: String) {
|
||||
self.state = state
|
||||
self.domain = domain
|
||||
self.events = EventHolder(on_queue: { ev in
|
||||
preload_events(state: state, events: [ev])
|
||||
})
|
||||
self.filter = NostrFilter()
|
||||
}
|
||||
|
||||
@MainActor func subscribe() {
|
||||
filter.limit = self.limit
|
||||
filter.kinds = [.text, .longform, .highlight]
|
||||
|
||||
var authors = Set<Pubkey>()
|
||||
for pubkey in state.contacts.get_friend_of_friends_list() {
|
||||
let profile_txn = state.profiles.lookup(id: pubkey)
|
||||
|
||||
guard let profile = profile_txn?.unsafeUnownedValue,
|
||||
let nip05_str = profile.nip05,
|
||||
let nip05 = NIP05.parse(nip05_str),
|
||||
nip05.host.caseInsensitiveCompare(domain) == .orderedSame else {
|
||||
continue
|
||||
}
|
||||
|
||||
authors.insert(pubkey)
|
||||
}
|
||||
if authors.isEmpty {
|
||||
return
|
||||
}
|
||||
filter.authors = Array(authors)
|
||||
|
||||
print("subscribing to notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
|
||||
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
loading = true
|
||||
state.nostrNetwork.pool.send(.subscribe(.init(filters: [filter], sub_id: sub_id)))
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
loading = false
|
||||
print("unsubscribing from notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
|
||||
}
|
||||
|
||||
func add_event(_ ev: NostrEvent) {
|
||||
if !event_matches_filter(ev, filter: filter) {
|
||||
return
|
||||
}
|
||||
|
||||
guard should_show_event(state: state, ev: ev) else {
|
||||
return
|
||||
}
|
||||
|
||||
if self.events.insert(ev) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
if sub_id == self.sub_id && ev.is_textlike && ev.should_show_event {
|
||||
self.add_event(ev)
|
||||
}
|
||||
}
|
||||
|
||||
guard done else {
|
||||
return
|
||||
}
|
||||
|
||||
self.loading = false
|
||||
|
||||
if sub_id == self.sub_id {
|
||||
guard let txn = NdbTxn(ndb: state.ndb) else { return }
|
||||
load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
//
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
//
|
||||
// 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."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
-127
@@ -73,143 +73,85 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -
|
||||
return .longform(LongformContent(ev.content))
|
||||
}
|
||||
|
||||
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
|
||||
return .separated(render_blocks(blocks: blocks, profiles: profiles))
|
||||
}
|
||||
|
||||
func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
|
||||
func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated {
|
||||
var invoices: [Invoice] = []
|
||||
var urls: [UrlType] = []
|
||||
let blocks = bs.blocks
|
||||
|
||||
var end_mention_count = 0
|
||||
var end_url_count = 0
|
||||
|
||||
// Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
|
||||
var hide_text_index = blocks.endIndex
|
||||
if can_hide_last_previewable_refs {
|
||||
outerLoop: for (i, block) in blocks.enumerated().reversed() {
|
||||
if block.is_previewable {
|
||||
switch block {
|
||||
case .mention:
|
||||
end_mention_count += 1
|
||||
|
||||
// If there is more than one previewable mention,
|
||||
// do not hide anything because we allow rich rendering of only one mention currently.
|
||||
// This should be fixed in the future to show events inline instead.
|
||||
if end_mention_count > 1 {
|
||||
hide_text_index = blocks.endIndex
|
||||
break outerLoop
|
||||
}
|
||||
case .url(let url):
|
||||
let url_type = classify_url(url)
|
||||
if case .link = url_type {
|
||||
end_url_count += 1
|
||||
|
||||
// If there is more than one link, do not hide anything because we allow rich rendering of only
|
||||
// one link.
|
||||
if end_url_count > 1 {
|
||||
hide_text_index = blocks.endIndex
|
||||
break outerLoop
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
hide_text_index = i
|
||||
} else if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
// We should hide whitespace at the end sequence.
|
||||
hide_text_index = i
|
||||
} else if case .hashtag = block {
|
||||
// We should keep hashtags at the end sequence but hide all the other previewables around it.
|
||||
hide_text_index = i
|
||||
} else {
|
||||
break
|
||||
|
||||
let one_note_ref = blocks
|
||||
.filter({
|
||||
if case .mention(let mention) = $0,
|
||||
case .note = mention.ref {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.count == 1
|
||||
|
||||
var ind: Int = -1
|
||||
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
|
||||
ind = ind + 1
|
||||
|
||||
// Add the rendered previewable blocks to their type-specific lists.
|
||||
switch block {
|
||||
case .invoice(let invoice):
|
||||
invoices.append(invoice)
|
||||
case .url(let url):
|
||||
let url_type = classify_url(url)
|
||||
urls.append(url_type)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if can_hide_last_previewable_refs {
|
||||
// If there are previewable blocks that occur before the consecutive sequence of them at the end of the content,
|
||||
// we should not hide the text representation of any previewable block to avoid altering the format of the note.
|
||||
if ind < hide_text_index && block.is_previewable {
|
||||
hide_text_index = blocks.endIndex
|
||||
}
|
||||
|
||||
// No need to show the text representation of the block if the only previewables are the sequence of them
|
||||
// found at the end of the content.
|
||||
// This is to save unnecessary use of screen space.
|
||||
// The only exception is that if there are hashtags embedded in the end sequence, which is not uncommon,
|
||||
// then we still want to show those hashtags but hide everything else that is previewable in the end sequence.
|
||||
if ind >= hide_text_index {
|
||||
if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if case .hashtag = blocks[safe: ind+1] {
|
||||
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt))
|
||||
}
|
||||
} else if case .hashtag(let htag) = block {
|
||||
return str + hashtag_str(htag)
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
if case .note = m.ref, one_note_ref {
|
||||
return str
|
||||
}
|
||||
return str + mention_str(m, profiles: profiles)
|
||||
case .text(let txt):
|
||||
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
|
||||
return str + CompatibleText(stringLiteral: reduce_text_block(blocks: blocks, ind: ind, txt: txt, one_note_ref: one_note_ref))
|
||||
|
||||
case .relay(let relay):
|
||||
return str + CompatibleText(stringLiteral: relay)
|
||||
|
||||
case .hashtag(let htag):
|
||||
return str + hashtag_str(htag)
|
||||
case .invoice(let invoice):
|
||||
return str + invoice_str(invoice)
|
||||
invoices.append(invoice)
|
||||
return str
|
||||
case .url(let url):
|
||||
return str + url_str(url)
|
||||
let url_type = classify_url(url)
|
||||
switch url_type {
|
||||
case .media:
|
||||
urls.append(url_type)
|
||||
return str
|
||||
case .link(let url):
|
||||
urls.append(url_type)
|
||||
return str + url_str(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
|
||||
}
|
||||
|
||||
func reduce_text_block(ind: Int, hide_text_index: Int, txt: String) -> String {
|
||||
func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String {
|
||||
var trimmed = txt
|
||||
|
||||
// Trim leading whitespaces.
|
||||
if ind == 0 {
|
||||
trimmed = trim_prefix(trimmed)
|
||||
|
||||
if let prev = blocks[safe: ind-1],
|
||||
case .url(let u) = prev,
|
||||
classify_url(u).is_media != nil {
|
||||
trimmed = " " + trim_prefix(trimmed)
|
||||
}
|
||||
|
||||
// Trim trailing whitespaces if the following blocks will be hidden or if this is the last block.
|
||||
if ind == hide_text_index - 1 {
|
||||
trimmed = trim_suffix(trimmed)
|
||||
|
||||
if let next = blocks[safe: ind+1] {
|
||||
if case .url(let u) = next, classify_url(u).is_media != nil {
|
||||
trimmed = trim_suffix(trimmed)
|
||||
} else if case .mention(let m) = next,
|
||||
case .note = m.ref,
|
||||
one_note_ref {
|
||||
trimmed = trim_suffix(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func invoice_str(_ invoice: Invoice) -> CompatibleText {
|
||||
var attributedString = AttributedString(stringLiteral: abbrev_identifier(invoice.string))
|
||||
attributedString.link = URL(string: "damus:lightning:\(invoice.string)")
|
||||
attributedString.foregroundColor = DamusColors.purple
|
||||
|
||||
return CompatibleText(attributed: attributedString)
|
||||
}
|
||||
|
||||
func url_str(_ url: URL) -> CompatibleText {
|
||||
var attributedString = AttributedString(stringLiteral: url.absoluteString)
|
||||
attributedString.link = url
|
||||
@@ -219,16 +161,17 @@ func url_str(_ url: URL) -> CompatibleText {
|
||||
}
|
||||
|
||||
func classify_url(_ url: URL) -> UrlType {
|
||||
let fileExtension = url.lastPathComponent.lowercased().components(separatedBy: ".").last
|
||||
|
||||
switch fileExtension {
|
||||
case "png", "jpg", "jpeg", "gif", "webp":
|
||||
let str = url.lastPathComponent.lowercased()
|
||||
|
||||
if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") {
|
||||
return .media(.image(url))
|
||||
case "mp4", "mov", "m3u8":
|
||||
return .media(.video(url))
|
||||
default:
|
||||
return .link(url)
|
||||
}
|
||||
|
||||
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
|
||||
return .media(.video(url))
|
||||
}
|
||||
|
||||
return .link(url)
|
||||
}
|
||||
|
||||
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
|
||||
@@ -251,11 +194,11 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
|
||||
let display_str: String = {
|
||||
switch m.ref {
|
||||
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
|
||||
case .note: return abbrev_identifier(bech32String)
|
||||
case .nevent: return abbrev_identifier(bech32String)
|
||||
case .note: return abbrev_pubkey(bech32String)
|
||||
case .nevent: return abbrev_pubkey(bech32String)
|
||||
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
|
||||
case .nrelay(let url): return url
|
||||
case .naddr: return abbrev_identifier(bech32String)
|
||||
case .naddr: return abbrev_pubkey(bech32String)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -270,20 +213,12 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
|
||||
|
||||
// trim suffix whitespace and newlines
|
||||
func trim_suffix(_ str: String) -> String {
|
||||
var result = str
|
||||
while result.last?.isWhitespace == true {
|
||||
result.removeLast()
|
||||
}
|
||||
return result
|
||||
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
|
||||
}
|
||||
|
||||
// trim prefix whitespace and newlines
|
||||
func trim_prefix(_ str: String) -> String {
|
||||
var result = str
|
||||
while result.first?.isWhitespace == true {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result
|
||||
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
|
||||
}
|
||||
|
||||
struct LongformContent {
|
||||
|
||||
@@ -41,10 +41,6 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
|
||||
return false
|
||||
}
|
||||
|
||||
if state.settings.hellthread_notifications_disabled && ev.is_hellthread(max_pubkeys: state.settings.hellthread_notification_max_pubkeys) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't show notifications that match mute list.
|
||||
if state.mutelist_manager.is_event_muted(ev) {
|
||||
return false
|
||||
@@ -54,14 +50,7 @@ func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent
|
||||
guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't show notifications for future events.
|
||||
// Allow notes that are created no more than 3 seconds in the future
|
||||
// to account for natural clock skew between sender and receiver.
|
||||
guard ev.age >= -3 else {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -10,18 +10,8 @@ import Foundation
|
||||
class ProfileModel: ObservableObject, Equatable {
|
||||
@Published var contacts: NostrEvent? = nil
|
||||
@Published var following: Int = 0
|
||||
@Published var relay_list: NIP65.RelayList? = nil
|
||||
@Published var legacy_relay_list: [RelayURL: LegacyKind3RelayRWConfiguration]? = nil
|
||||
@Published var relays: [RelayURL: RelayInfo]? = nil
|
||||
@Published var progress: Int = 0
|
||||
var relay_urls: [RelayURL]? {
|
||||
if let relay_list {
|
||||
return relay_list.relays.values.map({ $0.url })
|
||||
}
|
||||
if let legacy_relay_list {
|
||||
return Array(legacy_relay_list.keys)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private let MAX_SHARE_RELAYS = 4
|
||||
|
||||
@@ -32,10 +22,8 @@ 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
|
||||
@@ -69,45 +57,25 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
|
||||
func unsubscribe() {
|
||||
print("unsubscribing from profile \(pubkey) with sub_id \(sub_id)")
|
||||
damus.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
damus.nostrNetwork.pool.unsubscribe(sub_id: prof_subid)
|
||||
if pubkey != damus.pubkey {
|
||||
damus.nostrNetwork.pool.unsubscribe(sub_id: conversations_subid)
|
||||
}
|
||||
damus.pool.unsubscribe(sub_id: sub_id)
|
||||
damus.pool.unsubscribe(sub_id: prof_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 textlike events from profile \(pubkey) with sub_id \(sub_id)")
|
||||
|
||||
print("subscribing to profile \(pubkey) with sub_id \(sub_id)")
|
||||
//print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]])
|
||||
damus.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event)
|
||||
damus.nostrNetwork.pool.subscribe(sub_id: prof_subid, filters: [profile_filter, relay_list_filter], handler: handle_event)
|
||||
|
||||
subscribe_to_conversations()
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -120,10 +88,17 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
|
||||
self.contacts = ev
|
||||
self.following = count_pubkeys(ev.tags)
|
||||
self.legacy_relay_list = decode_json_relays(ev.content)
|
||||
self.relays = decode_json_relays(ev.content)
|
||||
}
|
||||
|
||||
func add_event(_ ev: NostrEvent) {
|
||||
guard ev.should_show_event else {
|
||||
return
|
||||
}
|
||||
|
||||
private func add_event(_ ev: NostrEvent) {
|
||||
if seen_event.contains(ev.id) {
|
||||
return
|
||||
}
|
||||
if ev.is_textlike || ev.known_kind == .boost {
|
||||
if self.events.insert(ev) {
|
||||
self.objectWillChange.send()
|
||||
@@ -131,63 +106,27 @@ 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 || resp.subid == self.conversations_subid else {
|
||||
guard resp.subid == self.sub_id || resp.subid == self.prof_subid else {
|
||||
return
|
||||
}
|
||||
switch resp {
|
||||
case .ok:
|
||||
break
|
||||
case .event(_, let ev):
|
||||
guard ev.should_show_event else {
|
||||
break
|
||||
}
|
||||
// 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 }
|
||||
|
||||
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)
|
||||
}
|
||||
add_event(ev)
|
||||
case .notice:
|
||||
break
|
||||
//notify(.notice, notice)
|
||||
@@ -206,7 +145,7 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
|
||||
private func findRelaysHandler(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
if case .nostr_event(let resp) = ev, case .event(_, let event) = resp, case .contacts = event.known_kind {
|
||||
self.legacy_relay_list = decode_json_relays(event.content)
|
||||
self.relays = decode_json_relays(event.content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,15 +153,15 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
var profile_filter = NostrFilter(kinds: [.contacts])
|
||||
profile_filter.authors = [pubkey]
|
||||
|
||||
damus.nostrNetwork.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
|
||||
damus.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
|
||||
}
|
||||
|
||||
func unsubscribeFindRelays() {
|
||||
damus.nostrNetwork.pool.unsubscribe(sub_id: findRelay_subid)
|
||||
damus.pool.unsubscribe(sub_id: findRelay_subid)
|
||||
}
|
||||
|
||||
func getCappedRelayStrings() -> [String] {
|
||||
return self.relay_urls?.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
||||
return relays?.keys.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ class DamusPurple: StoreObserverDelegate {
|
||||
case .none:
|
||||
return .sheet(.error(.init(
|
||||
user_visible_description: NSLocalizedString("You clicked on a Purple welcome link, but we could not find your checkout. This is likely a bug.", comment: "Error label upon continuing in the app from a Damus Purple purchase"),
|
||||
tip: NSLocalizedString("Please double-check the checkout web page, or go to the Side Menu → \"Purple\" to check your account status. If you have already paid, but still don't see your account active, please save the URL of the checkout page where you came from, contact our support, and give us the URL to help you with this issue.", comment: "User-facing tips on what to do if a Purple welcome link doesn't work"),
|
||||
tip: NSLocalizedString("Please double-check the checkout web page, or go to the Side Menu → \"Purple\" to check your account status. If you have already paid, but still don't see your account active, please save the URL of the checkout page where you came from, contact our support, and give us the URL to help you with this issue", comment: "User-facing tips on what to do if a Purple welcome link doesn't work"),
|
||||
technical_info: "Handling Purple URL \"\(purple_url)\" failed, the `is_good_to_go` result was `\(String(describing: is_good_to_go))`"
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
// Minimum threshold the hellthread pubkey tag count setting can go down to.
|
||||
let HELLTHREAD_MIN_PUBKEYS: Int = 6
|
||||
|
||||
// Maximum threshold the hellthread pubkey tag count setting can go up to.
|
||||
let HELLTHREAD_MAX_PUBKEYS: Int = 24
|
||||
|
||||
struct PushNotificationClient {
|
||||
let keypair: Keypair
|
||||
let settings: UserSettingsStore
|
||||
@@ -181,33 +175,15 @@ extension PushNotificationClient {
|
||||
}
|
||||
|
||||
struct NotificationSettings: Codable, Equatable {
|
||||
let zap_notifications_enabled: Bool?
|
||||
let mention_notifications_enabled: Bool?
|
||||
let repost_notifications_enabled: Bool?
|
||||
let reaction_notifications_enabled: Bool?
|
||||
let dm_notifications_enabled: Bool?
|
||||
let only_notifications_from_following_enabled: Bool?
|
||||
let hellthread_notifications_disabled: Bool?
|
||||
let hellthread_notifications_max_pubkeys: Int?
|
||||
|
||||
let zap_notifications_enabled: Bool
|
||||
let mention_notifications_enabled: Bool
|
||||
let repost_notifications_enabled: Bool
|
||||
let reaction_notifications_enabled: Bool
|
||||
let dm_notifications_enabled: Bool
|
||||
let only_notifications_from_following_enabled: Bool
|
||||
|
||||
static func from(json_data: Data) -> Self? {
|
||||
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
|
||||
|
||||
// Normalize hellthread_notifications_max_pubkeys in case
|
||||
// it goes beyond the expected range supported on the client.
|
||||
if let max_pubkeys = decoded.hellthread_notifications_max_pubkeys, max_pubkeys < HELLTHREAD_MIN_PUBKEYS || max_pubkeys > HELLTHREAD_MAX_PUBKEYS {
|
||||
return NotificationSettings(
|
||||
zap_notifications_enabled: decoded.zap_notifications_enabled,
|
||||
mention_notifications_enabled: decoded.mention_notifications_enabled,
|
||||
repost_notifications_enabled: decoded.repost_notifications_enabled,
|
||||
reaction_notifications_enabled: decoded.reaction_notifications_enabled,
|
||||
dm_notifications_enabled: decoded.dm_notifications_enabled,
|
||||
only_notifications_from_following_enabled: decoded.only_notifications_from_following_enabled,
|
||||
hellthread_notifications_disabled: decoded.hellthread_notifications_disabled,
|
||||
hellthread_notifications_max_pubkeys: max(min(HELLTHREAD_MAX_PUBKEYS, max_pubkeys), HELLTHREAD_MIN_PUBKEYS)
|
||||
)
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
@@ -218,9 +194,7 @@ extension PushNotificationClient {
|
||||
repost_notifications_enabled: settings.repost_notification,
|
||||
reaction_notifications_enabled: settings.like_notification,
|
||||
dm_notifications_enabled: settings.dm_notification,
|
||||
only_notifications_from_following_enabled: settings.notification_only_from_following,
|
||||
hellthread_notifications_disabled: settings.hellthread_notifications_disabled,
|
||||
hellthread_notifications_max_pubkeys: settings.hellthread_notification_max_pubkeys
|
||||
only_notifications_from_following_enabled: settings.notification_only_from_following
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//
|
||||
// SearchHomeModel.swift
|
||||
// damus
|
||||
//
|
||||
@@ -15,7 +16,6 @@ class SearchHomeModel: ObservableObject {
|
||||
var seen_pubkey: Set<Pubkey> = Set()
|
||||
let damus_state: DamusState
|
||||
let base_subid = UUID().description
|
||||
let follow_pack_subid = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
//let multiple_events_per_pubkey: Bool = false
|
||||
@@ -41,19 +41,13 @@ class SearchHomeModel: ObservableObject {
|
||||
|
||||
func subscribe() {
|
||||
loading = true
|
||||
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||
|
||||
var follow_list_filter = NostrFilter(kinds: [.follow_list])
|
||||
follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: follow_pack_subid, filters: [follow_list_filter], handler: handle_event, to: to_relays)
|
||||
let to_relays = determine_to_relays(pool: damus_state.pool, filters: damus_state.relay_filters)
|
||||
damus_state.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
loading = false
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: follow_pack_subid, to: to.map { [$0] })
|
||||
damus_state.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||
@@ -63,7 +57,7 @@ class SearchHomeModel: ObservableObject {
|
||||
|
||||
switch event {
|
||||
case .event(let sub_id, let ev):
|
||||
guard sub_id == self.base_subid || sub_id == self.profiles_subid || sub_id == self.follow_pack_subid else {
|
||||
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
|
||||
return
|
||||
}
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||
@@ -146,7 +140,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR
|
||||
|
||||
let filter = NostrFilter(kinds: [.metadata], authors: authors)
|
||||
|
||||
damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
|
||||
damus_state.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in
|
||||
|
||||
let now = UInt64(Date.now.timeIntervalSince1970)
|
||||
switch conn_ev {
|
||||
@@ -162,7 +156,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR
|
||||
}
|
||||
case .eose:
|
||||
print("load_profiles[\(context)]: done loading \(authors.count) profiles from \(relay_id)")
|
||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
|
||||
damus_state.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id])
|
||||
case .ok:
|
||||
break
|
||||
case .notice:
|
||||
|
||||
@@ -36,18 +36,18 @@ class SearchModel: ObservableObject {
|
||||
func subscribe() {
|
||||
// since 1 month
|
||||
search.limit = self.limit
|
||||
search.kinds = [.text, .like, .longform, .highlight, .follow_list]
|
||||
search.kinds = [.text, .like, .longform, .highlight]
|
||||
|
||||
//likes_filter.ids = ref_events.referenced_ids!
|
||||
|
||||
print("subscribing to search '\(search)' with sub_id \(sub_id)")
|
||||
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
state.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||
loading = true
|
||||
state.nostrNetwork.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||
state.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id)))
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||
state.pool.unsubscribe(sub_id: sub_id)
|
||||
loading = false
|
||||
print("unsubscribing from search '\(search)' with sub_id \(sub_id)")
|
||||
}
|
||||
@@ -67,7 +67,7 @@ class SearchModel: ObservableObject {
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
let (sub_id, done) = handle_subid_event(pool: state.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
if ev.is_textlike && ev.should_show_event {
|
||||
self.add_event(ev)
|
||||
}
|
||||
|
||||
@@ -29,18 +29,7 @@ class ThreadModel: ObservableObject {
|
||||
///
|
||||
/// This is a computed property because we then don't need to worry about keeping things in sync
|
||||
var parent_events: [NostrEvent] {
|
||||
// This block of code helps ensure `ThreadEventMap` stays in sync with `EventCache`
|
||||
let parent_events_from_cache = damus_state.events.parent_events(event: selected_event, keypair: damus_state.keypair)
|
||||
for parent_event in parent_events_from_cache {
|
||||
add_event(
|
||||
parent_event,
|
||||
keypair: damus_state.keypair,
|
||||
look_for_parent_events: false, // We have all parents we need for now
|
||||
publish_changes: false // Publishing changes during a view render is problematic
|
||||
)
|
||||
}
|
||||
|
||||
return parent_events_from_cache
|
||||
return event_map.parent_events(of: selected_event)
|
||||
}
|
||||
/// All of the direct and indirect replies of `selected_event` in the thread. sorted chronologically
|
||||
///
|
||||
@@ -88,12 +77,12 @@ class ThreadModel: ObservableObject {
|
||||
|
||||
/// Unsubscribe from events in the relay pool. Call this when unloading the view
|
||||
func unsubscribe() {
|
||||
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: base_subid)
|
||||
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: meta_subid)
|
||||
self.damus_state.nostrNetwork.pool.remove_handler(sub_id: profiles_subid)
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid)
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: meta_subid)
|
||||
self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid)
|
||||
self.damus_state.pool.remove_handler(sub_id: base_subid)
|
||||
self.damus_state.pool.remove_handler(sub_id: meta_subid)
|
||||
self.damus_state.pool.remove_handler(sub_id: profiles_subid)
|
||||
self.damus_state.pool.unsubscribe(sub_id: base_subid)
|
||||
self.damus_state.pool.unsubscribe(sub_id: meta_subid)
|
||||
self.damus_state.pool.unsubscribe(sub_id: profiles_subid)
|
||||
Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
|
||||
}
|
||||
|
||||
@@ -129,20 +118,14 @@ class ThreadModel: ObservableObject {
|
||||
let meta_filters = [meta_events, quote_events]
|
||||
|
||||
Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
|
||||
damus_state.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
|
||||
damus_state.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event)
|
||||
}
|
||||
|
||||
/// Adds an event to this thread.
|
||||
/// Normally this does not need to be called externally because it is the responsibility of this class to load the events, not the view's.
|
||||
/// However, this can be called externally for testing purposes (e.g. injecting events for testing)
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - ev: The event to add into the thread event map
|
||||
/// - keypair: The user's keypair
|
||||
/// - look_for_parent_events: Whether to search for parent events of the input event in NostrDB
|
||||
/// - publish_changes: Whether to publish changes at the end
|
||||
func add_event(_ ev: NostrEvent, keypair: Keypair, look_for_parent_events: Bool = true, publish_changes: Bool = true) {
|
||||
func add_event(_ ev: NostrEvent, keypair: Keypair) {
|
||||
if event_map.contains(id: ev.id) {
|
||||
return
|
||||
}
|
||||
@@ -153,22 +136,8 @@ class ThreadModel: ObservableObject {
|
||||
|
||||
event_map.add(event: ev)
|
||||
|
||||
if look_for_parent_events {
|
||||
// Add all parent events that we have on EventCache (and subsequently on NostrDB)
|
||||
// This helps ensure we include as many locally-stored notes as possible — even on poor networking conditions
|
||||
damus_state.events.parent_events(event: ev, keypair: damus_state.keypair).forEach {
|
||||
add_event(
|
||||
$0, // The `lookup` function in `parent_events` turns the event into an "owned" object, so we do not need to clone here
|
||||
keypair: damus_state.keypair,
|
||||
look_for_parent_events: false, // We do not need deep recursion
|
||||
publish_changes: false // Do not publish changes multiple times
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if publish_changes {
|
||||
objectWillChange.send()
|
||||
}
|
||||
// Publish changes
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
/// Handles an incoming event from a relay pool
|
||||
@@ -176,7 +145,7 @@ class ThreadModel: ObservableObject {
|
||||
/// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface
|
||||
@MainActor
|
||||
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
let (sub_id, done) = handle_subid_event(pool: damus_state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sid, ev in
|
||||
let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in
|
||||
guard subids.contains(sid) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ struct DamusURLHandler {
|
||||
let thread = await ThreadModel(event: nostrEvent, damus_state: damus_state)
|
||||
return .route(.Thread(thread: thread))
|
||||
case .event_reference(let event_reference):
|
||||
return .route(.LoadableNostrEvent(note_reference: event_reference))
|
||||
return .route(.ThreadFromReference(note_reference: event_reference))
|
||||
case .wallet_connect(let walletConnectURL):
|
||||
damus_state.wallet.new(walletConnectURL)
|
||||
return .route(.Wallet(wallet: damus_state.wallet))
|
||||
@@ -43,15 +43,6 @@ struct DamusURLHandler {
|
||||
return .route(.Script(script: model))
|
||||
case .purple(let purple_url):
|
||||
return await damus_state.purple.handle(purple_url: purple_url)
|
||||
case .invoice(let invoice):
|
||||
if damus_state.settings.show_wallet_selector {
|
||||
return .sheet(.select_wallet(invoice: invoice.string))
|
||||
} else {
|
||||
guard let url = try? getUrlToOpen(invoice: invoice.string, with: damus_state.settings.default_wallet.model) else {
|
||||
return .sheet(.select_wallet(invoice: invoice.string))
|
||||
}
|
||||
return .external_url(url)
|
||||
}
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
@@ -100,11 +91,6 @@ struct DamusURLHandler {
|
||||
return .filter(filt)
|
||||
case .script(let script):
|
||||
return .script(script)
|
||||
case .invoice(let bolt11):
|
||||
if let invoice = decode_bolt11(bolt11) {
|
||||
return .invoice(invoice)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -113,10 +99,9 @@ struct DamusURLHandler {
|
||||
case profile(Pubkey)
|
||||
case filter(NostrFilter)
|
||||
case event(NostrEvent)
|
||||
case event_reference(LoadableNostrEventViewModel.NoteReference)
|
||||
case event_reference(LoadableThreadModel.NoteReference)
|
||||
case wallet_connect(WalletConnectURL)
|
||||
case script([UInt8])
|
||||
case purple(DamusPurpleURL)
|
||||
case invoice(Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,12 +113,6 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "show_wallet_selector", default_value: false)
|
||||
var show_wallet_selector: Bool
|
||||
|
||||
@Setting(key: "dismiss_wallet_high_balance_warning", default_value: false)
|
||||
var dismiss_wallet_high_balance_warning: Bool
|
||||
|
||||
@Setting(key: "hide_wallet_balance", default_value: false)
|
||||
var hide_wallet_balance: Bool
|
||||
|
||||
@Setting(key: "left_handed", default_value: false)
|
||||
var left_handed: Bool
|
||||
|
||||
@@ -127,19 +121,10 @@ class UserSettingsStore: ObservableObject {
|
||||
|
||||
@Setting(key: "media_previews", default_value: true)
|
||||
var media_previews: Bool
|
||||
|
||||
@Setting(key: "show_trusted_replies_first", default_value: true)
|
||||
var show_trusted_replies_first: Bool
|
||||
|
||||
@Setting(key: "reset_tips_on_launch", default_value: false)
|
||||
var reset_tips_on_launch: Bool
|
||||
|
||||
|
||||
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
|
||||
var hide_nsfw_tagged_content: Bool
|
||||
|
||||
@Setting(key: "reduce_bitcoin_content", default_value: false)
|
||||
var reduce_bitcoin_content: Bool
|
||||
|
||||
@Setting(key: "show_profile_action_sheet_on_pfp_click", default_value: true)
|
||||
var show_profile_action_sheet_on_pfp_click: Bool
|
||||
|
||||
@@ -175,13 +160,7 @@ class UserSettingsStore: ObservableObject {
|
||||
|
||||
@Setting(key: "notification_only_from_following", default_value: false)
|
||||
var notification_only_from_following: Bool
|
||||
|
||||
@Setting(key: "hellthread_notifications_disabled", default_value: false)
|
||||
var hellthread_notifications_disabled: Bool
|
||||
|
||||
@Setting(key: "hellthread_notification_max_pubkeys", default_value: DEFAULT_HELLTHREAD_MAX_PUBKEYS)
|
||||
var hellthread_notification_max_pubkeys: Int
|
||||
|
||||
|
||||
@Setting(key: "translate_dms", default_value: false)
|
||||
var translate_dms: Bool
|
||||
|
||||
@@ -189,12 +168,8 @@ class UserSettingsStore: ObservableObject {
|
||||
var truncate_timeline_text: Bool
|
||||
|
||||
/// Nozaps mode gimps note zapping to fit into apple's content-tipping guidelines. It can not be configurable to end-users on the app store
|
||||
///
|
||||
/// Update 2025-05-12: This can be re-enabled 🥳. See https://github.com/damus-io/damus/issues/3016
|
||||
// @Setting(key: "nozaps", default_value: true)
|
||||
var nozaps: Bool {
|
||||
return false
|
||||
}
|
||||
@Setting(key: "nozaps", default_value: true)
|
||||
var nozaps: Bool
|
||||
|
||||
@Setting(key: "truncate_mention_text", default_value: true)
|
||||
var truncate_mention_text: Bool
|
||||
@@ -226,10 +201,6 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "developer_mode", default_value: false)
|
||||
var developer_mode: Bool
|
||||
|
||||
/// Makes all post content gibberish and blurhashes images, to avoid distractions when developers are working.
|
||||
@Setting(key: "undistract_mode", default_value: false)
|
||||
var undistractMode: Bool
|
||||
|
||||
@Setting(key: "always_show_onboarding_suggestions", default_value: false)
|
||||
var always_show_onboarding_suggestions: Bool
|
||||
|
||||
@@ -361,10 +332,6 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "draft_event_ids", default_value: nil)
|
||||
var draft_event_ids: [String]?
|
||||
|
||||
// TODO: Get rid of this once we have NostrDB query capabilities integrated
|
||||
@Setting(key: "latest_relay_list_event_id", default_value: nil)
|
||||
var latestRelayListEventIdHex: String?
|
||||
|
||||
// MARK: Helper types
|
||||
|
||||
enum NotificationsMode: String, CaseIterable, Identifiable, StringCodable, Equatable {
|
||||
|
||||
@@ -83,10 +83,8 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
|
||||
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:",
|
||||
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
|
||||
case .bitcoinbeach:
|
||||
// Blink used to be called Bitcoin Beach.
|
||||
// We have to keep the tag called "bitcoinbeach" for backwards compatibility.
|
||||
return .init(index: 10, tag: "bitcoinbeach", displayName: "Blink", link: "blink://",
|
||||
appStoreLink: "https://apps.apple.com/app/blink-bitcoin-wallet/id1531383905", image: "blink")
|
||||
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://",
|
||||
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
|
||||
case .blixtwallet:
|
||||
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
|
||||
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
|
||||
|
||||
@@ -13,25 +13,13 @@ 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
|
||||
|
||||
/// A dictionary listing continuations waiting for a response for each request note id.
|
||||
///
|
||||
/// Please see the `waitForResponse` method for context.
|
||||
private var continuations: [NoteId: CheckedContinuation<WalletConnect.Response.Result, any Error>] = [:]
|
||||
|
||||
init(state: WalletConnectState, settings: UserSettingsStore) {
|
||||
self.connect_state = state
|
||||
self.previous_state = .none
|
||||
@@ -73,68 +61,4 @@ class WalletModel: ObservableObject {
|
||||
self.connect_state = .existing(nwc)
|
||||
self.previous_state = .existing(nwc)
|
||||
}
|
||||
|
||||
/// Handles an NWC response event and updates the model.
|
||||
///
|
||||
/// This takes a response received from the NWC relay and updates the internal state of this model.
|
||||
///
|
||||
/// - Parameter response: The NWC response received from the network
|
||||
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
|
||||
if let error = response.response.error {
|
||||
self.resume(request: response.req_id, throwing: error)
|
||||
return
|
||||
}
|
||||
guard let result = response.response.result else { return }
|
||||
self.resume(request: response.req_id, with: result)
|
||||
switch result {
|
||||
case .get_balance(let balanceResp):
|
||||
self.balance = balanceResp.balance / 1000
|
||||
case .pay_invoice(_):
|
||||
return
|
||||
case .list_transactions(let transactionsResp):
|
||||
self.transactions = transactionsResp.transactions
|
||||
}
|
||||
}
|
||||
|
||||
func resetWalletStateInformation() {
|
||||
self.transactions = nil
|
||||
self.balance = nil
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Async wallet response waiting mechanism
|
||||
|
||||
func waitForResponse(for requestId: NoteId, timeout: Duration = .seconds(10)) async throws -> WalletConnect.Response.Result {
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
self.continuations[requestId] = continuation
|
||||
|
||||
let timeoutTask = Task {
|
||||
try? await Task.sleep(for: timeout)
|
||||
self.resume(request: requestId, throwing: WaitError.timeout) // Must resume the continuation exactly once even if there is no response
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func resume(request requestId: NoteId, with result: WalletConnect.Response.Result) {
|
||||
continuations[requestId]?.resume(returning: result)
|
||||
continuations[requestId] = nil // Never resume a continuation twice
|
||||
}
|
||||
|
||||
private func resume(request requestId: NoteId, throwing error: any Error) {
|
||||
if let continuation = continuations[requestId] {
|
||||
continuation.resume(throwing: error)
|
||||
continuations[requestId] = nil // Never resume a continuation twice
|
||||
return // Error will be handled by the listener, no need for the generic error sheet
|
||||
}
|
||||
|
||||
// No listeners to catch the error, show generic error sheet
|
||||
if let error = error as? WalletConnect.WalletResponseErr,
|
||||
let humanReadableError = error.humanReadableError {
|
||||
present_sheet(.error(humanReadableError))
|
||||
}
|
||||
}
|
||||
|
||||
enum WaitError: Error {
|
||||
case timeout
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ class ZapsModel: ObservableObject {
|
||||
case .note(let note_target):
|
||||
filter.referenced_ids = [note_target.note_id]
|
||||
}
|
||||
state.nostrNetwork.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
|
||||
state.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event)
|
||||
}
|
||||
|
||||
func unsubscribe() {
|
||||
state.nostrNetwork.pool.unsubscribe(sub_id: zaps_subid)
|
||||
state.pool.unsubscribe(sub_id: zaps_subid)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-01-20.
|
||||
//
|
||||
import NostrSDK
|
||||
import Foundation
|
||||
|
||||
/// This models a NIP-37 draft.
|
||||
@@ -76,7 +77,13 @@ struct NIP37Draft {
|
||||
guard let note_json_string = String(data: note_json_data, encoding: .utf8) else {
|
||||
throw NIP37DraftEventError.encoding_error
|
||||
}
|
||||
guard let contents = try? NIP44v2Encryption.encrypt(plaintext: note_json_string, privateKeyA: keypair.privkey, publicKeyB: keypair.pubkey) else {
|
||||
guard let secret_key = SecretKey.from(privkey: keypair.privkey) else {
|
||||
throw NIP37DraftEventError.invalid_keypair
|
||||
}
|
||||
guard let pubkey = PublicKey.from(pubkey: keypair.pubkey) else {
|
||||
throw NIP37DraftEventError.invalid_keypair
|
||||
}
|
||||
guard let contents = try? nip44Encrypt(secretKey: secret_key, publicKey: pubkey, content: note_json_string, version: Nip44Version.v2) else {
|
||||
return nil
|
||||
}
|
||||
var tags = [
|
||||
@@ -104,10 +111,16 @@ struct NIP37Draft {
|
||||
static func unwrap(note: NdbNote, keypair: FullKeypair) throws -> NdbNote? {
|
||||
let wrapped_note = note
|
||||
guard wrapped_note.known_kind == .draft else { return nil }
|
||||
guard let draft_event_json = try? NIP44v2Encryption.decrypt(
|
||||
payload: wrapped_note.content,
|
||||
privateKeyA: keypair.privkey,
|
||||
publicKeyB: keypair.pubkey
|
||||
guard let private_key = SecretKey.from(privkey: keypair.privkey) else {
|
||||
throw NIP37DraftEventError.invalid_keypair
|
||||
}
|
||||
guard let pubkey = PublicKey.from(pubkey: keypair.pubkey) else {
|
||||
throw NIP37DraftEventError.invalid_keypair
|
||||
}
|
||||
guard let draft_event_json = try? nip44Decrypt(
|
||||
secretKey: private_key,
|
||||
publicKey: pubkey,
|
||||
payload: wrapped_note.content
|
||||
) else { return nil }
|
||||
return NdbNote.owned_from_json(json: draft_event_json)
|
||||
}
|
||||
@@ -117,3 +130,17 @@ struct NIP37Draft {
|
||||
case encoding_error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience extensions
|
||||
|
||||
fileprivate extension PublicKey {
|
||||
static func from(pubkey: Pubkey) -> PublicKey? {
|
||||
return try? PublicKey.parse(publicKey: pubkey.hex())
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension SecretKey {
|
||||
static func from(privkey: Privkey) -> SecretKey? {
|
||||
return try? SecretKey.parse(secretKey: privkey.hex())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
//
|
||||
// NIP44.swift
|
||||
// damus
|
||||
//
|
||||
// Based on NIP44v2Encrypting.swift created by Terry Yiu on 3/16/24, from https://github.com/nostr-sdk/nostr-sdk-ios, which is MIT licensed.
|
||||
//
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2023 Nostr SDK
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
//
|
||||
//
|
||||
// Adapted by Daniel D’Aquino on 2025-02-10.
|
||||
//
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import CryptoSwift
|
||||
import secp256k1
|
||||
|
||||
struct NIP44v2Encryption {
|
||||
|
||||
/// Produces a `String` containing `plaintext` that has been encrypted using the `privateKey` of user A and the `publicKey` of user B.
|
||||
///
|
||||
/// The result is non-deterministic because a cryptographically secure pseudorandom generated nonce is used each time,
|
||||
/// but can be decrypted deterministically with a call to ``NIP44v2Encryption/decrypt(payload:privateKeyA:publicKeyB:)``,
|
||||
/// where user A and user B are interchangeable.
|
||||
///
|
||||
/// This function can `throw` an error from ``EncryptionError`` if it fails to encrypt the plaintext.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - plaintext: The plaintext to encrypt.
|
||||
/// - privateKeyA: The private key of user A.
|
||||
/// - publicKeyB: The public key of user B.
|
||||
/// - Returns: The encrypted ciphertext.
|
||||
static func encrypt(plaintext: String, privateKeyA: Privkey, publicKeyB: Pubkey) throws -> String {
|
||||
let conversationKey = try conversationKey(privateKeyA: privateKeyA, publicKeyB: publicKeyB)
|
||||
|
||||
return try encrypt(plaintext: plaintext, conversationKey: conversationKey)
|
||||
}
|
||||
|
||||
/// Produces a `String` containing `payload` that has been decrypted using the `privateKey` of user A and the `publicKey` of user B,
|
||||
/// and the result is identical to if the `privateKey` of user B and `publicKey` of user A were used to decrypt `payload` instead.
|
||||
///
|
||||
/// Any ciphertext returned from the call to ``NIP44v2Encryption/encrypt(plaintext:privateKeyA:publicKeyB:)``
|
||||
/// can be decrypted, where user A and B are interchangeable.
|
||||
///
|
||||
/// This function can `throw` an error from ``EncryptionError`` if it fails to decrypt the payload.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - payload: The payload to decrypt.
|
||||
/// - privateKeyA: The private key of user A.
|
||||
/// - publicKeyB: The public key of user B.
|
||||
/// - Returns: The decrypted plaintext message.
|
||||
static func decrypt(payload: String, privateKeyA: Privkey, publicKeyB: Pubkey) throws -> String {
|
||||
let conversationKey = try conversationKey(privateKeyA: privateKeyA, publicKeyB: publicKeyB)
|
||||
|
||||
return try decrypt(payload: payload, conversationKey: conversationKey)
|
||||
}
|
||||
|
||||
/// Calculates length of the padded byte array.
|
||||
static func calculatePaddedLength(_ unpaddedLength: Int) throws -> Int {
|
||||
guard unpaddedLength > 0 else {
|
||||
throw EncryptionError.unpaddedLengthInvalid(unpaddedLength)
|
||||
}
|
||||
if unpaddedLength <= 32 {
|
||||
return 32
|
||||
}
|
||||
|
||||
let nextPower = 1 << (Int(floor(log2(Double(unpaddedLength) - 1))) + 1)
|
||||
let chunk: Int
|
||||
|
||||
if nextPower <= 256 {
|
||||
chunk = 32
|
||||
} else {
|
||||
chunk = nextPower / 8
|
||||
}
|
||||
|
||||
return chunk * (Int(floor((Double(unpaddedLength) - 1) / Double(chunk))) + 1)
|
||||
}
|
||||
|
||||
/// Converts unpadded plaintext to padded bytes.
|
||||
static func pad(_ plaintext: String) throws -> Data {
|
||||
guard let unpadded = plaintext.data(using: .utf8) else {
|
||||
throw EncryptionError.utf8EncodingFailed
|
||||
}
|
||||
|
||||
let unpaddedLength = unpadded.count
|
||||
|
||||
guard 1...65535 ~= unpaddedLength else {
|
||||
throw EncryptionError.plaintextLengthInvalid(unpaddedLength)
|
||||
}
|
||||
|
||||
var prefix = Data(count: 2)
|
||||
prefix.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) in
|
||||
ptr.storeBytes(of: UInt16(unpaddedLength).bigEndian, as: UInt16.self)
|
||||
}
|
||||
|
||||
let suffix = Data(count: try calculatePaddedLength(unpaddedLength) - unpaddedLength)
|
||||
|
||||
return prefix + unpadded + suffix
|
||||
}
|
||||
|
||||
/// Converts padded bytes to unpadded plaintext.
|
||||
static func unpad(_ padded: Data) throws -> String {
|
||||
guard padded.count >= 2 else {
|
||||
throw EncryptionError.paddingInvalid
|
||||
}
|
||||
|
||||
let unpaddedLength = (Int(padded[0]) << 8) | Int(padded[1])
|
||||
|
||||
guard 2+unpaddedLength <= padded.count else {
|
||||
throw EncryptionError.paddingInvalid
|
||||
}
|
||||
|
||||
let unpadded = toBytes(from: padded)[2..<2+unpaddedLength]
|
||||
let paddedLength = try calculatePaddedLength(unpaddedLength)
|
||||
|
||||
guard unpaddedLength > 0,
|
||||
unpadded.count == unpaddedLength,
|
||||
padded.count == 2 + paddedLength,
|
||||
let result = String(data: Data(unpadded), encoding: .utf8) else {
|
||||
throw EncryptionError.paddingInvalid
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
static func decodePayload(_ payload: String) throws -> DecodedPayload {
|
||||
let payloadLength = payload.count
|
||||
|
||||
guard payloadLength > 0 && payload.first != "#" else {
|
||||
throw EncryptionError.unknownVersion()
|
||||
}
|
||||
guard 132...87472 ~= payloadLength else {
|
||||
throw EncryptionError.payloadSizeInvalid(payloadLength)
|
||||
}
|
||||
|
||||
guard let data = Data(base64Encoded: payload) else {
|
||||
throw EncryptionError.base64EncodingFailed
|
||||
}
|
||||
|
||||
let dataLength = data.count
|
||||
|
||||
guard 99...65603 ~= dataLength else {
|
||||
throw EncryptionError.dataSizeInvalid(dataLength)
|
||||
}
|
||||
|
||||
guard let version = data.first else {
|
||||
throw EncryptionError.unknownVersion()
|
||||
}
|
||||
|
||||
guard version == 2 else {
|
||||
throw EncryptionError.unknownVersion(Int(version))
|
||||
}
|
||||
|
||||
let nonce = data[data.index(data.startIndex, offsetBy: 1)..<data.index(data.startIndex, offsetBy: 33)]
|
||||
let ciphertext = data[data.index(data.startIndex, offsetBy: 33)..<data.index(data.startIndex, offsetBy: dataLength - 32)]
|
||||
let mac = data[data.index(data.startIndex, offsetBy: dataLength - 32)..<data.index(data.startIndex, offsetBy: dataLength)]
|
||||
|
||||
return DecodedPayload(nonce: nonce, ciphertext: ciphertext, mac: mac)
|
||||
}
|
||||
|
||||
static func hmacAad(key: Data, message: Data, aad: Data) throws -> Data {
|
||||
guard aad.count == 32 else {
|
||||
throw EncryptionError.aadLengthInvalid(aad.count)
|
||||
}
|
||||
|
||||
let combined = aad + message
|
||||
|
||||
return Data(CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: combined, using: SymmetricKey(data: key)))
|
||||
}
|
||||
|
||||
static func toBytes(from data: Data) -> [UInt8] {
|
||||
data.withUnsafeBytes { bytesPointer in Array(bytesPointer) }
|
||||
}
|
||||
|
||||
static func preparePublicKeyBytes(from publicKey: Pubkey) throws -> [UInt8] {
|
||||
let publicKeyBytes = publicKey.bytes
|
||||
|
||||
let prefix = Data([2])
|
||||
let prefixBytes = toBytes(from: prefix)
|
||||
|
||||
return prefixBytes + publicKeyBytes
|
||||
}
|
||||
|
||||
static func parsePublicKey(from bytes: [UInt8]) throws -> secp256k1_pubkey {
|
||||
var publicKey = secp256k1_pubkey()
|
||||
guard secp256k1_ec_pubkey_parse(secp256k1.Context.raw, &publicKey, bytes, bytes.count) == 1 else {
|
||||
throw EncryptionError.publicKeyInvalid
|
||||
}
|
||||
return publicKey
|
||||
}
|
||||
|
||||
static func computeSharedSecret(using publicKey: secp256k1_pubkey, and privateKeyBytes: [UInt8]) throws -> [UInt8] {
|
||||
var sharedSecret = [UInt8](repeating: 0, count: 32)
|
||||
var mutablePublicKey = publicKey
|
||||
|
||||
// Multiplication of point B by scalar a (a ⋅ B), defined in [BIP340](https://github.com/bitcoin/bips/blob/e918b50731397872ad2922a1b08a5a4cd1d6d546/bip-0340.mediawiki).
|
||||
// The operation produces a shared point, and we encode the shared point's 32-byte x coordinate, using method bytes(P) from BIP340.
|
||||
// Private and public keys must be validated as per BIP340: pubkey must be a valid, on-curve point, and private key must be a scalar in range [1, secp256k1_order - 1]
|
||||
guard secp256k1_ecdh(secp256k1.Context.raw, &sharedSecret, &mutablePublicKey, privateKeyBytes, { (output, x32, _, _) in
|
||||
memcpy(output, x32, 32)
|
||||
return 1
|
||||
}, nil) != 0 else {
|
||||
throw EncryptionError.sharedSecretComputationFailed
|
||||
}
|
||||
return sharedSecret
|
||||
}
|
||||
|
||||
/// Calculates long-term key between users A and B.
|
||||
/// The conversation key of A's private key and B's public key is equal to the conversation key of B's private key and A's public key.
|
||||
static func conversationKey(privateKeyA: Privkey, publicKeyB: Pubkey) throws -> ContiguousBytes {
|
||||
let privateKeyABytes = privateKeyA.bytes
|
||||
let publicKeyBBytes = try preparePublicKeyBytes(from: publicKeyB)
|
||||
let parsedPublicKeyB = try parsePublicKey(from: publicKeyBBytes)
|
||||
let sharedSecret = try computeSharedSecret(using: parsedPublicKeyB, and: privateKeyABytes)
|
||||
|
||||
return CryptoKit.HKDF<CryptoKit.SHA256>.extract(inputKeyMaterial: SymmetricKey(data: sharedSecret), salt: Data("nip44-v2".utf8))
|
||||
}
|
||||
|
||||
/// Calculates unique per-message key.
|
||||
static func messageKeys(conversationKey: ContiguousBytes, nonce: Data) throws -> MessageKeys {
|
||||
let conversationKeyByteCount = conversationKey.bytes.count
|
||||
guard conversationKeyByteCount == 32 else {
|
||||
throw EncryptionError.conversationKeyLengthInvalid(conversationKeyByteCount)
|
||||
}
|
||||
|
||||
guard nonce.count == 32 else {
|
||||
throw EncryptionError.nonceLengthInvalid(nonce.count)
|
||||
}
|
||||
|
||||
let keys = CryptoKit.HKDF<CryptoKit.SHA256>.expand(pseudoRandomKey: conversationKey, info: nonce, outputByteCount: 76)
|
||||
let keysBytes = keys.bytes
|
||||
|
||||
let chaChaKey = Data(keysBytes[0..<32])
|
||||
let chaChaNonce = Data(keysBytes[32..<44])
|
||||
let hmacKey = Data(keysBytes[44..<76])
|
||||
|
||||
return MessageKeys(chaChaKey: chaChaKey, chaChaNonce: chaChaNonce, hmacKey: hmacKey)
|
||||
}
|
||||
|
||||
static func encrypt(plaintext: String, conversationKey: ContiguousBytes, nonce: Data? = nil) throws -> String {
|
||||
let nonceData: Data
|
||||
if let nonce {
|
||||
nonceData = nonce
|
||||
} else {
|
||||
// Fetches randomness from CSPRNG.
|
||||
nonceData = Data.secureRandomBytes(count: 32)
|
||||
}
|
||||
|
||||
let messageKeys = try messageKeys(conversationKey: conversationKey, nonce: nonceData)
|
||||
let padded = try pad(plaintext)
|
||||
let paddedBytes = toBytes(from: padded)
|
||||
|
||||
let chaChaKey = toBytes(from: messageKeys.chaChaKey)
|
||||
let chaChaNonce = toBytes(from: messageKeys.chaChaNonce)
|
||||
|
||||
let ciphertext = try ChaCha20(key: chaChaKey, iv: chaChaNonce).encrypt(paddedBytes)
|
||||
let ciphertextData = Data(ciphertext)
|
||||
|
||||
let mac = try hmacAad(key: messageKeys.hmacKey, message: ciphertextData, aad: nonceData)
|
||||
|
||||
let data = Data([2]) + nonceData + ciphertextData + mac
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
static func decrypt(payload: String, conversationKey: ContiguousBytes) throws -> String {
|
||||
let decodedPayload = try decodePayload(payload)
|
||||
let nonce = decodedPayload.nonce
|
||||
let ciphertext = decodedPayload.ciphertext
|
||||
let ciphertextBytes = toBytes(from: ciphertext)
|
||||
let mac = decodedPayload.mac
|
||||
|
||||
let messageKeys = try messageKeys(conversationKey: conversationKey, nonce: nonce)
|
||||
|
||||
let calculatedMac = try hmacAad(key: messageKeys.hmacKey, message: ciphertext, aad: nonce)
|
||||
|
||||
guard calculatedMac == mac else {
|
||||
throw EncryptionError.macInvalid
|
||||
}
|
||||
|
||||
let chaChaNonce = toBytes(from: messageKeys.chaChaNonce)
|
||||
let chaChaKey = toBytes(from: messageKeys.chaChaKey)
|
||||
|
||||
let paddedPlaintext = try ChaCha20(key: chaChaKey, iv: chaChaNonce).decrypt(ciphertextBytes)
|
||||
let paddedPlaintextData = Data(paddedPlaintext.bytes)
|
||||
|
||||
return try unpad(paddedPlaintextData)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper structures and extensions
|
||||
|
||||
extension Data {
|
||||
/// Random data of a given size, from CSPRNG
|
||||
/// - Parameter count: The size of the data, in bytes
|
||||
/// - Returns: Bytes randomly generated from CSPRNG
|
||||
static func secureRandomBytes(count: Int) -> Data {
|
||||
var bytes = [Int8](repeating: 0, count: count)
|
||||
guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
|
||||
fatalError("can't copy secure random data")
|
||||
}
|
||||
return Data(bytes: bytes, count: count)
|
||||
}
|
||||
}
|
||||
|
||||
extension NIP44v2Encryption {
|
||||
struct DecodedPayload {
|
||||
let nonce: Data
|
||||
let ciphertext: Data
|
||||
let mac: Data
|
||||
}
|
||||
|
||||
struct MessageKeys {
|
||||
let chaChaKey: Data
|
||||
let chaChaNonce: Data
|
||||
let hmacKey: Data
|
||||
}
|
||||
|
||||
public enum EncryptionError: Error {
|
||||
case aadLengthInvalid(Int)
|
||||
case base64EncodingFailed
|
||||
case chaCha20DecryptionFailed
|
||||
case chaCha20EncryptionFailed
|
||||
case conversationKeyLengthInvalid(Int)
|
||||
case dataSizeInvalid(Int)
|
||||
case macInvalid
|
||||
case nonceLengthInvalid(Int)
|
||||
case paddingInvalid
|
||||
case payloadSizeInvalid(Int)
|
||||
case plaintextLengthInvalid(Int)
|
||||
case privateKeyInvalid
|
||||
case publicKeyInvalid
|
||||
case sharedSecretComputationFailed
|
||||
case unknownVersion(Int? = nil)
|
||||
case unpaddedLengthInvalid(Int)
|
||||
case utf8EncodingFailed
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
//
|
||||
// InterestList.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D'Aquino on 2025-06-23.
|
||||
//
|
||||
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Includes models and functions for working with NIP-51
|
||||
struct NIP51: Sendable {}
|
||||
|
||||
extension NIP51 {
|
||||
/// An error thrown when decoding an item into a NIP-51 list
|
||||
enum NIP51DecodingError: Error {
|
||||
/// The Nostr event being converted is not a NIP-51 interest list
|
||||
case notInterestList
|
||||
}
|
||||
}
|
||||
|
||||
extension NIP51 {
|
||||
/// Models a NIP-51 Interest List (kind:10015)
|
||||
struct InterestList: NostrEventConvertible, Sendable {
|
||||
typealias E = NIP51DecodingError
|
||||
|
||||
enum InterestItem: Sendable, Hashable {
|
||||
case hashtag(String)
|
||||
case interestSet(String, String, String) // a-tag: kind, pubkey, identifier
|
||||
|
||||
var tag: [String] {
|
||||
switch self {
|
||||
case .hashtag(let tag):
|
||||
return ["t", tag]
|
||||
case .interestSet(let kind, let pubkey, let identifier):
|
||||
var tag = ["a", "\(kind):\(pubkey):\(identifier)"]
|
||||
return tag
|
||||
}
|
||||
}
|
||||
|
||||
static func fromTag(tag: TagSequence) -> InterestItem? {
|
||||
var i = tag.makeIterator()
|
||||
|
||||
guard let t0 = i.next(),
|
||||
let t1 = i.next() else { return nil }
|
||||
|
||||
let tagName = t0.string()
|
||||
|
||||
if tagName == "t" {
|
||||
return .hashtag(t1.string())
|
||||
} else if tagName == "a" {
|
||||
let components = t1.string().split(separator: ":")
|
||||
guard components.count > 2 else { return nil }
|
||||
|
||||
let kind = String(components[0])
|
||||
let pubkey = String(components[1])
|
||||
let identifier = String(components[2])
|
||||
|
||||
return .interestSet(kind, pubkey, identifier)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
let interests: [InterestItem]
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(event: NdbNote) throws(E) {
|
||||
try self.init(event: UnownedNdbNote(event))
|
||||
}
|
||||
|
||||
init(event: borrowing UnownedNdbNote) throws(E) {
|
||||
guard event.known_kind == .interest_list else {
|
||||
throw E.notInterestList
|
||||
}
|
||||
|
||||
var interests: [InterestItem] = []
|
||||
|
||||
for tag in event.tags {
|
||||
if let interest = InterestItem.fromTag(tag: tag) {
|
||||
interests.append(interest)
|
||||
}
|
||||
}
|
||||
|
||||
self.interests = interests
|
||||
}
|
||||
|
||||
init?(event: NdbNote?) throws(E) {
|
||||
guard let event else { return nil }
|
||||
try self.init(event: event)
|
||||
}
|
||||
|
||||
init(interests: [InterestItem]) {
|
||||
self.interests = interests
|
||||
}
|
||||
|
||||
// MARK: - Conversion to a Nostr Event
|
||||
|
||||
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
|
||||
return NdbNote(
|
||||
content: "",
|
||||
keypair: keypair.to_keypair(),
|
||||
kind: NostrKind.interest_list.rawValue,
|
||||
tags: self.interests.map { $0.tag },
|
||||
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-22
@@ -34,19 +34,6 @@ protocol TagConvertible {
|
||||
static func from_tag(tag: TagSequence) -> Self?
|
||||
}
|
||||
|
||||
/// Protocol for types that can be converted from/to a tag sequence with the possibilty of an error
|
||||
protocol ThrowingTagConvertible {
|
||||
associatedtype E: Error
|
||||
var tag: [String] { get }
|
||||
static func fromTag(tag: TagSequence) throws(E) -> Self?
|
||||
}
|
||||
|
||||
/// Protocol for types that can be converted from/to a tag item
|
||||
protocol TagItemConvertible {
|
||||
var tagItem: String? { get }
|
||||
static func fromTagItem(_ item: String?) -> Self?
|
||||
}
|
||||
|
||||
struct QuoteId: IdType, TagKey, TagConvertible {
|
||||
let id: Data
|
||||
|
||||
@@ -143,16 +130,8 @@ struct ReplaceableParam: TagConvertible {
|
||||
var keychar: AsciiCharacter { "d" }
|
||||
}
|
||||
|
||||
struct Signature: Codable, Hashable, Equatable {
|
||||
struct Signature: Hashable, Equatable {
|
||||
let data: Data
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
self.init(try hex_decoder(decoder, expected_len: 64))
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
try hex_encoder(to: encoder, data: self.data)
|
||||
}
|
||||
|
||||
init(_ p: Data) {
|
||||
self.data = p
|
||||
|
||||
@@ -31,8 +31,7 @@ class ProfileRecord {
|
||||
}
|
||||
|
||||
guard let profile = data.profile,
|
||||
let addr = (profile.lud16 ?? profile.lud06)?.trimmingCharacters(in: .whitespaces)
|
||||
else {
|
||||
let addr = profile.lud16 ?? profile.lud06 else {
|
||||
return nil;
|
||||
}
|
||||
|
||||
@@ -58,7 +57,7 @@ extension NdbProfile {
|
||||
}
|
||||
|
||||
static func displayName(profile: Profile?, pubkey: Pubkey) -> DisplayName {
|
||||
return DisplayName(name: profile?.name, display_name: profile?.display_name, pubkey: pubkey)
|
||||
return parse_display_name(profile: profile, pubkey: pubkey)
|
||||
}
|
||||
|
||||
var damus_donation: Int? {
|
||||
@@ -302,7 +301,7 @@ class Profile: Codable {
|
||||
*/
|
||||
|
||||
func make_test_profile() -> Profile {
|
||||
return Profile(name: "jb55", display_name: "Will", about: "Its a me", picture: "https://cdn.jb55.com/img/red-me.jpg", banner: "https://pbs.twimg.com/profile_banners/9918032/1531711830/600x200", website: "jb55.com", lud06: nil, lud16: "jb55@jb55.com", nip05: "jb55@jb55.com", damus_donation: 1)
|
||||
return Profile(name: "jb55", display_name: "Will", about: "Its a me", picture: "https://cdn.jb55.com/img/red-me.jpg", banner: "https://pbs.twimg.com/profile_banners/9918032/1531711830/600x200", website: "jb55.com", lud06: "jb55@jb55.com", lud16: nil, nip05: "jb55@jb55.com", damus_donation: 1)
|
||||
}
|
||||
|
||||
func make_ln_url(_ str: String?) -> URL? {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: RelayPool.Relay) -> NostrEvent? {
|
||||
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: Relay) -> NostrEvent? {
|
||||
let tags: [[String]] = [["relay", relay.descriptor.url.absoluteString],["challenge", challenge_string]]
|
||||
let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags)
|
||||
return event
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayPool.RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
|
||||
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
|
||||
var tags = zap_target_to_tags(target)
|
||||
var relay_tag = ["relays"]
|
||||
relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
|
||||
@@ -68,7 +68,7 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair,
|
||||
|
||||
guard let note = NostrEvent(content: message, keypair: identity.to_keypair(), kind: 9733, tags: tags),
|
||||
let note_json = encode_json(note),
|
||||
let enc = NIP04.encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
|
||||
let enc = encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
@@ -78,8 +78,8 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair,
|
||||
|
||||
func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
|
||||
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
|
||||
let rw_relay_info = LegacyKind3RelayRWConfiguration(read: true, write: true)
|
||||
var relays: [RelayURL: LegacyKind3RelayRWConfiguration] = [:]
|
||||
let rw_relay_info = RelayInfo(read: true, write: true)
|
||||
var relays: [RelayURL: RelayInfo] = [:]
|
||||
|
||||
for relay in bootstrap_relays {
|
||||
relays[relay] = rw_relay_info
|
||||
|
||||
@@ -13,18 +13,6 @@ import CryptoKit
|
||||
import NaturalLanguage
|
||||
|
||||
|
||||
/// A protocol for structs and classes that can convert themselves from/to a NostrEvent
|
||||
protocol NostrEventConvertible {
|
||||
associatedtype E: Error
|
||||
|
||||
/// Iniitialize this type from a NostrEvent
|
||||
init(event: NostrEvent) throws(E)
|
||||
|
||||
/// Convert this type into a Nostr Event, using a keypair for signing and a specific timestamp
|
||||
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32?) -> NostrEvent?
|
||||
}
|
||||
|
||||
|
||||
enum ValidationResult: Decodable {
|
||||
case unknown
|
||||
case ok
|
||||
@@ -379,10 +367,6 @@ func decode_json<T: Decodable>(_ val: String) -> T? {
|
||||
return try? JSONDecoder().decode(T.self, from: Data(val.utf8))
|
||||
}
|
||||
|
||||
func decode_json_throwing<T: Decodable>(_ val: String) throws -> T {
|
||||
return try JSONDecoder().decode(T.self, from: Data(val.utf8))
|
||||
}
|
||||
|
||||
func decode_data<T: Decodable>(_ data: Data) -> T? {
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
@@ -543,7 +527,6 @@ func event_to_json(ev: NostrEvent) -> String {
|
||||
return str
|
||||
}
|
||||
|
||||
@available(*, deprecated, renamed: "NIP04.decryptContent", message: "Deprecated, please use NIP04.decryptContent instead")
|
||||
func decrypt_dm(_ privkey: Privkey?, pubkey: Pubkey, content: String, encoding: EncEncoding) -> String? {
|
||||
guard let privkey = privkey else {
|
||||
return nil
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import Foundation
|
||||
|
||||
|
||||
/// A known Nostr event kind, addressable by name, with the actual number assigned by the protocol as the value
|
||||
enum NostrKind: UInt32, Codable {
|
||||
case metadata = 0
|
||||
case text = 1
|
||||
@@ -19,8 +18,6 @@ enum NostrKind: UInt32, Codable {
|
||||
case like = 7
|
||||
case chat = 42
|
||||
case mute_list = 10000
|
||||
case relay_list = 10002
|
||||
case interest_list = 10015
|
||||
case list_deprecated = 30000
|
||||
case draft = 31234
|
||||
case longform = 30023
|
||||
@@ -31,5 +28,4 @@ enum NostrKind: UInt32, Codable {
|
||||
case nwc_response = 23195
|
||||
case http_auth = 27235
|
||||
case status = 30315
|
||||
case follow_list = 39089
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ enum NostrLink: Equatable {
|
||||
case ref(RefId)
|
||||
case filter(NostrFilter)
|
||||
case script([UInt8])
|
||||
case invoice(String)
|
||||
}
|
||||
|
||||
func encode_pubkey_uri(_ pubkey: Pubkey) -> String {
|
||||
@@ -94,15 +93,8 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
|
||||
return
|
||||
}
|
||||
|
||||
if parts.count >= 2 {
|
||||
switch parts[0] {
|
||||
case "t":
|
||||
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
|
||||
case "lightning":
|
||||
return .invoice(parts[1])
|
||||
default:
|
||||
break
|
||||
}
|
||||
if parts.count >= 2 && parts[0] == "t" {
|
||||
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
|
||||
}
|
||||
|
||||
guard parts.count == 1 else {
|
||||
|
||||
@@ -12,14 +12,11 @@ struct NostrSubscribe {
|
||||
let sub_id: String
|
||||
}
|
||||
|
||||
/// Models a request/message that is sent to a Nostr relay
|
||||
|
||||
enum NostrRequestType {
|
||||
/// A standard nostr request
|
||||
case typical(NostrRequest)
|
||||
/// A customized nostr request. Generally used in the context of a nostrscript.
|
||||
case custom(String)
|
||||
|
||||
/// Whether this request is meant to write data to a relay
|
||||
var is_write: Bool {
|
||||
guard case .typical(let req) = self else {
|
||||
return true
|
||||
@@ -28,7 +25,6 @@ enum NostrRequestType {
|
||||
return req.is_write
|
||||
}
|
||||
|
||||
/// Whether this request is meant to read data from a relay
|
||||
var is_read: Bool {
|
||||
guard case .typical(let req) = self else {
|
||||
return true
|
||||
@@ -38,18 +34,12 @@ enum NostrRequestType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Models a standard request/message that is sent to a Nostr relay.
|
||||
enum NostrRequest {
|
||||
/// Subscribes to receive information from the relay
|
||||
case subscribe(NostrSubscribe)
|
||||
/// Unsubscribes from an existing subscription, addressed by its id
|
||||
case unsubscribe(String)
|
||||
/// Posts an event
|
||||
case event(NostrEvent)
|
||||
/// Authenticate with the relay
|
||||
case auth(NostrEvent)
|
||||
|
||||
/// Whether this request is meant to write data to a relay
|
||||
var is_write: Bool {
|
||||
switch self {
|
||||
case .subscribe:
|
||||
@@ -63,7 +53,6 @@ enum NostrRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this request is meant to read data from a relay
|
||||
var is_read: Bool {
|
||||
return !is_write
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ enum NostrResponse {
|
||||
|
||||
static func owned_from_json(json: String) -> NostrResponse? {
|
||||
return json.withCString{ cstr in
|
||||
let bufsize: Int = max(Int(Double(json.utf8.count) * 8.0), Int(getpagesize()))
|
||||
let bufsize: Int = max(Int(Double(json.utf8.count) * 4.0), Int(getpagesize()))
|
||||
let data = malloc(bufsize)
|
||||
|
||||
if data == nil {
|
||||
|
||||
@@ -35,7 +35,6 @@ class Profiles {
|
||||
@MainActor
|
||||
private var profiles: [Pubkey: ProfileData] = [:]
|
||||
|
||||
// Map of validated NIP-05 address to pubkey.
|
||||
@MainActor
|
||||
var nip05_pubkey: [String: Pubkey] = [:]
|
||||
|
||||
|
||||
@@ -115,19 +115,6 @@ enum FollowRef: TagKeys, Hashable, TagConvertible, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Models common tag references defined by the Nostr protocol, and their associated values.
|
||||
///
|
||||
/// For example, this raw JSON tag sequence:
|
||||
/// ```json
|
||||
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
|
||||
/// ```
|
||||
///
|
||||
/// would be parsed into something equivalent to `.pubkey(Pubkey(hex: "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"))`
|
||||
///
|
||||
/// ## Notes
|
||||
///
|
||||
/// - Not all tag information from all NIPs can be modelled using this alone, as some NIPs may define extra associated values for specific event types. You may need to use a specialized type for some event kinds
|
||||
///
|
||||
enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case event(NoteId)
|
||||
case pubkey(Pubkey)
|
||||
@@ -137,7 +124,6 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case naddr(NAddr)
|
||||
case reference(String)
|
||||
|
||||
/// The key that defines the type of reference being made
|
||||
var key: RefKey {
|
||||
switch self {
|
||||
case .event: return .e
|
||||
@@ -150,14 +136,6 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines the type of reference being made on a Nostr event tag
|
||||
///
|
||||
/// Example:
|
||||
/// ```json
|
||||
/// ["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"]
|
||||
/// ```
|
||||
///
|
||||
/// The `RefKey` is "p"
|
||||
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
|
||||
case e, p, t, d, q, a, r
|
||||
|
||||
@@ -170,12 +148,10 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw nostr-style tag sequence representation of this object
|
||||
var tag: [String] {
|
||||
[self.key.description, self.description]
|
||||
}
|
||||
|
||||
/// Describes what is being referenced, as a `String`
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .event(let noteId): return noteId.hex()
|
||||
@@ -190,7 +166,6 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a raw tag sequence
|
||||
static func from_tag(tag: TagSequence) -> RefId? {
|
||||
var i = tag.makeIterator()
|
||||
|
||||
|
||||
+47
-85
@@ -7,25 +7,16 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct LegacyKind3RelayRWConfiguration: Codable, Sendable {
|
||||
public let read: Bool?
|
||||
public let write: Bool?
|
||||
public struct RelayInfo: Codable {
|
||||
let read: Bool?
|
||||
let write: Bool?
|
||||
|
||||
init(read: Bool, write: Bool) {
|
||||
self.read = read
|
||||
self.write = write
|
||||
}
|
||||
|
||||
static let rw = LegacyKind3RelayRWConfiguration(read: true, write: true)
|
||||
|
||||
func toNIP65RWConfiguration() -> NIP65.RelayList.RelayItem.RWConfiguration? {
|
||||
switch (self.read, self.write) {
|
||||
case (false, true): return .write
|
||||
case (true, false): return .read
|
||||
case (true, true): return .readWrite
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
static let rw = RelayInfo(read: true, write: true)
|
||||
}
|
||||
|
||||
enum RelayVariant {
|
||||
@@ -34,34 +25,31 @@ enum RelayVariant {
|
||||
case nwc
|
||||
}
|
||||
|
||||
extension RelayPool {
|
||||
/// Describes a relay for use in `RelayPool`
|
||||
public struct RelayDescriptor {
|
||||
let url: RelayURL
|
||||
var info: NIP65.RelayList.RelayItem.RWConfiguration
|
||||
let variant: RelayVariant
|
||||
|
||||
init(url: RelayURL, info: NIP65.RelayList.RelayItem.RWConfiguration, variant: RelayVariant = .regular) {
|
||||
self.url = url
|
||||
self.info = info
|
||||
self.variant = variant
|
||||
}
|
||||
|
||||
var ephemeral: Bool {
|
||||
switch variant {
|
||||
case .regular:
|
||||
return false
|
||||
case .ephemeral:
|
||||
return true
|
||||
case .nwc:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func nwc(url: RelayURL) -> RelayDescriptor {
|
||||
return RelayDescriptor(url: url, info: .readWrite, variant: .nwc)
|
||||
public struct RelayDescriptor {
|
||||
let url: RelayURL
|
||||
let info: RelayInfo
|
||||
let variant: RelayVariant
|
||||
|
||||
init(url: RelayURL, info: RelayInfo, variant: RelayVariant = .regular) {
|
||||
self.url = url
|
||||
self.info = info
|
||||
self.variant = variant
|
||||
}
|
||||
|
||||
var ephemeral: Bool {
|
||||
switch variant {
|
||||
case .regular:
|
||||
return false
|
||||
case .ephemeral:
|
||||
return true
|
||||
case .nwc:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func nwc(url: RelayURL) -> RelayDescriptor {
|
||||
return RelayDescriptor(url: url, info: .rw, variant: .nwc)
|
||||
}
|
||||
}
|
||||
|
||||
enum RelayFlags: Int {
|
||||
@@ -141,56 +129,30 @@ struct RelayMetadata: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
extension RelayPool {
|
||||
class Relay: Identifiable {
|
||||
var descriptor: RelayDescriptor
|
||||
let connection: RelayConnection
|
||||
var authentication_state: RelayAuthenticationState
|
||||
|
||||
var flags: Int
|
||||
|
||||
init(descriptor: RelayDescriptor, connection: RelayConnection) {
|
||||
self.flags = 0
|
||||
self.descriptor = descriptor
|
||||
self.connection = connection
|
||||
self.authentication_state = RelayAuthenticationState.none
|
||||
}
|
||||
|
||||
var is_broken: Bool {
|
||||
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
|
||||
}
|
||||
|
||||
var id: RelayURL {
|
||||
return descriptor.url
|
||||
}
|
||||
}
|
||||
}
|
||||
class Relay: Identifiable {
|
||||
let descriptor: RelayDescriptor
|
||||
let connection: RelayConnection
|
||||
var authentication_state: RelayAuthenticationState
|
||||
|
||||
extension RelayPool {
|
||||
enum RelayError: Error {
|
||||
case RelayAlreadyExists
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Extension to bridge NIP-65 relay list structs with app-native objects
|
||||
|
||||
extension NIP65.RelayList {
|
||||
static func fromLegacyContactList(_ contactList: NdbNote) throws(BridgeError) -> Self {
|
||||
guard let relayListInfo = decode_json_relays(contactList.content) else { throw .couldNotDecodeRelayListInfo }
|
||||
let relayItems = relayListInfo.map({ url, rwConfiguration in
|
||||
return RelayItem(url: url, rwConfiguration: rwConfiguration.toNIP65RWConfiguration() ?? .readWrite)
|
||||
})
|
||||
return NIP65.RelayList(relays: relayItems)
|
||||
var flags: Int
|
||||
|
||||
init(descriptor: RelayDescriptor, connection: RelayConnection) {
|
||||
self.flags = 0
|
||||
self.descriptor = descriptor
|
||||
self.connection = connection
|
||||
self.authentication_state = RelayAuthenticationState.none
|
||||
}
|
||||
|
||||
static func fromLegacyContactList(_ contactList: NdbNote?) throws(BridgeError) -> Self? {
|
||||
guard let contactList = contactList else { return nil }
|
||||
return try fromLegacyContactList(contactList)
|
||||
var is_broken: Bool {
|
||||
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
|
||||
}
|
||||
|
||||
enum BridgeError: Error {
|
||||
case couldNotDecodeRelayListInfo
|
||||
|
||||
var id: RelayURL {
|
||||
return descriptor.url
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum RelayError: Error {
|
||||
case RelayAlreadyExists
|
||||
}
|
||||
|
||||
@@ -24,15 +24,13 @@ struct SeenEvent: Hashable {
|
||||
let evid: NoteId
|
||||
}
|
||||
|
||||
/// Establishes and manages connections and subscriptions to a list of relays.
|
||||
class RelayPool {
|
||||
private(set) var relays: [Relay] = []
|
||||
var relays: [Relay] = []
|
||||
var handlers: [RelayHandler] = []
|
||||
var request_queue: [QueuedRequest] = []
|
||||
var seen: Set<SeenEvent> = Set()
|
||||
var counts: [RelayURL: UInt64] = [:]
|
||||
var ndb: Ndb
|
||||
/// The keypair used to authenticate with relays
|
||||
var keypair: Keypair?
|
||||
var message_received_function: (((String, RelayDescriptor)) -> Void)?
|
||||
var message_sent_function: (((String, Relay)) -> Void)?
|
||||
@@ -124,7 +122,7 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
|
||||
func add_relay(_ desc: RelayDescriptor) throws(RelayError) {
|
||||
func add_relay(_ desc: RelayDescriptor) throws {
|
||||
let relay_id = desc.url
|
||||
if get_relay(relay_id) != nil {
|
||||
throw RelayError.RelayAlreadyExists
|
||||
@@ -202,64 +200,6 @@ class RelayPool {
|
||||
register_handler(sub_id: sub_id, handler: handler)
|
||||
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
|
||||
}
|
||||
|
||||
/// Subscribes to data from the `RelayPool` based on a filter and a list of desired relays.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - filters: The filters specifying the desired content.
|
||||
/// - desiredRelays: The desired relays which to subsctibe to. If `nil`, it defaults to the `RelayPool`'s default list
|
||||
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal, in seconds
|
||||
/// - Returns: Returns an async stream that callers can easily consume via a for-loop
|
||||
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: TimeInterval = 10) -> AsyncStream<StreamItem> {
|
||||
let desiredRelays = desiredRelays ?? self.relays.map({ $0.descriptor.url })
|
||||
return AsyncStream<StreamItem> { continuation in
|
||||
let sub_id = UUID().uuidString
|
||||
var seenEvents: Set<NoteId> = []
|
||||
var relaysWhoFinishedInitialResults: Set<RelayURL> = []
|
||||
var eoseSent = false
|
||||
self.subscribe(sub_id: sub_id, filters: filters, handler: { (relayUrl, connectionEvent) in
|
||||
switch connectionEvent {
|
||||
case .ws_event(let ev):
|
||||
// Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here.
|
||||
// For the future, perhaps we should abstract away `.ws_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer.
|
||||
break
|
||||
case .nostr_event(let nostrResponse):
|
||||
guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription
|
||||
switch nostrResponse {
|
||||
case .event(_, let nostrEvent):
|
||||
if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events.
|
||||
continuation.yield(with: .success(.event(nostrEvent)))
|
||||
seenEvents.insert(nostrEvent.id)
|
||||
case .notice(let note):
|
||||
break // We do not support handling these yet
|
||||
case .eose(_):
|
||||
relaysWhoFinishedInitialResults.insert(relayUrl)
|
||||
if relaysWhoFinishedInitialResults == Set(desiredRelays) {
|
||||
continuation.yield(with: .success(.eose))
|
||||
eoseSent = true
|
||||
}
|
||||
case .ok(_): break // No need to handle this, we are not sending an event to the relay
|
||||
case .auth(_): break // Handled in a separate function in RelayPool
|
||||
}
|
||||
}
|
||||
}, to: desiredRelays)
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(eoseTimeout))
|
||||
if !eoseSent { continuation.yield(with: .success(.eose)) }
|
||||
}
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
self.unsubscribe(sub_id: sub_id, to: desiredRelays)
|
||||
self.remove_handler(sub_id: sub_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StreamItem {
|
||||
/// A Nostr event
|
||||
case event(NostrEvent)
|
||||
/// The "end of stored events" signal
|
||||
case eose
|
||||
}
|
||||
|
||||
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
|
||||
register_handler(sub_id: sub_id, handler: handler)
|
||||
@@ -303,19 +243,19 @@ class RelayPool {
|
||||
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
|
||||
self.send_raw_to_local_ndb(req) // Always send Nostr events and data to NostrDB for a local copy
|
||||
self.send_raw_to_local_ndb(req)
|
||||
|
||||
for relay in relays {
|
||||
if req.is_read && !(relay.descriptor.info.canRead) {
|
||||
continue // Do not send read requests to relays that are not READ relays
|
||||
if req.is_read && !(relay.descriptor.info.read ?? true) {
|
||||
continue
|
||||
}
|
||||
|
||||
if req.is_write && !(relay.descriptor.info.canWrite) {
|
||||
continue // Do not send write requests to relays that are not WRITE relays
|
||||
if req.is_write && !(relay.descriptor.info.write ?? true) {
|
||||
continue
|
||||
}
|
||||
|
||||
if relay.descriptor.ephemeral && skip_ephemeral {
|
||||
continue // Do not send requests to ephemeral relays if we want to skip them
|
||||
continue
|
||||
}
|
||||
|
||||
guard relay.connection.isConnected else {
|
||||
@@ -414,7 +354,7 @@ class RelayPool {
|
||||
}
|
||||
|
||||
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
|
||||
try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite))
|
||||
try? pool.add_relay(RelayDescriptor(url: url, info: .rw))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
extension QueueableNotify<LossyLocalNotification> {
|
||||
/// A shared singleton for opening local and push user notifications
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - The queue can only hold one element. This is done because if the user hypothetically opened 10 push notifications and there was a lag, we wouldn't want the app to suddenly open 10 different things.
|
||||
static let shared = QueueableNotify(maxQueueItems: 1)
|
||||
struct LocalNotificationNotify: Notify {
|
||||
typealias Payload = LossyLocalNotification
|
||||
var payload: Payload
|
||||
}
|
||||
|
||||
extension NotifyHandler {
|
||||
static var local_notification: NotifyHandler<LocalNotificationNotify> {
|
||||
.init()
|
||||
}
|
||||
}
|
||||
|
||||
extension Notifications {
|
||||
static func local_notification(_ payload: LossyLocalNotification) -> Notifications<LocalNotificationNotify> {
|
||||
.init(.init(payload: payload))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
//
|
||||
// QueueableNotify.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-02-14.
|
||||
//
|
||||
|
||||
/// This notifies another object about some payload,
|
||||
/// with automatic "queueing" of messages if there are no listeners.
|
||||
///
|
||||
/// When used as a singleton, this can be used to easily send notifications to be handled at the app-level.
|
||||
///
|
||||
/// This serves the same purpose as `Notify`, except this implements the queueing of messages,
|
||||
/// which means that messages can be handled even if the listener is not instantiated yet.
|
||||
///
|
||||
/// **Example:** The app delegate can send some events that need handling from `ContentView` — but some can occur before `ContentView` is even instantiated.
|
||||
///
|
||||
///
|
||||
/// ## Usage notes
|
||||
///
|
||||
/// - This code was mainly written to have one listener at a time. Have more than one listener may be possible, but this class has not been tested/optimized for that purpose.
|
||||
///
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - This makes heavy use of `AsyncStream` and continuations, because that allows complexities here to be handled elegantly with a simple "for-in" loop
|
||||
/// - Without this, it would take a couple of callbacks and manual handling of queued items to achieve the same effect
|
||||
/// - Modeled as an `actor` for extra thread-safety
|
||||
actor QueueableNotify<T: Sendable> {
|
||||
/// The continuation, which allows us to publish new items to the listener
|
||||
/// If `nil`, that means there is no listeners to the stream, which is used for determining whether to queue new incoming items.
|
||||
private var continuation: AsyncStream<T>.Continuation?
|
||||
/// Holds queue items
|
||||
private var queue: [T] = []
|
||||
/// The maximum amount of items allowed in the queue. Older items will be discarded from the queue after it is full
|
||||
var maxQueueItems: Int
|
||||
|
||||
/// Initializes the object
|
||||
/// - Parameter maxQueueItems: The maximum amount of items allowed in the queue. Older items will be discarded from the queue after it is full
|
||||
init(maxQueueItems: Int) {
|
||||
self.maxQueueItems = maxQueueItems
|
||||
}
|
||||
|
||||
/// The async stream, used for listening for notifications
|
||||
///
|
||||
/// This will first stream the queued "inbox" items that the listener may have missed, and then it will do a real-time stream of new items as they come in.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// ```swift
|
||||
/// for await notification in queueableNotify.stream {
|
||||
/// // Do something with the notification
|
||||
/// }
|
||||
/// ```
|
||||
var stream: AsyncStream<T> {
|
||||
return AsyncStream { continuation in
|
||||
// Stream queued "inbox" items that the listener may have missed
|
||||
for item in queue {
|
||||
continuation.yield(item)
|
||||
}
|
||||
|
||||
// Clean up if the stream closes
|
||||
continuation.onTermination = { continuation in
|
||||
Task { await self.cleanup() }
|
||||
}
|
||||
|
||||
// Point to this stream, so that it can receive new updates
|
||||
self.continuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleans up after a stream is closed by the listener
|
||||
private func cleanup() {
|
||||
self.continuation = nil // This will cause new items to be queued for when another listener is attached
|
||||
}
|
||||
|
||||
/// Adds a new notification item to be handled by a listener.
|
||||
///
|
||||
/// This will automatically stream the new item to the listener, or queue the item if no one is listening
|
||||
func add(item: T) {
|
||||
while queue.count >= maxQueueItems { queue.removeFirst() } // Ensures queue stays within the desired size
|
||||
guard let continuation else {
|
||||
// No one is listening, queue it (send it to an inbox for later handling)
|
||||
queue.append(item)
|
||||
return
|
||||
}
|
||||
// Send directly to the active listener stream
|
||||
continuation.yield(item)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,8 @@ var test_damus_state: DamusState = ({
|
||||
let our_pubkey = test_pubkey
|
||||
let pool = RelayPool(ndb: ndb)
|
||||
let settings = UserSettingsStore()
|
||||
let damus = DamusState(keypair: test_keypair,
|
||||
let damus = DamusState(pool: pool,
|
||||
keypair: test_keypair,
|
||||
likes: .init(our_pubkey: our_pubkey),
|
||||
boosts: .init(our_pubkey: our_pubkey),
|
||||
contacts: .init(our_pubkey: our_pubkey),
|
||||
@@ -99,6 +100,8 @@ var test_damus_state: DamusState = ({
|
||||
drafts: .init(),
|
||||
events: .init(ndb: ndb),
|
||||
bookmarks: .init(pubkey: our_pubkey),
|
||||
postbox: .init(pool: pool),
|
||||
bootstrap_relays: .init(),
|
||||
replies: .init(our_pubkey: our_pubkey),
|
||||
wallet: .init(settings: settings),
|
||||
nav: .init(),
|
||||
@@ -106,8 +109,7 @@ var test_damus_state: DamusState = ({
|
||||
video: .init(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: our_pubkey),
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||
favicon_cache: .init()
|
||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||
)
|
||||
|
||||
/*
|
||||
|
||||
+1
-27
@@ -37,23 +37,7 @@ enum Block: Equatable {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var is_previewable: Bool {
|
||||
switch self {
|
||||
case .mention(let m):
|
||||
switch m.ref {
|
||||
case .note, .nevent: return true
|
||||
default: return false
|
||||
}
|
||||
case .invoice:
|
||||
return true
|
||||
case .url:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
case text(String)
|
||||
case mention(Mention<MentionRef>)
|
||||
case hashtag(String)
|
||||
@@ -202,13 +186,3 @@ extension Block {
|
||||
}
|
||||
}
|
||||
}
|
||||
extension Block {
|
||||
var asInvoice: Invoice? {
|
||||
switch self {
|
||||
case .invoice(let invoice):
|
||||
return invoice
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,3 +45,4 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
|
||||
var expectedLud16: String? {
|
||||
guard let username else { return nil }
|
||||
return username + "@coinos.io"
|
||||
}
|
||||
|
||||
/// A deterministic password for a Coinos account
|
||||
private var password: String? {
|
||||
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
|
||||
return sha256Hex(text: "coinos_password:" + self.userKeypair.privkey.hex())
|
||||
}
|
||||
|
||||
/// A deterministic NWC app connection name
|
||||
private var nwcConnectionName: String { return "Damus" }
|
||||
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Initializes the client with the user's keypair
|
||||
init(userKeypair: FullKeypair) {
|
||||
self.userKeypair = userKeypair
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Authentication and registration
|
||||
|
||||
/// Tries to login to the user's deterministic account. If it cannot be found, it will register for one and log into that.
|
||||
func loginOrRegister() async throws {
|
||||
do {
|
||||
// Check if client has an account
|
||||
try await self.login()
|
||||
}
|
||||
catch {
|
||||
guard let error = error as? CoinosDeterministicAccountClient.ClientError, error == .unauthorized else { throw error }
|
||||
// Client does not seem to have an account, create one
|
||||
try await self.register()
|
||||
try await self.login()
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers for a Coinos account using deterministic account details.
|
||||
///
|
||||
/// It succeeds if it returns without throwing errors.
|
||||
func register() async throws {
|
||||
guard let username, let password else { throw ClientError.errorFormingRequest }
|
||||
let registerPayload = RegisterRequest(user: UserCredentials(username: username, password: password))
|
||||
let jsonData = try JSONEncoder().encode(registerPayload)
|
||||
|
||||
let url = URL(string: "https://coinos.io/api/register")!
|
||||
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
|
||||
return
|
||||
} else {
|
||||
throw ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Logs into the deterministic account, if an auth token is not present
|
||||
func loginIfNeeded() async throws {
|
||||
if self.jwtAuthToken == nil { try await self.login() }
|
||||
}
|
||||
|
||||
/// Logs into to our deterministic account.
|
||||
///
|
||||
/// Succeeds if it returns without returning errors.
|
||||
///
|
||||
/// Mutating function, will update the client's internal state.
|
||||
func login() async throws {
|
||||
self.jwtAuthToken = try await sendLoginRequest().token
|
||||
}
|
||||
|
||||
/// Sends the login request and return the response
|
||||
///
|
||||
/// Does NOT update the internal login state.
|
||||
private func sendLoginRequest() async throws -> AuthResponse {
|
||||
guard let url = URL(string: "https://coinos.io/api/login") else { throw ClientError.errorFormingRequest }
|
||||
guard let username, let password else { throw ClientError.errorFormingRequest }
|
||||
let credentials = UserCredentials(username: username, password: password)
|
||||
let jsonData = try JSONEncoder().encode(credentials)
|
||||
|
||||
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
|
||||
case 401: throw ClientError.unauthorized
|
||||
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Managing NWC connections
|
||||
|
||||
/// Creates a new NWC connection
|
||||
///
|
||||
/// Note: Account must exist before calling this endpoint
|
||||
func createNWCConnection() async throws -> WalletConnectURL {
|
||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
|
||||
|
||||
try await self.loginIfNeeded()
|
||||
|
||||
let config = try defaultWalletConnectionConfig()
|
||||
let configData = try encode_json_data(config)
|
||||
|
||||
let (data, response) = try await self.makeAuthenticatedRequest(
|
||||
method: .post,
|
||||
url: urlEndpoint,
|
||||
payload: configData,
|
||||
payload_type: .json
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
|
||||
return nwc
|
||||
case 401: throw ClientError.unauthorized
|
||||
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
/// Updates an existing NWC connection with a new maximum budget
|
||||
///
|
||||
/// Note: Account and NWC connection must exist before calling this endpoint
|
||||
func updateNWCConnection(maxAmount: UInt64) async throws -> WalletConnectURL {
|
||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
|
||||
|
||||
try await self.loginIfNeeded()
|
||||
|
||||
// Get existing config first
|
||||
guard let existingConfig = try await self.getNWCAppConnectionConfig() else {
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
// Create updated config with new max amount
|
||||
let updatedConfig = NewWalletConnectionConfig(
|
||||
name: existingConfig.name ?? self.nwcConnectionName,
|
||||
secret: existingConfig.secret ?? nwcKeypair.privkey.hex(),
|
||||
pubkey: existingConfig.pubkey ?? nwcKeypair.pubkey.hex(),
|
||||
max_amount: maxAmount,
|
||||
budget_renewal: .weekly
|
||||
)
|
||||
|
||||
let configData = try encode_json_data(updatedConfig)
|
||||
|
||||
let (data, response) = try await self.makeAuthenticatedRequest(
|
||||
method: .post,
|
||||
url: urlEndpoint,
|
||||
payload: configData,
|
||||
payload_type: .json
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
|
||||
return nwc
|
||||
case 401: throw ClientError.unauthorized
|
||||
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
/// Returns the default wallet connection config
|
||||
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
|
||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||
return NewWalletConnectionConfig(
|
||||
name: self.nwcConnectionName,
|
||||
secret: nwcKeypair.privkey.hex(),
|
||||
pubkey: nwcKeypair.pubkey.hex(),
|
||||
max_amount: 30000, // 30K sats per week maximum
|
||||
budget_renewal: .weekly
|
||||
)
|
||||
}
|
||||
|
||||
/// Gets the NWC URL for the deterministic NWC app connection
|
||||
///
|
||||
/// Account must already exist before calling this
|
||||
///
|
||||
/// Returns `nil` if no NWC url is found, (e.g. if app connection has not been configured yet)
|
||||
func getNWCUrl() async throws -> WalletConnectURL? {
|
||||
guard let connectionConfig = try await self.getNWCAppConnectionConfig(), let nwc = connectionConfig.nwc else { return nil }
|
||||
return WalletConnectURL(str: nwc)
|
||||
}
|
||||
|
||||
/// Gets the deterministic NWC app connection configuration details, if it exists
|
||||
///
|
||||
/// Account must already exist before calling this
|
||||
///
|
||||
/// Returns `nil` if no connection is found, (e.g. if app connection has not been configured yet)
|
||||
func getNWCAppConnectionConfig() async throws -> WalletConnectionConfig? {
|
||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||
guard let url = URL(string: "https://coinos.io/api/app/" + nwcKeypair.pubkey.hex()) else { throw ClientError.errorFormingRequest }
|
||||
|
||||
try await self.loginIfNeeded()
|
||||
|
||||
let (data, response) = try await self.makeAuthenticatedRequest(
|
||||
method: .get,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: nil
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200: return try JSONDecoder().decode(WalletConnectionConfig.self, from: data)
|
||||
case 401: throw ClientError.unauthorized
|
||||
case 404: return nil
|
||||
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
throw ClientError.errorProcessingResponse
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Lower level request convenience functions
|
||||
|
||||
/// Makes a request without any authorization
|
||||
func makeRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.httpBody = payload
|
||||
|
||||
if let payload_type {
|
||||
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
return try await URLSession.shared.data(for: request)
|
||||
}
|
||||
|
||||
/// Makes an authenticated request with our JWT auth token.
|
||||
///
|
||||
/// Client must be logged-in before calling this, otherwise an error will be thrown.
|
||||
func makeAuthenticatedRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
|
||||
guard let jwtAuthToken else { throw ClientError.errorFormingRequest }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method.rawValue
|
||||
request.httpBody = payload
|
||||
|
||||
request.setValue("Bearer " + jwtAuthToken, forHTTPHeaderField: "Authorization")
|
||||
if let payload_type {
|
||||
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
return try await URLSession.shared.data(for: request)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper structures
|
||||
|
||||
/// Payload for registering for a new Coinos account
|
||||
struct RegisterRequest: Codable {
|
||||
/// New user credentials
|
||||
let user: UserCredentials
|
||||
}
|
||||
|
||||
/// Payload for user credentials (sign-up and login)
|
||||
struct UserCredentials: Codable {
|
||||
/// The username
|
||||
let username: String
|
||||
/// The user password
|
||||
let password: String
|
||||
}
|
||||
|
||||
/// A successful response to a login auth endpoint
|
||||
struct AuthResponse: Codable {
|
||||
/// The JWT token to be applied to any authenticated API calls
|
||||
let token: String
|
||||
}
|
||||
|
||||
/// Used by the client to define new NWC configurations
|
||||
struct NewWalletConnectionConfig: Codable {
|
||||
/// The name of the connection
|
||||
let name: String
|
||||
/// 32 Hex-encoded bytes containing a shared private key secret
|
||||
let secret: String
|
||||
/// 32 Hex-encoded bytes containing the pubkey for the secret
|
||||
let pubkey: String
|
||||
/// Max amount that can be spent in each renewal period (measured in sats)
|
||||
let max_amount: UInt64
|
||||
/// The period of time it takes for the budget limits to reset
|
||||
let budget_renewal: BudgetRenewalPeriod
|
||||
}
|
||||
|
||||
/// The NWC connection configuration details
|
||||
///
|
||||
/// ## Implementation notes
|
||||
///
|
||||
/// - All items defined as optionals because the Coinos API may change in the future, so this may help increase future compatibility.
|
||||
struct WalletConnectionConfig: Codable {
|
||||
/// The name of the connection
|
||||
let name: String?
|
||||
/// 32 Hex-encoded bytes containing a shared private key secret
|
||||
let secret: String?
|
||||
/// 32 Hex-encoded bytes containing the pubkey for the secret
|
||||
let pubkey: String?
|
||||
/// Max amount that can be spent in every renewal period (measured in sats)
|
||||
let max_amount: UInt64?
|
||||
/// The NWC url generated by the server
|
||||
let nwc: String?
|
||||
/// Budget renewal information
|
||||
let budget_renewal: BudgetRenewalPeriod?
|
||||
}
|
||||
|
||||
/// A period of time it takes for budget limits to be reset
|
||||
enum BudgetRenewalPeriod: String, Codable {
|
||||
/// Resets once a week
|
||||
case weekly
|
||||
}
|
||||
|
||||
/// A client error occured
|
||||
enum ClientError: Error, Equatable {
|
||||
/// Received an unexpected HTTP response
|
||||
///
|
||||
/// Could be for a variety of reasons.
|
||||
case unexpectedHTTPResponse(status_code: Int, response: Data)
|
||||
/// Error forming the request, generally due to missing or inconsistent internal data
|
||||
///
|
||||
/// Probably caused by a programming error.
|
||||
case errorFormingRequest
|
||||
/// The client could not process the response from the server
|
||||
///
|
||||
/// Might be a sign of an incompatibility bug
|
||||
case errorProcessingResponse
|
||||
/// The action performed is not authorized
|
||||
/// Generally thrown if user does not exist, credentials do not match what Coinos has on file, or programming error
|
||||
case unauthorized
|
||||
/// Client not logged in on a call that expected login
|
||||
case notLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes a SHA256 hash digest from a piece of UTF-8 text, and returns the result as a "hex" string
|
||||
///
|
||||
/// When working only with strings, this can be more convenient than transforming text to data, and data back to text
|
||||
fileprivate func sha256Hex(text: String) -> String? {
|
||||
guard let data = text.data(using: .utf8) else { return nil }
|
||||
return sha256(data).toHexString()
|
||||
}
|
||||
@@ -14,13 +14,9 @@ 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"
|
||||
|
||||
// MARK: Curation
|
||||
static let ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY: Pubkey = Pubkey(hex: "895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba")!
|
||||
|
||||
// MARK: Push notification server
|
||||
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
|
||||
static let PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL: URL = URL(string: "https://notify-staging.damus.io")!
|
||||
|
||||
@@ -10,15 +10,7 @@ import Foundation
|
||||
enum DisplayName: Equatable {
|
||||
case both(username: String, displayName: String)
|
||||
case one(String)
|
||||
|
||||
init (profile: Profile?, pubkey: Pubkey) {
|
||||
self = parse_display_name(name: profile?.name, display_name: profile?.display_name, pubkey: pubkey)
|
||||
}
|
||||
|
||||
init (name: String?, display_name: String?, pubkey: Pubkey) {
|
||||
self = parse_display_name(name: name, display_name: display_name, pubkey: pubkey)
|
||||
}
|
||||
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .one(let one):
|
||||
@@ -36,37 +28,20 @@ enum DisplayName: Equatable {
|
||||
return username
|
||||
}
|
||||
}
|
||||
|
||||
func nameComponents() -> PersonNameComponents {
|
||||
var components = PersonNameComponents()
|
||||
switch self {
|
||||
case .one(let one):
|
||||
components.nickname = one
|
||||
return components
|
||||
case .both(username: let username, displayName: let displayName):
|
||||
components.nickname = username
|
||||
let names = displayName.split(separator: " ")
|
||||
if let name = names.first {
|
||||
components.givenName = String(name)
|
||||
components.familyName = names.dropFirst().joined(separator: " ")
|
||||
}
|
||||
return components
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) -> DisplayName {
|
||||
func parse_display_name(profile: Profile?, pubkey: Pubkey) -> DisplayName {
|
||||
if pubkey == ANON_PUBKEY {
|
||||
return .one(NSLocalizedString("Anonymous", comment: "Placeholder display name of anonymous user."))
|
||||
}
|
||||
|
||||
if name == nil && display_name == nil {
|
||||
|
||||
guard let profile else {
|
||||
return .one(abbrev_bech32_pubkey(pubkey: pubkey))
|
||||
}
|
||||
|
||||
let name = name?.isEmpty == false ? name : nil
|
||||
let disp_name = display_name?.isEmpty == false ? display_name : nil
|
||||
|
||||
let name = profile.name?.isEmpty == false ? profile.name : nil
|
||||
let disp_name = profile.display_name?.isEmpty == false ? profile.display_name : nil
|
||||
|
||||
if let name, let disp_name, name != disp_name {
|
||||
return .both(username: name, displayName: disp_name)
|
||||
@@ -80,9 +55,9 @@ func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) ->
|
||||
}
|
||||
|
||||
func abbrev_bech32_pubkey(pubkey: Pubkey) -> String {
|
||||
return abbrev_identifier(String(pubkey.npub.dropFirst(4)))
|
||||
return abbrev_pubkey(String(pubkey.npub.dropFirst(4)))
|
||||
}
|
||||
|
||||
func abbrev_identifier(_ pubkey: String, amount: Int = 8) -> String {
|
||||
func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String {
|
||||
return pubkey.prefix(amount) + ":" + pubkey.suffix(amount)
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@ func preload_image(url: URL) {
|
||||
|
||||
//print("Preloading image \(url.absoluteString)")
|
||||
|
||||
KingfisherManager.shared.retrieveImage(with: Kingfisher.KF.ImageResource(downloadURL: url)) { val in
|
||||
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: url)) { val in
|
||||
//print("Preloaded image \(url.absoluteString)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,15 +29,15 @@ extension KFOptionSetter {
|
||||
options.onlyLoadFirstFrame = disable_animation
|
||||
|
||||
switch imageContext {
|
||||
case .pfp, .favicon:
|
||||
options.diskCacheExpiration = .days(60)
|
||||
break
|
||||
case .banner:
|
||||
options.diskCacheExpiration = .days(5)
|
||||
break
|
||||
case .note:
|
||||
options.diskCacheExpiration = .days(1)
|
||||
break
|
||||
case .pfp:
|
||||
options.diskCacheExpiration = .days(60)
|
||||
break
|
||||
case .banner:
|
||||
options.diskCacheExpiration = .days(5)
|
||||
break
|
||||
case .note:
|
||||
options.diskCacheExpiration = .days(1)
|
||||
break
|
||||
}
|
||||
|
||||
return self
|
||||
@@ -52,7 +52,7 @@ extension KFOptionSetter {
|
||||
|
||||
func onFailure(fallbackUrl: URL?, cacheKey: String?) -> Self {
|
||||
guard let url = fallbackUrl, let key = cacheKey else { return self }
|
||||
let imageResource = Kingfisher.KF.ImageResource(downloadURL: url, cacheKey: key)
|
||||
let imageResource = Kingfisher.ImageResource(downloadURL: url, cacheKey: key)
|
||||
let source = imageResource.convertToSource()
|
||||
options.alternativeSources = [source]
|
||||
|
||||
@@ -82,14 +82,11 @@ enum ImageContext {
|
||||
case pfp
|
||||
case banner
|
||||
case note
|
||||
case favicon
|
||||
|
||||
|
||||
func maxMebibyteSize() -> Int {
|
||||
switch self {
|
||||
case .favicon:
|
||||
return 512_000 // 500KiB
|
||||
case .pfp:
|
||||
return 5_242_880 // 5MiB
|
||||
return 5_242_880 // 5Mib
|
||||
case .banner, .note:
|
||||
return 20_971_520 // 20MiB
|
||||
}
|
||||
@@ -97,8 +94,6 @@ enum ImageContext {
|
||||
|
||||
func downsampleSize() -> CGSize {
|
||||
switch self {
|
||||
case .favicon:
|
||||
return CGSize(width: 18, height: 18)
|
||||
case .pfp:
|
||||
return CGSize(width: 200, height: 200)
|
||||
case .banner:
|
||||
@@ -164,25 +159,20 @@ struct CustomCacheSerializer: CacheSerializer {
|
||||
}
|
||||
}
|
||||
|
||||
class CustomSessionDelegate: SessionDelegate, @unchecked Sendable {
|
||||
override func urlSession(
|
||||
_ session: URLSession,
|
||||
dataTask: URLSessionDataTask,
|
||||
didReceive response: URLResponse
|
||||
) async -> URLSession.ResponseDisposition {
|
||||
class CustomSessionDelegate: SessionDelegate {
|
||||
override func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
|
||||
let contentLength = response.expectedContentLength
|
||||
|
||||
// Content-Length header is optional (-1 when missing)
|
||||
if (contentLength != -1 && contentLength > MAX_FILE_SIZE) {
|
||||
return await super.urlSession(session, dataTask: dataTask, didReceive: URLResponse())
|
||||
return super.urlSession(session, dataTask: dataTask, didReceive: URLResponse(), completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
return await super.urlSession(session, dataTask: dataTask, didReceive: response)
|
||||
super.urlSession(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class CustomImageDownloader: ImageDownloader, @unchecked Sendable {
|
||||
class CustomImageDownloader: ImageDownloader {
|
||||
|
||||
static let shared = CustomImageDownloader(name: "shared")
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
//
|
||||
// FaviconCache.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 5/23/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import FaviconFinder
|
||||
|
||||
class FaviconCache {
|
||||
private var nip05DomainFavicons: [String: [FaviconURL]] = [:]
|
||||
|
||||
@MainActor
|
||||
func lookup(_ domain: String) async -> [FaviconURL] {
|
||||
let lowercasedDomain = domain.lowercased()
|
||||
if let faviconURLs = nip05DomainFavicons[lowercasedDomain] {
|
||||
return faviconURLs
|
||||
}
|
||||
|
||||
guard let siteURL = URL(string: "https://\(lowercasedDomain)"),
|
||||
let faviconURLs = try? await FaviconFinder(
|
||||
url: siteURL,
|
||||
configuration: .init(
|
||||
preferredSource: .ico, // Prefer using common favicon .ico filenames at root level to avoid scraping HTML when possible.
|
||||
preferences: [
|
||||
.html: FaviconFormatType.appleTouchIcon.rawValue,
|
||||
.ico: "favicon.ico",
|
||||
.webApplicationManifestFile: FaviconFormatType.launcherIcon4x.rawValue
|
||||
]
|
||||
)
|
||||
).fetchFaviconURLs()
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
nip05DomainFavicons[lowercasedDomain] = faviconURLs
|
||||
|
||||
return faviconURLs
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
//
|
||||
// ImageCacheMigrations.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2025-04-26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Kingfisher
|
||||
|
||||
struct ImageCacheMigrations {
|
||||
static func migrateKingfisherCacheIfNeeded() {
|
||||
let fileManager = FileManager.default
|
||||
let defaults = UserDefaults.standard
|
||||
let migration1Key = "KingfisherCacheMigrated" // Never ever changes
|
||||
let migration2Key = "KingfisherCacheMigratedV2" // Never ever changes
|
||||
|
||||
let migration1Done = defaults.bool(forKey: migration1Key)
|
||||
let migration2Done = defaults.bool(forKey: migration2Key)
|
||||
|
||||
guard !migration1Done || !migration2Done else {
|
||||
// All migrations are already done. Skip.
|
||||
return
|
||||
}
|
||||
|
||||
let oldCachePath = migration1Done ? migration1KingfisherCachePath() : migration0KingfisherCachePath()
|
||||
|
||||
// New shared cache location
|
||||
let newCachePath = kingfisherCachePath().path
|
||||
|
||||
if fileManager.fileExists(atPath: oldCachePath) {
|
||||
do {
|
||||
// Move the old cache to the new location
|
||||
try fileManager.moveItem(atPath: oldCachePath, toPath: newCachePath)
|
||||
Log.info("Successfully migrated Kingfisher cache to %s", for: .storage, newCachePath)
|
||||
} catch {
|
||||
do {
|
||||
// Cache data is not essential, fallback to deleting the cache and starting all over
|
||||
// It's better than leaving significant garbage data stuck indefinitely on the user's phone
|
||||
try fileManager.removeItem(atPath: newCachePath)
|
||||
try fileManager.removeItem(atPath: oldCachePath)
|
||||
}
|
||||
catch {
|
||||
Log.error("Failed to migrate cache: %s", for: .storage, error.localizedDescription)
|
||||
return // Do not mark them as complete, we can try again next time the user reloads the app
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark migrations as complete
|
||||
defaults.set(true, forKey: migration1Key)
|
||||
defaults.set(true, forKey: migration2Key)
|
||||
}
|
||||
|
||||
static private func migration0KingfisherCachePath() -> String {
|
||||
// Implementation note: These are old, so they should not be changed
|
||||
let defaultCache = ImageCache.default
|
||||
return defaultCache.diskStorage.directoryURL.path
|
||||
}
|
||||
|
||||
static private func migration1KingfisherCachePath() -> String {
|
||||
// Implementation note: These are old, so they are hard-coded on purpose, because we can't change these values from the past.
|
||||
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.damus")!
|
||||
return groupURL.appendingPathComponent("ImageCache").path
|
||||
}
|
||||
|
||||
/// The latest path for kingfisher to store cached images on.
|
||||
///
|
||||
/// Documentation references:
|
||||
/// - https://developer.apple.com/documentation/foundation/filemanager/containerurl(forsecurityapplicationgroupidentifier:)#:~:text=The%20system%20creates%20only%20the%20Library/Caches%20subdirectory%20automatically
|
||||
/// - https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#:~:text=Put%20data%20cache,files%20as%20needed.
|
||||
static func kingfisherCachePath() -> URL {
|
||||
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER)!
|
||||
return groupURL
|
||||
.appendingPathComponent("Library")
|
||||
.appendingPathComponent("Caches")
|
||||
.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,6 @@ let ANON_PUBKEY = Pubkey(Data([
|
||||
struct FullKeypair: Equatable {
|
||||
let pubkey: Pubkey
|
||||
let privkey: Privkey
|
||||
|
||||
init(pubkey: Pubkey, privkey: Privkey) {
|
||||
self.pubkey = pubkey
|
||||
self.privkey = privkey
|
||||
}
|
||||
|
||||
init?(privkey: Privkey) {
|
||||
self.privkey = privkey
|
||||
guard let pubkey = privkey_to_pubkey_raw(sec: privkey.bytes) else { return nil }
|
||||
self.pubkey = pubkey
|
||||
}
|
||||
|
||||
func to_keypair() -> Keypair {
|
||||
return Keypair(pubkey: pubkey, privkey: privkey)
|
||||
|
||||
@@ -14,14 +14,10 @@ enum LogCategory: String {
|
||||
case render
|
||||
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 {
|
||||
private let pool: RelayPool
|
||||
let pool: RelayPool
|
||||
var events: [NoteId: PostedEvent]
|
||||
|
||||
init(pool: RelayPool) {
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Stores information, metadata, and logs about different relays. Generally used as a singleton.
|
||||
///
|
||||
/// # Discussion
|
||||
///
|
||||
/// This class is primarily used as a shared singleton in `DamusState`, to allow other parts of the app to access information, metadata, and logs about relays without having to fetch it themselves.
|
||||
///
|
||||
/// For example, it is used by `RelayView` to supplement information about the relay without having to fetch those again from the network, as well as to display logs collected throughout the use of the app.
|
||||
final class RelayModelCache: ObservableObject {
|
||||
private var models = [RelayURL: RelayModel]()
|
||||
|
||||
|
||||
+7
-26
@@ -5,7 +5,6 @@
|
||||
// Created by Scott Penrose on 5/7/23.
|
||||
//
|
||||
|
||||
import FaviconFinder
|
||||
import SwiftUI
|
||||
|
||||
enum Route: Hashable {
|
||||
@@ -33,7 +32,7 @@ enum Route: Hashable {
|
||||
case DeveloperSettings(settings: UserSettingsStore)
|
||||
case FirstAidSettings(settings: UserSettingsStore)
|
||||
case Thread(thread: ThreadModel)
|
||||
case LoadableNostrEvent(note_reference: LoadableNostrEventViewModel.NoteReference)
|
||||
case ThreadFromReference(note_reference: LoadableThreadModel.NoteReference)
|
||||
case Reposts(reposts: EventsModel)
|
||||
case QuoteReposts(quotes: EventsModel)
|
||||
case Reactions(reactions: EventsModel)
|
||||
@@ -47,9 +46,6 @@ enum Route: Hashable {
|
||||
case Wallet(wallet: WalletModel)
|
||||
case WalletScanner(result: Binding<WalletScanResult>)
|
||||
case FollowersYouKnow(friendedFollowers: [Pubkey], followers: FollowersModel)
|
||||
case NIP05DomainEvents(events: NIP05DomainEventsModel, nip05_domain_favicon: FaviconURL?)
|
||||
case NIP05DomainPubkeys(domain: String, nip05_domain_favicon: FaviconURL?, pubkeys: [Pubkey])
|
||||
case FollowPack(followPack: NostrEvent, model: FollowPackModel, blur_imgs: Bool)
|
||||
|
||||
@ViewBuilder
|
||||
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
|
||||
@@ -101,8 +97,8 @@ enum Route: Hashable {
|
||||
case .Thread(let thread):
|
||||
ChatroomThreadView(damus: damusState, thread: thread)
|
||||
//ThreadView(state: damusState, thread: thread)
|
||||
case .LoadableNostrEvent(let note_reference):
|
||||
LoadableNostrEventView(state: damusState, note_reference: note_reference)
|
||||
case .ThreadFromReference(let note_reference):
|
||||
LoadableThreadView(state: damusState, note_reference: note_reference)
|
||||
case .Reposts(let reposts):
|
||||
RepostsView(damus_state: damusState, model: reposts)
|
||||
case .QuoteReposts(let quote_reposts):
|
||||
@@ -130,13 +126,7 @@ enum Route: Hashable {
|
||||
case .FollowersYouKnow(let friendedFollowers, let followers):
|
||||
FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers)
|
||||
case .Script(let load_model):
|
||||
LoadScript(pool: damusState.nostrNetwork.pool, model: load_model)
|
||||
case .NIP05DomainEvents(let events, let nip05_domain_favicon):
|
||||
NIP05DomainTimelineView(damus_state: damusState, model: events, nip05_domain_favicon: nip05_domain_favicon)
|
||||
case .NIP05DomainPubkeys(let domain, let nip05_domain_favicon, let pubkeys):
|
||||
NIP05DomainPubkeysView(damus_state: damusState, domain: domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: pubkeys)
|
||||
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
|
||||
FollowPackView(state: damusState, ev: followPack, model: followPackModel, blur_imgs: blur_imgs)
|
||||
LoadScript(pool: damusState.pool, model: load_model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,8 +190,8 @@ enum Route: Hashable {
|
||||
case .Thread(let threadModel):
|
||||
hasher.combine("thread")
|
||||
hasher.combine(threadModel.original_event.id)
|
||||
case .LoadableNostrEvent(note_reference: let note_reference):
|
||||
hasher.combine("loadable_nostr_event")
|
||||
case .ThreadFromReference(note_reference: let note_reference):
|
||||
hasher.combine("thread_from_reference")
|
||||
hasher.combine(note_reference)
|
||||
case .Reposts(let reposts):
|
||||
hasher.combine("reposts")
|
||||
@@ -219,7 +209,7 @@ enum Route: Hashable {
|
||||
case .Search(let search):
|
||||
hasher.combine("search")
|
||||
hasher.combine(search.search)
|
||||
case .NDBSearch:
|
||||
case .NDBSearch(let results):
|
||||
hasher.combine("results")
|
||||
case .EULA:
|
||||
hasher.combine("eula")
|
||||
@@ -241,15 +231,6 @@ enum Route: Hashable {
|
||||
case .Script(let model):
|
||||
hasher.combine("script")
|
||||
hasher.combine(model.data.count)
|
||||
case .NIP05DomainEvents(let events, _):
|
||||
hasher.combine("nip05DomainEvents")
|
||||
hasher.combine(events.domain)
|
||||
case .NIP05DomainPubkeys(let domain, _, _):
|
||||
hasher.combine("nip05DomainPubkeys")
|
||||
hasher.combine(domain)
|
||||
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
|
||||
hasher.combine("followPack")
|
||||
hasher.combine(followPack.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user