Compare commits

..

1 Commits

438 changed files with 8405 additions and 29360 deletions
-35
View File
@@ -1,35 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: 'Bug: '
labels: bug, Needs recreation
assignees: ''
---
**What happens**
When I perform action ___, _____ happens.
**What I expect to happen**
I expect _______ to happen.
**Link to noteID, npub**
Provide link to relevant noteID, npub etc.
**Screenshots/video recording**
If applicable, add screenshots to help explain your problem.
** Versions **
Damus version: [e.g. 1.7.2 (1()]
Operating system version: [e.g. iOS 17.2.1]
Device: e.g. iPhone 13 Pro
**Steps To Reproduce**
Steps to reproduce the behavior:
1. Open Damus
2. Tap on ___
3. Action ____
**Additional context**
Add any other context about the problem here.
-27
View File
@@ -1,27 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: 'Feature Request:'
labels: feature
assignees: ''
---
## Have a go at filling out the User Story template below
As a Damus user who is _____________, I would like to _________________, so that I achieve ___________.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
** When does this problem happen? **
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
-36
View File
@@ -1,36 +0,0 @@
## Summary
_[Please provide a summary of the changes in this PR.]_
## Checklist
- [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md)
- [ ] I have tested the changes in this PR
- [ ] 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)
- [ ] I do not need to add a changelog entry. Reason: _[Please provide a reason]_
- [ ] I have added appropriate `Closes:` or `Fixes:` tags in the commit messages wherever applicable, or made sure those are not needed. See [Submitting patches](https://github.com/damus-io/damus/blob/master/docs/CONTRIBUTING.md#submitting-patches)
## Test report
_Please provide a test report for the changes in this PR. You can use the template below, but feel free to modify it as needed._
**Device:** _[Please specify the device you used for testing]_
**iOS:** _[Please specify the iOS version you used for testing]_
**Damus:** _[Please specify the Damus version or commit hash you used for testing]_
**Setup:** _[Please provide a brief description of the setup you used for testing, if applicable]_
**Steps:** _[Please provide a list of steps you took to test the changes in this PR]_
**Results:**
- [ ] PASS
- [ ] Partial PASS
- Details: _[Please provide details of the partial pass]_
## Other notes
_[Please provide any other information that you think is relevant to this PR.]_
+1 -272
View File
@@ -1,275 +1,3 @@
## [v1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10) - 2024-11-18
### Added
- Add Damus Share Feature (Swift)
- Added new easy to use video controls for full screen video (Daniel DAquino)
- Add Edit, Share, and Tap-gesture in Profile pic image viewer (Swift Coder)
- Disappearing header, tabbar, and post button on scroll (ericholguin)
- Add Apple translation popovers for notes for iOS 17.4+ and macOS 14.4+ (Terry Yiu)
- Added NDB search functionality to the universe view (ericholguin)
- Added mute button to ProfileActionSheet (chungwwei)
- Added mute action to selected text menu (ericholguin)
- Added support for pasting images from the clipboard to the post composer (Swift Coder)
### Changed
- Improved image carousel image fill behavior (Daniel DAquino)
- Improved video syncing and bandwidth usage when switching between timeline video and full screen mode (Daniel DAquino)
- Swipe to dismiss on full screen carousel now shows an opacity effect for improved UX (Daniel DAquino)
- Removed event contents from full screen media carousel for cleaner view (Daniel DAquino)
- Add share button for images on full screen image carousel view (Swift)
- Changed boldness of font in side menu labels. (ericholguin)
- Changed search notes button with searched keyword (ericholguin)
- Changed opacity of tabbar and post button (ericholguin)
- Allow multiple images to be uploaded at the same time (swiftcoder) (William Casarin)
- Changed side menu design (ericholguin)
- Truncate fulltext search results (William Casarin)
- Expanded profile search results to 128 (William Casarin)
- Expand nostrdb text search results to 128 items (William Casarin)
- Use LazyVStack in text search results (William Casarin)
### Fixed
- Fixed missing tab bar on navigation (Swift Coder)
- Fixed some issues where QR code would not work, and improved UX (Daniel DAquino)
- Fixed iOS 18 gesture issues that would take user to the thread view when clicking on a video or unmuting it (Daniel DAquino)
- Fixed several issues that would cause video to automatically play or pause incorrectly (Daniel DAquino)
- Fixed issue where full screen video would disappear when going to landscape mode (Daniel DAquino)
- Fixed portrait video size on full screen carousel (Daniel DAquino)
- Fix avatar image on qrcode view (Swift Coder)
- Fix banner image upload (Swift Coder)
- Fix dismiss button visibility (Swift Coder)
- Fix quote repost counting (William Casarin)
- Fixed overlapping text in Universe View (ericholguin)
- Fixed localization issues and exported strings (Terry Yiu)
- Fix sensitive long-press gesture on event chat bubble in iOS 18 (Daniel DAquino)
- Fixed bottom padding for tabbar (ericholguin)
- Fixed localization build failures (Terry Yiu)
- Fixed back nav button placement in profile edit view (ericholguin)
- Friend profiles will now more likely show up in profile search (William Casarin)
- Fix broken QR code scanner and fix landscape mode (Terry Yiu)
[1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10): https://github.com/damus-io/damus/releases/tag/v1.11-10
## [1.10.1] - 2024-09-22
### Added
- Push notification support (Daniel DAquino)
- Added profile edit safe guards (Eric Holguin)
- Tor relay icon (ericholguin)
- Add highlighter for web pages (Daniel DAquino)
- Add support for adding comments when creating a highlight (Daniel DAquino)
- Add support for rendering highlights with comments (Daniel DAquino)
- Ability to create highlights (ericholguin)
- Highlights (NIP-84) (ericholguin)
- Revamp emoji picker to be less error-prone and add search, frequently used, and multiple skin tone support capabilities (Terry Yiu)
### Changed
- Improve notification view filtering UX (Daniel DAquino)
- Improve visibility of friends filter button (Daniel DAquino)
- Changed the default banner from ostriches to damoose (Eric Holguin)
- Changed image and banner url text fields to new sheet view (Eric Holguin)
- Onboarding design (ericholguin)
### Fixed
- Fix items that became unclickable on iOS 18 (Daniel DAquino)
- Fix many reconnection issues (William Casarin)
- Fixed issue where theme would be changed to black and can't be switched back on iOS 18 (cr0bar)
- Fixed some scenarios where the contact list would never be saved locally and cause issues when switching relays. (Daniel DAquino)
- Fix albyhub zaps not appearing (William Casarin)
- Fix inadvertent escape from mention suggestion menu when typing a space character (Daniel DAquino)
- Fix profile view toolbar alignment bug in iOS 18 (Terry Yiu)
- Create Account model now uses correct metadata (ericholguin)
- Restore localization for custom tabs (William Casarin)
- Fix iOS 18 reflection runtime error for custom picker (William Casarin)
[1.10.1]: https://github.com/damus-io/damus/releases/tag/v1.10.1
## [1.9.1 (4)] - 2024-08-13
### Fixed
- Fix crash when viewing notes with invalid image dimension metadata (Daniel DAquino)
[1.9.1 (4)]: https://github.com/damus-io/damus/releases/tag/v1.9.1-4
## [1.9 (14)] - 2024-07-14
### Added
- Completely new threads experience that is easier and more pleasant to use (Daniel DAquino)
- Add emoji search to emoji picker (Terry Yiu)
### Changed
- Added first aid contact damus support email (alltheseas)
- Disable mutiny wallet button (William Casarin)
- Make friends show up first when searching for profiles (Terry Yiu)
### Fixed
- Fix crash on profile page when there are profile updates (William Casarin)
- Fix crash when adding duplicate mute items (William Casarin)
- Fix pretty bad crash when building flatbuffer profiles (William Casarin)
- Fix reactions view to not show reactions from replies on parent note (Terry Yiu)
- Fix missing Mute button in profile view menu (Terry Yiu)
- Fixed wallet not disconnecting when a user logs out (ericholguin)
- Fix stale feed issue when follow list is too big (Daniel DAquino)
[1.9 (14)]: https://github.com/damus-io/damus/releases/tag/v1.9-14
## [1.8] - 2024-05-11
### Added
- Added nip10 marker replies (William Casarin)
- Add marker nip10 support when reading notes (William Casarin)
- Added title image and tags to longform events (ericholguin)
- Add First Aid solution for users who do not have a contact list created for their account (Daniel DAquino)
- Relay fees metadata (ericholguin)
- Added callbackuri for a better ux when connecting mutiny wallet nwc (ericholguin)
- Add event content preview to the full screen carousel (Daniel DAquino)
- Show list of quoted reposts in threads (William Casarin)
- Proxy Tags are now viewable on Selected Events (ericholguin)
- Connect to Mutiny Wallet Button (ericholguin)
- Add ability to mute words, add new mutelist interface (Charlie) (William Casarin)
- Add ability to mute hashtag from SearchView (Charlie Fish)
### Changed
- Change reactions to use a native looking emoji picker (Terry Yiu)
- Relay detail design (ericholguin)
- Updated Zeus logo (ericholguin)
- Improve UX around video playback (Daniel DAquino)
- Moved paste nwc button to main wallet view (ericholguin)
- Errors with an NWC will show as an alert (ericholguin)
- Relay config view user interface (ericholguin)
- Always strip GPS data from images (kernelkind)
### Fixed
- Fix thread bug where a quote isn't picked up as a reply (William Casarin)
- Fixed threads not loading sometimes (William Casarin)
- Fixed issue where some replies were including the q tag (William Casarin)
- Fixed issue where timeline was scrolling when it isn't supposed to (William Casarin)
- Fix issue where bootstrap relays would inadvertently be added to the user's list on connectivity issues (Daniel DAquino)
- Fix broken GIF uploads (Daniel DAquino)
- Fix ghost notifications caused by Purple impending expiration notifications (Daniel DAquino)
- Improve reliability of contact list creation during onboarding (Daniel DAquino)
- Fix emoji reactions being cut off (ericholguin)
- Fix image indicators to limit number of dots to not spill screen beyond visible margins (ericholguin)
- Fix bug that would cause connection issues with relays defined with a trailing slash URL, and an inability to delete them. (Daniel DAquino)
- Issue where NWC Scanner view would not dismiss after a failed scan/paste (ericholguin)
[1.8]: https://github.com/damus-io/damus/releases/tag/v1.8
## [1.7-rc2] - 2024-02-28
### Added
- Add support for Apple In-App purchases (Daniel DAquino)
- Notification reminders for Damus Purple impending expiration (Daniel DAquino)
- Damus Purple membership! (William Casarin)
- Fixed minor spacing and padding issues in onboarding views (ericholguin)
### Changed
- Disable inline text suggestions on 17.0 as they interfere with mention generation (William Casarin)
- EULA is not shown by default (ericholguin)
### Fixed
- Fix welcome screen not showing if the user enters the app directly after a successful checkout without going through the link (Daniel DAquino)
- Fix profile not updating bug (William Casarin)
- Fix nostrscripts not loading (William Casarin)
- Fix crash when accessing cached purple accounts (William Casarin)
- Hide member signup date on reposts (kernelkind)
- Fixed previews not rendering (ericholguin)
- Fix load media formatting on small screens (kernelkind)
- Fix shared nevents that are too long (kernelkind)
- Fix many nostrdb transaction related crashes (William Casarin)
### Removed
- Removed copying public key action (ericholguin)
[1.7-rc2]: https://github.com/damus-io/damus/releases/tag/v1.7-rc2
## [1.7-2] - 2024-01-24
### Added
- New fulltext search engine (William Casarin)
- Add "Always show onboarding suggestions" developer setting (Daniel DAquino)
- Add NIP-42 relay auth support (Charlie Fish)
- Add ability to hide suggested hashtags (ericholguin)
- Add ability to mute hashtag from SearchView (Charlie Fish)
- Add ability to preview media taken with camera (Suhail Saqan)
- Add ability to search for naddr, nprofiles, nevents (kernelkind)
- Add experimental push notification support (Daniel DAquino)
- Add naddr link support (kernelkind)
- Add regional relay recommendations to Relay configuration view (currently for Japanese users only) (Daniel DAquino)
- Add regional relays for Germany (Daniel DAquino)
- Add regional relays for Thailand (Daniel DAquino)
- Added a custom camera view (Suhail Saqan)
- Always convert damus.io links to inline mentions (William Casarin)
- Unfurl profile name on remote push notifications (Daniel DAquino)
- Zap notification support for push notifications (Daniel DAquino)
### Changed
- Generate nprofile/nevent links in share menus (kernelkind)
- Improve push notification support to match local notification support (Daniel DAquino)
- Move mute thread in menu so it's not clicked by accident (alltheseas)
- Prioritize friends when autocompleting (Charlie Fish)
### Fixed
- Add workaround to fix note language recognition and reduce wasteful translation requests (Terry Yiu)
- Allow mentioning users with punctuation characters in their names (kernelkind)
- Fix broken mentions when there is text is directly after (kernelkind)
- Fix crash on very large notes (Daniel DAquino)
- Fix crash when logging out and switching accounts (William Casarin)
- Fix duplicate notes getting written to nostrdb (William Casarin)
- Fix issue where adding relays might not work on corrupted contact lists (Charlie Fish)
- Fix onboarding post view not being dismissed under certain conditions (Daniel DAquino)
- Fix performance issue with gifs (William Casarin)
- Fix persistent local notifications even after logout (William Casarin)
- Fixed bug where sometimes notes from other profiles appear on profile pages (Charlie Fish)
- Remove extra space at the end of DM messages (kernelkind)
- Save current viewed image index when switching to fullscreen (kernelkind)
### Removed
- Removed old nsec key warning, nsec automatically convert to npub when posting (kernelkind)
[1.7-2]: https://github.com/damus-io/damus/releases/tag/v1.7-2
## [1.6-25] - 2023-10-31 ## [1.6-25] - 2023-10-31
### Added ### Added
@@ -1923,3 +1651,4 @@
[0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2 [0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
@@ -12,9 +10,5 @@
</array> </array>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.jb55.damus2</string>
</array>
</dict> </dict>
</plist> </plist>
@@ -0,0 +1,49 @@
//
// NostrEventInfoFromPushNotification.swift
// DamusNotificationService
//
// Created by Daniel DAquino on 2023-11-13.
//
import Foundation
/// The representation of a JSON-encoded Nostr Event used by the push notification server
/// Needs to match with https://gitlab.com/soapbox-pub/strfry-policies/-/raw/433459d8084d1f2d6500fdf916f22caa3b4d7be5/src/types.ts
struct NostrEventInfoFromPushNotification: Codable {
let id: String // Hex-encoded
let sig: String // Hex-encoded
let kind: NostrKind
let tags: [[String]]
let pubkey: String // Hex-encoded
let content: String
let created_at: Int
static func from(dictionary: [AnyHashable: Any]) -> NostrEventInfoFromPushNotification? {
guard let id = dictionary["id"] as? String,
let sig = dictionary["sig"] as? String,
let kind_int = dictionary["kind"] as? UInt32,
let kind = NostrKind(rawValue: kind_int),
let tags = dictionary["tags"] as? [[String]],
let pubkey = dictionary["pubkey"] as? String,
let content = dictionary["content"] as? String,
let created_at = dictionary["created_at"] as? Int else {
return nil
}
return NostrEventInfoFromPushNotification(id: id, sig: sig, kind: kind, tags: tags, pubkey: pubkey, content: content, created_at: created_at)
}
func reactionEmoji() -> String? {
guard self.kind == NostrKind.like else {
return nil
}
switch self.content {
case "", "+":
return "❤️"
case "-":
return "👎"
default:
return self.content
}
}
}
@@ -1,45 +0,0 @@
//
// NotificationExtensionState.swift
// DamusNotificationService
//
// Created by Daniel DAquino on 2023-11-27.
//
import Foundation
struct NotificationExtensionState: HeadlessDamusState {
let ndb: Ndb
let settings: UserSettingsStore
let contacts: Contacts
let mutelist_manager: MutelistManager
let keypair: Keypair
let profiles: Profiles
let zaps: Zaps
let lnurls: LNUrls
init?() {
guard let ndb = try? Ndb(owns_db_file: false) else { return nil }
self.ndb = ndb
guard let keypair = get_saved_keypair() else { return nil }
// dumb stuff needed for property wrappers
UserSettingsStore.pubkey = keypair.pubkey
self.settings = UserSettingsStore()
self.contacts = Contacts(our_pubkey: keypair.pubkey)
self.mutelist_manager = MutelistManager(user_keypair: keypair)
self.keypair = keypair
self.profiles = Profiles(ndb: ndb)
self.zaps = Zaps(our_pubkey: keypair.pubkey)
self.lnurls = LNUrls()
}
@discardableResult
func add_zap(zap: Zapping) -> Bool {
// store generic zap mapping
self.zaps.add_zap(zap: zap)
return true
}
}
@@ -11,17 +11,16 @@ import UserNotifications
struct NotificationFormatter { struct NotificationFormatter {
static var shared = NotificationFormatter() static var shared = NotificationFormatter()
// MARK: - Formatting with NdbNote // TODO: These is a very generic notification formatter. Once we integrate NostrDB into the extension, we should reuse various functions present in `HomeModel.swift`
func formatMessage(event: NostrEventInfoFromPushNotification) -> UNNotificationContent? {
func format_message(event: NdbNote) -> UNMutableNotificationContent? {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
if let event_json_data = try? JSONEncoder().encode(event), // Must be encoded, as the notification completion handler requires this object to conform to `NSSecureCoding` if let event_json_data = try? JSONEncoder().encode(event), // Must be encoded, as the notification completion handler requires this object to conform to `NSSecureCoding`
let event_json_string = String(data: event_json_data, encoding: .utf8) { let event_json_string = String(data: event_json_data, encoding: .utf8) {
content.userInfo = [ content.userInfo = [
NDB_NOTE_JSON_USER_INFO_KEY: event_json_string "nostr_event_info": event_json_string
] ]
} }
switch event.known_kind { switch event.kind {
case .text: case .text:
content.title = NSLocalizedString("Someone posted a note", comment: "Title label for push notification where someone posted a note") content.title = NSLocalizedString("Someone posted a note", comment: "Title label for push notification where someone posted a note")
content.body = event.content content.body = event.content
@@ -31,7 +30,7 @@ struct NotificationFormatter {
content.body = NSLocalizedString("(Contents are encrypted)", comment: "Label on push notification indicating that the contents of the message are encrypted") content.body = NSLocalizedString("(Contents are encrypted)", comment: "Label on push notification indicating that the contents of the message are encrypted")
break break
case .like: case .like:
guard let reactionEmoji = to_reaction_emoji(ev: event) else { guard let reactionEmoji = event.reactionEmoji() else {
content.title = NSLocalizedString("Someone reacted to your note", comment: "Generic title label for push notifications where someone reacted to the user's post") content.title = NSLocalizedString("Someone reacted to your note", comment: "Generic title label for push notifications where someone reacted to the user's post")
break break
} }
@@ -46,98 +45,4 @@ struct NotificationFormatter {
} }
return content return content
} }
// MARK: - Formatting with LocalNotification
func format_message(displayName: String, notify: LocalNotification) -> (content: UNMutableNotificationContent, identifier: String)? {
let content = UNMutableNotificationContent()
var title = ""
var identifier = ""
switch notify.type {
case .tagged:
title = String(format: NSLocalizedString("Tagged by %@", comment: "Tagged by heading in local notification"), displayName)
identifier = "myMentionNotification"
case .mention:
title = String(format: NSLocalizedString("Mentioned by %@", comment: "Mentioned by heading in local notification"), displayName)
identifier = "myMentionNotification"
case .repost:
title = String(format: NSLocalizedString("Reposted by %@", comment: "Reposted by heading in local notification"), displayName)
identifier = "myBoostNotification"
case .like:
title = String(format: NSLocalizedString("%@ reacted with %@", comment: "Reacted by heading in local notification"), displayName, to_reaction_emoji(ev: notify.event) ?? "")
identifier = "myLikeNotification"
case .dm:
title = displayName
identifier = "myDMNotification"
case .zap, .profile_zap:
// not handled here. Try `format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)?`
return nil
case .reply:
title = String(format: NSLocalizedString("%@ replied to your note", comment: "Heading for local notification indicating a new reply"), displayName)
identifier = "myReplyNotification"
}
content.title = title
content.body = notify.content
content.sound = UNNotificationSound.default
content.userInfo = notify.to_lossy().to_user_info()
return (content, identifier)
}
func format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)? {
// Try sync method first and return if it works
if let sync_formatted_message = self.format_message(displayName: displayName, notify: notify) {
return sync_formatted_message
}
// If it does not work, try async formatting methods
let content = UNMutableNotificationContent()
switch notify.type {
case .zap, .profile_zap:
guard let zap = await get_zap(from: notify.event, state: state) else {
Log.debug("format_message: async get_zap failed", for: .push_notifications)
return nil
}
content.title = Self.zap_notification_title(zap)
content.body = Self.zap_notification_body(profiles: state.profiles, zap: zap)
content.sound = UNNotificationSound.default
content.userInfo = LossyLocalNotification(type: .zap, mention: .note(notify.event.id)).to_user_info()
return (content, "myZapNotification")
default:
// The sync method should have taken care of this.
return nil
}
}
// MARK: - Formatting zap utility notifications
static func zap_notification_title(_ zap: Zap) -> String {
if zap.private_request != nil {
return NSLocalizedString("Private Zap", comment: "Title of notification when a private zap is received.")
} else {
return NSLocalizedString("Zap", comment: "Title of notification when a non-private zap is received.")
}
}
static func zap_notification_body(profiles: Profiles, zap: Zap, locale: Locale = Locale.current) -> String {
let src = zap.request.ev
let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey
let profile_txn = profiles.lookup(id: pk)
let profile = profile_txn?.unsafeUnownedValue
let name = Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50)
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
let formattedSats = format_msats_abbrev(zap.invoice.amount)
if src.content.isEmpty {
let format = localizedStringFormat(key: "zap_notification_no_message", locale: locale)
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name)
} else {
let format = localizedStringFormat(key: "zap_notification_with_message", locale: locale)
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name, src.content)
}
}
} }
@@ -16,72 +16,22 @@ class NotificationService: UNNotificationServiceExtension {
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler self.contentHandler = contentHandler
guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String, let ndb: Ndb? = try? Ndb(owns_db_file: false)
let nostr_event = NdbNote.owned_from_json(json: nostr_event_json)
else { // Modify the notification content here...
// No nostr event detected. Just display the original notification guard let nostrEventInfoDictionary = request.content.userInfo["nostr_event"] as? [AnyHashable: Any],
let nostrEventInfo = NostrEventInfoFromPushNotification.from(dictionary: nostrEventInfoDictionary) else {
contentHandler(request.content) contentHandler(request.content)
return; return;
} }
// Log that we got a push notification // Log that we got a push notification
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex()) if let pubkey = Pubkey(hex: nostrEventInfo.pubkey),
let txn = ndb?.lookup_profile(pubkey) {
guard let state = NotificationExtensionState() else { Log.debug("Got push notification from %s (%s)", for: .push_notifications, (txn.unsafeUnownedValue?.profile?.display_name ?? "Unknown"), nostrEventInfo.pubkey)
Log.debug("Failed to open nostrdb", for: .push_notifications)
// Something failed to initialize so let's go for the next best thing
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
// We cannot format this nostr event. Suppress notification.
contentHandler(UNNotificationContent())
return
}
contentHandler(improved_content)
return
}
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
let profile = txn?.unsafeUnownedValue?.profile
let name = Profile.displayName(profile: profile, pubkey: nostr_event.pubkey).displayName
// 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
if state.mutelist_manager.is_event_muted(nostr_event) {
// We cannot really suppress muted notifications until we have the notification supression entitlement.
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
content.sound = UNNotificationSound.default
contentHandler(content)
return
} }
guard should_display_notification(state: state, event: nostr_event, mode: .push) else { if let improvedContent = NotificationFormatter.shared.formatMessage(event: nostrEventInfo) {
Log.debug("should_display_notification failed", for: .push_notifications)
// We should not display notification for this event. Suppress notification.
// contentHandler(UNNotificationContent())
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
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.
// contentHandler(UNNotificationContent())
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
contentHandler(request.content)
return
}
Task {
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
}
contentHandler(improvedContent) contentHandler(improvedContent)
} }
} }
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>
-27
View File
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
</array>
</dict>
</plist>
+10 -40
View File
@@ -2,56 +2,28 @@
# damus # damus
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS. A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
<img src="./ss.png" width="50%" height="50%" /> <img src="./ss.png" width="50%" height="50%" />
[nostr]: https://github.com/fiatjaf/nostr [nostr]: https://github.com/fiatjaf/nostr
## 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.\
There are no ads.\
You don't have to reveal sensitive personal information to sign up.\
No email is required. \
No phone number is required. \
Damus is free and open source software. \
There is no Big Tech moat. Therefore, seamless interoperability with thousands or millions of other nostr apps is possible, and is how [Damus and nostr win](https://www.youtube.com/watch?v=qTixqS-W1yo).
## If there are no ads, how is Damus funded?
Damus offers a paid subscription 🟣 purple 🟣 https://damus.io/purple/. \
Initial benefits include a unique subscriber number, subscriber badge, and auto-translate powered by DeepL.
Damus has also graciously received donations or grants from hundreds of Damus users, [Opensats](https://opensats.org/), and the [Human Rights Foundation](https://hrf.org/).
## Spec Compliance ## Spec Compliance
damus implements the following [Nostr Implementation Possibilities][nips] damus implements the following [Nostr Implementation Possibilities][nips]
- [NIP-01: Basic protocol flow][nip01] - [NIP-01: Basic protocol flow][nip01]
- [NIP-04: Encrypted direct message][nip04]
- [NIP-08: Mentions][nip08] - [NIP-08: Mentions][nip08]
- [NIP-10: Reply conventions][nip10] - [NIP-10: Reply conventions][nip10]
- [NIP-12: Generic tag queries (hashtags)][nip12] - [NIP-12: Generic tag queries (hashtags)][nip12]
- [NIP-19: bech32-encoded entities][NIP19]
- [NIP-21: nostr: URI scheme][NIP21]
- [NIP-25: Reactions][NIP25]
- [NIP-42: Authentication of clients to relays][nip42] - [NIP-42: Authentication of clients to relays][nip42]
- [NIP-56: Reporting][nip56]
[nips]: https://github.com/nostr-protocol/nips [nips]: https://github.com/nostr-protocol/nips
[nip01]: https://github.com/nostr-protocol/nips/blob/master/01.md [nip01]: https://github.com/nostr-protocol/nips/blob/master/01.md
[nip04]: https://github.com/nostr-protocol/nips/blob/master/04.md
[nip08]: https://github.com/nostr-protocol/nips/blob/master/08.md [nip08]: https://github.com/nostr-protocol/nips/blob/master/08.md
[nip10]: https://github.com/nostr-protocol/nips/blob/master/10.md [nip10]: https://github.com/nostr-protocol/nips/blob/master/10.md
[nip12]: https://github.com/nostr-protocol/nips/blob/master/12.md [nip12]: https://github.com/nostr-protocol/nips/blob/master/12.md
[nip19]: https://github.com/nostr-protocol/nips/blob/master/19.md
[nip21]: https://github.com/nostr-protocol/nips/blob/master/21.md
[nip25]: https://github.com/nostr-protocol/nips/blob/master/25.md
[nip42]: https://github.com/nostr-protocol/nips/blob/master/42.md [nip42]: https://github.com/nostr-protocol/nips/blob/master/42.md
[nip56]: https://github.com/nostr-protocol/nips/blob/master/56.md
## Getting Started on Damus ## Getting Started on Damus
@@ -62,7 +34,7 @@ damus implements the following [Nostr Implementation Possibilities][nips]
- Relays: You can add more relays to send your notes to by tapping the "+". - Relays: You can add more relays to send your notes to by tapping the "+".
- Find more relays to add: https://nostr.info/relays/ - Find more relays to add: https://nostr.info/relays/
- Public Key (pubkey): Your public, personal address and how people can find and tag you - Public Key (pubkey): Your public, personal address and how people can find and tag you
- Secret Key: Your *private* key unique to you. Never share your private key publicly and share with other clients at your own risk! - Secret Key: Your *private* key unique to you. Never share your private key publically and share with other clients at your own risk!
- Save your keys somewhere safe - Save your keys somewhere safe
- Log out - Log out
@@ -76,15 +48,19 @@ damus implements the following [Nostr Implementation Possibilities][nips]
1. Search their username in the search bar at the top of the 🔍 Global Feed and click their profile 1. Search their username in the search bar at the top of the 🔍 Global Feed and click their profile
2. Tap the 🔑 icon which will copy their pubkey to your clipboard 2. Tap the 🔑 icon which will copy their pubkey to your clipboard
3. Go back to your 🏠 Personal Feed and tap the blue + button to compose your Note 3. Go back to your 🏠 Personal Feed and tap the blue + button to compose your Note
4. Add @ directly followed by the pubkey (e.g., `@npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s`) 4. Add @ direcly followed by the pubkey (e.g., `@npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s`)
- You can also tap the ellipsis menu of a Note (three dots in top right of note) to grab their User ID aka pubkey or Note ID to link directly to a Note. - You can also long-press a Note to grab their User ID aka pubkey or Note ID to link directly to a Note.
- Currently you can't delete your Notes in the iOS app - Currently you can't delete your Notes in the iOS app
- Share images by pasting the image url which you can grab from nostr.build, imgbb, imgur, etc. (i.e., `https://i.ibb.co/2SHZbwm/alpha60.jpg`). Currently images only load for people you follow in the 🏠 Personal Feed. Images are not automatically loaded in 🔍 Global Feed - Share images by pasting the image url which you can grab from imgbb, imgur, etc. (i.e., `https://i.ibb.co/2SHZbwm/alpha60.jpg`). Currently images only load for people you follow in the 🏠 Personal Feed. Images are not automatically loaded in 🔍 Global Feed
- Engaging with Notes - Engaging with Notes
- 💬 Replying to a Note: Tap the chat icon underneath the note. This will show up in the users notifications and in your 🏠 Personal and 🔍 Global Feeds - 💬 Replying to a Note: Tap the chat icon underneath the note. This will show up in the users notifications and in your 🏠 Personal and 🔍 Global Feeds
- ♺ Reposts: Tap the repost icon which will show up in your 🏠 Personal and 🔍 Global Feeds - ♺ Reposts: Tap the repost icon which will show up in your 🏠 Personal and 🔍 Global Feeds
- ♡ Likes: Tap the heart icon. Users will not get a notification, and cannot see who liked their note (currently, web clients can see your pfp only) - ♡ Likes: Tap the heart icon. Users will not get a notification, and cannot see who liked their note (currently, web clients can see your pfp only)
- Formatting Notes (may not format as intended in other web clients)
- Italics: 1 asterisk `*italic*`
- Bold: 2 asterisk `**bold**`
- Strikethrough: 1 tildes `~strikethrough~`
- Code: 1 back-tick `` `code` ``
#### 💬 Encrypted DMs (chat app, bottom navigation) #### 💬 Encrypted DMs (chat app, bottom navigation)
- Tap the chat icon and you'll notice there's nothing to see at first. Go to a user profile and tap the 💬 chat icon next to the follow button to begin a DM - Tap the chat icon and you'll notice there's nothing to see at first. Go to a user profile and tap the 💬 chat icon next to the follow button to begin a DM
@@ -102,9 +78,7 @@ damus implements the following [Nostr Implementation Possibilities][nips]
4. For PFP, insert a URL containing your image (support video: https://cdn.jb55.com/vid/pfp-editor.mp4) 4. For PFP, insert a URL containing your image (support video: https://cdn.jb55.com/vid/pfp-editor.mp4)
5. Save 5. Save
#### ⚡️ Request Sats #### ⚡️ Request Sats
Paste an invoice from your favorite LN wallet.
(Sats or Satoshis are the smallest denomination of bitcoin) (Sats or Satoshis are the smallest denomination of bitcoin)
**Alby (browser extension)** **Alby (browser extension)**
@@ -145,8 +119,6 @@ Your internet protocol (IP) address is exposed to the relays you connect to, and
The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address. The relay also learns which public keys you are requesting, meaning your public key will be tied to your IP address.
It is public information which other profiles (npubs) you are exchanging DMs with. The content of the DMs is encrypted.
### Translations ### Translations
Translators welcome! Join the [Transifex][transifex] project. Translators welcome! Join the [Transifex][transifex] project.
@@ -157,10 +129,8 @@ All user-facing strings must have a comment in order to provide context to trans
### Awards ### Awards
Damus lead dev and founder Will awards developers with satoshis!
There may be nostr badges awarded for contributors in the future... :) There may be nostr badges awarded for contributors in the future... :)
First contributors: First contributors:
1. @randymcmillan 1. @randymcmillan
-19
View File
@@ -7,10 +7,8 @@
#include "nostr_bech32.h" #include "nostr_bech32.h"
#include <stdlib.h> #include <stdlib.h>
#include "endian.h"
#include "cursor.h" #include "cursor.h"
#include "bech32.h" #include "bech32.h"
#include <stdbool.h>
#define MAX_TLVS 16 #define MAX_TLVS 16
@@ -147,11 +145,6 @@ static int tlvs_to_relays(struct nostr_tlvs *tlvs, struct relays *relays) {
return 1; return 1;
} }
static uint32_t decode_tlv_u32(const uint8_t *bytes) {
beint32_t *be32_bytes = (beint32_t*)bytes;
return be32_to_cpu(*be32_bytes);
}
static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *nevent) { static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *nevent) {
struct nostr_tlvs tlvs; struct nostr_tlvs tlvs;
struct nostr_tlv *tlv; struct nostr_tlv *tlv;
@@ -173,13 +166,6 @@ static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *n
nevent->pubkey = NULL; nevent->pubkey = NULL;
} }
if(find_tlv(&tlvs, TLV_KIND, &tlv)) {
nevent->kind = decode_tlv_u32(tlv->value);
nevent->has_kind = true;
} else {
nevent->has_kind = false;
}
return tlvs_to_relays(&tlvs, &nevent->relays); return tlvs_to_relays(&tlvs, &nevent->relays);
} }
@@ -201,11 +187,6 @@ static int parse_nostr_bech32_naddr(struct cursor *cur, struct bech32_naddr *nad
naddr->pubkey = tlv->value; naddr->pubkey = tlv->value;
if(!find_tlv(&tlvs, TLV_KIND, &tlv)) {
return 0;
}
naddr->kind = decode_tlv_u32(tlv->value);
return tlvs_to_relays(&tlvs, &naddr->relays); return tlvs_to_relays(&tlvs, &naddr->relays);
} }
-5
View File
@@ -11,8 +11,6 @@
#include <stdio.h> #include <stdio.h>
#include "str_block.h" #include "str_block.h"
#include "cursor.h" #include "cursor.h"
#include <stdbool.h>
typedef unsigned char u8; typedef unsigned char u8;
#define MAX_RELAYS 10 #define MAX_RELAYS 10
@@ -47,8 +45,6 @@ struct bech32_nevent {
struct relays relays; struct relays relays;
const u8 *event_id; const u8 *event_id;
const u8 *pubkey; // optional const u8 *pubkey; // optional
uint32_t kind;
bool has_kind;
}; };
struct bech32_nprofile { struct bech32_nprofile {
@@ -60,7 +56,6 @@ struct bech32_naddr {
struct relays relays; struct relays relays;
struct str_block identifier; struct str_block identifier;
const u8 *pubkey; const u8 *pubkey;
uint32_t kind;
}; };
struct bech32_nrelay { struct bech32_nrelay {
File diff suppressed because it is too large Load Diff
@@ -1,32 +1,5 @@
{ {
"originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
"pins" : [ "pins" : [
{
"identity" : "codescanner",
"kind" : "remoteSourceControl",
"location" : "https://github.com/twostraws/CodeScanner.git",
"state" : {
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
}
},
{
"identity" : "emojikit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/EmojiKit",
"state" : {
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
"version" : "0.1.2"
}
},
{
"identity" : "emojipicker",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/EmojiPicker.git",
"state" : {
"revision" : "0c28b4a1a6b8840cf2580bda59517f6d0a733dc8",
"version" : "0.1.1"
}
},
{ {
"identity" : "gsplayer", "identity" : "gsplayer",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -53,15 +26,6 @@
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9" "revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
} }
}, },
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "ee97538f5b81ae89698fd95938896dec5217b148",
"version" : "1.1.1"
}
},
{ {
"identity" : "swift-markdown-ui", "identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -87,24 +51,7 @@
"revision" : "74203046135342e4a4a627476dd6caf8b28fe11b", "revision" : "74203046135342e4a4a627476dd6caf8b28fe11b",
"version" : "509.0.0" "version" : "509.0.0"
} }
},
{
"identity" : "swift-trie",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/swift-trie",
"state" : {
"revision" : "4c50bff6c168f74425f70476be62a072980d2da7",
"version" : "0.1.2"
}
},
{
"identity" : "swipeactions",
"kind" : "remoteSourceControl",
"location" : "https://github.com/damus-io/SwipeActions.git",
"state" : {
"revision" : "33d99756c3112e1a07c1732e3cddc5ad5bd0c5f4"
}
} }
], ],
"version" : 3 "version" : 2
} }
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1520" LastUpgradeVersion = "1500"
wasCreatedForAppExtension = "YES" wasCreatedForAppExtension = "YES"
version = "2.0"> version = "2.0">
<BuildAction <BuildAction
@@ -59,7 +59,7 @@
<RemoteRunnable <RemoteRunnable
runnableDebuggingMode = "1" runnableDebuggingMode = "1"
BundleIdentifier = "com.jb55.damus2" BundleIdentifier = "com.jb55.damus2"
RemotePath = "/Users/danielnogueira/Library/Developer/CoreSimulator/Devices/99E60B35-CE5D-4B45-AC35-00818C0AF3CB/data/Containers/Bundle/Application/7D0A5302-D07E-4C7C-B509-A7C552BD5A65/damus.app"> RemotePath = "/Users/danielnogueira/Library/Developer/CoreSimulator/Devices/99E60B35-CE5D-4B45-AC35-00818C0AF3CB/data/Containers/Bundle/Application/5A083DD0-FDE2-43D7-9172-2F97FAD18F20/damus.app">
</RemoteRunnable> </RemoteRunnable>
<MacroExpansion> <MacroExpansion>
<BuildableReference <BuildableReference
@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1540"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D703D7162C66E47100A400EA"
BuildableName = "HighlighterActionExtension.appex"
BlueprintName = "HighlighterActionExtension"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<RemoteRunnable
runnableDebuggingMode = "1"
BundleIdentifier = "com.apple.mobilesafari"
RemotePath = "/Library/Developer/CoreSimulator/Volumes/iOS_21F79/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.5.simruntime/Contents/Resources/RuntimeRoot/Applications/MobileSafari.app">
</RemoteRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1520" LastUpgradeVersion = "1500"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1520" LastUpgradeVersion = "1500"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@@ -40,7 +40,7 @@
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
<TestableReference <TestableReference
skipped = "NO"> skipped = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEFC27F7A08200C66700" BlueprintIdentifier = "4CE6DEFC27F7A08200C66700"
@@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x1A",
"green" : "0x93",
"red" : "0xF7"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xD7",
"green" : "0xD1",
"red" : "0xD1"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x13",
"green" : "0x11",
"red" : "0x11"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF9",
"green" : "0xF3",
"red" : "0xF3"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x25",
"green" : "0x22",
"red" : "0x22"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "244",
"green" : "218",
"red" : "244"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "92",
"green" : "45",
"red" : "93"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "236",
"green" : "194",
"red" : "238"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "109",
"green" : "49",
"red" : "111"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "197",
"green" : "67",
"red" : "204"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "255",
"green" : "194",
"red" : "255"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,38 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF2",
"green" : "0xD8",
"red" : "0xF4"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x45",
"green" : "0x17",
"red" : "0x47"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,328 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="500"
height="130"
viewBox="0 0 132.29166 34.395832"
version="1.1"
id="svg8"
inkscape:version="0.92.1 r15371"
sodipodi:docname="ActivityPub-logo.svg">
<title
id="title4590">ActivityPub logo</title>
<defs
id="defs2">
<linearGradient
id="AP-4-0"
osb:paint="solid">
<stop
style="stop-color:#5e5e5e;stop-opacity:1;"
offset="0"
id="stop5660" />
</linearGradient>
<linearGradient
id="linearGradient5640"
osb:paint="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop5638" />
</linearGradient>
<linearGradient
id="linearGradient5634"
osb:paint="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop5632" />
</linearGradient>
<linearGradient
id="linearGradient5628"
osb:paint="solid">
<stop
style="stop-color:#000000;stop-opacity:1;"
offset="0"
id="stop5626" />
</linearGradient>
<linearGradient
id="AP-3-7"
osb:paint="solid">
<stop
style="stop-color:#c678c5;stop-opacity:1;"
offset="0"
id="stop5498" />
</linearGradient>
<linearGradient
id="AP-2-3"
osb:paint="solid">
<stop
style="stop-color:#6d6d6d;stop-opacity:1;"
offset="0"
id="stop5230" />
</linearGradient>
<linearGradient
id="AP1-5"
osb:paint="solid">
<stop
style="stop-color:#f1007e;stop-opacity:1;"
offset="0"
id="stop5212" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#AP-3-7"
id="linearGradient5749"
gradientUnits="userSpaceOnUse"
x1="3319.292"
y1="-1291.2802"
x2="3344.3645"
y2="-1291.2802" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient7297-7"
gradientUnits="userSpaceOnUse"
x1="3241.6836"
y1="-1355.4329"
x2="3254.9529"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP-2-3"
id="linearGradient7303-7"
gradientUnits="userSpaceOnUse"
x1="3225.7603"
y1="-1355.4329"
x2="3239.0295"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient8308"
gradientUnits="userSpaceOnUse"
x1="3241.6836"
y1="-1355.4329"
x2="3254.9529"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient8310"
gradientUnits="userSpaceOnUse"
x1="3241.6836"
y1="-1355.4329"
x2="3254.9529"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient8312"
gradientUnits="userSpaceOnUse"
x1="3241.6836"
y1="-1355.4329"
x2="3254.9529"
y2="-1355.4329" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP-2-3"
id="linearGradient8314"
gradientUnits="userSpaceOnUse"
x1="3225.7603"
y1="-1355.4329"
x2="3239.0295"
y2="-1355.4329"
gradientTransform="matrix(3.7000834,0,0,3.7000834,-11935.582,4544.6634)" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP-2-3"
id="linearGradient5188"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.42732603,0,0,0.42732603,-1363.3009,454.91899)"
x1="3269.126"
y1="-1354.6217"
x2="3322.1943"
y2="-1354.6217" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP-2-3"
id="linearGradient4523"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(3.5811973,0,0,3.5811973,-11532.084,4918.1922)"
x1="3269.126"
y1="-1354.6217"
x2="3322.1943"
y2="-1354.6217" />
<linearGradient
inkscape:collect="always"
xlink:href="#AP1-5"
id="linearGradient4526"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(3.5811973,0,0,3.5811973,-11528.758,4918.1922)"
x1="3323.9951"
y1="-1356.5363"
x2="3349.0676"
y2="-1356.5363" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="0.14509804"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.5"
inkscape:cx="395.506"
inkscape:cy="-201.19903"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:snap-global="true"
showguides="false"
inkscape:guide-bbox="true"
showborder="true"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:showpageshadow="false"
borderlayer="false"
units="px">
<inkscape:grid
type="xygrid"
id="grid4572"
enabled="false"
originx="7.1437514"
originy="-404.28382" />
<inkscape:grid
type="axonomgrid"
id="grid4574"
units="mm"
empspacing="12"
originx="7.1437514"
originy="-404.28382"
enabled="false" />
<sodipodi:guide
position="3278.981,1256.5057"
orientation="0,1"
id="guide5059"
inkscape:locked="false" />
<sodipodi:guide
position="3278.981,1238.2495"
orientation="0,1"
id="guide5061"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>ActivityPub logo</dc:title>
<cc:license
rdf:resource="http://creativecommons.org/publicdomain/zero/1.0/" />
<dc:date>2017-04-15</dc:date>
<dc:creator>
<cc:Agent>
<dc:title>Robert Martinez</dc:title>
</cc:Agent>
</dc:creator>
<dc:subject>
<rdf:Bag>
<rdf:li>ActivityPub</rdf:li>
</rdf:Bag>
</dc:subject>
</cc:Work>
<cc:License
rdf:about="http://creativecommons.org/publicdomain/zero/1.0/">
<cc:permits
rdf:resource="http://creativecommons.org/ns#Reproduction" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#Distribution" />
<cc:permits
rdf:resource="http://creativecommons.org/ns#DerivativeWorks" />
</cc:License>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
style="opacity:1"
transform="translate(7.1437516,141.67967)">
<path
style="fill:#000000;stroke-width:0.26458335"
d=""
id="path5497"
inkscape:connector-curvature="0" />
<g
id="g5197"
transform="translate(1.3229166)">
<g
id="g5132-90"
style="fill:url(#linearGradient7297-7);fill-opacity:1"
transform="matrix(0.9789804,0,0,0.9789804,-3157.9561,1202.4422)">
<g
transform="matrix(0.2553682,0,0,0.2553682,2615.9213,-1125.3113)"
id="g5080-78"
style="fill:url(#linearGradient8312);fill-opacity:1">
<path
inkscape:connector-curvature="0"
id="path5404-0-0"
d="m 2450.431,-937.13662 51.9615,30 v 12 l -51.9615,30 v -12 l 41.5693,-24 -41.5692,-24 z"
style="fill:url(#linearGradient8308);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
sodipodi:nodetypes="cccccccc" />
<path
sodipodi:nodetypes="cccc"
style="fill:url(#linearGradient8310);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 2450.431,-913.13662 20.7847,12 -20.7847,12 z"
id="path5406-6-3"
inkscape:connector-curvature="0" />
</g>
</g>
<g
id="g5127-1"
style="fill:url(#linearGradient7303-7);fill-opacity:1"
transform="matrix(0.9789804,0,0,0.9789804,-3157.9561,1202.4422)">
<path
id="path5467-2-0"
transform="matrix(0.27026418,0,0,0.27026418,3225.7603,-1228.2597)"
d="M 49.097656,-504.56641 0,-476.2207 v 11.33789 l 39.277344,-22.67578 v 45.35351 l 9.820312,5.66992 z m -19.638672,34.01563 -19.6406246,11.33789 19.6406246,11.33789 z"
style="fill:url(#linearGradient8314);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.25000042px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
inkscape:connector-curvature="0" />
</g>
</g>
<g
id="g5203"
transform="matrix(2.2173353,0,0,2.2173353,-35.445741,150.88402)">
<g
id="g4523">
<path
sodipodi:nodetypes="scscscscsscscscscscccccccccccccccscsccccscscccccccccccscsccccscsccccccccccccscscccsccccscscsccccscccccccccccccccccccccccccccccscssccccccccc"
inkscape:connector-curvature="0"
id="text5037-6"
transform="matrix(0.1193249,0,0,0.1193249,12.763965,-131.94382)"
d="m 263.22656,34.349609 c -1.66644,0 -2.95278,0.477436 -3.85742,1.429688 -0.90464,0.904639 -1.35742,2.069669 -1.35742,3.498047 0,1.428378 0.45278,2.59536 1.35742,3.5 0.90464,0.857027 2.19098,1.285156 3.85742,1.285156 1.66644,0 2.99818,-0.428129 3.99805,-1.285156 0.99986,-0.857027 1.5,-2.024009 1.5,-3.5 0,-1.475991 -0.50014,-2.665673 -1.5,-3.570313 -0.99987,-0.904639 -2.33161,-1.357422 -3.99805,-1.357422 z m 43.95117,0 c -1.66644,0 -2.95082,0.477436 -3.85546,1.429688 -0.90464,0.904639 -1.35743,2.069669 -1.35743,3.498047 0,1.428378 0.45279,2.59536 1.35743,3.5 0.90464,0.857027 2.18902,1.285156 3.85546,1.285156 1.66645,0 3.00014,-0.428129 4,-1.285156 0.99987,-0.857027 1.5,-2.024009 1.5,-3.5 0,-1.475991 -0.50013,-2.665673 -1.5,-3.570313 -0.99986,-0.904639 -2.33355,-1.357422 -4,-1.357422 z m -118.46166,0.357422 -14.49805,50.351563 h 8.92774 l 2.92773,-11.285156 h 11.78516 l 3.07031,11.285156 h 9.42773 L 195.78638,34.707031 Z m 58.71166,5.285157 -8.49804,2.642578 v 6.71289 h -3.92774 v 7.570313 h 3.92774 v 18.71289 c 0,3.713784 0.66684,6.356519 2,7.927735 1.38076,1.571216 3.42866,2.355468 6.14258,2.355468 1.5236,0 2.9747,-0.189411 4.35546,-0.570312 1.38077,-0.333288 2.59511,-0.761418 3.64258,-1.285156 L 254,77.273438 c -0.66658,0.285675 -1.28607,0.49974 -1.85742,0.642578 -0.57135,0.142837 -1.2155,0.214843 -1.92969,0.214843 -1.04748,0 -1.78438,-0.430082 -2.21289,-1.287109 -0.3809,-0.857027 -0.57227,-2.308127 -0.57227,-4.355469 V 56.917969 h 6.92774 v -7.570313 h -6.92774 z m 80.23243,0 -8.49805,2.642578 v 6.71289 h -3.92969 v 7.570313 h 3.92969 v 18.71289 c 0,3.713784 0.66489,6.356519 1.99805,7.927735 1.38076,1.571216 3.42866,2.355468 6.14257,2.355468 1.52361,0 2.97666,-0.189411 4.35743,-0.570312 1.38076,-0.333288 2.5951,-0.761418 3.64257,-1.285156 l -1.07226,-6.785156 c -0.66658,0.285675 -1.28607,0.49974 -1.85742,0.642578 -0.57135,0.142837 -1.21355,0.214843 -1.92774,0.214843 -1.04747,0 -1.78437,-0.430082 -2.21289,-1.287109 -0.3809,-0.857027 -0.57226,-2.308127 -0.57226,-4.355469 V 56.917969 h 6.92773 v -7.570313 h -6.92773 z m -135.65894,6.855468 h 0.28516 l 1.14453,7.857422 2.85547,11.640625 h -8.2832 l 2.78515,-11.570312 z m 31.94605,1.572266 c -4.33275,0 -7.61963,1.570458 -9.85743,4.71289 -2.23779,3.142433 -3.35546,7.833061 -3.35546,14.070313 0,2.856756 0.21406,5.452139 0.64257,7.785156 0.47613,2.285405 1.23768,4.261293 2.28516,5.927735 1.04748,1.618828 2.38117,2.880516 4,3.785156 1.66644,0.857027 3.71238,1.285156 6.14062,1.285156 1.66645,0 3.33356,-0.238718 5,-0.714844 1.66645,-0.476126 3.09485,-1.190326 4.28516,-2.142578 l -1.78516,-6.570312 c -0.71418,0.476126 -1.50039,0.881555 -2.35742,1.214844 -0.80941,0.285675 -1.80968,0.427734 -3,0.427734 -2.23779,0 -3.88025,-1.022971 -4.92773,-3.070313 -0.99987,-2.047342 -1.5,-4.690077 -1.5,-7.927734 0,-3.856621 0.50013,-6.641415 1.5,-8.355469 0.99986,-1.761666 2.50027,-2.642578 4.5,-2.642578 1.09509,0 2.02335,0.117406 2.78515,0.355469 0.80942,0.19045 1.64298,0.501174 2.5,0.929687 l 2,-7.070312 c -1.04747,-0.571351 -2.26181,-1.048787 -3.64257,-1.429688 -1.33316,-0.3809 -3.07033,-0.570312 -5.21289,-0.570312 z m 35.06445,0.927734 v 35.710938 h 8.5 V 49.347656 Z m 11.05469,0 12.64257,36.066406 h 5.7129 l 11.99804,-36.066406 h -9.14062 l -4.42774,18.570313 -0.78711,5.71289 h -0.28515 l -0.85742,-5.642578 -4.92774,-18.640625 z m 32.89843,0 v 35.710938 h 8.49805 V 49.347656 Z m 33.53125,0 12.42774,35.710938 c -0.28568,1.571216 -0.64375,2.832904 -1.07227,3.785156 -0.42851,0.952252 -0.92865,1.641799 -1.5,2.070312 -0.52374,0.476127 -1.11858,0.714844 -1.78515,0.714844 -0.61897,0.04761 -1.23846,-0.04905 -1.85743,-0.287109 l -1.42773,7.285156 c 0.66658,0.380901 1.45278,0.642319 2.35742,0.785156 0.95225,0.190451 1.92787,0.28711 2.92774,0.28711 1.42837,0 2.64271,-0.430083 3.64257,-1.28711 1.04748,-0.809414 1.97575,-1.999096 2.78516,-3.570312 0.80941,-1.571216 1.57097,-3.475098 2.28516,-5.712891 0.71419,-2.237792 1.47574,-4.761168 2.28515,-7.570312 l 8.92774,-32.210938 h -8.71289 l -4.14258,19.998047 -0.64258,5.642578 h -0.35742 l -0.92774,-5.572265 -5,-20.06836 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:condensed;font-size:8.52205467px;line-height:2.53632545px;font-family:'PT Sans Narrow';-inkscape-font-specification:'PT Sans Narrow Bold Condensed';letter-spacing:0.22319667px;word-spacing:2.60024095px;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:url(#linearGradient4523);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.27365798px;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate" />
<path
id="text5065-3"
transform="matrix(0.1193249,0,0,0.1193249,12.763965,-131.94382)"
d="m 386.9082,34.349609 c -2.04734,0 -4.09523,0.119359 -6.14258,0.357422 -2.04734,0.190451 -3.92657,0.476521 -5.64062,0.857422 v 49.494141 h 8.99805 V 67.845703 c 0.19045,0.04761 0.49922,0.09497 0.92773,0.142578 l 1.42969,0.142578 h 1.35742 0.92773 c 2.04735,0 4.02324,-0.30877 5.92774,-0.927734 1.95212,-0.618964 3.66659,-1.619234 5.14258,-3 1.47599,-1.380766 2.64102,-3.165289 3.49804,-5.355469 0.90464,-2.19018 1.35743,-4.857568 1.35743,-8 0,-3.47572 -0.52284,-6.285167 -1.57032,-8.427734 -1.04747,-2.19018 -2.40582,-3.879997 -4.07226,-5.070313 -1.66644,-1.190315 -3.57033,-1.976521 -5.71289,-2.357421 -2.09496,-0.428514 -4.23756,-0.642579 -6.42774,-0.642579 z m 51.72461,0.714844 v 48.564453 c 1.14271,0.571352 2.76052,1.09614 4.85547,1.572266 2.14257,0.476126 4.47653,0.71289 7,0.71289 4.61842,0 8.25948,-1.570458 10.92578,-4.71289 2.66631,-3.190045 4,-8.164791 4,-14.925781 0,-6.237252 -0.95292,-10.761169 -2.85742,-13.570313 -1.85689,-2.809144 -4.47497,-4.214844 -7.85547,-4.214844 -3.19004,0 -5.64141,1.047624 -7.35547,3.142578 h -0.21484 V 35.064453 Z m -50.86719,7.285156 c 0.99987,0 1.95279,0.142059 2.85743,0.427735 0.90464,0.285675 1.68889,0.761158 2.35547,1.427734 0.71418,0.618964 1.26167,1.477176 1.64257,2.572266 0.42852,1.09509 0.64258,2.428784 0.64258,4 0,1.856891 -0.21406,3.402697 -0.64258,4.640625 -0.42851,1.190315 -1.02335,2.143233 -1.78515,2.857422 -0.71419,0.666576 -1.54775,1.142058 -2.5,1.427734 -0.95225,0.285676 -1.95057,0.429687 -2.99805,0.429687 -0.28568,10e-7 -0.83316,-0.02465 -1.64258,-0.07227 -0.7618,-0.09522 -1.28659,-0.189931 -1.57226,-0.285156 v -17.06836 c 0.95225,-0.238063 2.16658,-0.357422 3.64257,-0.357422 z m 20.31836,6.998047 v 23.210938 c 0,2.666306 0.21407,4.880911 0.64258,6.642578 0.42852,1.714054 1.04606,3.070448 1.85547,4.070312 0.80942,0.999865 1.78699,1.691365 2.92969,2.072266 1.1427,0.428513 2.45174,0.642578 3.92773,0.642578 2.28541,0 4.18929,-0.547488 5.71289,-1.642578 1.57122,-1.09509 2.81021,-2.476137 3.71485,-4.142578 h 0.21289 l 1.5,4.857422 h 6.42773 c -0.3809,-1.523604 -0.64232,-3.215374 -0.78515,-5.072266 -0.14284,-1.904504 -0.21485,-3.833039 -0.21485,-5.785156 V 49.347656 h -8.49804 v 23.853516 c -0.38091,1.380765 -1.02505,2.572401 -1.92969,3.572266 -0.90464,0.952252 -2.02232,1.427734 -3.35547,1.427734 -1.38077,0 -2.33368,-0.547488 -2.85742,-1.642578 -0.52374,-1.09509 -0.78516,-3.046325 -0.78516,-5.855469 V 49.347656 Z m 43.83204,6.927735 c 1.61882,0 2.80851,0.858211 3.57031,2.572265 0.7618,1.666441 1.14258,4.307223 1.14258,7.925782 0,4.094684 -0.47549,7.023489 -1.42774,8.785156 -0.95225,1.714054 -2.3333,2.572265 -4.14258,2.572265 -1.61882,0 -2.92787,-0.26337 -3.92773,-0.787109 V 59.990234 c 0.80941,-2.475855 2.40453,-3.714843 4.78516,-3.714843 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:condensed;font-size:8.52205467px;line-height:2.53632545px;font-family:'PT Sans Narrow';-inkscape-font-specification:'PT Sans Narrow Bold Condensed';letter-spacing:0.22319667px;word-spacing:2.60024095px;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:url(#linearGradient4526);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.24196777px;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 18 KiB

@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "ActivityPub-logo.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "atproto.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

-12
View File
@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "damoose.jpeg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

@@ -1,20 +0,0 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
-12
View File
@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "mutiny.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

-12
View File
@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "rss.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

-12
View File
@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "tor.svg.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 109 KiB

+14 -7
View File
@@ -12,25 +12,31 @@ let RECTANGLE_GRADIENT = LinearGradient(gradient: Gradient(colors: [
DamusColors.blue DamusColors.blue
]), startPoint: .leading, endPoint: .trailing) ]), startPoint: .leading, endPoint: .trailing)
struct CustomPicker<SelectionValue: Hashable>: View { struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
let tabs: [(String, SelectionValue)]
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@Namespace var picker @Namespace var picker
@Binding var selection: SelectionValue @Binding var selection: SelectionValue
@ViewBuilder let content: Content
public var body: some View { public var body: some View {
let contentMirror = Mirror(reflecting: content)
let blocksCount = Mirror(reflecting: contentMirror.descendant("value")!).children.count
HStack { HStack {
ForEach(tabs, id: \.1) { (text, tag) in ForEach(0..<blocksCount, id: \.self) { index in
let tupleBlock = contentMirror.descendant("value", ".\(index)")
let text = Mirror(reflecting: tupleBlock!).descendant("content") as! Text
let tag = Mirror(reflecting: tupleBlock!).descendant("modifier", "value", "tagged") as! SelectionValue
Button { Button {
withAnimation(.spring()) { withAnimation(.spring()) {
selection = tag selection = tag
} }
} label: { } label: {
Text(text).padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0)) text
.padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
.font(.system(size: 14, weight: .heavy)) .font(.system(size: 14, weight: .heavy))
.tag(tag)
} }
.background( .background(
Group { Group {
@@ -46,6 +52,7 @@ struct CustomPicker<SelectionValue: Hashable>: View {
.accentColor(tag == selection ? textColor() : .gray) .accentColor(tag == selection ? textColor() : .gray)
} }
} }
.background(Color(UIColor.systemBackground))
} }
func textColor() -> Color { func textColor() -> Color {
-15
View File
@@ -10,27 +10,19 @@ import SwiftUI
class DamusColors { class DamusColors {
static let adaptableGrey = Color("DamusAdaptableGrey") static let adaptableGrey = Color("DamusAdaptableGrey")
static let adaptableGrey2 = Color("DamusAdaptableGrey 2")
static let adaptableLighterGrey = Color("DamusAdaptableLighterGrey")
static let adaptablePurpleBackground = Color("DamusAdaptablePurpleBackground 1")
static let adaptablePurpleBackground2 = Color("DamusAdaptablePurpleBackground 2")
static let adaptablePurpleForeground = Color("DamusAdaptablePurpleForeground")
static let adaptableBlack = Color("DamusAdaptableBlack") static let adaptableBlack = Color("DamusAdaptableBlack")
static let adaptableWhite = Color("DamusAdaptableWhite") static let adaptableWhite = Color("DamusAdaptableWhite")
static let white = Color("DamusWhite") static let white = Color("DamusWhite")
static let black = Color("DamusBlack") static let black = Color("DamusBlack")
static let brown = Color("DamusBrown") static let brown = Color("DamusBrown")
static let yellow = Color("DamusYellow") static let yellow = Color("DamusYellow")
static let gold = hex_col(r: 226, g: 168, b: 0)
static let lightGrey = Color("DamusLightGrey") static let lightGrey = Color("DamusLightGrey")
static let mediumGrey = Color("DamusMediumGrey") static let mediumGrey = Color("DamusMediumGrey")
static let darkGrey = Color("DamusDarkGrey") static let darkGrey = Color("DamusDarkGrey")
static let green = Color("DamusGreen") static let green = Color("DamusGreen")
static let purple = Color("DamusPurple") static let purple = Color("DamusPurple")
static let deepPurple = Color("DamusDeepPurple") static let deepPurple = Color("DamusDeepPurple")
static let highlight = Color("DamusHighlight")
static let blue = Color("DamusBlue") static let blue = Color("DamusBlue")
static let bitcoin = Color("Bitcoin")
static let success = Color("DamusSuccessPrimary") static let success = Color("DamusSuccessPrimary")
static let successSecondary = Color("DamusSuccessSecondary") static let successSecondary = Color("DamusSuccessSecondary")
static let successTertiary = Color("DamusSuccessTertiary") static let successTertiary = Color("DamusSuccessTertiary")
@@ -54,10 +46,3 @@ class DamusColors {
static let lightBackgroundPink = Color(red: 0xF8/255.0, green: 0xE7/255.0, blue: 0xF8/255.0) static let lightBackgroundPink = Color(red: 0xF8/255.0, green: 0xE7/255.0, blue: 0xF8/255.0)
} }
func hex_col(r: UInt8, g: UInt8, b: UInt8) -> Color {
return Color(.sRGB,
red: Double(r) / Double(0xff),
green: Double(g) / Double(0xff),
blue: Double(b) / Double(0xff),
opacity: 1.0)
}
@@ -20,7 +20,6 @@ struct DamusBackground: View {
.resizable() .resizable()
.frame(maxWidth: .infinity, maxHeight: maxHeight, alignment: .center) .frame(maxWidth: .infinity, maxHeight: maxHeight, alignment: .center)
.ignoresSafeArea() .ignoresSafeArea()
.accessibilityHidden(true)
} }
} }
@@ -7,7 +7,7 @@
import SwiftUI import SwiftUI
fileprivate let gold_grad_c1 = DamusColors.gold fileprivate let gold_grad_c1 = hex_col(r: 226, g: 168, b: 0)
fileprivate let gold_grad_c2 = hex_col(r: 249, g: 243, b: 100) fileprivate let gold_grad_c2 = hex_col(r: 249, g: 243, b: 100)
fileprivate let gold_grad = [gold_grad_c2, gold_grad_c1] fileprivate let gold_grad = [gold_grad_c2, gold_grad_c1]
@@ -1,15 +0,0 @@
//
// MutinyGradient.swift
// damus
//
// Created by eric on 3/9/24.
//
import SwiftUI
fileprivate let mutiny_grad_c1 = hex_col(r: 39, g: 95, b: 161)
fileprivate let mutiny_grad_c2 = hex_col(r: 13, g: 33, b: 56)
fileprivate let mutiny_grad = [mutiny_grad_c2, mutiny_grad_c1]
let MutinyGradient: LinearGradient =
LinearGradient(colors: mutiny_grad, startPoint: .top, endPoint: .bottom)
+128 -276
View File
@@ -7,7 +7,6 @@
import SwiftUI import SwiftUI
import Kingfisher import Kingfisher
import Combine
// TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16 // TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16
struct ShareSheet: UIViewControllerRepresentable { struct ShareSheet: UIViewControllerRepresentable {
@@ -32,49 +31,6 @@ struct ShareSheet: UIViewControllerRepresentable {
} }
} }
// Custom UIPageControl
struct PageControlView: UIViewRepresentable {
@Binding var currentPage: Int
var numberOfPages: Int
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> UIPageControl {
let uiView = UIPageControl()
uiView.backgroundStyle = .minimal
uiView.currentPageIndicatorTintColor = UIColor(Color("DamusPurple"))
uiView.pageIndicatorTintColor = UIColor(Color("DamusLightGrey"))
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
uiView.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged), for: .valueChanged)
return uiView
}
func updateUIView(_ uiView: UIPageControl, context: Context) {
uiView.currentPage = currentPage
uiView.numberOfPages = numberOfPages
}
}
extension PageControlView {
final class Coordinator: NSObject {
var parent: PageControlView
init(_ parent: PageControlView) {
self.parent = parent
}
@objc func valueChanged(sender: UIPageControl) {
let currentPage = sender.currentPage
withAnimation {
parent.currentPage = currentPage
}
}
}
}
enum ImageShape { enum ImageShape {
case square case square
@@ -96,203 +52,42 @@ enum ImageShape {
} }
} }
/// The `CarouselModel` helps `ImageCarousel` with some state management logic, keeping track of media sizes, and the ideal display size
///
/// This model is necessary because the state management logic required to keep track of media sizes for each one of the carousel items,
/// and the ideal display size at each moment is not a trivial task.
///
/// The rules for the media fill are as follows:
/// 1. The media item should generally have a width that completely fills the width of its parent view
/// 2. The height of the carousel should be adjusted accordingly
/// 3. The only exception to rules 1 and 2 is when the total height would be 20% larger than the height of the device
/// 4. If none of the above can be computed (e.g. due to missing information), default to a reasonable height, where the media item will fit into.
///
/// ## Usage notes
///
/// The view is has the following state management responsibilities:
/// 1. Watching the size of the images (via the `.observe_image_size` modifier)
/// 2. Notifying this class of geometry reader changes, by setting `geo_size`
///
/// ## Implementation notes
///
/// This class is organized in a way to reduce stateful behavior and the transiency bugs it can cause.
///
/// This is accomplished through the following pattern:
/// 1. The `current_item_fill` is a published property so that any updates instantly re-render the view
/// 2. However, `current_item_fill` has a mathematical dependency on other members of this class
/// 3. Therefore, the members on which the fill property depends on all have `didSet` observers that will cause the `current_item_fill` to be recalculated and published.
///
/// This pattern helps ensure that the state is always consistent and that the view is always up-to-date.
///
/// This class is marked as `@MainActor` since most of its properties are published and should be accessed from the main thread to avoid inconsistent SwiftUI state during renders
@MainActor
class CarouselModel: ObservableObject {
// MARK: Immutable object attributes
// These are some attributes that are not expected to change throughout the lifecycle of this object
// These should not be modified after initialization to avoid state inconsistency
/// The state of the app
let damus_state: DamusState
/// All urls in the carousel
let urls: [MediaUrl]
/// The default fill height for the carousel, if we cannot calculate a more appropriate height
/// **Usage note:** Default to this when `current_item_fill` is nil
let default_fill_height: CGFloat
/// The maximum height for any carousel item
let max_height: CGFloat
// MARK: Miscellaneous
/// Holds items that allows us to cancel video size observers during de-initialization
private var all_cancellables: [AnyCancellable] = []
// MARK: State management properties
/// Properties relevant to state management.
/// These should be made into computed/functional properties when possible to avoid stateful behavior
/// When that is not possible (e.g. when dealing with an observed published property), establish its mathematical dependencies,
/// and use `didSet` observers to ensure that the state is always re-computed when necessary.
/// Stores information about the size of each media item in `urls`.
/// **Usage note:** The view is responsible for setting the size of image urls
var media_size_information: [URL: CGSize] {
didSet {
guard let current_url else { return }
// 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()
}
}
}
/// Stores information about the geometry reader
/// **Usage note:** The view is responsible for setting this value
var geo_size: CGSize? {
didSet { self.refresh_current_item_fill() }
}
/// The index of the currently selected item
/// **Usage note:** The view is responsible for setting this value
@Published var selectedIndex: Int {
didSet { self.refresh_current_item_fill() }
}
/// The current fill for the media item.
/// **Usage note:** This property is read-only and should not be set directly. Update `selectedIndex` to update the current item being viewed.
var current_url: URL? {
return urls[safe: selectedIndex]?.url
}
/// Holds the ideal fill dimensions for the current item.
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly
/// **Implementation note:** This property is mathematically dependent on geo_size, media_size_information, and `selectedIndex`,
/// and is automatically updated upon changes to these properties.
@Published private(set) var current_item_fill: ImageFill?
// MARK: Initialization and de-initialization
/// Initializes the `CarouselModel` with the given `DamusState` and `MediaUrl` array
init(damus_state: DamusState, urls: [MediaUrl]) {
// Immutable object attributes
self.damus_state = damus_state
self.urls = urls
self.default_fill_height = 350
self.max_height = UIScreen.main.bounds.height * 1.2 // 1.2
// State management properties
self.selectedIndex = 0
self.current_item_fill = nil
self.geo_size = nil
self.media_size_information = [:]
// Setup the rest of the state management logic
self.observe_video_sizes()
Task {
self.refresh_current_item_fill()
}
}
/// This private function observes the video sizes for all videos
private func observe_video_sizes() {
for media_url in urls {
switch media_url {
case .video(let url):
let video_player = damus_state.video.get_player(for: url)
if let video_size = video_player.video_size {
self.media_size_information[url] = video_size // Set the initial size if available
}
let observer_cancellable = video_player.$video_size.sink(receiveValue: { new_size in
self.media_size_information[url] = new_size // Update the size when it changes
})
all_cancellables.append(observer_cancellable) // Store the cancellable to cancel it later
case .image(_):
break; // Observing an image size needs to be done on the view directly, through the `.observe_image_size` modifier
}
}
}
deinit {
for cancellable_item in all_cancellables {
cancellable_item.cancel()
}
}
// MARK: State management and logic
/// This function refreshes the current item fill 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 fill
private func refresh_current_item_fill() {
if let current_url,
let item_size = self.media_size_information[current_url],
let geo_size {
self.current_item_fill = ImageFill.calculate_image_fill(
geo_size: geo_size,
img_size: item_size,
maxHeight: self.max_height,
fillHeight: self.default_fill_height
)
}
else {
self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
}
}
}
// MARK: - Image Carousel // MARK: - Image Carousel
/// A carousel that displays images and videos
///
/// ## Implementation notes
///
/// - State management logic is mostly handled by `CarouselModel`, as it is complex, and becomes difficult to manage in a view
///
@MainActor @MainActor
struct ImageCarousel<Content: View>: View { struct ImageCarousel: View {
/// The event id of the note that this carousel is displaying var urls: [MediaUrl]
let evid: NoteId
/// The model that holds information and state of this carousel
/// This is observed to update the view when the model changes
@ObservedObject var model: CarouselModel
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
self.evid = evid
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls))
self.content = nil
}
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) { let evid: NoteId
let state: DamusState
@State private var open_sheet: Bool = false
@State private var current_url: URL? = nil
@State private var image_fill: ImageFill? = nil
@State private var fillHeight: CGFloat = 350
@State private var maxHeight: CGFloat = UIScreen.main.bounds.height * 1.2 // 1.2
@State private var firstImageHeight: CGFloat? = nil
@State private var currentImageHeight: CGFloat?
@State private var selectedIndex = 0
@State private var video_size: CGSize? = nil
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
_open_sheet = State(initialValue: false)
_current_url = State(initialValue: nil)
let media_model = state.events.get_cache_data(evid).media_metadata_model
_image_fill = State(initialValue: media_model.fill)
self.urls = urls
self.evid = evid self.evid = evid
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls)) self.state = state
self.content = content
} }
var filling: Bool { var filling: Bool {
model.current_item_fill?.filling == true image_fill?.filling == true
} }
var height: CGFloat { var height: CGFloat {
// Use the calculated fill height if available, otherwise use the default fill height firstImageHeight ?? image_fill?.height ?? fillHeight
model.current_item_fill?.height ?? model.default_fill_height
} }
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View { func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
@@ -300,7 +95,7 @@ struct ImageCarousel<Content: View>: View {
if num_urls > 1 { if num_urls > 1 {
// jb55: quick hack since carousel with multiple images looks horrible with blurhash background // jb55: quick hack since carousel with multiple images looks horrible with blurhash background
Color.clear Color.clear
} else if let meta = model.damus_state.events.lookup_img_metadata(url: url), } else if let meta = state.events.lookup_img_metadata(url: url),
case .processed(let blurhash) = meta.state { case .processed(let blurhash) = meta.state {
Image(uiImage: blurhash) Image(uiImage: blurhash)
.resizable() .resizable()
@@ -309,6 +104,12 @@ struct ImageCarousel<Content: View>: View {
Color.clear Color.clear
} }
} }
.onAppear {
if self.image_fill == nil, let size = state.video.size_for_url(url) {
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight)
self.image_fill = fill
}
}
} }
func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View { func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View {
@@ -317,17 +118,24 @@ struct ImageCarousel<Content: View>: View {
case .image(let url): case .image(let url):
Img(geo: geo, url: url, index: index) Img(geo: geo, url: url, index: index)
.onTapGesture { .onTapGesture {
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex)) open_sheet = true
} }
case .video(let url): case .video(let url):
let video_model = model.damus_state.video.get_player(for: url) DamusVideoPlayer(url: url, video_size: $video_size, controller: state.video)
DamusVideoPlayerView( .onChange(of: video_size) { size in
model: video_model, guard let size else { return }
coordinator: model.damus_state.video,
style: .preview(on_tap: { let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight)
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
}) print("video_size changed \(size)")
) if self.image_fill == nil {
print("video_size firstImageHeight \(fill.height)")
firstImageHeight = fill.height
state.events.get_cache_data(evid).media_metadata_model.fill = fill
}
self.image_fill = fill
}
} }
} }
} }
@@ -336,21 +144,33 @@ struct ImageCarousel<Content: View>: View {
KFAnimatedImage(url) KFAnimatedImage(url)
.callbackQueue(.dispatch(.global(qos:.background))) .callbackQueue(.dispatch(.global(qos:.background)))
.backgroundDecode(true) .backgroundDecode(true)
.imageContext(.note, disable_animation: model.damus_state.settings.disable_animation) .imageContext(.note, disable_animation: state.settings.disable_animation)
.image_fade(duration: 0.25) .image_fade(duration: 0.25)
.cancelOnDisappear(true) .cancelOnDisappear(true)
.configure { view in .configure { view in
view.framePreloadCount = 3 view.framePreloadCount = 3
} }
.observe_image_size(size_changed: { size in .imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in
// Observe the image size to update the model when the size changes, so we can calculate the fill state.events.get_cache_data(evid).media_metadata_model.fill = fill
model.media_size_information[url] = size // blur hash can be discarded when we have the url
}) // NOTE: this is the wrong place for this... we need to remove
// it when the image is loaded in memory. This may happen
// earlier than this (by the preloader, etc)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
state.events.lookup_img_metadata(url: url)?.state = .not_needed
}
image_fill = fill
if index == 0 {
firstImageHeight = fill.height
//maxHeight = firstImageHeight ?? maxHeight
} else {
//maxHeight = firstImageHeight ?? fill.height
}
}
.background { .background {
Placeholder(url: url, geo_size: geo.size, num_urls: model.urls.count) Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
} }
.aspectRatio(contentMode: filling ? .fill : .fit) .aspectRatio(contentMode: filling ? .fill : .fit)
.kfClickable()
.position(x: geo.size.width / 2, y: geo.size.height / 2) .position(x: geo.size.width / 2, y: geo.size.height / 2)
.tabItem { .tabItem {
Text(url.absoluteString) Text(url.absoluteString)
@@ -361,46 +181,79 @@ struct ImageCarousel<Content: View>: View {
} }
var Medias: some View { var Medias: some View {
TabView(selection: $model.selectedIndex) { TabView(selection: $selectedIndex) {
ForEach(model.urls.indices, id: \.self) { index in ForEach(urls.indices, id: \.self) { index in
GeometryReader { geo in GeometryReader { geo in
Media(geo: geo, url: model.urls[index], index: index) Media(geo: geo, url: urls[index], index: index)
.onChange(of: geo.size, perform: { new_size in
model.geo_size = new_size
})
.onAppear {
model.geo_size = geo.size
}
} }
} }
} }
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.frame(height: height) .fullScreenCover(isPresented: $open_sheet) {
.onChange(of: model.selectedIndex) { value in ImageView(video_controller: state.video, urls: urls, settings: state.settings)
model.selectedIndex = value
} }
.frame(height: height)
.onChange(of: selectedIndex) { value in
selectedIndex = value
}
.tabViewStyle(PageTabViewStyle())
} }
var body: some View { var body: some View {
VStack { VStack {
if #available(iOS 18.0, *) { Medias
Medias .onTapGesture { }
} else {
// An empty tap gesture recognizer is needed on iOS 17 and below to suppress other overlapping tap recognizers
// Otherwise it will both open the carousel and go to a note at the same time
Medias.onTapGesture { }
}
// This is our custom carousel image indicator
if model.urls.count > 1 { CarouselDotsView(urls: urls, selectedIndex: $selectedIndex)
PageControlView(currentPage: $model.selectedIndex, numberOfPages: model.urls.count)
.frame(maxWidth: 0, maxHeight: 0)
.padding(.top, 5)
}
} }
} }
} }
// MARK: - Custom Carousel
struct CarouselDotsView<T>: View {
let urls: [T]
@Binding var selectedIndex: Int
var body: some View {
if urls.count > 1 {
HStack {
ForEach(urls.indices, id: \.self) { index in
Circle()
.fill(index == selectedIndex ? Color("DamusPurple") : Color("DamusLightGrey"))
.frame(width: 10, height: 10)
.onTapGesture {
selectedIndex = index
}
}
}
.padding(.top, CGFloat(8))
.id(UUID())
}
}
}
// MARK: - Image Modifier
extension KFOptionSetter {
/// Sets a block to get image size
///
/// - Parameter block: The block which is used to read the image object.
/// - Returns: `Self` value after read size
public func imageFill(for size: CGSize, max: CGFloat, fill: CGFloat, block: @escaping (ImageFill) throws -> Void) -> Self {
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
let img_size = image.size
let geo_size = size
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: img_size, maxHeight: max, fillHeight: fill)
DispatchQueue.main.async { [block, fill] in
try? block(fill)
}
return image
}
options.imageModifier = modifier
return self
}
}
public struct ImageFill { public struct ImageFill {
let filling: Bool? let filling: Bool?
@@ -432,8 +285,7 @@ public struct ImageFill {
struct ImageCarousel_Previews: PreviewProvider { struct ImageCarousel_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!) let url: MediaUrl = .image(URL(string: "https://jb55.com/red-me.jpg")!)
let test_video_url: MediaUrl = .video(URL(string: "http://cdn.jb55.com/s/zaps-build.mp4")!) ImageCarousel(state: test_damus_state, evid: test_note.id, urls: [url, url])
ImageCarousel<AnyView>(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url])
.environmentObject(OrientationTracker())
} }
} }
+9 -4
View File
@@ -94,8 +94,8 @@ enum OpenWalletError: Error {
} }
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws { func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) { if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) {
this_app.open(url) UIApplication.shared.open(url)
} else { } else {
guard let store_link = wallet.appStoreLink else { guard let store_link = wallet.appStoreLink else {
throw OpenWalletError.no_wallet_to_open throw OpenWalletError.no_wallet_to_open
@@ -105,11 +105,11 @@ func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
throw OpenWalletError.store_link_invalid throw OpenWalletError.store_link_invalid
} }
guard this_app.canOpenURL(url) else { guard UIApplication.shared.canOpenURL(url) else {
throw OpenWalletError.system_cannot_open_store_link throw OpenWalletError.system_cannot_open_store_link
} }
this_app.open(url) UIApplication.shared.open(url)
} }
} }
@@ -122,3 +122,8 @@ struct InvoiceView_Previews: PreviewProvider {
.frame(width: 300, height: 200) .frame(width: 300, height: 200)
} }
} }
func present_sheet(_ sheet: Sheets) {
notify(.present_sheet(sheet))
}
+1 -1
View File
@@ -45,7 +45,7 @@ struct NIP05Badge: View {
} }
var username_matches_nip05: Bool { var username_matches_nip05: Bool {
guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value guard let name = profiles.lookup(id: pubkey).map({ p in p?.name }).value
else { else {
return false return false
} }
+2 -2
View File
@@ -70,11 +70,11 @@ struct NeutralButtonStyle_Previews: PreviewProvider {
.buttonStyle(NeutralButtonStyle()) .buttonStyle(NeutralButtonStyle())
.padding() .padding()
Button(String(stringLiteral: "Rounded Button"), action: {}) Button("Rounded Button", action: {})
.buttonStyle(NeutralButtonShape.rounded.style) .buttonStyle(NeutralButtonShape.rounded.style)
.padding() .padding()
Button(String(stringLiteral: "Capsule Button"), action: {}) Button("Capsule Button", action: {})
.buttonStyle(NeutralButtonShape.capsule.style) .buttonStyle(NeutralButtonShape.capsule.style)
.padding() .padding()
@@ -83,9 +83,9 @@ struct SingleCharacterAvatar: View {
var body: some View { var body: some View {
NonImageAvatar { NonImageAvatar {
Text(character) Text(verbatim: character)
.font(.largeTitle.bold()) .font(.largeTitle.bold())
.mask(Text(character) .mask(Text(verbatim: character)
.font(.largeTitle.bold())) .font(.largeTitle.bold()))
} }
} }
+10 -127
View File
@@ -9,19 +9,16 @@ import UIKit
import SwiftUI import SwiftUI
struct SelectableText: View { struct SelectableText: View {
let damus_state: DamusState
let event: NostrEvent?
let attributedString: AttributedString let attributedString: AttributedString
let textAlignment: NSTextAlignment let textAlignment: NSTextAlignment
@State private var selectedTextActionState: SelectedTextActionState = .hide
@State private var selectedTextHeight: CGFloat = .zero @State private var selectedTextHeight: CGFloat = .zero
@State private var selectedTextWidth: CGFloat = .zero @State private var selectedTextWidth: CGFloat = .zero
let size: EventViewKind let size: EventViewKind
init(damus_state: DamusState, event: NostrEvent?, attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) { init(attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
self.damus_state = damus_state
self.event = event
self.attributedString = attributedString self.attributedString = attributedString
self.textAlignment = textAlignment ?? NSTextAlignment.natural self.textAlignment = textAlignment ?? NSTextAlignment.natural
self.size = size self.size = size
@@ -35,13 +32,6 @@ struct SelectableText: View {
font: eventviewsize_to_uifont(size), font: eventviewsize_to_uifont(size),
fixedWidth: selectedTextWidth, fixedWidth: selectedTextWidth,
textAlignment: self.textAlignment, textAlignment: self.textAlignment,
enableHighlighting: self.enableHighlighting(),
postHighlight: { selectedText in
self.selectedTextActionState = .show_highlight_post_view(highlighted_text: selectedText)
},
muteWord: { selectedText in
self.selectedTextActionState = .show_mute_word_view(highlighted_text: selectedText)
},
height: $selectedTextHeight height: $selectedTextHeight
) )
.padding([.leading, .trailing], -1.0) .padding([.leading, .trailing], -1.0)
@@ -56,123 +46,22 @@ struct SelectableText: View {
self.selectedTextWidth = newSize.width self.selectedTextWidth = newSize.width
} }
} }
.sheet(isPresented: Binding(get: {
return self.selectedTextActionState.should_show_highlight_post_view()
}, set: { newValue in
self.selectedTextActionState = newValue ? .show_highlight_post_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
})) {
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
PostView(
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
damus_state: damus_state
)
.presentationDragIndicator(.visible)
.presentationDetents([.height(selectedTextHeight + 450), .medium, .large])
}
}
.sheet(isPresented: Binding(get: {
return self.selectedTextActionState.should_show_mute_word_view()
}, set: { newValue in
self.selectedTextActionState = newValue ? .show_mute_word_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
})) {
if case .show_mute_word_view(let highlighted_text) = selectedTextActionState {
AddMuteItemView(state: damus_state, new_text: .constant(highlighted_text))
.presentationDragIndicator(.visible)
.presentationDetents([.height(300), .medium, .large])
}
}
.frame(height: selectedTextHeight) .frame(height: selectedTextHeight)
} }
func enableHighlighting() -> Bool {
self.event != nil
}
enum SelectedTextActionState {
case hide
case show_highlight_post_view(highlighted_text: String)
case show_mute_word_view(highlighted_text: String)
func should_show_highlight_post_view() -> Bool {
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
return true
}
func should_show_mute_word_view() -> Bool {
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
return true
}
func highlighted_text() -> String? {
switch self {
case .hide:
return nil
case .show_mute_word_view(highlighted_text: let highlighted_text):
return highlighted_text
case .show_highlight_post_view(highlighted_text: let highlighted_text):
return highlighted_text
}
}
}
} }
fileprivate class TextView: UITextView { fileprivate struct TextViewRepresentable: UIViewRepresentable {
var postHighlight: (String) -> Void
var muteWord: (String) -> Void
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
self.postHighlight = postHighlight
self.muteWord = muteWord
super.init(frame: frame, textContainer: textContainer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(highlightText(_:)) {
return true
}
if action == #selector(muteText(_:)) {
return true
}
return super.canPerformAction(action, withSender: sender)
}
func getSelectedText() -> String? {
guard let selectedRange = self.selectedTextRange else { return nil }
return self.text(in: selectedRange)
}
@objc public func highlightText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.postHighlight(selectedText)
}
@objc public func muteText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return }
self.muteWord(selectedText)
}
}
fileprivate struct TextViewRepresentable: UIViewRepresentable {
let attributedString: AttributedString let attributedString: AttributedString
let textColor: UIColor let textColor: UIColor
let font: UIFont let font: UIFont
let fixedWidth: CGFloat let fixedWidth: CGFloat
let textAlignment: NSTextAlignment let textAlignment: NSTextAlignment
let enableHighlighting: Bool
let postHighlight: (String) -> Void
let muteWord: (String) -> Void
@Binding var height: CGFloat @Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView { func makeUIView(context: UIViewRepresentableContext<Self>) -> UITextView {
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord) let view = UITextView()
view.isEditable = false view.isEditable = false
view.dataDetectorTypes = .all view.dataDetectorTypes = .all
view.isSelectable = true view.isSelectable = true
@@ -182,16 +71,10 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
view.textContainerInset.left = 1.0 view.textContainerInset.left = 1.0
view.textContainerInset.right = 1.0 view.textContainerInset.right = 1.0
view.textAlignment = textAlignment view.textAlignment = textAlignment
let menuController = UIMenuController.shared
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
return view return view
} }
func updateUIView(_ uiView: TextView, context: UIViewRepresentableContext<Self>) { func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) {
let mutableAttributedString = createNSAttributedString() let mutableAttributedString = createNSAttributedString()
uiView.attributedText = mutableAttributedString uiView.attributedText = mutableAttributedString
uiView.textAlignment = self.textAlignment uiView.textAlignment = self.textAlignment
@@ -192,7 +192,7 @@ struct UserStatusSheet: View {
Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) { Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) {
ForEach(StatusDuration.allCases, id: \.self) { d in ForEach(StatusDuration.allCases, id: \.self) { d in
Text(d.description) Text(verbatim: d.description)
.tag(d) .tag(d)
} }
} }
+12 -60
View File
@@ -8,54 +8,23 @@
import SwiftUI import SwiftUI
struct SupporterBadge: View { struct SupporterBadge: View {
let percent: Int? let percent: Int
let purple_account: DamusPurple.Account?
let style: Style
let text_color: Color
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style, text_color: Color = .secondary) {
self.percent = percent
self.purple_account = purple_account
self.style = style
self.text_color = text_color
}
let size: CGFloat = 17 let size: CGFloat = 17
var body: some View { var body: some View {
HStack { if percent < 100 {
if let purple_account, purple_account.active == true { Image("star.fill")
HStack(spacing: 1) { .resizable()
Image("star.fill") .frame(width:size, height:size)
.resizable() .foregroundColor(support_level_color(percent))
.frame(width:size, height:size) } else {
.foregroundStyle(GoldGradient) Image("star.fill")
if self.style == .full { .resizable()
let date = format_date(date: purple_account.created_at, time_style: .none) .frame(width:size, height:size)
Text(date) .foregroundStyle(GoldGradient)
.foregroundStyle(text_color)
.font(.caption)
}
}
}
else if let percent, percent < 100 {
Image("star.fill")
.resizable()
.frame(width:size, height:size)
.foregroundColor(support_level_color(percent))
} else if let percent, percent == 100 {
Image("star.fill")
.resizable()
.frame(width:size, height:size)
.foregroundStyle(GoldGradient)
}
} }
} }
enum Style {
case full // Shows the entire badge with a purple subscriber number if present
case compact // Does not show purple subscriber number. Only shows the star (if applicable)
}
} }
func support_level_color(_ percent: Int) -> Color { func support_level_color(_ percent: Int) -> Color {
@@ -75,24 +44,13 @@ func support_level_color(_ percent: Int) -> Color {
struct SupporterBadge_Previews: PreviewProvider { struct SupporterBadge_Previews: PreviewProvider {
static func Level(_ p: Int) -> some View { static func Level(_ p: Int) -> some View {
HStack(alignment: .center) { HStack(alignment: .center) {
SupporterBadge(percent: p, style: .full) SupporterBadge(percent: p)
.frame(width: 50) .frame(width: 50)
Text(verbatim: p.formatted()) Text(verbatim: p.formatted())
.frame(width: 50) .frame(width: 50)
} }
} }
static func Purple(_ subscriber_number: Int) -> some View {
HStack(alignment: .center) {
SupporterBadge(
percent: nil,
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: subscriber_number, active: true),
style: .full
)
.frame(width: 100)
}
}
static var previews: some View { static var previews: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -108,12 +66,6 @@ struct SupporterBadge_Previews: PreviewProvider {
Level(80) Level(80)
Level(90) Level(90)
Level(100) Level(100)
Purple(1)
Purple(2)
Purple(3)
Purple(99)
Purple(100)
Purple(1971)
} }
} }
} }
+20 -83
View File
@@ -21,32 +21,23 @@ enum TranslateStatus: Equatable {
case not_needed case not_needed
} }
fileprivate let MIN_UNIQUE_CHARS = 2
struct TranslateView: View { struct TranslateView: View {
let damus_state: DamusState let damus_state: DamusState
let event: NostrEvent let event: NostrEvent
let size: EventViewKind let size: EventViewKind
@Binding var isAppleTranslationPopoverPresented: Bool
@ObservedObject var translations_model: TranslationModel @ObservedObject var translations_model: TranslationModel
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind, isAppleTranslationPopoverPresented: Binding<Bool>) { init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) {
self.damus_state = damus_state self.damus_state = damus_state
self.event = event self.event = event
self.size = size self.size = size
self._isAppleTranslationPopoverPresented = isAppleTranslationPopoverPresented
self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model) self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
} }
var TranslateButton: some View { var TranslateButton: some View {
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) { Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
if damus_state.settings.translation_service == .none { translate()
isAppleTranslationPopoverPresented = true
} else {
translate()
}
} }
.translate_button_style() .translate_button_style()
} }
@@ -58,9 +49,9 @@ struct TranslateView: View {
.foregroundColor(.gray) .foregroundColor(.gray)
.font(.footnote) .font(.footnote)
.padding([.top, .bottom], 10) .padding([.top, .bottom], 10)
if self.size == .selected { if self.size == .selected {
SelectableText(damus_state: damus_state, event: event, attributedString: artifacts.content.attributed, size: self.size) SelectableText(attributedString: artifacts.content.attributed, size: self.size)
} else { } else {
artifacts.content.text artifacts.content.text
.font(eventviewsize_to_font(self.size, font_size: font_size)) .font(eventviewsize_to_font(self.size, font_size: font_size))
@@ -79,27 +70,27 @@ struct TranslateView: View {
} }
} }
} }
func attempt_translation() {
guard should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: self.translations_model.note_language), damus_state.settings.auto_translate else {
return
}
translate()
}
func should_transl(_ note_lang: String) -> Bool { func should_transl(_ note_lang: String) -> Bool {
guard should_translate(event: event, our_keypair: damus_state.keypair, note_lang: note_lang) else { should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: note_lang)
return false
}
if TranslationService.isAppleTranslationPopoverSupported {
return damus_state.settings.translation_service == .none || damus_state.settings.can_translate
} else {
return damus_state.settings.can_translate
}
} }
var body: some View { var body: some View {
Group { Group {
switch self.translations_model.state { switch self.translations_model.state {
case .havent_tried: case .havent_tried:
if damus_state.settings.auto_translate && damus_state.settings.translation_service != .none { if damus_state.settings.auto_translate {
Text("") Text("")
} else if let note_lang = translations_model.note_language, should_transl(note_lang) { } else if let note_lang = translations_model.note_language, should_transl(note_lang) {
TranslateButton TranslateButton
} else { } else {
Text("") Text("")
} }
@@ -112,10 +103,9 @@ struct TranslateView: View {
Text("") Text("")
} }
} }
} .task {
attempt_translation()
func translationMeetsStringDistanceRequirements(original: String, translated: String) -> Bool { }
return levenshteinDistanceIsGreaterThanOrEqualTo(from: original, to: translated, threshold: MIN_UNIQUE_CHARS)
} }
} }
@@ -129,11 +119,9 @@ extension View {
} }
struct TranslateView_Previews: PreviewProvider { struct TranslateView_Previews: PreviewProvider {
@State static var isAppleTranslationPopoverPresented: Bool = false
static var previews: some View { static var previews: some View {
let ds = test_damus_state let ds = test_damus_state
TranslateView(damus_state: ds, event: test_note, size: .normal, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented) TranslateView(damus_state: ds, event: test_note, size: .normal)
} }
} }
@@ -153,10 +141,6 @@ func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, set
// if its the same, give up and don't retry // if its the same, give up and don't retry
return .not_needed return .not_needed
} }
guard translationMeetsStringDistanceRequirements(original: originalContent, translated: translated_note) else {
return .not_needed
}
// Render translated note // Render translated note
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags)) let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
@@ -174,50 +158,3 @@ func current_language() -> String {
} }
} }
func levenshteinDistanceIsGreaterThanOrEqualTo(from source: String, to target: String, threshold: Int) -> Bool {
let sourceCount = source.count
let targetCount = target.count
// Early return if the difference in lengths is already greater than or equal to the threshold,
// indicating the edit distance meets the condition without further calculation.
if abs(sourceCount - targetCount) >= threshold {
return true
}
var matrix = [[Int]](repeating: [Int](repeating: 0, count: targetCount + 1), count: sourceCount + 1)
for i in 0...sourceCount {
matrix[i][0] = i
}
for j in 0...targetCount {
matrix[0][j] = j
}
for i in 1...sourceCount {
var rowMin = Int.max
for j in 1...targetCount {
let sourceIndex = source.index(source.startIndex, offsetBy: i - 1)
let targetIndex = target.index(target.startIndex, offsetBy: j - 1)
let cost = source[sourceIndex] == target[targetIndex] ? 0 : 1
matrix[i][j] = min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + cost // Substitution
)
rowMin = min(rowMin, matrix[i][j])
}
// If the minimum edit distance found in any row is already greater than or equal to the threshold,
// you can conclude the edit distance meets the criteria.
if rowMin >= threshold {
return true
}
}
return matrix[sourceCount][targetCount] >= threshold
}
func translationMeetsStringDistanceRequirements(original: String, translated: String) -> Bool {
return levenshteinDistanceIsGreaterThanOrEqualTo(from: original, to: translated, threshold: MIN_UNIQUE_CHARS)
}
+5 -14
View File
@@ -9,14 +9,7 @@ import SwiftUI
struct TruncatedText: View { struct TruncatedText: View {
let text: CompatibleText let text: CompatibleText
let maxChars: Int let maxChars: Int = 280
let show_show_more_button: Bool
init(text: CompatibleText, maxChars: Int = 280, show_show_more_button: Bool) {
self.text = text
self.maxChars = maxChars
self.show_show_more_button = show_show_more_button
}
var body: some View { var body: some View {
let truncatedAttributedString: AttributedString? = text.attributed.truncateOrNil(maxLength: maxChars) let truncatedAttributedString: AttributedString? = text.attributed.truncateOrNil(maxLength: maxChars)
@@ -31,10 +24,8 @@ struct TruncatedText: View {
if truncatedAttributedString != nil { if truncatedAttributedString != nil {
Spacer() Spacer()
if self.show_show_more_button { Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { } .allowsHitTesting(false)
.allowsHitTesting(false)
}
} }
} }
} }
@@ -42,10 +33,10 @@ struct TruncatedText: View {
struct TruncatedText_Previews: PreviewProvider { struct TruncatedText_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
VStack(spacing: 100) { VStack(spacing: 100) {
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"), show_show_more_button: true) TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"))
.frame(width: 200, height: 200) .frame(width: 200, height: 200)
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"), show_show_more_button: true) TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"))
.frame(width: 200, height: 200) .frame(width: 200, height: 200)
} }
} }
+51 -42
View File
@@ -12,7 +12,7 @@ enum NoteContent {
case content(String, TagsSequence?) case content(String, TagsSequence?)
init(note: NostrEvent, keypair: Keypair) { init(note: NostrEvent, keypair: Keypair) {
if note.known_kind == .dm || note.known_kind == .highlight { if note.known_kind == .dm {
self = .content(note.get_content(keypair), note.tags) self = .content(note.get_content(keypair), note.tags)
} else { } else {
self = .note(note) self = .note(note)
@@ -59,57 +59,66 @@ func parse_note_content(content: NoteContent) -> Blocks {
} }
} }
func interpret_event_refs(tags: TagsSequence) -> ThreadReply? { func interpret_event_refs_ndb(blocks: [Block], tags: TagsSequence) -> [EventRef] {
// migration is long over, lets just do this to fix tests
return interpret_event_refs_ndb(tags: tags)
}
func interpret_event_refs_ndb(tags: TagsSequence) -> ThreadReply? {
if tags.count == 0 { if tags.count == 0 {
return nil return []
} }
/// build a set of indices for each event mention
let mention_indices = build_mention_indices(blocks, type: .e)
return interp_event_refs_without_mentions_ndb(References<NoteRef>(tags: tags)) /// simpler case with no mentions
if mention_indices.count == 0 {
return interp_event_refs_without_mentions_ndb(tags.note.referenced_noterefs)
}
return interp_event_refs_with_mentions_ndb(tags: tags, mention_indices: mention_indices)
} }
func interp_event_refs_without_mentions_ndb(_ ev_tags: References<NoteRef>) -> ThreadReply? { func interp_event_refs_without_mentions_ndb(_ ev_tags: References<NoteRef>) -> [EventRef] {
var count = 0
var evrefs: [EventRef] = []
var first: Bool = true var first: Bool = true
var root_id: NoteRef? = nil var first_ref: NoteRef? = nil
var reply_id: NoteRef? = nil
var mention: NoteRef? = nil
var any_marker: Bool = false
for ref in ev_tags { for ref in ev_tags {
if let marker = ref.marker { if first {
any_marker = true first_ref = ref
switch marker { evrefs.append(.thread_id(ref))
case .root: root_id = ref first = false
case .reply: reply_id = ref } else {
case .mention: mention = ref
} evrefs.append(.reply(ref))
// deprecated form, only activate if we don't have any markers set }
} else if !any_marker { count += 1
if first { }
root_id = ref
first = false if let first_ref, count == 1 {
let r = first_ref
return [.reply_to_root(r)]
}
return evrefs
}
func interp_event_refs_with_mentions_ndb(tags: TagsSequence, mention_indices: Set<Int>) -> [EventRef] {
var mentions: [EventRef] = []
var ev_refs: [NoteRef] = []
var i: Int = 0
for tag in tags {
if let note_id = NoteRef.from_tag(tag: tag) {
if mention_indices.contains(i) {
mentions.append(.mention(.noteref(note_id, index: i)))
} else { } else {
reply_id = ref ev_refs.append(note_id)
} }
} }
i += 1
} }
// If either reply or root_id is blank while the other is not, then this is var replies = interp_event_refs_without_mentions(ev_refs)
// considered reply-to-root. We should always have a root and reply tag, if they replies.append(contentsOf: mentions)
// are equal this is reply-to-root return replies
if reply_id == nil && root_id != nil {
reply_id = root_id
} else if root_id == nil && reply_id != nil {
root_id = reply_id
}
guard let reply_id, let root_id else {
return nil
}
return ThreadReply(root: root_id, reply: reply_id, mention: mention.map { m in .noteref(m) })
} }
+174 -310
View File
@@ -8,7 +8,6 @@
import SwiftUI import SwiftUI
import AVKit import AVKit
import MediaPlayer import MediaPlayer
import EmojiPicker
struct ZapSheet { struct ZapSheet {
let target: ZapTarget let target: ZapTarget
@@ -29,8 +28,6 @@ enum Sheets: Identifiable {
case filter case filter
case user_status case user_status
case onboardingSuggestions case onboardingSuggestions
case purple(DamusPurpleURL)
case purple_onboarding
static func zap(target: ZapTarget, lnurl: String) -> Sheets { static func zap(target: ZapTarget, lnurl: String) -> Sheets {
return .zap(ZapSheet(target: target, lnurl: lnurl)) return .zap(ZapSheet(target: target, lnurl: lnurl))
@@ -51,53 +48,10 @@ enum Sheets: Identifiable {
case .select_wallet: return "select-wallet" case .select_wallet: return "select-wallet"
case .filter: return "filter" case .filter: return "filter"
case .onboardingSuggestions: return "onboarding-suggestions" case .onboardingSuggestions: return "onboarding-suggestions"
case .purple(let purple_url): return "purple" + purple_url.url_string()
case .purple_onboarding: return "purple_onboarding"
} }
} }
} }
/// An item to be presented full screen in a mechanism that is more robust for timeline views.
///
/// ## Implementation notes
///
/// This is part of the `present(full_screen_item: FullScreenItem)` interface that allows views in a timeline to show something full-screen without the lazy stack issues
/// Full screen cover modifiers are not suitable in those cases because device orientation changes or programmatic scroll commands will cause the view to be unloaded along with the cover,
/// causing the user to lose the full screen view randomly.
///
/// The `ContentView` is responsible for handling these objects
///
/// New items can be added as needed.
///
enum FullScreenItem: Identifiable, Equatable {
/// A full screen media carousel for images and videos.
case full_screen_carousel(urls: [MediaUrl], selectedIndex: Binding<Int>)
var id: String {
switch self {
case .full_screen_carousel(let urls, _): return "full_screen_carousel:\(urls.map(\.url))"
}
}
static func == (lhs: FullScreenItem, rhs: FullScreenItem) -> Bool {
return lhs.id == rhs.id
}
/// The view to display the item
func view(damus_state: DamusState) -> some View {
switch self {
case .full_screen_carousel(let urls, let selectedIndex):
return FullScreenCarouselView<AnyView>(video_coordinator: damus_state.video, urls: urls, settings: damus_state.settings, selectedIndex: selectedIndex)
}
}
}
func present_sheet(_ sheet: Sheets) {
notify(.present_sheet(sheet))
}
var tabHeight: CGFloat = 0.0
struct ContentView: View { struct ContentView: View {
let keypair: Keypair let keypair: Keypair
let appDelegate: AppDelegate? let appDelegate: AppDelegate?
@@ -113,29 +67,78 @@ struct ContentView: View {
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State var active_sheet: Sheets? = nil @State var active_sheet: Sheets? = nil
@State var active_full_screen_item: FullScreenItem? = nil
@State var damus_state: DamusState! @State var damus_state: DamusState!
@State var menu_subtitle: String? = nil @SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home { @State var muting: Pubkey? = nil
willSet {
self.menu_subtitle = nil
}
}
@State var muting: MuteItem? = nil
@State var confirm_mute: Bool = false @State var confirm_mute: Bool = false
@State var hide_bar: Bool = false @State var hide_bar: Bool = false
@State var user_muted_confirm: Bool = false @State var user_muted_confirm: Bool = false
@State var confirm_overwrite_mutelist: Bool = false @State var confirm_overwrite_mutelist: Bool = false
@SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
@State private var isSideBarOpened = false @State private var isSideBarOpened = false
@State var headerOffset: CGFloat = 0.0
var home: HomeModel = HomeModel() var home: HomeModel = HomeModel()
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator() @StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false @AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
let sub_id = UUID().description let sub_id = UUID().description
@Environment(\.colorScheme) var colorScheme
// connect retry timer // connect retry timer
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var mystery: some View {
Text("Are you lost?", comment: "Text asking the user if they are lost in the app.")
.id("what")
}
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state!)
filters.append(fstate.filter)
return ContentFilters(filters: filters).filter
}
var PostingTimelineView: some View {
VStack {
ZStack {
TabView(selection: $filter_state) {
// This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why.
mystery
contentTimelineView(filter: content_filter(.posts))
.tag(FilterState.posts)
.id(FilterState.posts)
contentTimelineView(filter: content_filter(.posts_and_replies))
.tag(FilterState.posts_and_replies)
.id(FilterState.posts_and_replies)
}
.tabViewStyle(.page(indexDisplayMode: .never))
if privkey != nil {
PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) {
self.active_sheet = .post(.posting(.none))
}
}
}
}
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
CustomPicker(selection: $filter_state, content: {
Text("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies).").tag(FilterState.posts)
Text("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes).").tag(FilterState.posts_and_replies)
})
Divider()
.frame(height: 1)
}
.background(colorScheme == .dark ? Color.black : Color.white)
}
}
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
TimelineView(events: home.events, loading: .constant(false), damus: damus_state, show_friend_icon: false, filter: filter) {
PullDownSearchView(state: damus_state, on_cancel: {})
}
}
func navIsAtRoot() -> Bool { func navIsAtRoot() -> Bool {
return navigationCoordinator.isAtRoot() return navigationCoordinator.isAtRoot()
} }
@@ -145,16 +148,9 @@ struct ContentView: View {
isSideBarOpened = false isSideBarOpened = false
} }
var timelineNavItem: some View { var timelineNavItem: Text {
VStack { return Text(timeline_name(selected_timeline))
Text(timeline_name(selected_timeline)) .bold()
.bold()
if let menu_subtitle {
Text(menu_subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
} }
func MainContent(damus: DamusState) -> some View { func MainContent(damus: DamusState) -> some View {
@@ -170,25 +166,34 @@ struct ContentView: View {
} }
case .home: case .home:
PostingTimelineView(damus_state: damus_state!, home: home, isSideBarOpened: $isSideBarOpened, active_sheet: $active_sheet, headerOffset: $headerOffset) PostingTimelineView
case .notifications: case .notifications:
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle) NotificationsView(state: damus, notifications: home.notifications)
case .dms: case .dms:
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings) DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
} }
} }
.background(DamusColors.adaptableWhite)
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
.navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline) .navigationBarTitle(timeline_name(selected_timeline), displayMode: .inline)
.toolbar(selected_timeline != .home ? .visible : .hidden)
.toolbar { .toolbar {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
VStack { VStack {
timelineNavItem if selected_timeline == .home {
.opacity(isSideBarOpened ? 0 : 1) Image("damus-home")
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened) .resizable()
.frame(width:30,height:30)
.shadow(color: DamusColors.purple, radius: 2)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
.onTapGesture {
isSideBarOpened.toggle()
}
} else {
timelineNavItem
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
}
} }
} }
} }
@@ -239,7 +244,14 @@ struct ContentView: View {
MainContent(damus: damus) MainContent(damus: damus)
.toolbar() { .toolbar() {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
TopbarSideMenuButton(damus_state: damus, isSideBarOpened: $isSideBarOpened) Button {
isSideBarOpened.toggle()
} label: {
ProfilePicView(pubkey: damus_state!.pubkey, size: 32, highlight: .none, profiles: damus_state!.profiles, disable_animation: damus_state!.settings.disable_animation)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
}
.disabled(isSideBarOpened)
} }
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@@ -260,11 +272,9 @@ struct ContentView: View {
} }
} }
} }
.background(DamusColors.adaptableWhite)
.edgesIgnoringSafeArea(selected_timeline != .home ? [] : [.top, .bottom])
.tabViewStyle(.page(indexDisplayMode: .never)) .tabViewStyle(.page(indexDisplayMode: .never))
.overlay( .overlay(
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation(), selected: $selected_timeline) SideMenuView(damus_state: damus, isSidebarVisible: $isSideBarOpened.animation())
) )
.navigationDestination(for: Route.self) { route in .navigationDestination(for: Route.self) { route in
route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!) route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
@@ -274,28 +284,13 @@ struct ContentView: View {
} }
} }
.navigationViewStyle(.stack) .navigationViewStyle(.stack)
.damus_full_screen_cover($active_full_screen_item, damus_state: damus, content: { item in
return item.view(damus_state: damus) if !hide_bar {
}) TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
.overlay(alignment: .bottom) { .padding([.bottom], 8)
if !hide_bar { .background(Color(uiColor: .systemBackground).ignoresSafeArea())
if !isSideBarOpened { } else {
TabBar(nstatus: home.notification_status, navIsAtRoot: navIsAtRoot(), selected: $selected_timeline, headerOffset: $headerOffset, settings: damus.settings, action: switch_timeline) Text("")
.padding([.bottom], 8)
.background(selected_timeline != .home || (selected_timeline == .home && !self.navIsAtRoot()) ? DamusColors.adaptableWhite : DamusColors.adaptableWhite.opacity(abs(1.25 - (abs(headerOffset/100.0)))))
.anchorPreference(key: HeaderBoundsKey.self, value: .bounds){$0}
.overlayPreferenceValue(HeaderBoundsKey.self) { value in
GeometryReader{ proxy in
if let anchor = value{
Color.clear
.onAppear {
tabHeight = proxy[anchor].height
}
}
}
}
}
}
} }
} }
} }
@@ -309,7 +304,7 @@ struct ContentView: View {
active_sheet = .onboardingSuggestions active_sheet = .onboardingSuggestions
hasSeenOnboardingSuggestions = true hasSeenOnboardingSuggestions = true
} }
self.appDelegate?.state = damus_state self.appDelegate?.settings = damus_state?.settings
} }
.sheet(item: $active_sheet) { item in .sheet(item: $active_sheet) { item in
switch item { switch item {
@@ -335,10 +330,6 @@ struct ContentView: View {
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
case .onboardingSuggestions: case .onboardingSuggestions:
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!)) OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
case .purple(let purple_url):
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
case .purple_onboarding:
DamusPurpleNewUserOnboardingView(damus_state: damus_state)
} }
} }
.onOpenURL { url in .onOpenURL { url in
@@ -348,26 +339,11 @@ struct ContentView: View {
} }
switch res { switch res {
case .filter(let filt): self.open_search(filt: filt) case .filter(let filt): self.open_search(filt: filt)
case .profile(let pk): self.open_profile(pubkey: pk) case .profile(let pk): self.open_profile(pubkey: pk)
case .event(let ev): self.open_event(ev: ev) case .event(let ev): self.open_event(ev: ev)
case .wallet_connect(let nwc): self.open_wallet(nwc: nwc) case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)
case .script(let data): self.open_script(data) case .script(let data): self.open_script(data)
case .purple(let purple_url):
if case let .welcome(checkout_id) = purple_url.variant {
// If this is a welcome link, do the following before showing the onboarding screen:
// 1. Check if this is legitimate and good to go.
// 2. Mark as complete if this is good to go.
Task {
let is_good_to_go = try? await damus_state.purple.check_and_mark_ln_checkout_is_good_to_go(checkout_id: checkout_id)
if is_good_to_go == true {
self.active_sheet = .purple(purple_url)
}
}
}
else {
self.active_sheet = .purple(purple_url)
}
} }
} }
} }
@@ -385,8 +361,8 @@ struct ContentView: View {
.onReceive(handle_notify(.report)) { target in .onReceive(handle_notify(.report)) { target in
self.active_sheet = .report(target) self.active_sheet = .report(target)
} }
.onReceive(handle_notify(.mute)) { mute_item in .onReceive(handle_notify(.mute)) { pubkey in
self.muting = mute_item self.muting = pubkey
self.confirm_mute = true self.confirm_mute = true
} }
.onReceive(handle_notify(.attached_wallet)) { nwc in .onReceive(handle_notify(.attached_wallet)) { nwc in
@@ -394,9 +370,14 @@ struct ContentView: View {
// wallet with an associated // wallet with an associated
guard let ds = self.damus_state, guard let ds = self.damus_state,
let lud16 = nwc.lud16, let lud16 = nwc.lud16,
let keypair = ds.keypair.to_full(), let keypair = ds.keypair.to_full()
let profile_txn = ds.profiles.lookup(id: ds.pubkey), else {
let profile = profile_txn.unsafeUnownedValue, return
}
let profile_txn = ds.profiles.lookup(id: ds.pubkey)
guard let profile = profile_txn.unsafeUnownedValue,
lud16 != profile.lud16 else { lud16 != profile.lud16 else {
return return
} }
@@ -453,9 +434,6 @@ struct ContentView: View {
.onReceive(handle_notify(.present_sheet)) { sheet in .onReceive(handle_notify(.present_sheet)) { sheet in
self.active_sheet = sheet self.active_sheet = sheet
} }
.onReceive(handle_notify(.present_full_screen_item)) { item in
self.active_full_screen_item = item
}
.onReceive(handle_notify(.zapping)) { zap_ev in .onReceive(handle_notify(.zapping)) { zap_ev in
guard !zap_ev.is_custom else { guard !zap_ev.is_custom else {
return return
@@ -483,51 +461,18 @@ struct ContentView: View {
.onReceive(handle_notify(.disconnect_relays)) { () in .onReceive(handle_notify(.disconnect_relays)) { () in
damus_state.pool.disconnect() damus_state.pool.disconnect()
} }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in
print("txn: 📙 DAMUS ACTIVE NOTIFY")
if damus_state.ndb.reopen() {
print("txn: NOSTRDB REOPENED")
} else {
print("txn: NOSTRDB FAILED TO REOPEN closed:\(damus_state.ndb.is_closed)")
}
if damus_state.purple.checkout_ids_in_progress.count > 0 {
// For extra assurance, run this after one second, to avoid race conditions if the app is also handling a damus purple welcome url.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
Task {
let freshly_completed_checkout_ids = try? await damus_state.purple.check_status_of_checkouts_in_progress()
let there_is_a_completed_checkout: Bool = (freshly_completed_checkout_ids?.count ?? 0) > 0
let account_info = try await damus_state.purple.fetch_account(pubkey: self.keypair.pubkey)
if there_is_a_completed_checkout == true && account_info?.active == true {
if damus_state.purple.onboarding_status.user_has_never_seen_the_onboarding_before() {
// Show welcome sheet
self.active_sheet = .purple_onboarding
}
else {
self.active_sheet = .purple(DamusPurpleURL.init(is_staging: damus_state.purple.environment == .staging, variant: .landing))
}
}
}
}
}
Task {
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
}
}
.onChange(of: scenePhase) { (phase: ScenePhase) in .onChange(of: scenePhase) { (phase: ScenePhase) in
guard let damus_state else { return }
switch phase { switch phase {
case .background: case .background:
print("txn: 📙 DAMUS BACKGROUNDED") print("📙 DAMUS BACKGROUNDED")
Task { @MainActor in
damus_state.ndb.close()
}
break break
case .inactive: case .inactive:
print("txn: 📙 DAMUS INACTIVE") print("📙 DAMUS INACTIVE")
break break
case .active: case .active:
print("txn: 📙 DAMUS ACTIVE") print("📙 DAMUS ACTIVE")
damus_state.pool.ping() guard let ds = damus_state else { return }
ds.pool.ping()
@unknown default: @unknown default:
break break
} }
@@ -540,15 +485,21 @@ struct ContentView: View {
open_profile(pubkey: pubkey) open_profile(pubkey: pubkey)
case .note(let noteId): case .note(let noteId):
openEvent(noteId: noteId, notificationType: local.type) guard let target = damus_state.events.lookup(noteId) else {
case .nevent(let nevent): return
openEvent(noteId: nevent.noteid, notificationType: local.type) }
case .nprofile(let nprofile):
open_profile(pubkey: nprofile.author) switch local.type {
case .nrelay(_): case .dm:
break selected_timeline = .dms
case .naddr(let naddr): damus_state.dms.set_active_dm(target.pubkey)
break navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
case .like, .zap, .mention, .repost:
open_event(ev: target)
case .profile_zap:
// Handled separately above.
break
}
} }
@@ -556,9 +507,10 @@ struct ContentView: View {
.onReceive(handle_notify(.onlyzaps_mode)) { hide in .onReceive(handle_notify(.onlyzaps_mode)) { hide in
home.filter_events() home.filter_events()
guard let ds = damus_state, guard let ds = damus_state else { return }
let profile_txn = ds.profiles.lookup(id: ds.pubkey), let profile_txn = ds.profiles.lookup(id: ds.pubkey)
let profile = profile_txn.unsafeUnownedValue,
guard let profile = profile_txn.unsafeUnownedValue,
let keypair = ds.keypair.to_full() let keypair = ds.keypair.to_full()
else { else {
return return
@@ -574,10 +526,10 @@ struct ContentView: View {
user_muted_confirm = false user_muted_confirm = false
} }
}, message: { }, message: {
if case let .user(pubkey, _) = self.muting { if let pubkey = self.muting {
let profile_txn = damus_state!.profiles.lookup(id: pubkey) let name = damus_state!.profiles.lookup(id: pubkey).map { profile in
let profile = profile_txn?.unsafeUnownedValue Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50) }.value
Text("\(name) has been muted", comment: "Alert message that informs a user was muted.") Text("\(name) has been muted", comment: "Alert message that informs a user was muted.")
} else { } else {
Text("User has been muted", comment: "Alert message that informs a user was muted.") Text("User has been muted", comment: "Alert message that informs a user was muted.")
@@ -592,13 +544,13 @@ struct ContentView: View {
Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) { Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
guard let ds = damus_state, guard let ds = damus_state,
let keypair = ds.keypair.to_full(), let keypair = ds.keypair.to_full(),
let muting, let pubkey = muting,
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting) let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: .pubkey(pubkey))
else { else {
return return
} }
ds.mutelist_manager.set_mutelist(mutelist) damus_state?.contacts.set_mutelist(mutelist)
ds.postbox.send(mutelist) ds.postbox.send(mutelist)
confirm_overwrite_mutelist = false confirm_overwrite_mutelist = false
@@ -617,28 +569,28 @@ struct ContentView: View {
return return
} }
if ds.mutelist_manager.event == nil { if ds.contacts.mutelist == nil {
confirm_overwrite_mutelist = true confirm_overwrite_mutelist = true
} else { } else {
guard let keypair = ds.keypair.to_full(), guard let keypair = ds.keypair.to_full(),
let muting let pubkey = muting
else { else {
return return
} }
guard let ev = create_or_update_mutelist(keypair: keypair, mprev: ds.mutelist_manager.event, to_add: muting) else { guard let ev = create_or_update_mutelist(keypair: keypair, mprev: ds.contacts.mutelist, to_add: .pubkey(pubkey)) else {
return return
} }
ds.mutelist_manager.set_mutelist(ev) damus_state?.contacts.set_mutelist(ev)
ds.postbox.send(ev) ds.postbox.send(ev)
} }
} }
}, message: { }, message: {
if case let .user(pubkey, _) = muting { if let pubkey = muting {
let profile_txn = damus_state?.profiles.lookup(id: pubkey) let name = damus_state?.profiles.lookup(id: pubkey).map({ profile in
let profile = profile_txn?.unsafeUnownedValue Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50) }).value ?? "unknown"
Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.") Text("Mute \(name)?", comment: "Alert message prompt to ask if a user should be muted.")
} else { } else {
Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.") Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.")
@@ -671,7 +623,7 @@ struct ContentView: View {
// out of space or something?? maybe we need a in-memory fallback // out of space or something?? maybe we need a in-memory fallback
if mndb == nil { if mndb == nil {
logout(nil) notify(.logout)
return return
} }
} }
@@ -683,14 +635,19 @@ struct ContentView: View {
let relay_filters = RelayFilters(our_pubkey: pubkey) let relay_filters = RelayFilters(our_pubkey: pubkey)
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey) let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey) // dumb stuff needed for property wrappers
UserSettingsStore.pubkey = pubkey
let settings = UserSettingsStore()
UserSettingsStore.shared = settings
let new_relay_filters = load_relay_filters(pubkey) == nil let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays { for relay in bootstrap_relays {
let descriptor = RelayDescriptor(url: relay, info: .rw) if let url = RelayURL(relay) {
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) let descriptor = RelayDescriptor(url: url, 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) pool.register_handler(sub_id: sub_id, handler: home.handle_event)
if let nwc_str = settings.nostr_wallet_connect, if let nwc_str = settings.nostr_wallet_connect,
@@ -703,7 +660,6 @@ struct ContentView: View {
likes: EventCounter(our_pubkey: pubkey), likes: EventCounter(our_pubkey: pubkey),
boosts: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey),
contacts: Contacts(our_pubkey: pubkey), contacts: Contacts(our_pubkey: pubkey),
mutelist_manager: MutelistManager(user_keypair: keypair),
profiles: Profiles(ndb: ndb), profiles: Profiles(ndb: ndb),
dms: home.dms, dms: home.dms,
previews: PreviewCache(), previews: PreviewCache(),
@@ -718,23 +674,19 @@ struct ContentView: View {
postbox: PostBox(pool: pool), postbox: PostBox(pool: pool),
bootstrap_relays: bootstrap_relays, bootstrap_relays: bootstrap_relays,
replies: ReplyCounter(our_pubkey: pubkey), replies: ReplyCounter(our_pubkey: pubkey),
muted_threads: MutedThreadsManager(keypair: keypair),
wallet: WalletModel(settings: settings), wallet: WalletModel(settings: settings),
nav: self.navigationCoordinator, nav: self.navigationCoordinator,
music: MusicController(onChange: music_changed), music: MusicController(onChange: music_changed),
video: DamusVideoCoordinator(), video: VideoController(),
ndb: ndb, ndb: ndb
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
) )
home.damus_state = self.damus_state! home.damus_state = self.damus_state!
if let damus_state, damus_state.purple.enable_purple { if let damus_state, damus_state.settings.enable_experimental_purple_api {
// Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases // Assign delegate so that we can send receipts to the Purple API server as soon as we get updates from user's purchases
StoreObserver.standard.delegate = damus_state.purple StoreObserver.standard.delegate = damus_state.purple
Task {
await damus_state.purple.check_and_send_app_notifications_if_needed(handler: home.handle_damus_app_notification)
}
} }
else { else {
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts // Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
@@ -767,41 +719,6 @@ struct ContentView: View {
} }
} }
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
}
}
}
struct TopbarSideMenuButton: View {
let damus_state: DamusState
@Binding var isSideBarOpened: Bool
var body: some View {
Button {
isSideBarOpened.toggle()
} label: {
ProfilePicView(pubkey: damus_state.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
.opacity(isSideBarOpened ? 0 : 1)
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
.accessibilityHidden(true) // Knowing there is a profile picture here leads to no actionable outcome to VoiceOver users, so it is best not to show it
}
.accessibilityIdentifier(AppAccessibilityIdentifiers.main_side_menu_button.rawValue)
.accessibilityLabel(NSLocalizedString("Side menu", comment: "Accessibility label for the side menu button at the topbar"))
.disabled(isSideBarOpened)
}
} }
struct ContentView_Previews: PreviewProvider { struct ContentView_Previews: PreviewProvider {
@@ -856,12 +773,6 @@ func save_last_event(_ ev: NostrEvent, timeline: Timeline) {
UserDefaults.standard.set(String(ev.created_at), forKey: "last_\(str)_time") UserDefaults.standard.set(String(ev.created_at), forKey: "last_\(str)_time")
} }
func save_last_event(_ ev_id: NoteId, created_at: UInt32, timeline: Timeline) {
let str = timeline.rawValue
UserDefaults.standard.set(ev_id.hex(), forKey: "last_\(str)")
UserDefaults.standard.set(String(created_at), forKey: "last_\(str)_time")
}
func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [NostrFilter]) -> [NostrFilter] { func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [NostrFilter]) -> [NostrFilter] {
return filters.map { filter in return filters.map { filter in
@@ -897,7 +808,7 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos
func setup_notifications() { func setup_notifications() {
this_app.registerForRemoteNotifications() UIApplication.shared.registerForRemoteNotifications()
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in center.getNotificationSettings { settings in
@@ -913,13 +824,13 @@ func setup_notifications() {
struct FindEvent { struct FindEvent {
let type: FindEventType let type: FindEventType
let find_from: [RelayURL]? let find_from: [String]?
static func profile(pubkey: Pubkey, find_from: [RelayURL]? = nil) -> FindEvent { static func profile(pubkey: Pubkey, find_from: [String]? = nil) -> FindEvent {
return FindEvent(type: .profile(pubkey), find_from: find_from) return FindEvent(type: .profile(pubkey), find_from: find_from)
} }
static func event(evid: NoteId, find_from: [RelayURL]? = nil) -> FindEvent { static func event(evid: NoteId, find_from: [String]? = nil) -> FindEvent {
return FindEvent(type: .event(evid), find_from: find_from) return FindEvent(type: .event(evid), find_from: find_from)
} }
} }
@@ -947,8 +858,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
switch query { switch query {
case .profile(let pubkey): case .profile(let pubkey):
if let profile_txn = state.ndb.lookup_profile(pubkey), if let record = state.ndb.lookup_profile(pubkey).unsafeUnownedValue,
let record = profile_txn.unsafeUnownedValue,
record.profile != nil record.profile != nil
{ {
callback(.profile(pubkey)) callback(.profile(pubkey))
@@ -1014,34 +924,6 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
} }
} }
func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (NostrEvent?) -> ()) {
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
let subid = UUID().description
damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in
guard case .nostr_event(let ev) = res else {
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
return
}
if case .event(_, let ev) = ev {
for tag in ev.tags {
if(tag.count >= 2 && tag[0].string() == "d"){
if (tag[1].string() == naddr.identifier){
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
callback(ev)
return
}
}
}
}
damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id])
}
}
func timeline_name(_ timeline: Timeline?) -> String { func timeline_name(_ timeline: Timeline?) -> String {
guard let timeline else { guard let timeline else {
return "" return ""
@@ -1129,7 +1011,7 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
//let post = tup.0 //let post = tup.0
//let to_relays = tup.1 //let to_relays = tup.1
print("post \(post.content)") print("post \(post.content)")
guard let new_ev = post.to_event(keypair: keypair) else { guard let new_ev = post_to_event(post: post, keypair: keypair) else {
return false return false
} }
postbox.send(new_ev) postbox.send(new_ev)
@@ -1159,15 +1041,9 @@ enum OpenResult {
case event(NostrEvent) case event(NostrEvent)
case wallet_connect(WalletConnectURL) case wallet_connect(WalletConnectURL)
case script([UInt8]) case script([UInt8])
case purple(DamusPurpleURL)
} }
func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> Void) { func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> Void) {
if let purple_url = DamusPurpleURL(url: url) {
result(.purple(purple_url))
return
}
if let nwc = WalletConnectURL(str: url.absoluteString) { if let nwc = WalletConnectURL(str: url.absoluteString) {
result(.wallet_connect(nwc)) result(.wallet_connect(nwc))
return return
@@ -1189,15 +1065,10 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
result(.event(ev)) result(.event(ev))
} }
case .hashtag(let ht): case .hashtag(let ht):
result(.filter(.filter_hashtag([ht.hashtag]))) result(.filter(.filter_hashtag([ht.string()])))
case .param, .quote, .reference: case .param, .quote:
// doesn't really make sense here // doesn't really make sense here
break break
case .naddr(let naddr):
naddrLookup(damus_state: state, naddr: naddr) { res in
guard let res = res else { return }
result(.event(res))
}
} }
case .filter(let filt): case .filter(let filt):
result(.filter(filt)) result(.filter(filt))
@@ -1209,10 +1080,3 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
} }
} }
func logout(_ state: DamusState?)
{
state?.close()
notify(.logout)
}
+4
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDocumentTypes</key>
<array>
<dict/>
</array>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
+1 -11
View File
@@ -16,12 +16,10 @@ enum Zapped {
class ActionBarModel: ObservableObject { class ActionBarModel: ObservableObject {
@Published var our_like: NostrEvent? @Published var our_like: NostrEvent?
@Published var our_boost: NostrEvent? @Published var our_boost: NostrEvent?
@Published var our_quote_repost: NostrEvent?
@Published var our_reply: NostrEvent? @Published var our_reply: NostrEvent?
@Published var our_zap: Zapping? @Published var our_zap: Zapping?
@Published var likes: Int @Published var likes: Int
@Published var boosts: Int @Published var boosts: Int
@Published var quote_reposts: Int
@Published private(set) var zaps: Int @Published private(set) var zaps: Int
@Published var zap_total: Int64 @Published var zap_total: Int64
@Published var replies: Int @Published var replies: Int
@@ -30,7 +28,7 @@ class ActionBarModel: ObservableObject {
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil) return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
} }
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0) { init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil) {
self.likes = likes self.likes = likes
self.boosts = boosts self.boosts = boosts
self.zaps = zaps self.zaps = zaps
@@ -40,8 +38,6 @@ class ActionBarModel: ObservableObject {
self.our_boost = our_boost self.our_boost = our_boost
self.our_zap = our_zap self.our_zap = our_zap
self.our_reply = our_reply self.our_reply = our_reply
self.our_quote_repost = our_quote_repost
self.quote_reposts = quote_reposts
} }
func update(damus: DamusState, evid: NoteId) { func update(damus: DamusState, evid: NoteId) {
@@ -49,13 +45,11 @@ class ActionBarModel: ObservableObject {
self.boosts = damus.boosts.counts[evid] ?? 0 self.boosts = damus.boosts.counts[evid] ?? 0
self.zaps = damus.zaps.event_counts[evid] ?? 0 self.zaps = damus.zaps.event_counts[evid] ?? 0
self.replies = damus.replies.get_replies(evid) self.replies = damus.replies.get_replies(evid)
self.quote_reposts = damus.quote_reposts.counts[evid] ?? 0
self.zap_total = damus.zaps.event_totals[evid] ?? 0 self.zap_total = damus.zaps.event_totals[evid] ?? 0
self.our_like = damus.likes.our_events[evid] self.our_like = damus.likes.our_events[evid]
self.our_boost = damus.boosts.our_events[evid] self.our_boost = damus.boosts.our_events[evid]
self.our_zap = damus.zaps.our_zaps[evid]?.first self.our_zap = damus.zaps.our_zaps[evid]?.first
self.our_reply = damus.replies.our_reply(evid) self.our_reply = damus.replies.our_reply(evid)
self.our_quote_repost = damus.quote_reposts.our_events[evid]
self.objectWillChange.send() self.objectWillChange.send()
} }
@@ -74,8 +68,4 @@ class ActionBarModel: ObservableObject {
var boosted: Bool { var boosted: Bool {
return our_boost != nil return our_boost != nil
} }
var quoted: Bool {
return our_quote_repost != nil
}
} }
+1 -1
View File
@@ -151,7 +151,7 @@ public class CameraService: NSObject, Identifiable {
DispatchQueue.main.async { DispatchQueue.main.async {
self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: { self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: {
this_app.open(URL(string: UIApplication.openSettingsURLString)!, UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!,
options: [:], completionHandler: nil) options: [:], completionHandler: nil)
}, secondaryAction: nil) }, secondaryAction: nil)
-23
View File
@@ -1,23 +0,0 @@
//
// CommentItem.swift
// damus
//
// Created by Daniel DAquino on 2024-08-14.
//
import Foundation
struct CommentItem: TagConvertible {
static let TAG_KEY: String = "comment"
let content: String
var tag: [String] {
return [Self.TAG_KEY, content]
}
static func from_tag(tag: TagSequence) -> CommentItem? {
guard tag.count == 2 else { return nil }
guard tag[0].string() == Self.TAG_KEY else { return nil }
return CommentItem(content: tag[1].string())
}
}
-149
View File
@@ -1,149 +0,0 @@
//
// Contacts+.swift
// damus
//
// Extra functionality and utilities for `Contacts.swift`
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
guard let ev = follow_user_event(our_contacts: our_contacts, keypair: keypair, follow: follow) else {
return nil
}
box.send(ev)
return ev
}
func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
guard let cs = our_contacts else {
return nil
}
guard let ev = unfollow_reference_event(our_contacts: cs, keypair: keypair, unfollow: unfollow) else {
return nil
}
postbox.send(ev)
return ev
}
func unfollow_reference_event(our_contacts: NostrEvent, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
let tags = our_contacts.tags.reduce(into: [[String]]()) { ts, tag in
if let tag = FollowRef.from_tag(tag: tag), tag == unfollow {
return
}
ts.append(tag.strings())
}
let kind = NostrKind.contacts.rawValue
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: Array(tags))
}
func follow_user_event(our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
guard let cs = our_contacts else {
// don't create contacts for now so we don't nuke our contact list due to connectivity issues
// we should only create contacts during profile creation
//return create_contacts(relays: relays, our_pubkey: our_pubkey, follow: follow)
return nil
}
guard let ev = follow_with_existing_contacts(keypair: keypair, our_contacts: cs, follow: follow) else {
return nil
}
return ev
}
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) {
case let (.hashtag(ht), .hashtag(follow_ht)):
return ht.hashtag == follow_ht
case let (.pubkey(pk), .pubkey(follow_pk)):
return pk == follow_pk
case (.hashtag, .pubkey), (.pubkey, .hashtag),
(.event, _), (.quote, _), (.param, _), (.naddr, _), (.reference(_), _):
return false
}
}
}
func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEvent, follow: FollowRef) -> NostrEvent? {
// don't update if we're already following
if is_already_following(contacts: our_contacts, follow: follow) {
return nil
}
let kind = NostrKind.contacts.rawValue
var tags = our_contacts.tags.strings()
tags.append(follow.tag)
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)
}
+180 -11
View File
@@ -7,25 +7,57 @@
import Foundation import Foundation
class Contacts { class Contacts {
private var friends: Set<Pubkey> = Set() private var friends: Set<Pubkey> = Set()
private var friend_of_friends: Set<Pubkey> = Set() private var friend_of_friends: Set<Pubkey> = Set()
/// Tracks which friends are friends of a given pubkey. /// Tracks which friends are friends of a given pubkey.
private var pubkey_to_our_friends = [Pubkey : Set<Pubkey>]() private var pubkey_to_our_friends = [Pubkey : Set<Pubkey>]()
private var muted: Set<Pubkey> = Set()
let our_pubkey: Pubkey let our_pubkey: Pubkey
var delegate: ContactsDelegate? = nil var event: NostrEvent?
var event: NostrEvent? { var mutelist: NostrEvent?
didSet {
guard let event else { return }
self.delegate?.latest_contact_event_changed(new_event: event)
}
}
init(our_pubkey: Pubkey) { init(our_pubkey: Pubkey) {
self.our_pubkey = our_pubkey self.our_pubkey = our_pubkey
} }
func is_muted(_ pk: Pubkey) -> Bool {
return muted.contains(pk)
}
func set_mutelist(_ ev: NostrEvent) {
let oldlist = self.mutelist
self.mutelist = ev
let old = oldlist.map({ ev in Set(ev.referenced_pubkeys) }) ?? Set<Pubkey>()
let new = Set(ev.referenced_pubkeys)
let diff = old.symmetricDifference(new)
var new_mutes = Set<Pubkey>()
var new_unmutes = Set<Pubkey>()
for d in diff {
if new.contains(d) {
new_mutes.insert(d)
} else {
new_unmutes.insert(d)
}
}
// TODO: set local mutelist here
self.muted = Set(ev.referenced_pubkeys)
if new_mutes.count > 0 {
notify(.new_mutes(new_mutes))
}
if new_unmutes.count > 0 {
notify(.new_unmutes(new_unmutes))
}
}
func remove_friend(_ pubkey: Pubkey) { func remove_friend(_ pubkey: Pubkey) {
friends.remove(pubkey) friends.remove(pubkey)
@@ -94,7 +126,144 @@ class Contacts {
} }
} }
/// Delegate protocol for `Contacts`. Use this to listen to significant updates from a `Contacts` instance func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
protocol ContactsDelegate { guard let ev = follow_user_event(our_contacts: our_contacts, keypair: keypair, follow: follow) else {
func latest_contact_event_changed(new_event: NostrEvent) return nil
}
box.send(ev)
return ev
}
func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
guard let cs = our_contacts else {
return nil
}
guard let ev = unfollow_reference_event(our_contacts: cs, keypair: keypair, unfollow: unfollow) else {
return nil
}
postbox.send(ev)
return ev
}
func unfollow_reference_event(our_contacts: NostrEvent, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? {
let tags = our_contacts.tags.reduce(into: [[String]]()) { ts, tag in
if let tag = FollowRef.from_tag(tag: tag), tag == unfollow {
return
}
ts.append(tag.strings())
}
let kind = NostrKind.contacts.rawValue
return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: Array(tags))
}
func follow_user_event(our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? {
guard let cs = our_contacts else {
// don't create contacts for now so we don't nuke our contact list due to connectivity issues
// we should only create contacts during profile creation
//return create_contacts(relays: relays, our_pubkey: our_pubkey, follow: follow)
return nil
}
guard let ev = follow_with_existing_contacts(keypair: keypair, our_contacts: cs, follow: follow) else {
return nil
}
return ev
}
func decode_json_relays(_ content: String) -> [String: RelayInfo]? {
return decode_json(content)
}
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)
guard 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 make_relay_metadata(relays: [RelayDescriptor], keypair: FullKeypair) -> NostrEvent? {
let tags = relays.compactMap { r -> [String]? in
var tag = ["r", r.url.id]
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)
}
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) {
case let (.hashtag(ht), .hashtag(follow_ht)):
return ht.string() == follow_ht
case let (.pubkey(pk), .pubkey(follow_pk)):
return pk == follow_pk
case (.hashtag, .pubkey), (.pubkey, .hashtag),
(.event, _), (.quote, _), (.param, _):
return false
}
}
}
func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEvent, follow: FollowRef) -> NostrEvent? {
// don't update if we're already following
if is_already_following(contacts: our_contacts, follow: follow) {
return nil
}
let kind = NostrKind.contacts.rawValue
var tags = our_contacts.tags.strings()
tags.append(follow.tag)
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
}
} }
+3 -8
View File
@@ -16,7 +16,7 @@ enum FilterState : Int {
func filter(ev: NostrEvent) -> Bool { func filter(ev: NostrEvent) -> Bool {
switch self { switch self {
case .posts: case .posts:
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply() return ev.known_kind == .boost || !ev.is_reply(.empty)
case .posts_and_replies: case .posts_and_replies:
return true return true
} }
@@ -31,9 +31,8 @@ func nsfw_tag_filter(ev: NostrEvent) -> Bool {
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) { func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
return { ev in return { ev in
guard ev.known_kind == .boost else { return true } guard ev.known_kind == .boost else { return true }
// This needs to use cached because it can be way too slow otherwise guard let inner_ev = ev.get_inner_event(cache: damus_state.events) else { return true }
guard let inner_ev = ev.get_cached_inner_event(cache: damus_state.events) else { return true } return should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: inner_ev)
return should_show_event(state: damus_state, ev: inner_ev)
} }
} }
@@ -53,10 +52,6 @@ struct ContentFilters {
} }
extension ContentFilters { extension ContentFilters {
static func default_filters(damus_state: DamusState) -> ContentFilters {
return ContentFilters(filters: ContentFilters.defaults(damus_state: damus_state))
}
static func defaults(damus_state: DamusState) -> [(NostrEvent) -> Bool] { static func defaults(damus_state: DamusState) -> [(NostrEvent) -> Bool] {
var filters = Array<(NostrEvent) -> Bool>() var filters = Array<(NostrEvent) -> Bool>()
if damus_state.settings.hide_nsfw_tagged_content { if damus_state.settings.hide_nsfw_tagged_content {
+8 -8
View File
@@ -9,31 +9,31 @@ import Foundation
class CreateAccountModel: ObservableObject { class CreateAccountModel: ObservableObject {
@Published var display_name: String = "" @Published var real_name: String = ""
@Published var name: String = "" @Published var nick_name: String = ""
@Published var about: String = "" @Published var about: String = ""
@Published var pubkey: Pubkey = .empty @Published var pubkey: Pubkey = .empty
@Published var privkey: Privkey = .empty @Published var privkey: Privkey = .empty
@Published var profile_image: URL? = nil @Published var profile_image: URL? = nil
var rendered_name: String { var rendered_name: String {
if display_name.isEmpty { if real_name.isEmpty {
return name return nick_name
} }
return display_name return real_name
} }
var keypair: Keypair { var keypair: Keypair {
return Keypair(pubkey: self.pubkey, privkey: self.privkey) return Keypair(pubkey: self.pubkey, privkey: self.privkey)
} }
init(display_name: String = "", name: String = "", about: String = "") { init(real: String = "", nick: String = "", about: String = "") {
let keypair = generate_new_keypair() let keypair = generate_new_keypair()
self.pubkey = keypair.pubkey self.pubkey = keypair.pubkey
self.privkey = keypair.privkey self.privkey = keypair.privkey
self.display_name = display_name self.real_name = real
self.name = name self.nick_name = nick
self.about = about self.about = about
} }
} }
+12 -101
View File
@@ -7,16 +7,13 @@
import Foundation import Foundation
import LinkPresentation import LinkPresentation
import EmojiPicker
class DamusState: HeadlessDamusState { struct DamusState {
let pool: RelayPool let pool: RelayPool
let keypair: Keypair let keypair: Keypair
let likes: EventCounter let likes: EventCounter
let boosts: EventCounter let boosts: EventCounter
let quote_reposts: EventCounter
let contacts: Contacts let contacts: Contacts
let mutelist_manager: MutelistManager
let profiles: Profiles let profiles: Profiles
let dms: DirectMessagesModel let dms: DirectMessagesModel
let previews: PreviewCache let previews: PreviewCache
@@ -29,24 +26,22 @@ class DamusState: HeadlessDamusState {
let events: EventCache let events: EventCache
let bookmarks: BookmarksManager let bookmarks: BookmarksManager
let postbox: PostBox let postbox: PostBox
let bootstrap_relays: [RelayURL] let bootstrap_relays: [String]
let replies: ReplyCounter let replies: ReplyCounter
let muted_threads: MutedThreadsManager
let wallet: WalletModel let wallet: WalletModel
let nav: NavigationCoordinator let nav: NavigationCoordinator
let music: MusicController? let music: MusicController?
let video: DamusVideoCoordinator let video: VideoController
let ndb: Ndb let ndb: Ndb
var purple: DamusPurple var purple: DamusPurple
var push_notification_client: PushNotificationClient
let emoji_provider: EmojiProvider init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, 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: [String], replies: ReplyCounter, muted_threads: MutedThreadsManager, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: VideoController, ndb: Ndb, purple: DamusPurple? = nil) {
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.pool = pool
self.keypair = keypair self.keypair = keypair
self.likes = likes self.likes = likes
self.boosts = boosts self.boosts = boosts
self.contacts = contacts self.contacts = contacts
self.mutelist_manager = mutelist_manager
self.profiles = profiles self.profiles = profiles
self.dms = dms self.dms = dms
self.previews = previews self.previews = previews
@@ -61,91 +56,16 @@ class DamusState: HeadlessDamusState {
self.postbox = postbox self.postbox = postbox
self.bootstrap_relays = bootstrap_relays self.bootstrap_relays = bootstrap_relays
self.replies = replies self.replies = replies
self.muted_threads = muted_threads
self.wallet = wallet self.wallet = wallet
self.nav = nav self.nav = nav
self.music = music self.music = music
self.video = video self.video = video
self.ndb = ndb self.ndb = ndb
self.purple = purple ?? DamusPurple( self.purple = purple ?? DamusPurple(
settings: settings, environment: settings.purple_api_local_test_mode ? .local_test : .production,
keypair: keypair keypair: keypair
) )
self.quote_reposts = quote_reposts
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
self.emoji_provider = emoji_provider
}
@MainActor
convenience init?(keypair: Keypair) {
// nostrdb
var mndb = Ndb()
if mndb == nil {
// try recovery
print("DB ISSUE! RECOVERING")
mndb = Ndb.safemode()
// out of space or something?? maybe we need a in-memory fallback
if mndb == nil {
logout(nil)
return nil
}
}
let navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
let home: HomeModel = HomeModel()
let sub_id = UUID().uuidString
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),
contacts: Contacts(our_pubkey: pubkey),
mutelist_manager: MutelistManager(user_keypair: keypair),
profiles: Profiles(ndb: ndb),
dms: home.dms,
previews: PreviewCache(),
zaps: Zaps(our_pubkey: pubkey),
lnurls: LNUrls(),
settings: settings,
relay_filters: relay_filters,
relay_model_cache: model_cache,
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,
music: MusicController(onChange: { _ in }),
video: DamusVideoCoordinator(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
} }
@discardableResult @discardableResult
@@ -172,14 +92,7 @@ class DamusState: HeadlessDamusState {
var is_privkey_user: Bool { var is_privkey_user: Bool {
keypair.privkey != nil keypair.privkey != nil
} }
func close() {
print("txn: damus close")
wallet.disconnect()
pool.close()
ndb.close()
}
static var empty: DamusState { static var empty: DamusState {
let empty_pub: Pubkey = .empty let empty_pub: Pubkey = .empty
let empty_sec: Privkey = .empty let empty_sec: Privkey = .empty
@@ -191,7 +104,6 @@ class DamusState: HeadlessDamusState {
likes: EventCounter(our_pubkey: empty_pub), likes: EventCounter(our_pubkey: empty_pub),
boosts: EventCounter(our_pubkey: empty_pub), boosts: EventCounter(our_pubkey: empty_pub),
contacts: Contacts(our_pubkey: empty_pub), contacts: Contacts(our_pubkey: empty_pub),
mutelist_manager: MutelistManager(user_keypair: kp),
profiles: Profiles(ndb: .empty), profiles: Profiles(ndb: .empty),
dms: DirectMessagesModel(our_pubkey: empty_pub), dms: DirectMessagesModel(our_pubkey: empty_pub),
previews: PreviewCache(), previews: PreviewCache(),
@@ -206,13 +118,12 @@ class DamusState: HeadlessDamusState {
postbox: PostBox(pool: RelayPool(ndb: .empty)), postbox: PostBox(pool: RelayPool(ndb: .empty)),
bootstrap_relays: [], bootstrap_relays: [],
replies: ReplyCounter(our_pubkey: empty_pub), replies: ReplyCounter(our_pubkey: empty_pub),
muted_threads: MutedThreadsManager(keypair: kp),
wallet: WalletModel(settings: UserSettingsStore()), wallet: WalletModel(settings: UserSettingsStore()),
nav: NavigationCoordinator(), nav: NavigationCoordinator(),
music: nil, music: nil,
video: DamusVideoCoordinator(), video: VideoController(),
ndb: .empty, ndb: .empty
quote_reposts: .init(our_pubkey: empty_pub),
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
) )
} }
} }
-133
View File
@@ -1,133 +0,0 @@
//
// DamusUserDefaults.swift
// damus
//
// Created by Daniel DAquino on 2023-11-25.
//
import Foundation
/// # DamusUserDefaults
///
/// This struct acts like a UserDefaults object, but is also capable of automatically mirroring values to a separate store.
///
/// It works by using a specific store container as the main source of truth, and by optionally mirroring values to a different container if needed.
///
/// This is useful when the data of a UserDefaults object needs to be accessible from another store container,
/// as it offers ways to automatically mirror information over a different container (e.g. When using app extensions)
///
/// Since it mirrors items instead of migrating them, this object can be used in a backwards compatible manner.
///
/// The easiest way to use this is to use `DamusUserDefaults.standard` as a drop-in replacement for `UserDefaults.standard`
/// Or, you can initialize a custom object with customizable stores.
struct DamusUserDefaults {
// MARK: - Helper data structures
enum Store: Equatable {
case standard
case shared
case custom(UserDefaults)
func get_user_defaults() -> UserDefaults? {
switch self {
case .standard:
return UserDefaults.standard
case .shared:
return UserDefaults(suiteName: Constants.DAMUS_APP_GROUP_IDENTIFIER)
case .custom(let user_defaults):
return user_defaults
}
}
}
enum DamusUserDefaultsError: Error {
case cannot_initialize_user_defaults
case cannot_mirror_main_user_defaults
}
// MARK: - Stored properties
private let main: UserDefaults
private let mirrors: [UserDefaults]
// MARK: - Initializers
init?(main: Store, mirror mirrors: [Store] = []) throws {
guard let main_user_defaults = main.get_user_defaults() else { throw DamusUserDefaultsError.cannot_initialize_user_defaults }
let mirror_user_defaults: [UserDefaults] = try mirrors.compactMap({ mirror_store in
guard let mirror_user_default = mirror_store.get_user_defaults() else {
throw DamusUserDefaultsError.cannot_initialize_user_defaults
}
guard mirror_store != main else {
throw DamusUserDefaultsError.cannot_mirror_main_user_defaults
}
return mirror_user_default
})
self.main = main_user_defaults
self.mirrors = mirror_user_defaults
}
// MARK: - Functions for feature parity with UserDefaults
func string(forKey defaultName: String) -> String? {
let value = self.main.string(forKey: defaultName)
self.mirror(value, forKey: defaultName)
return value
}
func set(_ value: Any?, forKey defaultName: String) {
self.main.set(value, forKey: defaultName)
self.mirror(value, forKey: defaultName)
}
func removeObject(forKey defaultName: String) {
self.main.removeObject(forKey: defaultName)
self.mirror_object_removal(forKey: defaultName)
}
func object(forKey defaultName: String) -> Any? {
let value = self.main.object(forKey: defaultName)
self.mirror(value, forKey: defaultName)
return value
}
// MARK: - Mirroring utilities
private func mirror(_ value: Any?, forKey defaultName: String) {
for mirror in self.mirrors {
mirror.set(value, forKey: defaultName)
}
}
private func mirror_object_removal(forKey defaultName: String) {
for mirror in self.mirrors {
mirror.removeObject(forKey: defaultName)
}
}
}
// MARK: - Default convenience objects
/// # Convenience objects
///
/// - `DamusUserDefaults.standard`: will detect the bundle identifier and pick an appropriate object. You should generally use this one.
/// - `DamusUserDefaults.app`: stores things on its own container, and mirrors them to the shared container.
/// - `DamusUserDefaults.shared`: stores things on the shared container and does no mirroring
extension DamusUserDefaults {
static let app: DamusUserDefaults = try! DamusUserDefaults(main: .standard, mirror: [.shared])! // Since the underlying behavior is very static, the risk of crashing on force unwrap is low
static let shared: DamusUserDefaults = try! DamusUserDefaults(main: .shared)! // Since the underlying behavior is very static, the risk of crashing on force unwrap is low
static var standard: DamusUserDefaults {
get {
switch Bundle.main.bundleIdentifier {
case Constants.MAIN_APP_BUNDLE_IDENTIFIER:
return Self.app
case Constants.NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER:
return Self.shared
default:
return Self.shared
}
}
}
}
-1
View File
@@ -28,5 +28,4 @@ class Drafts: ObservableObject {
@Published var post: DraftArtifacts? = nil @Published var post: DraftArtifacts? = nil
@Published var replies: [NostrEvent: DraftArtifacts] = [:] @Published var replies: [NostrEvent: DraftArtifacts] = [:]
@Published var quotes: [NostrEvent: DraftArtifacts] = [:] @Published var quotes: [NostrEvent: DraftArtifacts] = [:]
@Published var highlights: [HighlightSource: DraftArtifacts] = [:]
} }
+147
View File
@@ -0,0 +1,147 @@
//
// EventRef.swift
// damus
//
// Created by William Casarin on 2022-05-08.
//
import Foundation
enum EventRef: Equatable {
case mention(Mention<NoteRef>)
case thread_id(NoteRef)
case reply(NoteRef)
case reply_to_root(NoteRef)
var is_mention: NoteRef? {
if case .mention(let m) = self { return m.ref }
return nil
}
var is_direct_reply: NoteRef? {
switch self {
case .mention:
return nil
case .thread_id:
return nil
case .reply(let refid):
return refid
case .reply_to_root(let refid):
return refid
}
}
var is_thread_id: NoteRef? {
switch self {
case .mention:
return nil
case .thread_id(let referencedId):
return referencedId
case .reply:
return nil
case .reply_to_root(let referencedId):
return referencedId
}
}
var is_reply: NoteRef? {
switch self {
case .mention:
return nil
case .thread_id:
return nil
case .reply(let refid):
return refid
case .reply_to_root(let refid):
return refid
}
}
}
func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> {
return blocks.reduce(into: []) { acc, block in
switch block {
case .mention(let m):
if m.ref.key == type, let idx = m.index {
acc.insert(idx)
}
case .relay:
return
case .text:
return
case .hashtag:
return
case .url:
return
case .invoice:
return
}
}
}
func interp_event_refs_without_mentions(_ refs: [NoteRef]) -> [EventRef] {
if refs.count == 0 {
return []
}
if refs.count == 1 {
return [.reply_to_root(refs[0])]
}
var evrefs: [EventRef] = []
var first: Bool = true
for ref in refs {
if first {
evrefs.append(.thread_id(ref))
first = false
} else {
evrefs.append(.reply(ref))
}
}
return evrefs
}
func interp_event_refs_with_mentions(tags: Tags, mention_indices: Set<Int>) -> [EventRef] {
var mentions: [EventRef] = []
var ev_refs: [NoteRef] = []
var i: Int = 0
for tag in tags {
if let ref = NoteRef.from_tag(tag: tag) {
if mention_indices.contains(i) {
let mention = Mention<NoteRef>(index: i, ref: ref)
mentions.append(.mention(mention))
} else {
ev_refs.append(ref)
}
}
i += 1
}
var replies = interp_event_refs_without_mentions(ev_refs)
replies.append(contentsOf: mentions)
return replies
}
func interpret_event_refs(blocks: [Block], tags: Tags) -> [EventRef] {
if tags.count == 0 {
return []
}
/// build a set of indices for each event mention
let mention_indices = build_mention_indices(blocks, type: .e)
/// simpler case with no mentions
if mention_indices.count == 0 {
return interp_event_refs_without_mentions_ndb(References<NoteRef>(tags: tags))
}
return interp_event_refs_with_mentions(tags: tags, mention_indices: mention_indices)
}
func event_is_reply(_ refs: [EventRef]) -> Bool {
return refs.contains { evref in
return evref.is_reply != nil
}
}
+24 -60
View File
@@ -7,62 +7,25 @@
import Foundation import Foundation
class EventsModel: ObservableObject { class EventsModel: ObservableObject {
let state: DamusState let state: DamusState
let target: NoteId let target: NoteId
let kind: QueryKind let kind: NostrKind
let sub_id = UUID().uuidString let sub_id = UUID().uuidString
let profiles_id = UUID().uuidString let profiles_id = UUID().uuidString
var events: EventHolder
@Published var loading: Bool @Published var events: [NostrEvent] = []
enum QueryKind {
case kind(NostrKind)
case quotes
}
init(state: DamusState, target: NoteId, kind: NostrKind) { init(state: DamusState, target: NoteId, kind: NostrKind) {
self.state = state self.state = state
self.target = target self.target = target
self.kind = .kind(kind) self.kind = kind
self.loading = true
self.events = EventHolder(on_queue: { ev in
preload_events(state: state, events: [ev])
})
} }
init(state: DamusState, target: NoteId, query: EventsModel.QueryKind) {
self.state = state
self.target = target
self.kind = query
self.loading = true
self.events = EventHolder(on_queue: { ev in
preload_events(state: state, events: [ev])
})
}
public static func quotes(state: DamusState, target: NoteId) -> EventsModel {
EventsModel(state: state, target: target, query: .quotes)
}
public static func reposts(state: DamusState, target: NoteId) -> EventsModel {
EventsModel(state: state, target: target, kind: .boost)
}
public static func likes(state: DamusState, target: NoteId) -> EventsModel {
EventsModel(state: state, target: target, kind: .like)
}
private func get_filter() -> NostrFilter { private func get_filter() -> NostrFilter {
var filter: NostrFilter var filter = NostrFilter(kinds: [kind])
switch kind { filter.referenced_ids = [target]
case .kind(let k):
filter = NostrFilter(kinds: [k])
filter.referenced_ids = [target]
case .quotes:
filter = NostrFilter(kinds: [.text])
filter.quotes = [target]
}
filter.limit = 500 filter.limit = 500
return filter return filter
} }
@@ -76,19 +39,23 @@ class EventsModel: ObservableObject {
func unsubscribe() { func unsubscribe() {
state.pool.unsubscribe(sub_id: sub_id) state.pool.unsubscribe(sub_id: sub_id)
} }
private func handle_event(relay_id: RelayURL, ev: NostrEvent) { private func handle_event(relay_id: String, ev: NostrEvent) {
if events.insert(ev) { guard ev.kind == kind.rawValue,
objectWillChange.send() ev.referenced_ids.last == target else {
}
}
func handle_nostr_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev, nev.subid == self.sub_id
else {
return return
} }
if insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { a, b in a.created_at < b.created_at } ) {
objectWillChange.send()
}
}
func handle_nostr_event(relay_id: String, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else {
return
}
switch nev { switch nev {
case .event(_, let ev): case .event(_, let ev):
handle_event(relay_id: relay_id, ev: ev) handle_event(relay_id: relay_id, ev: ev)
@@ -99,11 +66,8 @@ class EventsModel: ObservableObject {
case .auth: case .auth:
break break
case .eose: case .eose:
self.loading = false let txn = NdbTxn(ndb: self.state.ndb)
guard let txn = NdbTxn(ndb: self.state.ndb) else { load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state, txn: txn)
return
}
load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events.all_events), damus_state: state, txn: txn)
} }
} }
} }
-15
View File
@@ -1,15 +0,0 @@
//
// FollowState.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
enum FollowState {
case follows
case following
case unfollowing
case unfollows
}
+5 -5
View File
@@ -52,8 +52,8 @@ class FollowersModel: ObservableObject {
contacts?.append(ev.pubkey) contacts?.append(ev.pubkey)
has_contact.insert(ev.pubkey) has_contact.insert(ev.pubkey)
} }
func load_profiles<Y>(relay_id: RelayURL, txn: NdbTxn<Y>) { func load_profiles<Y>(relay_id: String, txn: NdbTxn<Y>) {
let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [], txn: txn) let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [], txn: txn)
if authors.isEmpty { if authors.isEmpty {
return return
@@ -63,8 +63,8 @@ class FollowersModel: ObservableObject {
authors: authors) authors: authors)
damus_state.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) { func handle_event(relay_id: String, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else { guard case .nostr_event(let nev) = ev else {
return return
} }
@@ -83,7 +83,7 @@ class FollowersModel: ObservableObject {
case .eose(let sub_id): case .eose(let sub_id):
if sub_id == self.sub_id { if sub_id == self.sub_id {
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return } let txn = NdbTxn(ndb: self.damus_state.ndb)
load_profiles(relay_id: relay_id, txn: txn) load_profiles(relay_id: relay_id, txn: txn)
} else if sub_id == self.profiles_id { } else if sub_id == self.profiles_id {
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id]) damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
+2 -2
View File
@@ -52,8 +52,8 @@ class FollowingModel {
print("unsubscribing from following \(sub_id)") print("unsubscribing from following \(sub_id)")
self.damus_state.pool.unsubscribe(sub_id: sub_id) self.damus_state.pool.unsubscribe(sub_id: sub_id)
} }
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { func handle_event(relay_id: String, ev: NostrConnectionEvent) {
// don't need to do anything here really // don't need to do anything here really
} }
} }
-43
View File
@@ -1,43 +0,0 @@
//
// FriendFilter.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
enum FriendFilter: String, StringCodable {
case all
case friends_of_friends
init?(from string: String) {
guard let ff = FriendFilter(rawValue: string) else {
return nil
}
self = ff
}
func to_string() -> String {
self.rawValue
}
func filter(contacts: Contacts, pubkey: Pubkey) -> Bool {
switch self {
case .all:
return true
case .friends_of_friends:
return contacts.is_in_friendosphere(pubkey)
}
}
func description() -> String {
switch self {
case .all:
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
case .friends_of_friends:
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
}
}
}
-26
View File
@@ -1,26 +0,0 @@
//
// HeadlessDamusState.swift
// damus
//
// Created by Daniel DAquino on 2023-11-27.
//
import Foundation
/// HeadlessDamusState
///
/// A protocl for a lighter headless alternative to DamusState that does not have dependencies on View objects or UI logic.
/// This is useful in limited environments (e.g. Notification Service Extension) where we do not want View/UI dependencies
protocol HeadlessDamusState {
var ndb: Ndb { get }
var settings: UserSettingsStore { get }
var contacts: Contacts { get }
var mutelist_manager: MutelistManager { get }
var keypair: Keypair { get }
var profiles: Profiles { get }
var zaps: Zaps { get }
var lnurls: LNUrls { get }
@discardableResult
func add_zap(zap: Zapping) -> Bool
}
-239
View File
@@ -1,239 +0,0 @@
//
// HighlightEvent.swift
// damus
//
// Created by eric on 4/22/24.
//
import Foundation
struct HighlightEvent {
let event: NostrEvent
var event_ref: String? = nil
var url_ref: URL? = nil
var context: String? = nil
// MARK: - Initializers and parsers
static func parse(from ev: NostrEvent) -> HighlightEvent {
var highlight = HighlightEvent(event: ev)
var best_url_source: (url: URL, tagged_as_source: Bool)? = nil
for tag in ev.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "e": highlight.event_ref = tag[1].string()
case "a": highlight.event_ref = tag[1].string()
case "r":
if tag.count >= 3,
tag[2].string() == HighlightSource.TAG_SOURCE_ELEMENT,
let url = URL(string: tag[1].string()) {
// URL marked as source. Very good candidate
best_url_source = (url: url, tagged_as_source: true)
}
else if tag.count >= 3 && tag[2].string() != HighlightSource.TAG_SOURCE_ELEMENT {
// URL marked as something else (not source). Not the source we are after
}
else if let url = URL(string: tag[1].string()), tag.count == 2 {
// Unmarked URL. This might be what we are after (For NIP-84 backwards compatibility)
if (best_url_source?.tagged_as_source ?? false) == false {
// No URL candidates marked as the source. Mark this as the best option we have
best_url_source = (url: url, tagged_as_source: false)
}
}
case "context": highlight.context = tag[1].string()
default:
break
}
}
if let best_url_source {
highlight.url_ref = best_url_source.url
}
return highlight
}
// MARK: - Getting information about source
func source_description_info(highlighted_event: NostrEvent?) -> ReplyDesc {
var others_count = 0
var highlighted_authors: [Pubkey] = []
var i = event.tags.count
if let highlighted_event {
highlighted_authors.append(highlighted_event.pubkey)
}
for tag in event.tags {
if let pubkey_with_role = PubkeyWithRole.from_tag(tag: tag) {
others_count += 1
if highlighted_authors.count < 2 {
if let highlighted_event, pubkey_with_role.pubkey == highlighted_event.pubkey {
continue
} else {
switch pubkey_with_role.role {
case .author:
highlighted_authors.append(pubkey_with_role.pubkey)
default:
break
}
}
}
}
i -= 1
}
return ReplyDesc(pubkeys: highlighted_authors, others: others_count)
}
func source_description_text(ndb: Ndb, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
let description_info = self.source_description_info(highlighted_event: highlighted_event)
let pubkeys = description_info.pubkeys
let bundle = bundleForLocale(locale: locale)
if pubkeys.count == 0 {
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
}
guard let profile_txn = NdbTxn(ndb: ndb) else {
return ""
}
let names: [String] = pubkeys.map { pk in
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
}
let uniqueNames: [String] = Array(Set(names))
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
}
}
// MARK: - Helper structures
extension HighlightEvent {
struct PubkeyWithRole: TagKey, TagConvertible {
let pubkey: Pubkey
let role: Role
var tag: [String] {
if let role_text = self.role.rawValue {
return [keychar.description, self.pubkey.hex(), role_text]
}
else {
return [keychar.description, self.pubkey.hex()]
}
}
var keychar: AsciiCharacter { "p" }
static func from_tag(tag: TagSequence) -> HighlightEvent.PubkeyWithRole? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
key == "p",
let t1 = i.next(),
let pubkey = t1.id().map(Pubkey.init)
else { return nil }
let t3: String? = i.next()?.string()
let role = Role(rawValue: t3)
return PubkeyWithRole(pubkey: pubkey, role: role)
}
enum Role: RawRepresentable {
case author
case editor
case mention
case other(String)
case no_role
typealias RawValue = String?
var rawValue: String? {
switch self {
case .author: "author"
case .editor: "editor"
case .mention: "mention"
case .other(let role): role
case .no_role: nil
}
}
init(rawValue: String?) {
switch rawValue {
case "author": self = .author
case "editor": self = .editor
case "mention": self = .mention
default:
if let rawValue {
self = .other(rawValue)
}
else {
self = .no_role
}
}
}
}
}
}
struct HighlightContentDraft: Hashable {
let selected_text: String
let source: HighlightSource
}
enum HighlightSource: Hashable {
static let TAG_SOURCE_ELEMENT = "source"
case event(NostrEvent)
case external_url(URL)
func tags() -> [[String]] {
switch self {
case .event(let event):
return [ ["e", "\(event.id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
case .external_url(let url):
return [ ["r", "\(url)", HighlightSource.TAG_SOURCE_ELEMENT] ]
}
}
func ref() -> RefId {
switch self {
case .event(let event):
return .event(event.id)
case .external_url(let url):
return .reference(url.absoluteString)
}
}
}
struct ShareContent {
let title: String
let content: ContentType
enum ContentType {
case link(URL)
case media([PreUploadedMedia])
}
func getLinkURL() -> URL? {
if case let .link(url) = content {
return url
}
return nil
}
func getMediaArray() -> [PreUploadedMedia] {
if case let .media(mediaArray) = content {
return mediaArray
}
return []
}
}
+355 -160
View File
@@ -8,6 +8,21 @@
import Foundation import Foundation
import UIKit import UIKit
struct NewEventsBits: OptionSet {
let rawValue: Int
static let home = NewEventsBits(rawValue: 1 << 0)
static let zaps = NewEventsBits(rawValue: 1 << 1)
static let mentions = NewEventsBits(rawValue: 1 << 2)
static let reposts = NewEventsBits(rawValue: 1 << 3)
static let likes = NewEventsBits(rawValue: 1 << 4)
static let search = NewEventsBits(rawValue: 1 << 5)
static let dms = NewEventsBits(rawValue: 1 << 6)
static let all = NewEventsBits(rawValue: 0xFFFFFFFF)
static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions]
}
enum Resubscribe { enum Resubscribe {
case following case following
case unfollowing(FollowRef) case unfollowing(FollowRef)
@@ -41,24 +56,16 @@ enum HomeResubFilter {
} }
} }
class HomeModel: ContactsDelegate { class HomeModel {
// The maximum amount of contacts placed on a home feed subscription filter.
// If the user has more contacts, chunking or other techniques will be used to avoid sending huge filters
let MAX_CONTACTS_ON_FILTER = 500
// Don't trigger a user notification for events older than a certain age // Don't trigger a user notification for events older than a certain age
static let event_max_age_for_notification: TimeInterval = EVENT_MAX_AGE_FOR_NOTIFICATION static let event_max_age_for_notification: TimeInterval = 12 * 60 * 60
var damus_state: DamusState { var damus_state: DamusState
didSet {
self.load_our_stuff_from_damus_state()
}
}
// NDBTODO: let's get rid of this entirely, let nostrdb handle it // NDBTODO: let's get rid of this entirely, let nostrdb handle it
var has_event: [String: Set<NoteId>] = [:] var has_event: [String: Set<NoteId>] = [:]
var deleted_events: Set<NoteId> = Set() var deleted_events: Set<NoteId> = Set()
var last_event_of_kind: [RelayURL: [UInt32: NostrEvent]] = [:] var last_event_of_kind: [String: [UInt32: NostrEvent]] = [:]
var done_init: Bool = false var done_init: Bool = false
var incoming_dms: [NostrEvent] = [] var incoming_dms: [NostrEvent] = []
let dm_debouncer = Debouncer(interval: 0.5) let dm_debouncer = Debouncer(interval: 0.5)
@@ -116,32 +123,6 @@ class HomeModel: ContactsDelegate {
self.should_debounce_dms = false self.should_debounce_dms = false
} }
} }
// MARK: - Loading items from DamusState
/// This is called whenever DamusState gets set. This function is used to load or setup anything we need from the new DamusState
func load_our_stuff_from_damus_state() {
self.load_latest_contact_event_from_damus_state()
}
/// This loads the latest contact event we have on file from NostrDB. This should be called as soon as we get the new DamusState
/// Loading the latest contact list event into our `Contacts` instance from storage is important to avoid getting into weird states when the network is unreliable or when relays delete such information
func load_latest_contact_event_from_damus_state() {
damus_state.contacts.delegate = self
guard let latest_contact_event_id_hex = damus_state.settings.latest_contact_event_id_hex else { return }
guard let latest_contact_event_id = NoteId(hex: latest_contact_event_id_hex) else { return }
guard let latest_contact_event: NdbNote = damus_state.ndb.lookup_note( latest_contact_event_id)?.unsafeUnownedValue?.to_owned() else { return }
process_contact_event(state: damus_state, ev: latest_contact_event)
}
// MARK: - ContactsDelegate functions
func latest_contact_event_changed(new_event: NostrEvent) {
// When the latest user contact event has changed, save its ID so we know exactly where to find it next time
damus_state.settings.latest_contact_event_id_hex = new_event.id.hex()
}
// MARK: - Nostr event and subscription handling
func resubscribe(_ resubbing: Resubscribe) { func resubscribe(_ resubbing: Resubscribe) {
if self.should_debounce_dms { if self.should_debounce_dms {
@@ -169,7 +150,7 @@ class HomeModel: ContactsDelegate {
} }
@MainActor @MainActor
func process_event(sub_id: String, relay_id: RelayURL, ev: NostrEvent) { func process_event(sub_id: String, relay_id: String, ev: NostrEvent) {
if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) { if has_sub_id_event(sub_id: sub_id, ev_id: ev.id) {
return return
} }
@@ -184,17 +165,15 @@ class HomeModel: ContactsDelegate {
} }
switch kind { switch kind {
case .chat, .longform, .text, .highlight: case .chat, .longform, .text:
handle_text_event(sub_id: sub_id, ev) handle_text_event(sub_id: sub_id, ev)
case .contacts: case .contacts:
handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev) handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
case .metadata: case .metadata:
// profile metadata processing is handled by nostrdb // profile metadata processing is handled by nostrdb
break break
case .list_deprecated: case .list:
handle_old_list_event(ev) handle_list_event(ev)
case .mute_list:
handle_mute_list_event(ev)
case .boost: case .boost:
handle_boost_event(sub_id: sub_id, ev) handle_boost_event(sub_id: sub_id, ev)
case .like: case .like:
@@ -245,7 +224,7 @@ class HomeModel: ContactsDelegate {
pdata.status.update_status(st) pdata.status.update_status(st)
} }
func handle_nwc_response(_ ev: NostrEvent, relay: RelayURL) { func handle_nwc_response(_ ev: NostrEvent, relay: String) {
Task { @MainActor in Task { @MainActor in
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time // TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
guard let nwc_str = damus_state.settings.nostr_wallet_connect, guard let nwc_str = damus_state.settings.nostr_wallet_connect,
@@ -253,10 +232,10 @@ class HomeModel: ContactsDelegate {
let resp = await FullWalletResponse(from: ev, nwc: nwc) else { let resp = await FullWalletResponse(from: ev, nwc: nwc) else {
return return
} }
// since command results are not returned for ephemeral events, // since command results are not returned for ephemeral events,
// remove the request from the postbox which is likely failing over and over // remove the request from the postbox which is likely failing over and over
if damus_state.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) { if damus_state.postbox.remove_relayer(relay_id: nwc.relay.id, event_id: resp.req_id) {
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]") print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
} else { } else {
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]") print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
@@ -275,10 +254,10 @@ class HomeModel: ContactsDelegate {
@MainActor @MainActor
func handle_zap_event(_ ev: NostrEvent) { func handle_zap_event(_ ev: NostrEvent) {
process_zap_event(state: damus_state, ev: ev) { zapres in process_zap_event(damus_state: damus_state, ev: ev) { zapres in
guard case .done(let zap) = zapres, guard case .done(let zap) = zapres,
zap.target.pubkey == self.damus_state.keypair.pubkey, zap.target.pubkey == self.damus_state.keypair.pubkey,
should_show_event(state: self.damus_state, ev: zap.request.ev) else { should_show_event(keypair: self.damus_state.keypair, hellthreads: self.damus_state.muted_threads, contacts: self.damus_state.contacts, ev: zap.request.ev) else {
return return
} }
@@ -310,27 +289,13 @@ class HomeModel: ContactsDelegate {
} }
@MainActor
func handle_damus_app_notification(_ notification: DamusAppNotification) async {
if self.notifications.insert_app_notification(notification: notification) {
let last_notification = get_last_event(.notifications)
if last_notification == nil || last_notification!.created_at < notification.last_event_at {
save_last_event(NoteId.empty, created_at: notification.last_event_at, timeline: .notifications)
// If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits
// This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification
self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue)
}
return
}
}
func filter_events() { func filter_events() {
events.filter { ev in events.filter { ev in
!damus_state.mutelist_manager.is_muted(.user(ev.pubkey, nil)) !damus_state.contacts.is_muted(ev.pubkey)
} }
self.dms.dms = dms.dms.filter { ev in self.dms.dms = dms.dms.filter { ev in
!damus_state.mutelist_manager.is_muted(.user(ev.pubkey, nil)) !damus_state.contacts.is_muted(ev.pubkey)
} }
notifications.filter { ev in notifications.filter { ev in
@@ -338,8 +303,7 @@ class HomeModel: ContactsDelegate {
return false return false
} }
let event_muted = damus_state.mutelist_manager.is_event_muted(ev) return !damus_state.contacts.is_muted(ev.pubkey) && !damus_state.muted_threads.isMutedThread(ev, keypair: damus_state.keypair)
return !event_muted
} }
} }
@@ -347,7 +311,7 @@ class HomeModel: ContactsDelegate {
self.deleted_events.insert(ev.id) self.deleted_events.insert(ev.id)
} }
func handle_contact_event(sub_id: String, relay_id: RelayURL, ev: NostrEvent) { func handle_contact_event(sub_id: String, relay_id: String, ev: NostrEvent) {
process_contact_event(state: self.damus_state, ev: ev) process_contact_event(state: self.damus_state, ev: ev)
if sub_id == init_subid { if sub_id == init_subid {
@@ -386,19 +350,12 @@ class HomeModel: ContactsDelegate {
case .already_counted: case .already_counted:
break break
case .success(let n): case .success(let n):
let boosted = Counted(event: ev, id: e, total: n)
notify(.reposted(boosted))
notify(.update_stats(note_id: e)) notify(.update_stats(note_id: e))
} }
} }
func handle_quote_repost_event(_ ev: NostrEvent, target: NoteId) {
switch damus_state.quote_reposts.add_event(ev, target: target) {
case .already_counted:
break
case .success(let n):
notify(.update_stats(note_id: target))
}
}
func handle_like_event(_ ev: NostrEvent) { func handle_like_event(_ ev: NostrEvent) {
guard let e = ev.last_refid() else { guard let e = ev.last_refid() else {
// no id ref? invalid like event // no id ref? invalid like event
@@ -421,7 +378,7 @@ class HomeModel: ContactsDelegate {
} }
@MainActor @MainActor
func handle_event(relay_id: RelayURL, conn_event: NostrConnectionEvent) { func handle_event(relay_id: String, conn_event: NostrConnectionEvent) {
switch conn_event { switch conn_event {
case .ws_event(let ev): case .ws_event(let ev):
switch ev { switch ev {
@@ -439,7 +396,7 @@ class HomeModel: ContactsDelegate {
let r = pool.get_relay(relay_id), let r = pool.get_relay(relay_id),
r.descriptor.variant == .nwc, r.descriptor.variant == .nwc,
let nwc = WalletConnectURL(str: nwc_str), let nwc = WalletConnectURL(str: nwc_str),
nwc.relay == relay_id nwc.relay.id == relay_id
{ {
subscribe_to_nwc(url: nwc, pool: pool) subscribe_to_nwc(url: nwc, pool: pool)
} }
@@ -472,10 +429,8 @@ class HomeModel: ContactsDelegate {
print(msg) print(msg)
case .eose(let sub_id): case .eose(let sub_id):
guard let txn = NdbTxn(ndb: damus_state.ndb) else {
return let txn = NdbTxn(ndb: damus_state.ndb)
}
if sub_id == dms_subid { if sub_id == dms_subid {
var dms = dms.dms.flatMap { $0.events } var dms = dms.dms.flatMap { $0.events }
dms.append(contentsOf: incoming_dms) dms.append(contentsOf: incoming_dms)
@@ -500,14 +455,14 @@ class HomeModel: ContactsDelegate {
/// Send the initial filters, just our contact list mostly /// Send the initial filters, just our contact list mostly
func send_initial_filters(relay_id: RelayURL) { func send_initial_filters(relay_id: String) {
let filter = NostrFilter(kinds: [.contacts], limit: 1, authors: [damus_state.pubkey]) let filter = NostrFilter(kinds: [.contacts], limit: 1, authors: [damus_state.pubkey])
let subscription = NostrSubscribe(filters: [filter], sub_id: init_subid) let subscription = NostrSubscribe(filters: [filter], sub_id: init_subid)
pool.send(.subscribe(subscription), to: [relay_id]) pool.send(.subscribe(subscription), to: [relay_id])
} }
/// After initial connection or reconnect, send subscription filters for the home timeline, DMs, and notifications /// After initial connection or reconnect, send subscription filters for the home timeline, DMs, and notifications
func send_home_filters(relay_id: RelayURL?) { func send_home_filters(relay_id: String?) {
// TODO: since times should be based on events from a specific relay // TODO: since times should be based on events from a specific relay
// perhaps we could mark this in the relay pool somehow // perhaps we could mark this in the relay pool somehow
@@ -519,13 +474,10 @@ class HomeModel: ContactsDelegate {
var our_contacts_filter = NostrFilter(kinds: [.contacts, .metadata]) var our_contacts_filter = NostrFilter(kinds: [.contacts, .metadata])
our_contacts_filter.authors = [damus_state.pubkey] our_contacts_filter.authors = [damus_state.pubkey]
var our_old_blocklist_filter = NostrFilter(kinds: [.list_deprecated]) var our_blocklist_filter = NostrFilter(kinds: [.list])
our_old_blocklist_filter.parameter = ["mute"] our_blocklist_filter.parameter = ["mute"]
our_old_blocklist_filter.authors = [damus_state.pubkey]
var our_blocklist_filter = NostrFilter(kinds: [.mute_list])
our_blocklist_filter.authors = [damus_state.pubkey] our_blocklist_filter.authors = [damus_state.pubkey]
var dms_filter = NostrFilter(kinds: [.dm]) var dms_filter = NostrFilter(kinds: [.dm])
var our_dms_filter = NostrFilter(kinds: [.dm]) var our_dms_filter = NostrFilter(kinds: [.dm])
@@ -549,8 +501,7 @@ class HomeModel: ContactsDelegate {
notifications_filter.limit = 500 notifications_filter.limit = 500
var notifications_filters = [notifications_filter] var notifications_filters = [notifications_filter]
let contacts_filter_chunks = contacts_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_FILTER) var contacts_filters = [contacts_filter, our_contacts_filter, our_blocklist_filter]
var contacts_filters = contacts_filter_chunks + [our_contacts_filter, our_blocklist_filter, our_old_blocklist_filter]
var dms_filters = [dms_filter, our_dms_filter] var dms_filters = [dms_filter, our_dms_filter]
let last_of_kind = get_last_of_kind(relay_id: relay_id) let last_of_kind = get_last_of_kind(relay_id: relay_id)
@@ -569,7 +520,7 @@ class HomeModel: ContactsDelegate {
pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)), to: relay_ids) pool.send(.subscribe(.init(filters: dms_filters, sub_id: dms_subid)), to: relay_ids)
} }
func get_last_of_kind(relay_id: RelayURL?) -> [UInt32: NostrEvent] { func get_last_of_kind(relay_id: String?) -> [UInt32: NostrEvent] {
return relay_id.flatMap { last_event_of_kind[$0] } ?? [:] return relay_id.flatMap { last_event_of_kind[$0] } ?? [:]
} }
@@ -583,10 +534,10 @@ class HomeModel: ContactsDelegate {
return Array(friends) return Array(friends)
} }
func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: RelayURL? = nil) { func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: String? = nil) {
// TODO: separate likes? // TODO: separate likes?
var home_filter_kinds: [NostrKind] = [ var home_filter_kinds: [NostrKind] = [
.text, .longform, .boost, .highlight .text, .longform, .boost
] ]
if !damus_state.settings.onlyzaps_mode { if !damus_state.settings.onlyzaps_mode {
home_filter_kinds.append(.like) home_filter_kinds.append(.like)
@@ -603,7 +554,7 @@ class HomeModel: ContactsDelegate {
home_filter.authors = friends home_filter.authors = friends
home_filter.limit = 500 home_filter.limit = 500
var home_filters = home_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_FILTER) var home_filters = [home_filter]
let followed_hashtags = Array(damus_state.contacts.get_followed_hashtags()) let followed_hashtags = Array(damus_state.contacts.get_followed_hashtags())
if followed_hashtags.count != 0 { if followed_hashtags.count != 0 {
@@ -619,32 +570,13 @@ class HomeModel: ContactsDelegate {
pool.send(.subscribe(sub), to: relay_ids) pool.send(.subscribe(sub), to: relay_ids)
} }
func handle_mute_list_event(_ ev: NostrEvent) { func handle_list_event(_ ev: NostrEvent) {
// we only care about our mutelist
guard ev.pubkey == damus_state.pubkey else {
return
}
// we only care about the most recent mutelist
if let mutelist = damus_state.mutelist_manager.event {
if ev.created_at <= mutelist.created_at {
return
}
}
damus_state.mutelist_manager.set_mutelist(ev)
migrate_old_muted_threads_to_new_mutelist(keypair: damus_state.keypair, damus_state: damus_state)
}
func handle_old_list_event(_ ev: NostrEvent) {
// we only care about our lists // we only care about our lists
guard ev.pubkey == damus_state.pubkey else { guard ev.pubkey == damus_state.pubkey else {
return return
} }
// we only care about the most recent mutelist if let mutelist = damus_state.contacts.mutelist {
if let mutelist = damus_state.mutelist_manager.event {
if ev.created_at <= mutelist.created_at { if ev.created_at <= mutelist.created_at {
return return
} }
@@ -654,12 +586,10 @@ class HomeModel: ContactsDelegate {
return return
} }
damus_state.mutelist_manager.set_mutelist(ev) damus_state.contacts.set_mutelist(ev)
migrate_old_muted_threads_to_new_mutelist(keypair: damus_state.keypair, damus_state: damus_state)
} }
func get_last_event_of_kind(relay_id: RelayURL, kind: UInt32) -> NostrEvent? { func get_last_event_of_kind(relay_id: String, kind: UInt32) -> NostrEvent? {
guard let m = last_event_of_kind[relay_id] else { guard let m = last_event_of_kind[relay_id] else {
last_event_of_kind[relay_id] = [:] last_event_of_kind[relay_id] = [:]
return nil return nil
@@ -672,7 +602,7 @@ class HomeModel: ContactsDelegate {
// don't show notifications from ourselves // don't show notifications from ourselves
guard ev.pubkey != damus_state.pubkey, guard ev.pubkey != damus_state.pubkey,
event_has_our_pubkey(ev, our_pubkey: self.damus_state.pubkey), event_has_our_pubkey(ev, our_pubkey: self.damus_state.pubkey),
should_show_event(state: damus_state, ev: ev) else { should_show_event(keypair: self.damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) else {
return return
} }
@@ -687,7 +617,7 @@ class HomeModel: ContactsDelegate {
} }
if handle_last_event(ev: ev, timeline: .notifications) { if handle_last_event(ev: ev, timeline: .notifications) {
process_local_notification(state: damus_state, event: ev) process_local_notification(damus_state: damus_state, event: ev)
} }
} }
@@ -710,7 +640,7 @@ class HomeModel: ContactsDelegate {
func handle_text_event(sub_id: String, _ ev: NostrEvent) { func handle_text_event(sub_id: String, _ ev: NostrEvent) {
guard should_show_event(state: damus_state, ev: ev) else { guard should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) else {
return return
} }
@@ -719,10 +649,6 @@ class HomeModel: ContactsDelegate {
damus_state.replies.count_replies(ev, keypair: self.damus_state.keypair) damus_state.replies.count_replies(ev, keypair: self.damus_state.keypair)
damus_state.events.insert(ev) damus_state.events.insert(ev)
if let quoted_event = ev.referenced_quote_ids.first {
handle_quote_repost_event(ev, target: quoted_event.note_id)
}
if sub_id == home_subid { if sub_id == home_subid {
insert_home_event(ev) insert_home_event(ev)
} else if sub_id == notifications_subid { } else if sub_id == notifications_subid {
@@ -733,17 +659,15 @@ class HomeModel: ContactsDelegate {
func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) { func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) {
notification_status.new_events = notifs notification_status.new_events = notifs
guard should_display_notification(state: damus_state, event: ev, mode: .local), if damus_state.settings.dm_notification && ev.age < HomeModel.event_max_age_for_notification {
let notification_object = generate_local_notification_object(from: ev, state: damus_state) let convo = ev.decrypted(keypair: self.damus_state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
else { let notify = LocalNotification(type: .dm, event: ev, target: ev, content: convo)
return create_local_notification(profiles: damus_state.profiles, notify: notify)
} }
create_local_notification(profiles: damus_state.profiles, notify: notification_object)
} }
func handle_dm(_ ev: NostrEvent) { func handle_dm(_ ev: NostrEvent) {
guard should_show_event(state: damus_state, ev: ev) else { guard should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) else {
return return
} }
@@ -923,23 +847,23 @@ func process_contact_event(state: DamusState, ev: NostrEvent) {
} }
func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) { func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
let bootstrap_dict: [RelayURL: RelayInfo] = [:] let bootstrap_dict: [String: RelayInfo] = [:]
let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? state.bootstrap_relays.reduce(into: bootstrap_dict) { (d, r) in 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 d[r] = .rw
} }
guard let decoded: [RelayURL: RelayInfo] = decode_json_relays(ev.content) else { guard let decoded: [String: RelayInfo] = decode_json_relays(ev.content) else {
return return
} }
var changed = false var changed = false
var new = Set<RelayURL>() var new = Set<String>()
for key in decoded.keys { for key in decoded.keys {
new.insert(key) new.insert(key)
} }
var old = Set<RelayURL>() var old = Set<String>()
for key in old_decoded.keys { for key in old_decoded.keys {
old.insert(key) old.insert(key)
} }
@@ -950,8 +874,10 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
for d in diff { for d in diff {
changed = true changed = true
if new.contains(d) { if new.contains(d) {
let descriptor = RelayDescriptor(url: d, info: decoded[d] ?? .rw) if let url = RelayURL(d) {
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) let descriptor = RelayDescriptor(url: url, 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 { } else {
state.pool.remove_relay(d) state.pool.remove_relay(d)
} }
@@ -967,8 +893,8 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) { 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) try? pool.add_relay(descriptor)
let url = descriptor.url let url = descriptor.url
let relay_id = url let relay_id = url.id
guard model_cache.model(withURL: url) == nil else { guard model_cache.model(withURL: url) == nil else {
return return
} }
@@ -994,10 +920,10 @@ func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, po
} }
} }
func fetch_relay_metadata(relay_id: RelayURL) async throws -> RelayMetadata? { func fetch_relay_metadata(relay_id: String) async throws -> RelayMetadata? {
var urlString = relay_id.absoluteString.replacingOccurrences(of: "wss://", with: "https://") var urlString = relay_id.replacingOccurrences(of: "wss://", with: "https://")
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://") urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
return nil return nil
} }
@@ -1148,14 +1074,19 @@ func event_has_our_pubkey(_ ev: NostrEvent, our_pubkey: Pubkey) -> Bool {
func should_show_event(event: NostrEvent, damus_state: DamusState) -> Bool { func should_show_event(event: NostrEvent, damus_state: DamusState) -> Bool {
return should_show_event( return should_show_event(
state: damus_state, keypair: damus_state.keypair,
hellthreads: damus_state.muted_threads,
contacts: damus_state.contacts,
ev: event ev: event
) )
} }
func should_show_event(state: DamusState, ev: NostrEvent) -> Bool { func should_show_event(keypair: Keypair, hellthreads: MutedThreadsManager, contacts: Contacts, ev: NostrEvent) -> Bool {
let event_muted = state.mutelist_manager.is_event_muted(ev) if contacts.is_muted(ev.pubkey) {
if event_muted { return false
}
if hellthreads.isMutedThread(ev, keypair: keypair) {
return false return false
} }
@@ -1175,11 +1106,39 @@ func zap_vibrate(zap_amount: Int64) {
vibration_generator.impactOccurred() vibration_generator.impactOccurred()
} }
func zap_notification_title(_ zap: Zap) -> String {
if zap.private_request != nil {
return NSLocalizedString("Private Zap", comment: "Title of notification when a private zap is received.")
} else {
return NSLocalizedString("Zap", comment: "Title of notification when a non-private zap is received.")
}
}
func zap_notification_body(profiles: Profiles, zap: Zap, locale: Locale = Locale.current) -> String {
let src = zap.request.ev
let pk = zap.is_anon ? ANON_PUBKEY : src.pubkey
let name = profiles.lookup(id: pk).map { profile in
Profile.displayName(profile: profile, pubkey: pk).displayName.truncate(maxLength: 50)
}.value
let sats = NSNumber(value: (Double(zap.invoice.amount) / 1000.0))
let formattedSats = format_msats_abbrev(zap.invoice.amount)
if src.content.isEmpty {
let format = localizedStringFormat(key: "zap_notification_no_message", locale: locale)
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name)
} else {
let format = localizedStringFormat(key: "zap_notification_with_message", locale: locale)
return String(format: format, locale: locale, sats.decimalValue as NSDecimalNumber, formattedSats, name, src.content)
}
}
func create_in_app_profile_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, profile_id: Pubkey) { func create_in_app_profile_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, profile_id: Pubkey) {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = NotificationFormatter.zap_notification_title(zap) content.title = zap_notification_title(zap)
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale) content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
content.sound = UNNotificationSound.default content.sound = UNNotificationSound.default
content.userInfo = LossyLocalNotification(type: .profile_zap, mention: .pubkey(profile_id)).to_user_info() content.userInfo = LossyLocalNotification(type: .profile_zap, mention: .pubkey(profile_id)).to_user_info()
@@ -1199,8 +1158,8 @@ func create_in_app_profile_zap_notification(profiles: Profiles, zap: Zap, locale
func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, evId: NoteId) { func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale: Locale = Locale.current, evId: NoteId) {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = NotificationFormatter.zap_notification_title(zap) content.title = zap_notification_title(zap)
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale) content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
content.sound = UNNotificationSound.default content.sound = UNNotificationSound.default
content.userInfo = LossyLocalNotification(type: .zap, mention: .note(evId)).to_user_info() content.userInfo = LossyLocalNotification(type: .zap, mention: .note(evId)).to_user_info()
@@ -1216,3 +1175,239 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale:
} }
} }
} }
func render_notification_content_preview(cache: EventCache, ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> String {
let prefix_len = 300
let artifacts = cache.get_cache_data(ev.id).artifacts.artifacts ?? render_note_content(ev: ev, profiles: profiles, keypair: keypair)
// special case for longform events
if ev.known_kind == .longform {
let longform = LongformEvent(event: ev)
return longform.title ?? longform.summary ?? "Longform Event"
}
switch artifacts {
case .longform:
// we should never hit this until we have more note types built out of parts
// since we handle this case above in known_kind == .longform
return String(ev.content.prefix(prefix_len))
case .separated(let artifacts):
return String(NSAttributedString(artifacts.content.attributed).string.prefix(prefix_len))
}
}
func process_local_notification(damus_state: DamusState, event ev: NostrEvent) {
guard let type = ev.known_kind else {
return
}
if damus_state.settings.notification_only_from_following,
damus_state.contacts.follow_state(ev.pubkey) != .follows
{
return
}
// Don't show notifications from muted threads.
if damus_state.muted_threads.isMutedThread(ev, keypair: damus_state.keypair) {
return
}
// Don't show notifications for old events
guard ev.age < HomeModel.event_max_age_for_notification else {
return
}
if type == .text, damus_state.settings.mention_notification {
let blocks = ev.blocks(damus_state.keypair).blocks
for case .mention(let mention) in blocks {
guard case .pubkey(let pk) = mention.ref, pk == damus_state.keypair.pubkey else {
continue
}
let content_preview = render_notification_content_preview(cache: damus_state.events, ev: ev, profiles: damus_state.profiles, keypair: damus_state.keypair)
let notify = LocalNotification(type: .mention, event: ev, target: ev, content: content_preview)
create_local_notification(profiles: damus_state.profiles, notify: notify )
}
} else if type == .boost,
damus_state.settings.repost_notification,
let inner_ev = ev.get_inner_event(cache: damus_state.events)
{
let content_preview = render_notification_content_preview(cache: damus_state.events, ev: inner_ev, profiles: damus_state.profiles, keypair: damus_state.keypair)
let notify = LocalNotification(type: .repost, event: ev, target: inner_ev, content: content_preview)
create_local_notification(profiles: damus_state.profiles, notify: notify)
} else if type == .like,
damus_state.settings.like_notification,
let evid = ev.referenced_ids.last,
let liked_event = damus_state.events.lookup(evid)
{
let content_preview = render_notification_content_preview(cache: damus_state.events, ev: liked_event, profiles: damus_state.profiles, keypair: damus_state.keypair)
let notify = LocalNotification(type: .like, event: ev, target: liked_event, content: content_preview)
create_local_notification(profiles: damus_state.profiles, notify: notify)
}
}
func create_local_notification(profiles: Profiles, notify: LocalNotification) {
let content = UNMutableNotificationContent()
var title = ""
var identifier = ""
let displayName = event_author_name(profiles: profiles, pubkey: notify.event.pubkey)
switch notify.type {
case .mention:
title = String(format: NSLocalizedString("Mentioned by %@", comment: "Mentioned by heading in local notification"), displayName)
identifier = "myMentionNotification"
case .repost:
title = String(format: NSLocalizedString("Reposted by %@", comment: "Reposted by heading in local notification"), displayName)
identifier = "myBoostNotification"
case .like:
title = String(format: NSLocalizedString("%@ reacted with %@", comment: "Reacted by heading in local notification"), displayName, to_reaction_emoji(ev: notify.event) ?? "")
identifier = "myLikeNotification"
case .dm:
title = displayName
identifier = "myDMNotification"
case .zap, .profile_zap:
// not handled here
break
}
content.title = title
content.body = notify.content
content.sound = UNNotificationSound.default
content.userInfo = notify.to_lossy().to_user_info()
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error: \(error)")
} else {
print("Local notification scheduled")
}
}
}
enum ProcessZapResult {
case already_processed(Zap)
case done(Zap)
case failed
}
extension Sequence {
func just_one() -> Element? {
var got_one = false
var the_x: Element? = nil
for x in self {
guard !got_one else {
return nil
}
the_x = x
got_one = true
}
return the_x
}
}
// securely get the zap target's pubkey. this can be faked so we need to be
// careful
func get_zap_target_pubkey(ev: NostrEvent, events: EventCache) -> Pubkey? {
let etags = Array(ev.referenced_ids)
guard let etag = etags.first else {
// no etags, ptag-only case
guard let a = ev.referenced_pubkeys.just_one() else {
return nil
}
// TODO: just return data here
return a
}
// we have an e-tag
// ensure that there is only 1 etag to stop fake note zap attacks
guard etags.count == 1 else {
return nil
}
// we can't trust the p tag on note zaps because they can be faked
guard let pk = events.lookup(etag)?.pubkey else {
// We don't have the event in cache so we can't check the pubkey.
// We could return this as an invalid zap but that wouldn't be correct
// all of the time, and may reject valid zaps. What we need is a new
// unvalidated zap state, but for now we simply leak a bit of correctness...
return ev.referenced_pubkeys.just_one()
}
return pk
}
@MainActor
func process_zap_event(damus_state: DamusState, ev: NostrEvent, completion: @escaping (ProcessZapResult) -> Void) {
// These are zap notifications
guard let ptag = get_zap_target_pubkey(ev: ev, events: damus_state.events) else {
completion(.failed)
return
}
// just return the zap if we already have it
if let zap = damus_state.zaps.zaps[ev.id], case .zap(let z) = zap {
completion(.already_processed(z))
return
}
if let local_zapper = damus_state.profiles.lookup_zapper(pubkey: ptag) {
guard let zap = process_zap_event_with_zapper(damus_state: damus_state, ev: ev, zapper: local_zapper) else {
completion(.failed)
return
}
damus_state.add_zap(zap: .zap(zap))
completion(.done(zap))
return
}
guard let lnurl = damus_state.profiles.lookup_with_timestamp(ptag)
.map({ pr in pr?.lnurl }).value else {
completion(.failed)
return
}
Task { [lnurl] in
guard let zapper = await fetch_zapper_from_lnurl(lnurls: damus_state.lnurls, pubkey: ptag, lnurl: lnurl) else {
completion(.failed)
return
}
DispatchQueue.main.async {
damus_state.profiles.profile_data(ptag).zapper = zapper
guard let zap = process_zap_event_with_zapper(damus_state: damus_state, ev: ev, zapper: zapper) else {
completion(.failed)
return
}
damus_state.add_zap(zap: .zap(zap))
completion(.done(zap))
}
}
}
fileprivate func process_zap_event_with_zapper(damus_state: DamusState, ev: NostrEvent, zapper: Pubkey) -> Zap? {
let our_keypair = damus_state.keypair
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else {
return nil
}
damus_state.add_zap(zap: .zap(zap))
return zap
}
+2 -46
View File
@@ -8,13 +8,6 @@
import Foundation import Foundation
import UIKit import UIKit
enum PreUploadedMedia {
case uiimage(UIImage)
case processed_image(URL)
case unprocessed_image(URL)
case processed_video(URL)
case unprocessed_video(URL)
}
enum MediaUpload { enum MediaUpload {
case image(URL) case image(URL)
@@ -49,32 +42,6 @@ enum MediaUpload {
return false return false
} }
var mime_type: String {
switch self.file_extension {
case "jpg", "jpeg":
return "image/jpg"
case "png":
return "image/png"
case "gif":
return "image/gif"
case "tiff", "tif":
return "image/tiff"
case "mp4":
return "video/mp4"
case "ogg":
return "video/ogg"
case "webm":
return "video/webm"
default:
switch self {
case .image:
return "image/jpg"
case .video:
return "video/mp4"
}
}
}
} }
class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject { class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
@@ -82,20 +49,9 @@ class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
func start(media: MediaUpload, uploader: MediaUploader, keypair: Keypair? = nil) async -> ImageUploadResult { func start(media: MediaUpload, uploader: MediaUploader, keypair: Keypair? = nil) async -> ImageUploadResult {
let res = await create_upload_request(mediaToUpload: media, mediaUploader: uploader, progress: self, keypair: keypair) let res = await create_upload_request(mediaToUpload: media, mediaUploader: uploader, progress: self, keypair: keypair)
DispatchQueue.main.async {
switch res { self.progress = nil
case .success(_):
DispatchQueue.main.async {
self.progress = nil
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
case .failed(_):
DispatchQueue.main.async {
self.progress = nil
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
} }
return res return res
} }
-41
View File
@@ -1,41 +0,0 @@
//
// LongformEvent.swift
// damus
//
// Created by Daniel Nogueira on 2023-11-24.
//
import Foundation
struct LongformEvent {
let event: NostrEvent
var title: String? = nil
var image: URL? = nil
var summary: String? = nil
var published_at: Date? = nil
var labels: [String]? = nil
static func parse(from ev: NostrEvent) -> LongformEvent {
var longform = LongformEvent(event: ev)
for tag in ev.tags {
guard tag.count >= 2 else { continue }
switch tag[0].string() {
case "title": longform.title = tag[1].string()
case "image": longform.image = URL(string: tag[1].string())
case "summary": longform.summary = tag[1].string()
case "published_at":
longform.published_at = Double(tag[1].string()).map { d in Date(timeIntervalSince1970: d) }
case "t":
if (longform.labels?.append(tag[1].string())) == nil {
longform.labels = [tag[1].string()]
}
default:
break
}
}
return longform
}
}
-95
View File
@@ -1,95 +0,0 @@
//
// MediaUploader.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
var id: String { self.rawValue }
case nostrBuild
case nostrcheck
init?(from string: String) {
guard let mu = MediaUploader(rawValue: string) else {
return nil
}
self = mu
}
func to_string() -> String {
return rawValue
}
var nameParam: String {
switch self {
case .nostrBuild:
return "\"fileToUpload\""
default:
return "\"file\""
}
}
var supportsVideo: Bool {
switch self {
case .nostrBuild:
return true
case .nostrcheck:
return true
}
}
struct Model: Identifiable, Hashable {
var id: String { self.tag }
var index: Int
var tag: String
var displayName : String
}
var model: Model {
switch self {
case .nostrBuild:
return .init(index: -1, tag: "nostrBuild", displayName: "nostr.build")
case .nostrcheck:
return .init(index: 0, tag: "nostrcheck", displayName: "nostrcheck.me")
}
}
var postAPI: String {
switch self {
case .nostrBuild:
return "https://nostr.build/api/v2/nip96/upload"
case .nostrcheck:
return "https://nostrcheck.me/api/v2/media"
}
}
func getMediaURL(from data: Data) -> String? {
do {
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
let status = jsonObject["status"] as? String {
if status == "success", let nip94Event = jsonObject["nip94_event"] as? [String: Any] {
if let tags = nip94Event["tags"] as? [[String]] {
for tagArray in tags {
if tagArray.count > 1, tagArray[0] == "url" {
return tagArray[1]
}
}
}
} else if status == "error", let message = jsonObject["message"] as? String {
print("Upload Error: \(message)")
return nil
}
}
} catch {
print("Failed JSONSerialization")
return nil
}
return nil
}
}
+50 -54
View File
@@ -10,8 +10,6 @@ import Foundation
enum MentionType: AsciiCharacter, TagKey { enum MentionType: AsciiCharacter, TagKey {
case p case p
case e case e
case a
case r
var keychar: AsciiCharacter { var keychar: AsciiCharacter {
self.rawValue self.rawValue
@@ -19,26 +17,21 @@ enum MentionType: AsciiCharacter, TagKey {
} }
enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable { enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
case pubkey(Pubkey) case pubkey(Pubkey) // TODO: handle nprofile
case note(NoteId) case note(NoteId)
case nevent(NEvent)
case nprofile(NProfile)
case nrelay(String)
case naddr(NAddr)
var key: MentionType { var key: MentionType {
switch self { switch self {
case .pubkey: return .p case .pubkey: return .p
case .note: return .e case .note: return .e
case .nevent: return .e
case .nprofile: return .p
case .nrelay: return .r
case .naddr: return .a
} }
} }
var bech32: String { var bech32: String {
return Bech32Object.encode(toBech32Object()) switch self {
case .pubkey(let pubkey): return bech32_pubkey(pubkey)
case .note(let noteId): return bech32_note_id(noteId)
}
} }
static func from_bech32(str: String) -> MentionRef? { static func from_bech32(str: String) -> MentionRef? {
@@ -53,10 +46,6 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
switch self { switch self {
case .pubkey(let pubkey): return pubkey case .pubkey(let pubkey): return pubkey
case .note: return nil case .note: return nil
case .nevent(let nevent): return nevent.author
case .nprofile(let nprofile): return nprofile.author
case .nrelay: return nil
case .naddr: return nil
} }
} }
@@ -64,10 +53,6 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
switch self { switch self {
case .pubkey(let pubkey): return ["p", pubkey.hex()] case .pubkey(let pubkey): return ["p", pubkey.hex()]
case .note(let noteId): return ["e", noteId.hex()] case .note(let noteId): return ["e", noteId.hex()]
case .nevent(let nevent): return ["e", nevent.noteid.hex()]
case .nprofile(let nprofile): return ["p", nprofile.author.hex()]
case .nrelay(let url): return ["r", url]
case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()]
} }
} }
@@ -79,45 +64,14 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
guard let t0 = i.next(), guard let t0 = i.next(),
let chr = t0.single_char, let chr = t0.single_char,
let mention_type = MentionType(rawValue: chr), let mention_type = MentionType(rawValue: chr),
let element = i.next() let id = i.next()?.id()
else { else {
return nil return nil
} }
switch mention_type { switch mention_type {
case .p: case .p: return .pubkey(Pubkey(id))
guard let data = element.id() else { return nil } case .e: return .note(NoteId(id))
return .pubkey(Pubkey(data))
case .e:
guard let data = element.id() else { return nil }
return .note(NoteId(data))
case .a:
let str = element.string()
let data = str.split(separator: ":")
if(data.count != 3) { return nil }
guard let pubkey = Pubkey(hex: String(data[1])) else { return nil }
guard let kind = UInt32(data[0]) else { return nil }
return .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: [], kind: kind))
case .r: return .nrelay(element.string())
}
}
func toBech32Object() -> Bech32Object {
switch self {
case .pubkey(let pk):
return .npub(pk)
case .note(let noteid):
return .note(noteid)
case .naddr(let naddr):
return .naddr(naddr)
case .nevent(let nevent):
return .nevent(nevent)
case .nprofile(let nprofile):
return .nprofile(nprofile)
case .nrelay(let url):
return .nrelay(url)
} }
} }
} }
@@ -256,3 +210,45 @@ func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
return nil return nil
} }
struct PostTags {
let blocks: [Block]
let tags: [[String]]
}
/// Convert
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
var new_tags = tags
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
if case .note = mention.ref {
continue
}
new_tags.append(mention.ref.tag)
case .hashtag(let hashtag):
new_tags.append(["t", hashtag.lowercased()])
case .text: break
case .invoice: break
case .relay: break
case .url(let url):
new_tags.append(["r", url.absoluteString])
break
}
}
return PostTags(blocks: post_blocks, tags: new_tags)
}
func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? {
let tags = post.references.map({ r in r.tag }) + post.tags
let post_blocks = parse_post_blocks(content: post.content)
let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags)
let content = post_tags.blocks
.map(\.asString)
.joined(separator: "")
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: post.kind.rawValue, tags: post_tags.tags)
}
-8
View File
@@ -1,8 +0,0 @@
//
// MuteManager.swift
// damus
//
// Created by William Casarin on 2024-01-25.
//
import Foundation
-202
View File
@@ -1,202 +0,0 @@
//
// MuteItem.swift
// damus
//
// Created by Charlie Fish on 1/13/24.
//
import Foundation
/// Represents an item that is muted.
enum MuteItem: Hashable, Equatable {
/// A user that is muted.
///
/// The associated type is the ``Pubkey`` that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires.
case user(Pubkey, Date?)
/// A hashtag that is muted.
///
/// The associated type is the hashtag string that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires.
case hashtag(Hashtag, Date?)
/// A word/phrase that is muted.
///
/// The associated type is the word/phrase that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires.
case word(String, Date?)
/// A thread that is muted.
///
/// The associated type is the `id` of the note that is muted. The second associated type is the date that the item should expire at. If no date is supplied, assume the muted item should remain active until it expires.
case thread(NoteId, Date?)
func is_expired() -> Bool {
switch self {
case .user(_, let expiration_date):
return expiration_date ?? .distantFuture < Date()
case .hashtag(_, let expiration_date):
return expiration_date ?? .distantFuture < Date()
case .word(_, let expiration_date):
return expiration_date ?? .distantFuture < Date()
case .thread(_, let expiration_date):
return expiration_date ?? .distantFuture < Date()
}
}
static func == (lhs: MuteItem, rhs: MuteItem) -> Bool {
// lhs is the item we want to check (ie. the item the user is attempting to display)
// rhs is the item we want to check against (ie. the item in the mute list)
switch (lhs, rhs) {
case (.user(let lhs_pubkey, _), .user(let rhs_pubkey, let rhs_expiration_date)):
return lhs_pubkey == rhs_pubkey && !rhs.is_expired()
case (.hashtag(let lhs_hashtag, _), .hashtag(let rhs_hashtag, let rhs_expiration_date)):
return lhs_hashtag == rhs_hashtag && !rhs.is_expired()
case (.word(let lhs_word, _), .word(let rhs_word, let rhs_expiration_date)):
return lhs_word == rhs_word && !rhs.is_expired()
case (.thread(let lhs_thread, _), .thread(let rhs_thread, let rhs_expiration_date)):
return lhs_thread == rhs_thread && !rhs.is_expired()
default:
return false
}
}
private var refTags: [String] {
switch self {
case .user(let pubkey, _):
return RefId.pubkey(pubkey).tag
case .hashtag(let hashtag, _):
return RefId.hashtag(hashtag).tag
case .word(let string, _):
return ["word", string]
case .thread(let noteId, _):
return RefId.event(noteId).tag
}
}
var tag: [String] {
var tag = self.refTags
switch self {
case .user(_, let date):
if let date {
tag.append("\(Int(date.timeIntervalSince1970))")
}
case .hashtag(_, let date):
if let date {
tag.append("\(Int(date.timeIntervalSince1970))")
}
case .word(_, let date):
if let date {
tag.append("\(Int(date.timeIntervalSince1970))")
}
case .thread(_, let date):
if let date {
tag.append("\(Int(date.timeIntervalSince1970))")
}
}
return tag
}
var title: String {
switch self {
case .user:
return "user"
case .hashtag:
return "hashtag"
case .word:
return "word"
case .thread:
return "thread"
}
}
init?(_ tag: [String]) {
guard let tag_id = tag.first else { return nil }
guard let tag_content = tag[safe: 1] else { return nil }
let tag_expiration_date: Date? = {
if let tag_expiration_string: String = tag[safe: 2],
let tag_expiration_number: TimeInterval = Double(tag_expiration_string) {
return Date(timeIntervalSince1970: tag_expiration_number)
} else {
return nil
}
}()
switch tag_id {
case "p":
guard let pubkey = Pubkey(hex: tag_content) else { return nil }
self = MuteItem.user(pubkey, tag_expiration_date)
break
case "t":
self = MuteItem.hashtag(Hashtag(hashtag: tag_content), tag_expiration_date)
break
case "word":
self = MuteItem.word(tag_content, tag_expiration_date)
break
case "thread":
guard let note_id = NoteId(hex: tag_content) else { return nil }
self = MuteItem.thread(note_id, tag_expiration_date)
break
default:
return nil
}
}
}
// - MARK: TagConvertible
extension MuteItem: TagConvertible {
enum MuteKeys: String {
case p, t, word, e
init?(tag: NdbTagElem) {
let len = tag.count
if len == 1 {
switch tag.single_char {
case "p": self = .p
case "t": self = .t
case "e": self = .e
default: return nil
}
} else if len == 4 && tag.matches_str("word", tag_len: 4) {
self = .word
} else {
return nil
}
}
var description: String { self.rawValue }
}
static func from_tag(tag: TagSequence) -> MuteItem? {
guard tag.count >= 2 else { return nil }
var i = tag.makeIterator()
guard let t0 = i.next(),
let mkey = MuteKeys(tag: t0),
let t1 = i.next()
else {
return nil
}
var expiry: Date? = nil
if let expiry_str = i.next(), let ts = expiry_str.u64() {
expiry = Date(timeIntervalSince1970: Double(ts))
}
switch mkey {
case .p:
return t1.id().map({ .user(Pubkey($0), expiry) })
case .t:
return .hashtag(Hashtag(hashtag: t1.string()), expiry)
case .word:
return .word(t1.string(), expiry)
case .e:
guard let id = t1.id() else { return nil }
return .thread(NoteId(id), expiry)
}
}
}
+53 -17
View File
@@ -11,7 +11,7 @@ fileprivate func getMutedThreadsKey(pubkey: Pubkey) -> String {
pk_setting_key(pubkey, key: "muted_threads") pk_setting_key(pubkey, key: "muted_threads")
} }
func loadOldMutedThreads(pubkey: Pubkey) -> [NoteId] { func loadMutedThreads(pubkey: Pubkey) -> [NoteId] {
let key = getMutedThreadsKey(pubkey: pubkey) let key = getMutedThreadsKey(pubkey: pubkey)
let xs = UserDefaults.standard.stringArray(forKey: key) ?? [] let xs = UserDefaults.standard.stringArray(forKey: key) ?? []
return xs.reduce(into: [NoteId]()) { ids, k in return xs.reduce(into: [NoteId]()) { ids, k in
@@ -20,20 +20,56 @@ func loadOldMutedThreads(pubkey: Pubkey) -> [NoteId] {
} }
} }
// We need to still use it since existing users might have their muted threads stored in UserDefaults func saveMutedThreads(pubkey: Pubkey, currentValue: [NoteId], value: [NoteId]) -> Bool {
// So now all it's doing is moving a users muted threads to the new kind:10000 system let uniqueMutedThreads = Array(Set(value))
// It should not be used for any purpose beyond that
func migrate_old_muted_threads_to_new_mutelist(keypair: Keypair, damus_state: DamusState) { if uniqueMutedThreads != currentValue {
// Ensure that keypair is fullkeypair let ids = uniqueMutedThreads.map { note_id in return note_id.hex() }
guard let fullKeypair = keypair.to_full() else { return } UserDefaults.standard.set(ids, forKey: getMutedThreadsKey(pubkey: pubkey))
// Load existing muted threads return true
let mutedThreads = loadOldMutedThreads(pubkey: fullKeypair.pubkey) }
guard !mutedThreads.isEmpty else { return }
// Set new muted system for those existing threads return false
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) class MutedThreadsManager: ObservableObject {
damus_state.postbox.send(new_mutelist_event)
// Set existing muted threads to an empty array private let keypair: Keypair
UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey))
private var _mutedThreadsSet: Set<NoteId>
private var _mutedThreads: [NoteId]
var mutedThreads: [NoteId] {
get {
return _mutedThreads
}
set {
if saveMutedThreads(pubkey: keypair.pubkey, currentValue: _mutedThreads, value: newValue) {
self._mutedThreads = newValue
self.objectWillChange.send()
}
}
}
init(keypair: Keypair) {
self._mutedThreads = loadMutedThreads(pubkey: keypair.pubkey)
self._mutedThreadsSet = Set(_mutedThreads)
self.keypair = keypair
}
func isMutedThread(_ ev: NostrEvent, keypair: Keypair) -> Bool {
return _mutedThreadsSet.contains(ev.thread_id(keypair: keypair))
}
func updateMutedThread(_ ev: NostrEvent) {
let threadId = ev.thread_id(keypair: keypair)
if isMutedThread(ev, keypair: keypair) {
mutedThreads = mutedThreads.filter { $0 != threadId }
_mutedThreadsSet.remove(threadId)
notify(.unmute_thread(ev))
} else {
mutedThreads.append(threadId)
_mutedThreadsSet.insert(threadId)
notify(.mute_thread(ev))
}
}
} }
-211
View File
@@ -1,211 +0,0 @@
//
// MutelistManager.swift
// damus
//
// Created by Charlie Fish on 1/28/24.
//
import Foundation
class MutelistManager {
let user_keypair: Keypair
private(set) var event: NostrEvent? = nil
var users: Set<MuteItem> = [] {
didSet { self.reset_cache() }
}
var hashtags: Set<MuteItem> = [] {
didSet { self.reset_cache() }
}
var threads: Set<MuteItem> = [] {
didSet { self.reset_cache() }
}
var words: Set<MuteItem> = [] {
didSet { self.reset_cache() }
}
var muted_notes_cache: [NoteId: EventMuteStatus] = [:]
init(user_keypair: Keypair) {
self.user_keypair = user_keypair
}
func refresh_sets() {
guard let referenced_mute_items = event?.referenced_mute_items else { return }
var new_users: Set<MuteItem> = []
var new_hashtags: Set<MuteItem> = []
var new_threads: Set<MuteItem> = []
var new_words: Set<MuteItem> = []
for mute_item in referenced_mute_items {
switch mute_item {
case .user:
new_users.insert(mute_item)
case .hashtag:
new_hashtags.insert(mute_item)
case .word:
new_words.insert(mute_item)
case .thread:
new_threads.insert(mute_item)
}
}
users = new_users
hashtags = new_hashtags
threads = new_threads
words = new_words
}
func reset_cache() {
self.muted_notes_cache = [:]
}
func is_muted(_ item: MuteItem) -> Bool {
switch item {
case .user(_, _):
return users.contains(item)
case .hashtag(_, _):
return hashtags.contains(item)
case .word(_, _):
return words.contains(item)
case .thread(_, _):
return threads.contains(item)
}
}
func is_event_muted(_ ev: NostrEvent) -> Bool {
return self.event_muted_reason(ev) != nil
}
func set_mutelist(_ ev: NostrEvent) {
let oldlist = self.event
self.event = ev
let old: Set<MuteItem> = oldlist?.mute_list ?? Set<MuteItem>()
let new: Set<MuteItem> = ev.mute_list ?? Set<MuteItem>()
let diff = old.symmetricDifference(new)
var new_mutes = Set<MuteItem>()
var new_unmutes = Set<MuteItem>()
for d in diff {
if new.contains(d) {
add_mute_item(d)
new_mutes.insert(d)
} else {
remove_mute_item(d)
new_unmutes.insert(d)
}
}
if new_mutes.count > 0 {
notify(.new_mutes(new_mutes))
}
if new_unmutes.count > 0 {
notify(.new_unmutes(new_unmutes))
}
}
private func add_mute_item(_ item: MuteItem) {
switch item {
case .user(_, _):
guard !users.contains(item) else { return }
users.insert(item)
case .hashtag(_, _):
guard !hashtags.contains(item) else { return }
hashtags.insert(item)
case .word(_, _):
guard !words.contains(item) else { return }
words.insert(item)
case .thread(_, _):
guard !threads.contains(item) else { return }
threads.insert(item)
}
}
private func remove_mute_item(_ item: MuteItem) {
switch item {
case .user(_, _):
users.remove(item)
case .hashtag(_, _):
hashtags.remove(item)
case .word(_, _):
words.remove(item)
case .thread(_, _):
threads.remove(item)
}
}
func event_muted_reason(_ ev: NostrEvent) -> MuteItem? {
if let cached_mute_status = self.muted_notes_cache[ev.id] {
return cached_mute_status.mute_reason()
}
if let reason = self.compute_event_muted_reason(ev) {
self.muted_notes_cache[ev.id] = .muted(reason: reason)
return reason
}
self.muted_notes_cache[ev.id] = .not_muted
return nil
}
/// Check if an event is muted given a collection of ``MutedItem``.
///
/// - Parameter ev: The ``NostrEvent`` that you want to check the muted reason for.
/// - Returns: The ``MuteItem`` that matched the event. Or `nil` if the event is not muted.
func compute_event_muted_reason(_ ev: NostrEvent) -> MuteItem? {
// Events from the current user should not be muted.
guard self.user_keypair.pubkey != ev.pubkey else { return nil }
// Check if user is muted
let check_user_item = MuteItem.user(ev.pubkey, nil)
if users.contains(check_user_item) {
return check_user_item
}
// Check if hashtag is muted
for hashtag in ev.referenced_hashtags {
let check_hashtag_item = MuteItem.hashtag(hashtag, nil)
if hashtags.contains(check_hashtag_item) {
return check_hashtag_item
}
}
// Check if thread is muted
for thread_id in ev.referenced_ids {
let check_thread_item = MuteItem.thread(thread_id, nil)
if threads.contains(check_thread_item) {
return check_thread_item
}
}
// Check if word is muted
if let content: String = ev.maybe_get_content(self.user_keypair)?.lowercased() {
for word in words {
if case .word(let string, _) = word {
if content.contains(string.lowercased()) {
return word
}
}
}
}
return nil
}
enum EventMuteStatus {
case muted(reason: MuteItem)
case not_muted
func mute_reason() -> MuteItem? {
switch self {
case .muted(reason: let reason):
return reason
case .not_muted:
return nil
}
}
}
}
-24
View File
@@ -1,24 +0,0 @@
//
// NewEventsBits.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
struct NewEventsBits: OptionSet {
let rawValue: Int
static let home = NewEventsBits(rawValue: 1 << 0)
static let zaps = NewEventsBits(rawValue: 1 << 1)
static let mentions = NewEventsBits(rawValue: 1 << 2)
static let reposts = NewEventsBits(rawValue: 1 << 3)
static let likes = NewEventsBits(rawValue: 1 << 4)
static let search = NewEventsBits(rawValue: 1 << 5)
static let dms = NewEventsBits(rawValue: 1 << 6)
static let damus_app_notifications = NewEventsBits(rawValue: 1 << 7)
static let all = NewEventsBits(rawValue: 0xFFFFFFFF)
static let notifications: NewEventsBits = [.zaps, .likes, .reposts, .mentions, .damus_app_notifications]
}
-358
View File
@@ -1,358 +0,0 @@
//
// NoteContent.swift
// damus
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
import MarkdownUI
import UIKit
struct NoteArtifactsSeparated: Equatable {
static func == (lhs: NoteArtifactsSeparated, rhs: NoteArtifactsSeparated) -> Bool {
return lhs.content == rhs.content
}
let content: CompatibleText
let words: Int
let urls: [UrlType]
let invoices: [Invoice]
var media: [MediaUrl] {
return urls.compactMap { url in url.is_media }
}
var images: [URL] {
return urls.compactMap { url in url.is_img }
}
var links: [URL] {
return urls.compactMap { url in url.is_link }
}
static func just_content(_ content: String) -> NoteArtifactsSeparated {
let txt = CompatibleText(attributed: AttributedString(stringLiteral: content))
return NoteArtifactsSeparated(content: txt, words: 0, urls: [], invoices: [])
}
}
enum NoteArtifactState {
case not_loaded
case loading
case loaded(NoteArtifacts)
var artifacts: NoteArtifacts? {
if case .loaded(let artifacts) = self {
return artifacts
}
return nil
}
var should_preload: Bool {
switch self {
case .loaded:
return false
case .loading:
return false
case .not_loaded:
return true
}
}
}
func note_artifact_is_separated(kind: NostrKind?) -> Bool {
return kind != .longform
}
func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> NoteArtifacts {
let blocks = ev.blocks(keypair)
if ev.known_kind == .longform {
return .longform(LongformContent(ev.content))
}
return .separated(render_blocks(blocks: blocks, profiles: profiles))
}
func render_blocks(blocks bs: Blocks, profiles: Profiles) -> NoteArtifactsSeparated {
var invoices: [Invoice] = []
var urls: [UrlType] = []
let blocks = bs.blocks
let one_note_ref = blocks
.filter({
if case .mention(let mention) = $0,
case .note = mention.ref {
return true
}
else {
return false
}
})
.count == 1
var ind: Int = -1
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
ind = ind + 1
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(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):
invoices.append(invoice)
return str
case .url(let url):
let url_type = classify_url(url)
switch url_type {
case .media:
urls.append(url_type)
return str
case .link(let url):
urls.append(url_type)
return str + url_str(url)
}
}
}
return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
}
func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String {
var trimmed = txt
if let prev = blocks[safe: ind-1],
case .url(let u) = prev,
classify_url(u).is_media != nil {
trimmed = " " + trim_prefix(trimmed)
}
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 url_str(_ url: URL) -> CompatibleText {
var attributedString = AttributedString(stringLiteral: url.absoluteString)
attributedString.link = url
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
func classify_url(_ url: URL) -> UrlType {
let str = url.lastPathComponent.lowercased()
if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") {
return .media(.image(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) {
let attachment = NSTextAttachment()
attachment.image = img
let attachmentString = NSAttributedString(attachment: attachment)
let wrapped = AttributedString(attachmentString)
astr.append(wrapped)
}
func getDisplayName(pk: Pubkey, profiles: Profiles) -> String {
let profile_txn = profiles.lookup(id: pk, txn_name: "getDisplayName")
let profile = profile_txn?.unsafeUnownedValue
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 50)
}
func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText {
let bech32String = Bech32Object.encode(m.ref.toBech32Object())
let display_str: String = {
switch m.ref {
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
case .note: return abbrev_pubkey(bech32String)
case .nevent: return abbrev_pubkey(bech32String)
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
case .nrelay(let url): return url
case .naddr: return abbrev_pubkey(bech32String)
}
}()
let display_str_with_at = "@\(display_str)"
var attributedString = AttributedString(stringLiteral: display_str_with_at)
attributedString.link = URL(string: "damus:nostr:\(bech32String)")
attributedString.foregroundColor = DamusColors.purple
return CompatibleText(attributed: attributedString)
}
// trim suffix whitespace and newlines
func trim_suffix(_ str: String) -> String {
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
}
// trim prefix whitespace and newlines
func trim_prefix(_ str: String) -> String {
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
}
struct LongformContent {
let markdown: MarkdownContent
let words: Int
init(_ markdown: String) {
let blocks = [BlockNode].init(markdown: markdown)
self.markdown = MarkdownContent(blocks: blocks)
self.words = count_markdown_words(blocks: blocks)
}
}
func count_markdown_words(blocks: [BlockNode]) -> Int {
return blocks.reduce(0) { words, block in
switch block {
case .paragraph(let content):
return words + count_inline_nodes_words(nodes: content)
case .blockquote, .bulletedList, .numberedList, .taskList, .codeBlock, .htmlBlock, .heading, .table, .thematicBreak:
return words
}
}
}
func count_words(_ s: String) -> Int {
return s.components(separatedBy: .whitespacesAndNewlines).count
}
func count_inline_nodes_words(nodes: [InlineNode]) -> Int {
return nodes.reduce(0) { words, node in
switch node {
case .text(let words):
return count_words(words)
case .emphasis(let children):
return words + count_inline_nodes_words(nodes: children)
case .strong(let children):
return words + count_inline_nodes_words(nodes: children)
case .strikethrough(let children):
return words + count_inline_nodes_words(nodes: children)
case .softBreak, .lineBreak, .code, .html, .image, .link:
return words
}
}
}
enum NoteArtifacts {
case separated(NoteArtifactsSeparated)
case longform(LongformContent)
var images: [URL] {
switch self {
case .separated(let arts):
return arts.images
case .longform:
return []
}
}
}
enum UrlType {
case media(MediaUrl)
case link(URL)
var url: URL {
switch self {
case .media(let media_url):
switch media_url {
case .image(let url):
return url
case .video(let url):
return url
}
case .link(let url):
return url
}
}
var is_video: URL? {
switch self {
case .media(let media_url):
switch media_url {
case .image:
return nil
case .video(let url):
return url
}
case .link:
return nil
}
}
var is_img: URL? {
switch self {
case .media(let media_url):
switch media_url {
case .image(let url):
return url
case .video:
return nil
}
case .link:
return nil
}
}
var is_link: URL? {
switch self {
case .media:
return nil
case .link(let url):
return url
}
}
var is_media: MediaUrl? {
switch self {
case .media(let murl):
return murl
case .link:
return nil
}
}
}
enum MediaUrl {
case image(URL)
case video(URL)
var url: URL {
switch self {
case .image(let url):
return url
case .video(let url):
return url
}
}
}
-286
View File
@@ -1,286 +0,0 @@
//
// NotificationsManager.swift
// damus
//
// Handles several aspects of notification logic (Both local and push notifications)
//
// Created by Daniel DAquino on 2023-11-24.
//
import Foundation
import UIKit
let EVENT_MAX_AGE_FOR_NOTIFICATION: TimeInterval = 12 * 60 * 60
func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent) {
guard should_display_notification(state: state, event: ev, mode: .local) else {
// We should not display notification. Exit.
return
}
guard let local_notification = generate_local_notification_object(from: ev, state: state) else {
return
}
create_local_notification(profiles: state.profiles, notify: local_notification)
}
func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent, mode: UserSettingsStore.NotificationsMode) -> Bool {
// Do not show notification if it's coming from a mode different from the one selected by our user
guard state.settings.notification_mode == mode else {
return false
}
if ev.known_kind == nil {
return false
}
if state.settings.notification_only_from_following,
state.contacts.follow_state(ev.pubkey) != .follows
{
return false
}
// Don't show notifications that match mute list.
if state.mutelist_manager.is_event_muted(ev) {
return false
}
// Don't show notifications for old events
guard ev.age < EVENT_MAX_AGE_FOR_NOTIFICATION else {
return false
}
return true
}
func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamusState) -> LocalNotification? {
guard let type = ev.known_kind else {
return nil
}
if type == .text, state.settings.mention_notification {
let blocks = ev.blocks(state.keypair).blocks
for case .mention(let mention) in blocks {
guard case .pubkey(let pk) = mention.ref, pk == state.keypair.pubkey else {
continue
}
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .mention, event: ev, target: .note(ev), content: content_preview)
}
if ev.referenced_ids.contains(where: { note_id in
guard let note_author: Pubkey = state.ndb.lookup_note(note_id)?.unsafeUnownedValue?.pubkey else { return false }
guard note_author == state.keypair.pubkey else { return false }
return true
}) {
// This is a reply to one of our posts
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .reply, event: ev, target: .note(ev), content: content_preview)
}
if ev.referenced_pubkeys.contains(state.keypair.pubkey) {
// not mentioned or replied to, just tagged
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .tagged, event: ev, target: .note(ev), content: content_preview)
}
} else if type == .boost,
state.settings.repost_notification,
let inner_ev = ev.get_inner_event()
{
let content_preview = render_notification_content_preview(ev: inner_ev, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .repost, event: ev, target: .note(inner_ev), content: content_preview)
} else if type == .like, state.settings.like_notification, let evid = ev.referenced_ids.last {
if let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
let liked_event = txn.unsafeUnownedValue
{
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
return LocalNotification(type: .like, event: ev, target: .note(liked_event), content: content_preview)
} else {
return LocalNotification(type: .like, event: ev, target: .note_id(evid), content: "")
}
}
else if type == .dm,
state.settings.dm_notification {
let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
return LocalNotification(type: .dm, event: ev, target: .note(ev), content: convo)
}
else if type == .zap,
state.settings.zap_notification {
return LocalNotification(type: .zap, event: ev, target: .note(ev), content: ev.content)
}
return nil
}
func create_local_notification(profiles: Profiles, notify: LocalNotification) {
let displayName = event_author_name(profiles: profiles, pubkey: notify.event.pubkey)
guard let (content, identifier) = NotificationFormatter.shared.format_message(displayName: displayName, notify: notify) else { return }
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Error: \(error)")
} else {
print("Local notification scheduled")
}
}
}
func render_notification_content_preview(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -> String {
let prefix_len = 300
let artifacts = render_note_content(ev: ev, profiles: profiles, keypair: keypair)
// special case for longform events
if ev.known_kind == .longform {
let longform = LongformEvent(event: ev)
return longform.title ?? longform.summary ?? "Longform Event"
}
switch artifacts {
case .longform:
// we should never hit this until we have more note types built out of parts
// since we handle this case above in known_kind == .longform
return String(ev.content.prefix(prefix_len))
case .separated(let artifacts):
return String(NSAttributedString(artifacts.content.attributed).string.prefix(prefix_len))
}
}
func event_author_name(profiles: Profiles, pubkey: Pubkey) -> String {
let profile_txn = profiles.lookup(id: pubkey)
let profile = profile_txn?.unsafeUnownedValue
return Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
}
@MainActor
func get_zap(from ev: NostrEvent, state: HeadlessDamusState) async -> Zap? {
return await withCheckedContinuation { continuation in
process_zap_event(state: state, ev: ev) { zapres in
continuation.resume(returning: zapres.get_zap())
}
}
}
@MainActor
func process_zap_event(state: HeadlessDamusState, ev: NostrEvent, completion: @escaping (ProcessZapResult) -> Void) {
// These are zap notifications
guard let ptag = get_zap_target_pubkey(ev: ev, ndb: state.ndb) else {
completion(.failed)
return
}
// just return the zap if we already have it
if let zap = state.zaps.zaps[ev.id], case .zap(let z) = zap {
completion(.already_processed(z))
return
}
if let local_zapper = state.profiles.lookup_zapper(pubkey: ptag) {
guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: local_zapper) else {
completion(.failed)
return
}
state.add_zap(zap: .zap(zap))
completion(.done(zap))
return
}
guard let txn = state.profiles.lookup_with_timestamp(ptag),
let lnurl = txn.map({ pr in pr?.lnurl }).value else {
completion(.failed)
return
}
Task { [lnurl] in
guard let zapper = await fetch_zapper_from_lnurl(lnurls: state.lnurls, pubkey: ptag, lnurl: lnurl) else {
completion(.failed)
return
}
DispatchQueue.main.async {
state.profiles.profile_data(ptag).zapper = zapper
guard let zap = process_zap_event_with_zapper(state: state, ev: ev, zapper: zapper) else {
completion(.failed)
return
}
state.add_zap(zap: .zap(zap))
completion(.done(zap))
}
}
}
// securely get the zap target's pubkey. this can be faked so we need to be
// careful
func get_zap_target_pubkey(ev: NostrEvent, ndb: Ndb) -> Pubkey? {
let etags = Array(ev.referenced_ids)
guard let etag = etags.first else {
// no etags, ptag-only case
guard let a = ev.referenced_pubkeys.just_one() else {
return nil
}
// TODO: just return data here
return a
}
// we have an e-tag
// ensure that there is only 1 etag to stop fake note zap attacks
guard etags.count == 1 else {
return nil
}
// we can't trust the p tag on note zaps because they can be faked
guard let txn = ndb.lookup_note(etag),
let pk = txn.unsafeUnownedValue?.pubkey else {
// We don't have the event in cache so we can't check the pubkey.
// We could return this as an invalid zap but that wouldn't be correct
// all of the time, and may reject valid zaps. What we need is a new
// unvalidated zap state, but for now we simply leak a bit of correctness...
return ev.referenced_pubkeys.just_one()
}
return pk
}
fileprivate func process_zap_event_with_zapper(state: HeadlessDamusState, ev: NostrEvent, zapper: Pubkey) -> Zap? {
let our_keypair = state.keypair
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else {
return nil
}
state.add_zap(zap: .zap(zap))
return zap
}
enum ProcessZapResult {
case already_processed(Zap)
case done(Zap)
case failed
func get_zap() -> Zap? {
switch self {
case .already_processed(let zap):
return zap
case .done(let zap):
return zap
default:
return nil
}
}
}
-63
View File
@@ -13,7 +13,6 @@ enum NotificationItem {
case profile_zap(ZapGroup) case profile_zap(ZapGroup)
case event_zap(NoteId, ZapGroup) case event_zap(NoteId, ZapGroup)
case reply(NostrEvent) case reply(NostrEvent)
case damus_app_notification(DamusAppNotification)
var is_reply: NostrEvent? { var is_reply: NostrEvent? {
if case .reply(let ev) = self { if case .reply(let ev) = self {
@@ -34,8 +33,6 @@ enum NotificationItem {
return nil return nil
case .repost: case .repost:
return nil return nil
case .damus_app_notification(_):
return nil
} }
} }
@@ -51,8 +48,6 @@ enum NotificationItem {
return zapgrp.last_event_at return zapgrp.last_event_at
case .reply(let reply): case .reply(let reply):
return reply.created_at return reply.created_at
case .damus_app_notification(let notification):
return notification.last_event_at
} }
} }
@@ -68,8 +63,6 @@ enum NotificationItem {
return zapgrp.would_filter(isIncluded) return zapgrp.would_filter(isIncluded)
case .reply(let ev): case .reply(let ev):
return !isIncluded(ev) return !isIncluded(ev)
case .damus_app_notification(_):
return true
} }
} }
@@ -86,8 +79,6 @@ enum NotificationItem {
case .reply(let ev): case .reply(let ev):
if isIncluded(ev) { return .reply(ev) } if isIncluded(ev) { return .reply(ev) }
return nil return nil
case .damus_app_notification(_):
return self
} }
} }
} }
@@ -103,9 +94,6 @@ class NotificationsModel: ObservableObject, ScrollQueue {
var reactions: [NoteId: EventGroup] = [:] var reactions: [NoteId: EventGroup] = [:]
var reposts: [NoteId: EventGroup] = [:] var reposts: [NoteId: EventGroup] = [:]
var replies: [NostrEvent] = [] var replies: [NostrEvent] = []
var incoming_app_notifications: [DamusAppNotification] = []
var app_notifications: [DamusAppNotification] = []
var has_app_notification = Set<DamusAppNotification.Content>()
var has_reply = Set<NoteId>() var has_reply = Set<NoteId>()
var has_ev = Set<NoteId>() var has_ev = Set<NoteId>()
@@ -172,10 +160,6 @@ class NotificationsModel: ObservableObject, ScrollQueue {
notifs.append(.reply(reply)) notifs.append(.reply(reply))
} }
for app_notification in app_notifications {
notifs.append(.damus_app_notification(app_notification))
}
notifs.sort { $0.last_event_at > $1.last_event_at } notifs.sort { $0.last_event_at > $1.last_event_at }
return notifs return notifs
} }
@@ -270,33 +254,6 @@ class NotificationsModel: ObservableObject, ScrollQueue {
return false return false
} }
func insert_app_notification(notification: DamusAppNotification) -> Bool {
if has_app_notification.contains(notification.content) {
return false
}
if should_queue {
incoming_app_notifications.append(notification)
return true
}
if insert_app_notification_immediate(notification: notification) {
self.notifications = build_notifications()
return true
}
return false
}
func insert_app_notification_immediate(notification: DamusAppNotification) -> Bool {
if has_app_notification.contains(notification.content) {
return false
}
self.app_notifications.append(notification)
has_app_notification.insert(notification.content)
return true
}
func insert_zap(_ zap: Zapping) -> Bool { func insert_zap(_ zap: Zapping) -> Bool {
if should_queue { if should_queue {
return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap) return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap)
@@ -362,10 +319,6 @@ class NotificationsModel: ObservableObject, ScrollQueue {
inserted = insert_event_immediate(event, cache: damus_state.events) || inserted inserted = insert_event_immediate(event, cache: damus_state.events) || inserted
} }
for incoming_app_notification in incoming_app_notifications {
inserted = insert_app_notification_immediate(notification: incoming_app_notification) || inserted
}
if inserted { if inserted {
self.notifications = build_notifications() self.notifications = build_notifications()
} }
@@ -373,19 +326,3 @@ class NotificationsModel: ObservableObject, ScrollQueue {
return inserted return inserted
} }
} }
struct DamusAppNotification {
let notification_timestamp: Date
var last_event_at: UInt32 { UInt32(notification_timestamp.timeIntervalSince1970) }
let content: Content
init(content: Content, timestamp: Date) {
self.notification_timestamp = timestamp
self.content = content
}
enum Content: Hashable, Equatable {
case purple_impending_expiration(days_remaining: Int, expiry_date: UInt64)
case purple_expired(expiry_date: UInt64)
}
}
+4 -78
View File
@@ -10,93 +10,19 @@ import Foundation
struct NostrPost { struct NostrPost {
let kind: NostrKind let kind: NostrKind
let content: String let content: String
let references: [RefId]
let tags: [[String]] let tags: [[String]]
init(content: String, kind: NostrKind = .text, tags: [[String]] = []) { init(content: String, references: [RefId], kind: NostrKind = .text, tags: [[String]] = []) {
self.content = content self.content = content
self.references = references
self.kind = kind self.kind = kind
self.tags = tags self.tags = tags
} }
func to_event(keypair: FullKeypair) -> NostrEvent? {
let post_blocks = self.parse_blocks()
let post_tags = self.make_post_tags(post_blocks: post_blocks, tags: self.tags)
let content = post_tags.blocks
.map(\.asString)
.joined(separator: "")
if self.kind == .highlight {
var new_tags = post_tags.tags.filter({ $0[safe: 0] != "comment" })
if content.count > 0 {
new_tags.append(["comment", content])
}
return NostrEvent(content: self.content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: new_tags)
}
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: post_tags.tags)
}
func parse_blocks() -> [Block] {
guard let content_for_parsing = self.default_content_for_block_parsing() else { return [] }
return parse_post_blocks(content: content_for_parsing)
}
private func default_content_for_block_parsing() -> String? {
switch kind {
case .highlight:
return tags.filter({ $0[safe: 0] == "comment" }).first?[safe: 1]
default:
return self.content
}
}
/// Parse the post's contents to find more tags to apply to the final nostr event
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
var new_tags = tags
for post_block in post_blocks {
switch post_block {
case .mention(let mention):
switch(mention.ref) {
case .note, .nevent:
continue
default:
break
}
if self.kind == .highlight, case .pubkey(_) = mention.ref {
var new_tag = mention.ref.tag
new_tag.append("mention")
new_tags.append(new_tag)
}
else {
new_tags.append(mention.ref.tag)
}
case .hashtag(let hashtag):
new_tags.append(["t", hashtag.lowercased()])
case .text: break
case .invoice: break
case .relay: break
case .url(let url):
new_tags.append(self.kind == .highlight ? ["r", url.absoluteString, "mention"] : ["r", url.absoluteString])
break
}
}
return PostTags(blocks: post_blocks, tags: new_tags)
}
} }
// MARK: - Helper structures and functions
extension NostrPost {
/// A struct used for temporarily holding tag information that was parsed from a post contents to aid in building a nostr event
struct PostTags {
let blocks: [Block]
let tags: [[String]]
}
}
/// Return a list of tags
func parse_post_blocks(content: String) -> [Block] { func parse_post_blocks(content: String) -> [Block] {
return parse_note_content(content: .content(content, nil)).blocks return parse_note_content(content: .content(content, nil)).blocks
} }
+7 -31
View File
@@ -10,10 +10,8 @@ import Foundation
class ProfileModel: ObservableObject, Equatable { class ProfileModel: ObservableObject, Equatable {
@Published var contacts: NostrEvent? = nil @Published var contacts: NostrEvent? = nil
@Published var following: Int = 0 @Published var following: Int = 0
@Published var relays: [RelayURL: RelayInfo]? = nil @Published var relays: [String: RelayInfo]? = nil
@Published var progress: Int = 0 @Published var progress: Int = 0
private let MAX_SHARE_RELAYS = 4
var events: EventHolder var events: EventHolder
let pubkey: Pubkey let pubkey: Pubkey
@@ -22,7 +20,6 @@ class ProfileModel: ObservableObject, Equatable {
var seen_event: Set<NoteId> = Set() var seen_event: Set<NoteId> = Set()
var sub_id = UUID().description var sub_id = UUID().description
var prof_subid = UUID().description var prof_subid = UUID().description
var findRelay_subid = UUID().description
init(pubkey: Pubkey, damus: DamusState) { init(pubkey: Pubkey, damus: DamusState) {
self.pubkey = pubkey self.pubkey = pubkey
@@ -60,11 +57,11 @@ class ProfileModel: ObservableObject, Equatable {
damus.pool.unsubscribe(sub_id: sub_id) damus.pool.unsubscribe(sub_id: sub_id)
damus.pool.unsubscribe(sub_id: prof_subid) damus.pool.unsubscribe(sub_id: prof_subid)
} }
func subscribe() { func subscribe() {
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight]) var text_filter = NostrFilter(kinds: [.text, .longform])
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost]) var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
profile_filter.authors = [pubkey] profile_filter.authors = [pubkey]
text_filter.authors = [pubkey] text_filter.authors = [pubkey]
@@ -108,8 +105,8 @@ class ProfileModel: ObservableObject, Equatable {
} }
seen_event.insert(ev.id) seen_event.insert(ev.id)
} }
private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { private func handle_event(relay_id: String, ev: NostrConnectionEvent) {
switch ev { switch ev {
case .ws_event: case .ws_event:
return return
@@ -131,7 +128,7 @@ class ProfileModel: ObservableObject, Equatable {
break break
//notify(.notice, notice) //notify(.notice, notice)
case .eose: case .eose:
guard let txn = NdbTxn(ndb: damus.ndb) else { return } let txn = NdbTxn(ndb: damus.ndb)
if resp.subid == sub_id { if resp.subid == sub_id {
load_profiles(context: "profile", profiles_subid: prof_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus, txn: txn) load_profiles(context: "profile", profiles_subid: prof_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus, txn: txn)
} }
@@ -142,27 +139,6 @@ class ProfileModel: ObservableObject, Equatable {
} }
} }
} }
private func findRelaysHandler(relay_id: RelayURL, ev: NostrConnectionEvent) {
if case .nostr_event(let resp) = ev, case .event(_, let event) = resp, case .contacts = event.known_kind {
self.relays = decode_json_relays(event.content)
}
}
func subscribeToFindRelays() {
var profile_filter = NostrFilter(kinds: [.contacts])
profile_filter.authors = [pubkey]
damus.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler)
}
func unsubscribeFindRelays() {
damus.pool.unsubscribe(sub_id: findRelay_subid)
}
func getCappedRelayStrings() -> [String] {
return relays?.keys.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
}
} }
+89 -398
View File
@@ -6,62 +6,37 @@
// //
import Foundation import Foundation
import StoreKit
class DamusPurple: StoreObserverDelegate { class DamusPurple: StoreObserverDelegate {
let settings: UserSettingsStore let environment: ServerEnvironment
let keypair: Keypair let keypair: Keypair
var storekit_manager: StoreKitManager var starred_profiles_cache: [Pubkey: Bool]
var checkout_ids_in_progress: Set<String> = []
var onboarding_status: OnboardingStatus init(environment: ServerEnvironment, keypair: Keypair) {
self.environment = environment
@MainActor
var account_cache: [Pubkey: Account]
@MainActor
var account_uuid_cache: [Pubkey: UUID]
init(settings: UserSettingsStore, keypair: Keypair) {
self.settings = settings
self.keypair = keypair self.keypair = keypair
self.account_cache = [:] self.starred_profiles_cache = [:]
self.account_uuid_cache = [:]
self.storekit_manager = StoreKitManager.standard // Use singleton to avoid losing local purchase data
self.onboarding_status = OnboardingStatus()
Task {
let account: Account? = try await self.fetch_account(pubkey: self.keypair.pubkey)
if account == nil {
self.onboarding_status.account_existed_at_the_start = false
}
else {
self.onboarding_status.account_existed_at_the_start = true
}
}
} }
// MARK: Functions // MARK: Functions
func is_profile_subscribed_to_purple(pubkey: Pubkey) async -> Bool? { func is_profile_subscribed_to_purple(pubkey: Pubkey) async -> Bool? {
return try? await self.get_maybe_cached_account(pubkey: pubkey)?.active if let cached_result = self.starred_profiles_cache[pubkey] {
return cached_result
}
guard let data = await self.get_account_data(pubkey: pubkey) else { return nil }
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let active = json["active"] as? Bool {
self.starred_profiles_cache[pubkey] = active
return active
}
return nil
} }
var environment: DamusPurpleEnvironment {
return self.settings.purple_enviroment
}
var enable_purple: Bool {
return true
// TODO: On release, we could just replace this with `true` (or some other feature flag)
//return self.settings.enable_experimental_purple_api
}
// Whether to enable Apple In-app purchase support
var enable_purple_iap_support: Bool {
// TODO: When we have full support for Apple In-app purchases, we can replace this with `true` (or another feature flag)
// return self.settings.enable_experimental_purple_iap_support
return true
}
func account_exists(pubkey: Pubkey) async -> Bool? { func account_exists(pubkey: Pubkey) async -> Bool? {
guard let account_data = try? await self.get_account_data(pubkey: pubkey) else { return nil } guard let account_data = await self.get_account_data(pubkey: pubkey) else { return nil }
if let account_info = try? JSONDecoder().decode(AccountInfo.self, from: account_data) { if let account_info = try? JSONDecoder().decode(AccountInfo.self, from: account_data) {
return account_info.pubkey == pubkey.hex() return account_info.pubkey == pubkey.hex()
@@ -69,30 +44,29 @@ class DamusPurple: StoreObserverDelegate {
return false return false
} }
@MainActor func get_account_data(pubkey: Pubkey) async -> Data? {
func get_maybe_cached_account(pubkey: Pubkey) async throws -> Account? { let url = environment.get_base_url().appendingPathComponent("accounts/\(pubkey.hex())")
if let account = self.account_cache[pubkey] { var request = URLRequest(url: url)
return account request.httpMethod = "GET"
do {
let (data, _) = try await URLSession.shared.data(for: request)
return data
} catch {
print("Failed to fetch data: \(error)")
} }
return try await fetch_account(pubkey: pubkey)
} return nil
@MainActor
func fetch_account(pubkey: Pubkey) async throws -> Account? {
guard let data = try await self.get_account_data(pubkey: pubkey) ,
let account = Account.from(json_data: data) else {
return nil
}
self.account_cache[pubkey] = account
return account
} }
func get_account_data(pubkey: Pubkey) async throws -> Data? { func create_account(pubkey: Pubkey) async throws {
let url = environment.api_base_url().appendingPathComponent("accounts/\(pubkey.hex())") let url = environment.get_base_url().appendingPathComponent("accounts")
Log.info("Creating account with Damus Purple server", for: .damus_purple)
let (data, response) = try await make_nip98_authenticated_request( let (data, response) = try await make_nip98_authenticated_request(
method: .get, method: .post,
url: url, url: url,
payload: nil, payload: nil,
payload_type: nil, payload_type: nil,
@@ -102,131 +76,59 @@ class DamusPurple: StoreObserverDelegate {
if let httpResponse = response as? HTTPURLResponse { if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode { switch httpResponse.statusCode {
case 200: case 200:
return data Log.info("Created an account with Damus Purple server", for: .damus_purple)
case 404:
return nil
default: default:
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data) Log.error("Error in creating account with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
}
}
throw PurpleError.error_processing_response
}
func make_iap_purchase(product: Product) async throws {
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
let result = try await self.storekit_manager.purchase(product: product, id: account_uuid)
switch result {
case .success(.verified(let tx)):
// Record the purchase with the storekit manager, to make sure we have the update on the UIs as soon as possible.
// During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted.
self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product))
await tx.finish()
// Send the transaction id to the server
try await self.send_transaction_id(transaction_id: tx.originalID)
default:
// Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error.
throw PurpleError.iap_purchase_error(result: result)
}
}
@MainActor
func get_maybe_cached_uuid_for_account() async throws -> UUID {
if let account_uuid = self.account_uuid_cache[self.keypair.pubkey] {
return account_uuid
}
return try await fetch_uuid_for_account()
}
@MainActor
func fetch_uuid_for_account() async throws -> UUID {
let url = self.environment.api_base_url().appending(path: "/accounts/\(self.keypair.pubkey)/account-uuid")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Got user UUID from Damus Purple server", for: .damus_purple)
default:
Log.error("Error in getting user UUID with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
} }
} }
let account_uuid_info = try JSONDecoder().decode(AccountUUIDInfo.self, from: data) return
self.account_uuid_cache[self.keypair.pubkey] = account_uuid_info.account_uuid
return account_uuid_info.account_uuid
} }
func send_receipt() async throws { func create_account_if_not_existing(pubkey: Pubkey) async throws {
guard await !(self.account_exists(pubkey: pubkey) ?? false) else { return }
try await self.create_account(pubkey: pubkey)
}
func send_receipt() async {
// Get the receipt if it's available. // Get the receipt if it's available.
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL, if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) { FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped) try? await create_account_if_not_existing(pubkey: keypair.pubkey)
let receipt_base64_string = receiptData.base64EncodedString()
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString]
let json_data = try JSONSerialization.data(withJSONObject: json_text)
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt") do {
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
Log.info("Sending in-app purchase receipt to Damus Purple server: %s", for: .damus_purple, receipt_base64_string) let url = environment.get_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/app-store-receipt")
let (data, response) = try await make_nip98_authenticated_request( Log.info("Sending in-app purchase receipt to Damus Purple server", for: .damus_purple)
method: .post,
url: url, let (data, response) = try await make_nip98_authenticated_request(
payload: json_data, method: .post,
payload_type: .json, url: url,
auth_keypair: self.keypair payload: receiptData,
) payload_type: .binary,
auth_keypair: self.keypair
if let httpResponse = response as? HTTPURLResponse { )
switch httpResponse.statusCode {
case 200: if let httpResponse = response as? HTTPURLResponse {
Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple) switch httpResponse.statusCode {
default: case 200:
Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown") Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data) default:
Log.error("Error in sending in-app purchase receipt to Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
}
} }
}
catch {
Log.error("Couldn't read receipt data with error: %s", for: .damus_purple, error.localizedDescription)
} }
} }
} }
func send_transaction_id(transaction_id: UInt64) async throws {
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
let json_text: [String: Any] = ["transaction_id": transaction_id, "account_uuid": account_uuid.uuidString]
let json_data = try JSONSerialization.data(withJSONObject: json_text)
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/transaction-id")
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
url: url,
payload: json_data,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Sent transaction ID to Damus Purple server and activated successfully", for: .damus_purple)
default:
Log.error("Error in sending or verifying transaction ID with Damus Purple server. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
}
}
}
func translate(text: String, source source_language: String, target target_language: String) async throws -> String { func translate(text: String, source source_language: String, target target_language: String) async throws -> String {
var url = environment.api_base_url() var url = environment.get_base_url()
url.append(path: "/translate") url.append(path: "/translate")
url.append(queryItems: [ url.append(queryItems: [
.init(name: "source", value: source_language), .init(name: "source", value: source_language),
@@ -254,166 +156,6 @@ class DamusPurple: StoreObserverDelegate {
throw PurpleError.translation_no_response throw PurpleError.translation_no_response
} }
} }
func verify_npub_for_checkout(checkout_id: String) async throws {
var url = environment.api_base_url()
url.append(path: "/ln-checkout/\(checkout_id)/verify")
let (data, response) = try await make_nip98_authenticated_request(
method: .put,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Verified npub for checkout id `%s` with Damus Purple server", for: .damus_purple, checkout_id)
default:
Log.error("Error in verifying npub with Damus Purple. HTTP status code: %d; Response: %s; Checkout id: ", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown", checkout_id)
throw PurpleError.checkout_npub_verification_error
}
}
}
@MainActor
func fetch_ln_checkout_object(checkout_id: String) async throws -> LNCheckoutInfo? {
let url = environment.api_base_url().appendingPathComponent("ln-checkout/\(checkout_id)")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: nil,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
case 404:
return nil
default:
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw PurpleError.error_processing_response
}
@MainActor
func new_ln_checkout(product_template_name: String) async throws -> LNCheckoutInfo? {
let url = environment.api_base_url().appendingPathComponent("ln-checkout")
let json_text: [String: String] = ["product_template_name": product_template_name]
let json_data = try JSONSerialization.data(withJSONObject: json_text)
let (data, response) = try await make_nip98_authenticated_request(
method: .post,
url: url,
payload: json_data,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
return try JSONDecoder().decode(LNCheckoutInfo.self, from: data)
case 404:
return nil
default:
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw PurpleError.error_processing_response
}
@MainActor
func generate_verified_ln_checkout_link(product_template_name: String) async throws -> URL {
let checkout = try await self.new_ln_checkout(product_template_name: product_template_name)
guard let checkout_id = checkout?.id.uuidString.lowercased() else { throw PurpleError.error_processing_response }
try await self.verify_npub_for_checkout(checkout_id: checkout_id)
return self.environment.purple_landing_page_url()
.appendingPathComponent("checkout")
.appending(queryItems: [URLQueryItem(name: "id", value: checkout_id)])
}
@MainActor
/// This function checks the status of all checkout objects in progress with the server, and it does two things:
/// - It returns the ones that were freshly completed
/// - It internally marks them as "completed"
/// Important note: If you call this function, you must use the result, as those checkouts will not be returned the next time you call this function
///
/// - Returns: An array of checkout objects that have been successfully completed.
func check_status_of_checkouts_in_progress() async throws -> [String] {
var freshly_completed_checkouts: [String] = []
for checkout_id in self.checkout_ids_in_progress {
let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
if checkout_info?.is_all_good() == true {
freshly_completed_checkouts.append(checkout_id)
}
if checkout_info?.completed == true {
self.checkout_ids_in_progress.remove(checkout_id)
}
}
return freshly_completed_checkouts
}
@MainActor
/// This function checks the status of a specific checkout id with the server
/// You should use this result immediately, since it will internally be marked as handled
///
/// - Returns: true if this checkout is all good to go. false if not. nil if checkout was not found.
func check_and_mark_ln_checkout_is_good_to_go(checkout_id: String) async throws -> Bool? {
let checkout_info = try await self.fetch_ln_checkout_object(checkout_id: checkout_id)
if checkout_info?.completed == true {
self.checkout_ids_in_progress.remove(checkout_id) // Remove if from the list of checkouts in progress
}
return checkout_info?.is_all_good()
}
struct Account {
let pubkey: Pubkey
let created_at: Date
let expiry: Date
let subscriber_number: Int
let active: Bool
func ordinal() -> String? {
let number = Int(self.subscriber_number)
let formatter = NumberFormatter()
formatter.numberStyle = .ordinal
return formatter.string(from: NSNumber(integerLiteral: number))
}
static func from(json_data: Data) -> Self? {
guard let payload = try? JSONDecoder().decode(Payload.self, from: json_data) else { return nil }
return Self.from(payload: payload)
}
static func from(payload: Payload) -> Self? {
guard let pubkey = Pubkey(hex: payload.pubkey) else { return nil }
return Self(
pubkey: pubkey,
created_at: Date.init(timeIntervalSince1970: TimeInterval(payload.created_at)),
expiry: Date.init(timeIntervalSince1970: TimeInterval(payload.expiry)),
subscriber_number: Int(payload.subscriber_number),
active: payload.active
)
}
struct Payload: Codable {
let pubkey: String // Hex-encoded string
let created_at: UInt64 // Unix timestamp
let expiry: UInt64 // Unix timestamp
let subscriber_number: UInt
let active: Bool
}
}
} }
// MARK: API types // MARK: API types
@@ -425,82 +167,31 @@ extension DamusPurple {
let expiry: UInt64? let expiry: UInt64?
let active: Bool let active: Bool
} }
struct LNCheckoutInfo: Codable {
// Note: Swift will decode a JSON full of extra fields into a Struct with only a subset of them, but not the other way around
// Therefore, to avoid compatibility concerns and complexity, we should only use the fields we need
// The ones we do not need yet will be left commented out until we need them.
let id: UUID
/*
let product_template_name: String
let verified_pubkey: String?
*/
let invoice: Invoice?
let completed: Bool
struct Invoice: Codable {
/*
let bolt11: String
let label: String
let connection_params: ConnectionParams
*/
let paid: Bool?
/*
struct ConnectionParams: Codable {
let nodeid: String
let address: String
let rune: String
}
*/
}
/// Indicates whether this checkout is all good to go.
/// The checkout is good to go if it is marked as complete and the invoice has been successfully paid
/// - Returns: true if this checkout is all good to go. false otherwise
func is_all_good() -> Bool {
return self.completed == true && self.invoice?.paid == true
}
}
fileprivate struct AccountUUIDInfo: Codable {
let account_uuid: UUID
}
} }
// MARK: Helper structures // MARK: Helper structures
extension DamusPurple { extension DamusPurple {
enum ServerEnvironment {
case local_test
case production
func get_base_url() -> URL {
switch self {
case .local_test:
Constants.PURPLE_API_TEST_BASE_URL
case .production:
Constants.PURPLE_API_PRODUCTION_BASE_URL
}
}
}
enum PurpleError: Error { enum PurpleError: Error {
case translation_error(status_code: Int, response: Data) case translation_error(status_code: Int, response: Data)
case http_response_error(status_code: Int, response: Data)
case error_processing_response
case iap_purchase_error(result: Product.PurchaseResult)
case iap_receipt_verification_error(status: Int, response: Data)
case translation_no_response case translation_no_response
case checkout_npub_verification_error
} }
struct TranslationResult: Codable { struct TranslationResult: Codable {
let text: String let text: String
} }
struct OnboardingStatus {
var account_existed_at_the_start: Bool? = nil
var onboarding_was_shown: Bool = false
init() {
}
init(account_active_at_the_start: Bool, onboarding_was_shown: Bool) {
self.account_existed_at_the_start = account_active_at_the_start
self.onboarding_was_shown = onboarding_was_shown
}
func user_has_never_seen_the_onboarding_before() -> Bool {
return onboarding_was_shown == false && account_existed_at_the_start == false
}
}
} }
@@ -1,120 +0,0 @@
//
// DamusPurpleEnvironment.swift
// damus
//
// Created by Daniel DAquino on 2024-01-29.
//
import Foundation
enum DamusPurpleEnvironment: CaseIterable, Codable, Identifiable, StringCodable, Equatable, Hashable {
static var allCases: [DamusPurpleEnvironment] = [.local_test(host: nil), .staging, .production]
case local_test(host: String?)
case staging
case production
func text_description() -> String {
switch self {
case .local_test:
return NSLocalizedString("Test (local)", comment: "Label indicating a local test environment for Damus Purple functionality (Developer feature)")
case .staging:
return NSLocalizedString("Staging", comment: "Label indicating a staging test environment for Damus Purple functionality (Developer feature)")
case .production:
return NSLocalizedString("Production", comment: "Label indicating the production environment for Damus Purple")
}
}
func api_base_url() -> URL {
switch self {
case .local_test(let host):
URL(string: "http://\(host ?? "localhost"):8989") ?? Constants.PURPLE_API_LOCAL_TEST_BASE_URL
case .staging:
Constants.PURPLE_API_STAGING_BASE_URL
case .production:
Constants.PURPLE_API_PRODUCTION_BASE_URL
}
}
func purple_landing_page_url() -> URL {
switch self {
case .local_test(let host):
URL(string: "http://\(host ?? "localhost"):3000/purple") ?? Constants.PURPLE_LANDING_PAGE_LOCAL_TEST_URL
case .staging:
Constants.PURPLE_LANDING_PAGE_STAGING_URL
case .production:
Constants.PURPLE_LANDING_PAGE_PRODUCTION_URL
}
}
func damus_website_url() -> URL {
switch self {
case .local_test(let host):
URL(string: "http://\(host ?? "localhost"):3000") ?? Constants.DAMUS_WEBSITE_LOCAL_TEST_URL
case .staging:
Constants.DAMUS_WEBSITE_STAGING_URL
case .production:
Constants.DAMUS_WEBSITE_PRODUCTION_URL
}
}
func custom_host() -> String? {
switch self {
case .local_test(let host):
return host
default:
return nil
}
}
init?(from string: String) {
switch string {
case "local_test":
self = .local_test(host: nil)
case "staging":
self = .staging
case "production":
self = .production
default:
let components = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if components.count == 2 && components[0] == "local_test" {
self = .local_test(host: String(components[1]))
} else {
return nil
}
}
}
func to_string() -> String {
switch self {
case .local_test(let host):
if let host {
return "local_test:\(host)"
}
return "local_test"
case .staging:
return "staging"
case .production:
return "production"
}
}
var id: String {
switch self {
case .local_test(let host):
if let host {
return "local_test:\(host)"
}
else {
return "local_test"
}
case .staging:
return "staging"
case .production:
return "production"
}
}
}
-63
View File
@@ -1,63 +0,0 @@
//
// DamusPurpleURL.swift
// damus
//
// Created by Daniel Nogueira on 2024-01-13.
//
import Foundation
struct DamusPurpleURL: Equatable {
let is_staging: Bool
let variant: Self.Variant
enum Variant: Equatable {
case verify_npub(checkout_id: String)
case welcome(checkout_id: String)
case landing
}
init(is_staging: Bool, variant: Self.Variant) {
self.is_staging = is_staging
self.variant = variant
}
init?(url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil }
guard components.scheme == "damus" else { return nil }
let is_staging = components.find("staging") != nil
switch components.path {
case "purple:verify":
guard let checkout_id = components.find("id") else { return nil }
self = .init(is_staging: is_staging, variant: .verify_npub(checkout_id: checkout_id))
case "purple:welcome":
guard let checkout_id = components.find("id") else { return nil }
self = .init(is_staging: is_staging, variant: .welcome(checkout_id: checkout_id))
case "purple:landing":
self = .init(is_staging: is_staging, variant: .landing)
default:
return nil
}
}
func url_string() -> String {
let staging = is_staging ? "&staging=true" : ""
switch self.variant {
case .verify_npub(let id):
return "damus:purple:verify?id=\(id)\(staging)"
case .welcome(let id):
return "damus:purple:welcome?id=\(id)\(staging)"
case .landing:
let staging = is_staging ? "?staging=true" : ""
return "damus:purple:landing\(staging)"
}
}
}
extension URLComponents {
func find(_ name: String) -> String? {
self.queryItems?.first(where: { qi in qi.name == name })?.value
}
}
@@ -1,60 +0,0 @@
//
// DamusPurpleNotificationManagement.swift
// damus
//
// Created by Daniel DAquino on 2024-02-26.
//
import Foundation
/// A definition of how many days in advance to notify the user of impending expiration. (e.g. 3 days before expiration AND 2 days before expiration AND 1 day before expiration)
fileprivate let PURPLE_IMPENDING_EXPIRATION_NOTIFICATION_SCHEDULE: Set<Int> = [7, 3, 1]
fileprivate let ONE_DAY: TimeInterval = 60 * 60 * 24
extension DamusPurple {
typealias NotificationHandlerFunction = (DamusAppNotification) async -> Void
func check_and_send_app_notifications_if_needed(handler: NotificationHandlerFunction) async {
await self.check_and_send_purple_expiration_notifications_if_needed(handler: handler)
}
/// Checks if we need to send a DamusPurple impending expiration notification to the user, and sends them if needed.
///
/// **Note:** To keep things simple at this point, this function uses a "best effort" strategy, and silently fails if something is wrong, as it is not an essential component of the app to avoid adding more error handling complexity to the app
private func check_and_send_purple_expiration_notifications_if_needed(handler: NotificationHandlerFunction) async {
if self.storekit_manager.recorded_purchased_products.count > 0 {
// If user has a recurring IAP purchase, there no need to notify them of impending expiration
return
}
guard let purple_expiration_date: Date = try? await self.get_maybe_cached_account(pubkey: self.keypair.pubkey)?.expiry else {
return // If there are no expiry dates (e.g. The user is not a Purple user) or we cannot get it for some reason (e.g. server is temporarily down and we have no cache), don't bother sending notifications
}
let days_to_expiry: Int = round_days_to_date(purple_expiration_date, from: Date.now)
let applicable_impending_expiry_notification_schedule_items: [Int] = PURPLE_IMPENDING_EXPIRATION_NOTIFICATION_SCHEDULE.filter({ $0 >= days_to_expiry })
for applicable_impending_expiry_notification_schedule_item in applicable_impending_expiry_notification_schedule_items {
// Send notifications predicted by the schedule
// Note: The `insert_app_notification` has built-in logic to prevent us from sending two identical notifications, so we need not worry about it here.
await handler(.init(
content: .purple_impending_expiration(
days_remaining: applicable_impending_expiry_notification_schedule_item,
expiry_date: UInt64(purple_expiration_date.timeIntervalSince1970)
),
timestamp: purple_expiration_date.addingTimeInterval(-Double(applicable_impending_expiry_notification_schedule_item) * ONE_DAY))
)
}
if days_to_expiry < 0 {
await handler(.init(
content: .purple_expired(expiry_date: UInt64(purple_expiration_date.timeIntervalSince1970)),
timestamp: purple_expiration_date)
)
}
}
}
fileprivate func round_days_to_date(_ target_date: Date, from from_date: Date) -> Int {
return Int(round(target_date.timeIntervalSince(from_date) / ONE_DAY))
}
@@ -1,130 +0,0 @@
//
// PurpleStoreKitManager.swift
// damus
//
// Created by Daniel DAquino on 2024-02-09.
//
import Foundation
import StoreKit
extension DamusPurple {
class StoreKitManager { // Has to be a class to get around Swift-imposed limitations of mutations on concurrently executing code associated with the purchase update monitoring task.
// The delegate is any object that wants to be notified of successful purchases. (e.g. A view that needs to update its UI)
var delegate: DamusPurpleStoreKitManagerDelegate? = nil {
didSet {
// Whenever the delegate is set, send it all recorded transactions to make sure it's up to date.
Task {
Log.info("Delegate changed. Try sending all recorded valid product transactions", for: .damus_purple)
guard let new_delegate = delegate else {
Log.info("Delegate is nil. Cannot send recorded product transactions", for: .damus_purple)
return
}
Log.info("Sending all %d recorded valid product transactions", for: .damus_purple, self.recorded_purchased_products.count)
for purchased_product in self.recorded_purchased_products {
new_delegate.product_was_purchased(product: purchased_product)
Log.info("Sent StoreKit tx to delegate", for: .damus_purple)
}
}
}
}
// Keep track of all recorded purchases so that we can send them to the delegate when it's set (whenever it's set)
var recorded_purchased_products: [PurchasedProduct] = []
// Helper struct to keep track of a purchased product and its transaction
struct PurchasedProduct {
let tx: StoreKit.Transaction
let product: Product
}
// Singleton instance of StoreKitManager. To avoid losing purchase updates, there should only be one instance of StoreKitManager on the app.
static let standard = StoreKitManager()
init() {
Log.info("Initiliazing StoreKitManager", for: .damus_purple)
self.start()
}
func start() {
Task {
try await monitor_updates()
}
}
func get_products() async throws -> [Product] {
return try await Product.products(for: DamusPurpleType.allCases.map({ $0.rawValue }))
}
// Use this function to manually and immediately record a purchased product update
func record_purchased_product(_ purchased_product: PurchasedProduct) {
self.recorded_purchased_products.append(purchased_product)
self.delegate?.product_was_purchased(product: purchased_product)
}
// This function starts a task that monitors StoreKit updates and sends them to the delegate.
// This function will run indefinitely (It should never return), so it is important to run this as a background task.
private func monitor_updates() async throws {
Log.info("Monitoring StoreKit updates", for: .damus_purple)
// StoreKit.Transaction.updates is an async stream that emits updates whenever a purchase is verified.
for await update in StoreKit.Transaction.updates {
switch update {
case .verified(let tx):
let products = try await self.get_products()
let prod = products.filter({ prod in tx.productID == prod.id }).first
if let prod,
let expiration = tx.expirationDate,
Date.now < expiration
{
Log.info("Received valid transaction update from StoreKit", for: .damus_purple)
let purchased_product = PurchasedProduct(tx: tx, product: prod)
self.recorded_purchased_products.append(purchased_product)
self.delegate?.product_was_purchased(product: purchased_product)
Log.info("Sent tx to delegate (if exists)", for: .damus_purple)
}
case .unverified:
continue
}
}
}
// Use this function to complete a StoreKit purchase
// Specify the product and the app account token (UUID) to complete the purchase
// The account token is used to associate with the user's account on the server.
func purchase(product: Product, id: UUID) async throws -> Product.PurchaseResult {
return try await product.purchase(options: [.appAccountToken(id)])
}
}
}
extension DamusPurple.StoreKitManager {
// This helper struct is used to encapsulate StoreKit products, metadata, and supplement with additional information
enum DamusPurpleType: String, CaseIterable {
case yearly = "purpleyearly"
case monthly = "purple"
func non_discounted_price(product: Product) -> String? {
switch self {
case .yearly:
return (product.price * 1.1984569224).formatted(product.priceFormatStyle)
case .monthly:
return nil
}
}
func label() -> String {
switch self {
case .yearly:
return NSLocalizedString("Annually", comment: "Annual renewal of purple subscription")
case .monthly:
return NSLocalizedString("Monthly", comment: "Monthly renewal of purple subscription")
}
}
}
}
// This protocol is used to describe the delegate of the StoreKitManager, which will receive updates.
protocol DamusPurpleStoreKitManagerDelegate {
func product_was_purchased(product: DamusPurple.StoreKitManager.PurchasedProduct)
}
+3 -4
View File
@@ -21,14 +21,13 @@ class StoreObserver: NSObject, SKPaymentTransactionObserver {
//Observe transaction updates. //Observe transaction updates.
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
//Handle transaction states here. //Handle transaction states here.
Log.info("StoreObserver received a transaction update. Notifying to delegate.", for: .damus_purple)
Task { Task {
try await self.delegate?.send_receipt() await self.delegate?.send_receipt()
} }
} }
} }
protocol StoreObserverDelegate { protocol StoreObserverDelegate {
func send_receipt() async throws func send_receipt() async
} }
-289
View File
@@ -1,289 +0,0 @@
//
// PushNotificationClient.swift
// damus
//
// Created by Daniel DAquino on 2024-05-17.
//
import Foundation
struct PushNotificationClient {
let keypair: Keypair
let settings: UserSettingsStore
private(set) var device_token: Data? = nil
var device_token_hex: String? {
guard let device_token else { return nil }
return device_token.map { String(format: "%02.2hhx", $0) }.joined()
}
mutating func set_device_token(new_device_token: Data) async throws {
self.device_token = new_device_token
if settings.enable_push_notifications && settings.notification_mode == .push {
try await self.send_token()
}
}
func send_token() async throws {
// Send the device token and pubkey to the server
guard let token = device_token_hex else { return }
Log.info("Sending device token to server: %s", for: .push_notifications, token)
// create post request
let url = self.current_push_notification_environment().api_base_url()
.appendingPathComponent("user-info")
.appendingPathComponent(self.keypair.pubkey.hex())
.appendingPathComponent(token)
let (data, response) = try await make_nip98_authenticated_request(
method: .put,
url: url,
payload: nil,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Sent device token to Damus push notification server successfully", for: .push_notifications)
default:
Log.error("Error in sending device_token to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
return
}
func revoke_token() async throws {
guard let token = device_token_hex else { return }
Log.info("Revoking device token from server: %s", for: .push_notifications, token)
let pubkey = self.keypair.pubkey
// create post request
let url = self.current_push_notification_environment().api_base_url()
.appendingPathComponent("user-info")
.appendingPathComponent(pubkey.hex())
.appendingPathComponent(token)
let (data, response) = try await make_nip98_authenticated_request(
method: .delete,
url: url,
payload: nil,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Sent device token removal request to Damus push notification server successfully", for: .push_notifications)
default:
Log.error("Error in sending device_token removal to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
return
}
func set_settings(_ new_settings: NotificationSettings? = nil) async throws {
// Send the device token and pubkey to the server
guard let token = device_token_hex else { return }
Log.info("Sending notification preferences to the server", for: .push_notifications)
let url = self.current_push_notification_environment().api_base_url()
.appendingPathComponent("user-info")
.appendingPathComponent(self.keypair.pubkey.hex())
.appendingPathComponent(token)
.appendingPathComponent("preferences")
let json_payload = try JSONEncoder().encode(new_settings ?? NotificationSettings.from(settings: settings))
let (data, response) = try await make_nip98_authenticated_request(
method: .put,
url: url,
payload: json_payload,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
Log.info("Sent notification settings to Damus push notification server successfully", for: .push_notifications)
default:
Log.error("Error in sending notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
return
}
func get_settings() async throws -> NotificationSettings {
// Send the device token and pubkey to the server
guard let token = device_token_hex else {
throw ClientError.no_device_token
}
let url = self.current_push_notification_environment().api_base_url()
.appendingPathComponent("user-info")
.appendingPathComponent(self.keypair.pubkey.hex())
.appendingPathComponent(token)
.appendingPathComponent("preferences")
let (data, response) = try await make_nip98_authenticated_request(
method: .get,
url: url,
payload: nil,
payload_type: .json,
auth_keypair: self.keypair
)
if let httpResponse = response as? HTTPURLResponse {
switch httpResponse.statusCode {
case 200:
guard let notification_settings = NotificationSettings.from(json_data: data) else { throw ClientError.json_decoding_error }
return notification_settings
default:
Log.error("Error in getting notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
}
}
throw ClientError.could_not_process_response
}
func current_push_notification_environment() -> Environment {
return self.settings.push_notification_environment
}
}
// MARK: Helper structures
extension PushNotificationClient {
enum ClientError: Error {
case http_response_error(status_code: Int, response: Data)
case could_not_process_response
case no_device_token
case json_decoding_error
}
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
static func from(json_data: Data) -> Self? {
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
return decoded
}
static func from(settings: UserSettingsStore) -> Self {
return NotificationSettings(
zap_notifications_enabled: settings.zap_notification,
mention_notifications_enabled: settings.mention_notification,
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
)
}
}
enum Environment: CaseIterable, Codable, Identifiable, StringCodable, Equatable, Hashable {
static var allCases: [Environment] = [.local_test(host: nil), .staging, .production]
case local_test(host: String?)
case staging
case production
func text_description() -> String {
switch self {
case .local_test:
return NSLocalizedString("Test (local)", comment: "Label indicating a local test environment for Push notification functionality (Developer feature)")
case .production:
return NSLocalizedString("Production", comment: "Label indicating the production environment for Push notification functionality")
case .staging:
return NSLocalizedString("Staging (for dev builds)", comment: "Label indicating the staging environment for Push notification functionality")
}
}
func api_base_url() -> URL {
switch self {
case .local_test(let host):
URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL
case .production:
Constants.PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL
case .staging:
Constants.PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL
}
}
func custom_host() -> String? {
switch self {
case .local_test(let host):
return host
default:
return nil
}
}
init?(from string: String) {
switch string {
case "local_test":
self = .local_test(host: nil)
case "production":
self = .production
case "staging":
self = .staging
default:
let components = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if components.count == 2 && components[0] == "local_test" {
self = .local_test(host: String(components[1]))
} else {
return nil
}
}
}
func to_string() -> String {
switch self {
case .local_test(let host):
if let host {
return "local_test:\(host)"
}
return "local_test"
case .staging:
return "staging"
case .production:
return "production"
}
}
var id: String {
switch self {
case .local_test(let host):
if let host {
return "local_test:\(host)"
}
else {
return "local_test"
}
case .production:
return "production"
case .staging:
return "staging"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
//
// LikesModel.swift
// damus
//
// Created by William Casarin on 2023-01-11.
//
import Foundation
final class ReactionsModel: EventsModel {
init(state: DamusState, target: NoteId) {
super.init(state: state, target: target, kind: .like)
}
}

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