Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
1110ffa8af
|
@@ -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.
|
||||
@@ -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.
|
||||
+1
-92
@@ -1,95 +1,3 @@
|
||||
## [1.7-rc2] - 2024-02-28
|
||||
|
||||
### Added
|
||||
|
||||
- Add support for Apple In-App purchases (Daniel D’Aquino)
|
||||
- Notification reminders for Damus Purple impending expiration (Daniel D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- Add naddr link support (kernelkind)
|
||||
- Add regional relay recommendations to Relay configuration view (currently for Japanese users only) (Daniel D’Aquino)
|
||||
- Add regional relays for Germany (Daniel D’Aquino)
|
||||
- Add regional relays for Thailand (Daniel D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- Zap notification support for push notifications (Daniel D’Aquino)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Generate nprofile/nevent links in share menus (kernelkind)
|
||||
- Improve push notification support to match local notification support (Daniel D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- 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 D’Aquino)
|
||||
- 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
|
||||
|
||||
### Added
|
||||
@@ -1743,3 +1651,4 @@
|
||||
|
||||
|
||||
[0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// NostrEventInfoFromPushNotification.swift
|
||||
// DamusNotificationService
|
||||
//
|
||||
// Created by Daniel D’Aquino 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 D’Aquino 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()
|
||||
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 {
|
||||
static var shared = NotificationFormatter()
|
||||
|
||||
// MARK: - Formatting with NdbNote
|
||||
|
||||
func format_message(event: NdbNote) -> UNMutableNotificationContent? {
|
||||
// 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? {
|
||||
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`
|
||||
let event_json_string = String(data: event_json_data, encoding: .utf8) {
|
||||
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:
|
||||
content.title = NSLocalizedString("Someone posted a note", comment: "Title label for push notification where someone posted a note")
|
||||
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")
|
||||
break
|
||||
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")
|
||||
break
|
||||
}
|
||||
@@ -46,91 +45,4 @@ struct NotificationFormatter {
|
||||
}
|
||||
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 .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
|
||||
}
|
||||
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 {
|
||||
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,46 +16,23 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
self.contentHandler = contentHandler
|
||||
|
||||
guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String,
|
||||
let nostr_event = NdbNote.owned_from_json(json: nostr_event_json)
|
||||
else {
|
||||
// No nostr event detected. Just display the original notification
|
||||
let ndb: Ndb? = try? Ndb(owns_db_file: false)
|
||||
|
||||
// Modify the notification content here...
|
||||
guard let nostrEventInfoDictionary = request.content.userInfo["nostr_event"] as? [AnyHashable: Any],
|
||||
let nostrEventInfo = NostrEventInfoFromPushNotification.from(dictionary: nostrEventInfoDictionary) else {
|
||||
contentHandler(request.content)
|
||||
return;
|
||||
}
|
||||
|
||||
// Log that we got a push notification
|
||||
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex())
|
||||
|
||||
guard let state = NotificationExtensionState(),
|
||||
let display_name = state.ndb.lookup_profile(nostr_event.pubkey)?.unsafeUnownedValue?.profile?.display_name // We are not holding the txn here.
|
||||
else {
|
||||
// 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
|
||||
if let pubkey = Pubkey(hex: nostrEventInfo.pubkey),
|
||||
let txn = ndb?.lookup_profile(pubkey) {
|
||||
Log.debug("Got push notification from %s (%s)", for: .push_notifications, (txn.unsafeUnownedValue?.profile?.display_name ?? "Unknown"), nostrEventInfo.pubkey)
|
||||
}
|
||||
|
||||
guard should_display_notification(state: state, event: nostr_event) else {
|
||||
// We should not display notification for this event. Suppress notification.
|
||||
contentHandler(UNNotificationContent())
|
||||
return
|
||||
}
|
||||
|
||||
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
|
||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||
contentHandler(UNNotificationContent())
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
if let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object, state: state) {
|
||||
contentHandler(improvedContent)
|
||||
}
|
||||
if let improvedContent = NotificationFormatter.shared.formatMessage(event: nostrEventInfo) {
|
||||
contentHandler(improvedContent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,56 +2,28 @@
|
||||
|
||||
# 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%" />
|
||||
|
||||
[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
|
||||
|
||||
damus implements the following [Nostr Implementation Possibilities][nips]
|
||||
|
||||
- [NIP-01: Basic protocol flow][nip01]
|
||||
- [NIP-04: Encrypted direct message][nip04]
|
||||
- [NIP-08: Mentions][nip08]
|
||||
- [NIP-10: Reply conventions][nip10]
|
||||
- [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-56: Reporting][nip56]
|
||||
|
||||
[nips]: https://github.com/nostr-protocol/nips
|
||||
[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
|
||||
[nip10]: https://github.com/nostr-protocol/nips/blob/master/10.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
|
||||
[nip56]: https://github.com/nostr-protocol/nips/blob/master/56.md
|
||||
|
||||
|
||||
## 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 "+".
|
||||
- Find more relays to add: https://nostr.info/relays/
|
||||
- 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
|
||||
- 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
|
||||
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
|
||||
4. Add @ directly 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.
|
||||
4. Add @ direcly followed by the pubkey (e.g., `@npub1xtscya34g58tk0z605fvr788k263gsu6cy9x0mhnm87echrgufzsevkk5s`)
|
||||
- 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
|
||||
- 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
|
||||
- 💬 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
|
||||
- ♡ 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)
|
||||
- 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)
|
||||
5. Save
|
||||
|
||||
|
||||
#### ⚡️ Request Sats
|
||||
Paste an invoice from your favorite LN wallet.
|
||||
(Sats or Satoshis are the smallest denomination of bitcoin)
|
||||
|
||||
**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.
|
||||
|
||||
It is public information which other profiles (npubs) you are exchanging DMs with. The content of the DMs is encrypted.
|
||||
|
||||
### Translations
|
||||
|
||||
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
|
||||
|
||||
Damus lead dev and founder Will awards developers with satoshis!
|
||||
There may be nostr badges awarded for contributors in the future... :)
|
||||
|
||||
|
||||
First contributors:
|
||||
|
||||
1. @randymcmillan
|
||||
|
||||
@@ -7,10 +7,8 @@
|
||||
|
||||
#include "nostr_bech32.h"
|
||||
#include <stdlib.h>
|
||||
#include "endian.h"
|
||||
#include "cursor.h"
|
||||
#include "bech32.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#define MAX_TLVS 16
|
||||
|
||||
@@ -147,11 +145,6 @@ static int tlvs_to_relays(struct nostr_tlvs *tlvs, struct relays *relays) {
|
||||
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) {
|
||||
struct nostr_tlvs tlvs;
|
||||
struct nostr_tlv *tlv;
|
||||
@@ -173,13 +166,6 @@ static int parse_nostr_bech32_nevent(struct cursor *cur, struct bech32_nevent *n
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -201,11 +187,6 @@ static int parse_nostr_bech32_naddr(struct cursor *cur, struct bech32_naddr *nad
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
#include <stdio.h>
|
||||
#include "str_block.h"
|
||||
#include "cursor.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
typedef unsigned char u8;
|
||||
#define MAX_RELAYS 10
|
||||
|
||||
@@ -47,8 +45,6 @@ struct bech32_nevent {
|
||||
struct relays relays;
|
||||
const u8 *event_id;
|
||||
const u8 *pubkey; // optional
|
||||
uint32_t kind;
|
||||
bool has_kind;
|
||||
};
|
||||
|
||||
struct bech32_nprofile {
|
||||
@@ -60,7 +56,6 @@ struct bech32_naddr {
|
||||
struct relays relays;
|
||||
struct str_block identifier;
|
||||
const u8 *pubkey;
|
||||
uint32_t kind;
|
||||
};
|
||||
|
||||
struct bech32_nrelay {
|
||||
|
||||
+61
-427
File diff suppressed because it is too large
Load Diff
@@ -18,15 +18,6 @@
|
||||
"version" : "7.6.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mcemojipicker",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/izyumkin/MCEmojiPicker",
|
||||
"state" : {
|
||||
"revision" : "e0b4903b75ae1cc418d276d84d1cb946b8a1d73c",
|
||||
"version" : "1.2.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "secp256k1.swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1520"
|
||||
LastUpgradeVersion = "1500"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
@@ -59,7 +59,7 @@
|
||||
<RemoteRunnable
|
||||
runnableDebuggingMode = "1"
|
||||
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>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1520"
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1520"
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "rss.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 109 KiB |
@@ -16,7 +16,6 @@ class DamusColors {
|
||||
static let black = Color("DamusBlack")
|
||||
static let brown = Color("DamusBrown")
|
||||
static let yellow = Color("DamusYellow")
|
||||
static let gold = hex_col(r: 226, g: 168, b: 0)
|
||||
static let lightGrey = Color("DamusLightGrey")
|
||||
static let mediumGrey = Color("DamusMediumGrey")
|
||||
static let darkGrey = Color("DamusDarkGrey")
|
||||
@@ -24,7 +23,6 @@ class DamusColors {
|
||||
static let purple = Color("DamusPurple")
|
||||
static let deepPurple = Color("DamusDeepPurple")
|
||||
static let blue = Color("DamusBlue")
|
||||
static let bitcoin = Color("Bitcoin")
|
||||
static let success = Color("DamusSuccessPrimary")
|
||||
static let successSecondary = Color("DamusSuccessSecondary")
|
||||
static let successTertiary = Color("DamusSuccessTertiary")
|
||||
@@ -48,10 +46,3 @@ class DamusColors {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
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 = [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)
|
||||
@@ -31,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 {
|
||||
case square
|
||||
@@ -95,64 +52,42 @@ enum ImageShape {
|
||||
}
|
||||
}
|
||||
|
||||
class CarouselModel: ObservableObject {
|
||||
var current_url: URL?
|
||||
var fillHeight: CGFloat
|
||||
var maxHeight: CGFloat
|
||||
var firstImageHeight: CGFloat?
|
||||
|
||||
@Published var open_sheet: Bool
|
||||
@Published var selectedIndex: Int
|
||||
@Published var video_size: CGSize?
|
||||
@Published var image_fill: ImageFill?
|
||||
|
||||
init(image_fill: ImageFill?) {
|
||||
self.current_url = nil
|
||||
self.fillHeight = 350
|
||||
self.maxHeight = UIScreen.main.bounds.height * 1.2 // 1.2
|
||||
self.firstImageHeight = nil
|
||||
self.open_sheet = false
|
||||
self.selectedIndex = 0
|
||||
self.video_size = nil
|
||||
self.image_fill = image_fill
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Carousel
|
||||
@MainActor
|
||||
struct ImageCarousel<Content: View>: View {
|
||||
struct ImageCarousel: View {
|
||||
var urls: [MediaUrl]
|
||||
|
||||
let evid: NoteId
|
||||
|
||||
let state: DamusState
|
||||
@ObservedObject var model: CarouselModel
|
||||
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
|
||||
|
||||
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
|
||||
self.urls = urls
|
||||
self.evid = evid
|
||||
self.state = state
|
||||
let media_model = state.events.get_cache_data(evid).media_metadata_model
|
||||
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
|
||||
self.content = nil
|
||||
}
|
||||
|
||||
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
|
||||
@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.state = state
|
||||
let media_model = state.events.get_cache_data(evid).media_metadata_model
|
||||
self._model = ObservedObject(initialValue: CarouselModel(image_fill: media_model.fill))
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var filling: Bool {
|
||||
model.image_fill?.filling == true
|
||||
image_fill?.filling == true
|
||||
}
|
||||
|
||||
var height: CGFloat {
|
||||
model.firstImageHeight ?? model.image_fill?.height ?? model.fillHeight
|
||||
firstImageHeight ?? image_fill?.height ?? fillHeight
|
||||
}
|
||||
|
||||
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
|
||||
@@ -170,9 +105,9 @@ struct ImageCarousel<Content: View>: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if self.model.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: model.maxHeight, fillHeight: model.fillHeight)
|
||||
self.model.image_fill = fill
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,23 +118,23 @@ struct ImageCarousel<Content: View>: View {
|
||||
case .image(let url):
|
||||
Img(geo: geo, url: url, index: index)
|
||||
.onTapGesture {
|
||||
model.open_sheet = true
|
||||
open_sheet = true
|
||||
}
|
||||
case .video(let url):
|
||||
DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true }))
|
||||
.onChange(of: model.video_size) { size in
|
||||
DamusVideoPlayer(url: url, video_size: $video_size, controller: state.video)
|
||||
.onChange(of: video_size) { size in
|
||||
guard let size else { return }
|
||||
|
||||
let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
|
||||
|
||||
let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: maxHeight, fillHeight: fillHeight)
|
||||
|
||||
print("video_size changed \(size)")
|
||||
if self.model.image_fill == nil {
|
||||
if self.image_fill == nil {
|
||||
print("video_size firstImageHeight \(fill.height)")
|
||||
self.model.firstImageHeight = fill.height
|
||||
firstImageHeight = fill.height
|
||||
state.events.get_cache_data(evid).media_metadata_model.fill = fill
|
||||
}
|
||||
|
||||
self.model.image_fill = fill
|
||||
self.image_fill = fill
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,7 +150,7 @@ struct ImageCarousel<Content: View>: View {
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.imageFill(for: geo.size, max: model.maxHeight, fill: model.fillHeight) { fill in
|
||||
.imageFill(for: geo.size, max: maxHeight, fill: fillHeight) { fill in
|
||||
state.events.get_cache_data(evid).media_metadata_model.fill = fill
|
||||
// blur hash can be discarded when we have the url
|
||||
// NOTE: this is the wrong place for this... we need to remove
|
||||
@@ -224,9 +159,9 @@ struct ImageCarousel<Content: View>: View {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
state.events.lookup_img_metadata(url: url)?.state = .not_needed
|
||||
}
|
||||
self.model.image_fill = fill
|
||||
image_fill = fill
|
||||
if index == 0 {
|
||||
self.model.firstImageHeight = fill.height
|
||||
firstImageHeight = fill.height
|
||||
//maxHeight = firstImageHeight ?? maxHeight
|
||||
} else {
|
||||
//maxHeight = firstImageHeight ?? fill.height
|
||||
@@ -246,7 +181,7 @@ struct ImageCarousel<Content: View>: View {
|
||||
}
|
||||
|
||||
var Medias: some View {
|
||||
TabView(selection: $model.selectedIndex) {
|
||||
TabView(selection: $selectedIndex) {
|
||||
ForEach(urls.indices, id: \.self) { index in
|
||||
GeometryReader { geo in
|
||||
Media(geo: geo, url: urls[index], index: index)
|
||||
@@ -254,22 +189,14 @@ struct ImageCarousel<Content: View>: View {
|
||||
}
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||
.fullScreenCover(isPresented: $model.open_sheet) {
|
||||
if let content {
|
||||
FullScreenCarouselView<Content>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex) {
|
||||
content({ // Dismiss closure
|
||||
model.open_sheet = false
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
FullScreenCarouselView<AnyView>(video_controller: state.video, urls: urls, settings: state.settings, selectedIndex: $model.selectedIndex)
|
||||
}
|
||||
.fullScreenCover(isPresented: $open_sheet) {
|
||||
ImageView(video_controller: state.video, urls: urls, settings: state.settings)
|
||||
}
|
||||
.frame(height: height)
|
||||
.onChange(of: model.selectedIndex) { value in
|
||||
model.selectedIndex = value
|
||||
.onChange(of: selectedIndex) { value in
|
||||
selectedIndex = value
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -277,11 +204,31 @@ struct ImageCarousel<Content: View>: View {
|
||||
Medias
|
||||
.onTapGesture { }
|
||||
|
||||
if urls.count > 1 {
|
||||
PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
|
||||
.frame(maxWidth: 0, maxHeight: 0)
|
||||
.padding(.top, 5)
|
||||
// This is our custom carousel image indicator
|
||||
CarouselDotsView(urls: urls, selectedIndex: $selectedIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,9 +285,7 @@ public struct ImageFill {
|
||||
struct ImageCarousel_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
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<AnyView>(state: test_damus_state, evid: test_note.id, urls: [test_video_url, url])
|
||||
.environmentObject(OrientationTracker())
|
||||
ImageCarousel(state: test_damus_state, evid: test_note.id, urls: [url, url])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ struct NIP05Badge: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@ struct NeutralButtonStyle_Previews: PreviewProvider {
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
.padding()
|
||||
|
||||
Button(String(stringLiteral: "Rounded Button"), action: {})
|
||||
Button("Rounded Button", action: {})
|
||||
.buttonStyle(NeutralButtonShape.rounded.style)
|
||||
.padding()
|
||||
|
||||
Button(String(stringLiteral: "Capsule Button"), action: {})
|
||||
Button("Capsule Button", action: {})
|
||||
.buttonStyle(NeutralButtonShape.capsule.style)
|
||||
.padding()
|
||||
|
||||
|
||||
@@ -83,9 +83,9 @@ struct SingleCharacterAvatar: View {
|
||||
|
||||
var body: some View {
|
||||
NonImageAvatar {
|
||||
Text(character)
|
||||
Text(verbatim: character)
|
||||
.font(.largeTitle.bold())
|
||||
.mask(Text(character)
|
||||
.mask(Text(verbatim: character)
|
||||
.font(.largeTitle.bold()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ struct UserStatusSheet: View {
|
||||
|
||||
Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) {
|
||||
ForEach(StatusDuration.allCases, id: \.self) { d in
|
||||
Text(d.description)
|
||||
Text(verbatim: d.description)
|
||||
.tag(d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,52 +8,23 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SupporterBadge: View {
|
||||
let percent: Int?
|
||||
let purple_account: DamusPurple.Account?
|
||||
let style: Style
|
||||
|
||||
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style) {
|
||||
self.percent = percent
|
||||
self.purple_account = purple_account
|
||||
self.style = style
|
||||
}
|
||||
let percent: Int
|
||||
|
||||
let size: CGFloat = 17
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if let purple_account, purple_account.active == true {
|
||||
HStack(spacing: 1) {
|
||||
Image("star.fill")
|
||||
.resizable()
|
||||
.frame(width:size, height:size)
|
||||
.foregroundStyle(GoldGradient)
|
||||
if self.style == .full {
|
||||
let date = format_date(date: purple_account.created_at, time_style: .none)
|
||||
Text(date)
|
||||
.foregroundStyle(.secondary)
|
||||
.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)
|
||||
}
|
||||
if percent < 100 {
|
||||
Image("star.fill")
|
||||
.resizable()
|
||||
.frame(width:size, height:size)
|
||||
.foregroundColor(support_level_color(percent))
|
||||
} else {
|
||||
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 {
|
||||
@@ -73,24 +44,13 @@ func support_level_color(_ percent: Int) -> Color {
|
||||
struct SupporterBadge_Previews: PreviewProvider {
|
||||
static func Level(_ p: Int) -> some View {
|
||||
HStack(alignment: .center) {
|
||||
SupporterBadge(percent: p, style: .full)
|
||||
SupporterBadge(percent: p)
|
||||
.frame(width: 50)
|
||||
Text(verbatim: p.formatted())
|
||||
.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 {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
@@ -106,12 +66,6 @@ struct SupporterBadge_Previews: PreviewProvider {
|
||||
Level(80)
|
||||
Level(90)
|
||||
Level(100)
|
||||
Purple(1)
|
||||
Purple(2)
|
||||
Purple(3)
|
||||
Purple(99)
|
||||
Purple(100)
|
||||
Purple(1971)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,6 @@ enum TranslateStatus: Equatable {
|
||||
case not_needed
|
||||
}
|
||||
|
||||
fileprivate let MIN_UNIQUE_CHARS = 2
|
||||
|
||||
struct TranslateView: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
@@ -72,7 +70,15 @@ 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 {
|
||||
should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: note_lang)
|
||||
}
|
||||
@@ -97,10 +103,9 @@ struct TranslateView: View {
|
||||
Text("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func translationMeetsStringDistanceRequirements(original: String, translated: String) -> Bool {
|
||||
return levenshteinDistanceIsGreaterThanOrEqualTo(from: original, to: translated, threshold: MIN_UNIQUE_CHARS)
|
||||
.task {
|
||||
attempt_translation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,10 +141,6 @@ func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, set
|
||||
// if its the same, give up and don't retry
|
||||
return .not_needed
|
||||
}
|
||||
|
||||
guard translationMeetsStringDistanceRequirements(original: originalContent, translated: translated_note) else {
|
||||
return .not_needed
|
||||
}
|
||||
|
||||
// Render translated note
|
||||
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
|
||||
@@ -157,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)
|
||||
}
|
||||
|
||||
@@ -9,12 +9,7 @@ import SwiftUI
|
||||
|
||||
struct TruncatedText: View {
|
||||
let text: CompatibleText
|
||||
let maxChars: Int
|
||||
|
||||
init(text: CompatibleText, maxChars: Int = 280) {
|
||||
self.text = text
|
||||
self.maxChars = maxChars
|
||||
}
|
||||
let maxChars: Int = 280
|
||||
|
||||
var body: some View {
|
||||
let truncatedAttributedString: AttributedString? = text.attributed.truncateOrNil(maxLength: maxChars)
|
||||
|
||||
+77
-189
@@ -28,8 +28,6 @@ enum Sheets: Identifiable {
|
||||
case filter
|
||||
case user_status
|
||||
case onboardingSuggestions
|
||||
case purple(DamusPurpleURL)
|
||||
case purple_onboarding
|
||||
|
||||
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
|
||||
return .zap(ZapSheet(target: target, lnurl: lnurl))
|
||||
@@ -50,8 +48,6 @@ enum Sheets: Identifiable {
|
||||
case .select_wallet: return "select-wallet"
|
||||
case .filter: return "filter"
|
||||
case .onboardingSuggestions: return "onboarding-suggestions"
|
||||
case .purple(let purple_url): return "purple" + purple_url.url_string()
|
||||
case .purple_onboarding: return "purple_onboarding"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +69,7 @@ struct ContentView: View {
|
||||
@State var active_sheet: Sheets? = nil
|
||||
@State var damus_state: DamusState!
|
||||
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
|
||||
@State var muting: MuteItem? = nil
|
||||
@State var muting: Pubkey? = nil
|
||||
@State var confirm_mute: Bool = false
|
||||
@State var hide_bar: Bool = false
|
||||
@State var user_muted_confirm: Bool = false
|
||||
@@ -278,7 +274,7 @@ struct ContentView: View {
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.overlay(
|
||||
SideMenuView(damus_state: damus_state!, isSidebarVisible: $isSideBarOpened.animation())
|
||||
SideMenuView(damus_state: damus, isSidebarVisible: $isSideBarOpened.animation())
|
||||
)
|
||||
.navigationDestination(for: Route.self) { route in
|
||||
route.view(navigationCoordinator: navigationCoordinator, damusState: damus_state!)
|
||||
@@ -334,10 +330,6 @@ struct ContentView: View {
|
||||
.presentationDragIndicator(.visible)
|
||||
case .onboardingSuggestions:
|
||||
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
|
||||
@@ -347,26 +339,11 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
switch res {
|
||||
case .filter(let filt): self.open_search(filt: filt)
|
||||
case .profile(let pk): self.open_profile(pubkey: pk)
|
||||
case .event(let ev): self.open_event(ev: ev)
|
||||
case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)
|
||||
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)
|
||||
}
|
||||
case .filter(let filt): self.open_search(filt: filt)
|
||||
case .profile(let pk): self.open_profile(pubkey: pk)
|
||||
case .event(let ev): self.open_event(ev: ev)
|
||||
case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)
|
||||
case .script(let data): self.open_script(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,8 +361,8 @@ struct ContentView: View {
|
||||
.onReceive(handle_notify(.report)) { target in
|
||||
self.active_sheet = .report(target)
|
||||
}
|
||||
.onReceive(handle_notify(.mute)) { mute_item in
|
||||
self.muting = mute_item
|
||||
.onReceive(handle_notify(.mute)) { pubkey in
|
||||
self.muting = pubkey
|
||||
self.confirm_mute = true
|
||||
}
|
||||
.onReceive(handle_notify(.attached_wallet)) { nwc in
|
||||
@@ -393,9 +370,14 @@ struct ContentView: View {
|
||||
// wallet with an associated
|
||||
guard let ds = self.damus_state,
|
||||
let lud16 = nwc.lud16,
|
||||
let keypair = ds.keypair.to_full(),
|
||||
let profile_txn = ds.profiles.lookup(id: ds.pubkey),
|
||||
let profile = profile_txn.unsafeUnownedValue,
|
||||
let keypair = ds.keypair.to_full()
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let profile_txn = ds.profiles.lookup(id: ds.pubkey)
|
||||
|
||||
guard let profile = profile_txn.unsafeUnownedValue,
|
||||
lud16 != profile.lud16 else {
|
||||
return
|
||||
}
|
||||
@@ -479,51 +461,18 @@ struct ContentView: View {
|
||||
.onReceive(handle_notify(.disconnect_relays)) { () in
|
||||
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
|
||||
guard let damus_state else { return }
|
||||
switch phase {
|
||||
case .background:
|
||||
print("txn: 📙 DAMUS BACKGROUNDED")
|
||||
Task { @MainActor in
|
||||
damus_state.ndb.close()
|
||||
}
|
||||
print("📙 DAMUS BACKGROUNDED")
|
||||
break
|
||||
case .inactive:
|
||||
print("txn: 📙 DAMUS INACTIVE")
|
||||
print("📙 DAMUS INACTIVE")
|
||||
break
|
||||
case .active:
|
||||
print("txn: 📙 DAMUS ACTIVE")
|
||||
damus_state.pool.ping()
|
||||
print("📙 DAMUS ACTIVE")
|
||||
guard let ds = damus_state else { return }
|
||||
ds.pool.ping()
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
@@ -536,15 +485,21 @@ struct ContentView: View {
|
||||
open_profile(pubkey: pubkey)
|
||||
|
||||
case .note(let noteId):
|
||||
openEvent(noteId: noteId, notificationType: local.type)
|
||||
case .nevent(let nevent):
|
||||
openEvent(noteId: nevent.noteid, notificationType: local.type)
|
||||
case .nprofile(let nprofile):
|
||||
open_profile(pubkey: nprofile.author)
|
||||
case .nrelay(_):
|
||||
break
|
||||
case .naddr(let naddr):
|
||||
break
|
||||
guard let target = damus_state.events.lookup(noteId) else {
|
||||
return
|
||||
}
|
||||
|
||||
switch local.type {
|
||||
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:
|
||||
open_event(ev: target)
|
||||
case .profile_zap:
|
||||
// Handled separately above.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -552,9 +507,10 @@ struct ContentView: View {
|
||||
.onReceive(handle_notify(.onlyzaps_mode)) { hide in
|
||||
home.filter_events()
|
||||
|
||||
guard let ds = damus_state,
|
||||
let profile_txn = ds.profiles.lookup(id: ds.pubkey),
|
||||
let profile = profile_txn.unsafeUnownedValue,
|
||||
guard let ds = damus_state else { return }
|
||||
let profile_txn = ds.profiles.lookup(id: ds.pubkey)
|
||||
|
||||
guard let profile = profile_txn.unsafeUnownedValue,
|
||||
let keypair = ds.keypair.to_full()
|
||||
else {
|
||||
return
|
||||
@@ -570,10 +526,10 @@ struct ContentView: View {
|
||||
user_muted_confirm = false
|
||||
}
|
||||
}, message: {
|
||||
if case let .user(pubkey, _) = self.muting {
|
||||
let profile_txn = damus_state!.profiles.lookup(id: pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
|
||||
if let pubkey = self.muting {
|
||||
let name = damus_state!.profiles.lookup(id: pubkey).map { profile in
|
||||
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.")
|
||||
} else {
|
||||
Text("User has been muted", comment: "Alert message that informs a user was muted.")
|
||||
@@ -588,13 +544,13 @@ struct ContentView: View {
|
||||
Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) {
|
||||
guard let ds = damus_state,
|
||||
let keypair = ds.keypair.to_full(),
|
||||
let muting,
|
||||
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting)
|
||||
let pubkey = muting,
|
||||
let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: .pubkey(pubkey))
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
ds.mutelist_manager.set_mutelist(mutelist)
|
||||
damus_state?.contacts.set_mutelist(mutelist)
|
||||
ds.postbox.send(mutelist)
|
||||
|
||||
confirm_overwrite_mutelist = false
|
||||
@@ -613,28 +569,28 @@ struct ContentView: View {
|
||||
return
|
||||
}
|
||||
|
||||
if ds.mutelist_manager.event == nil {
|
||||
if ds.contacts.mutelist == nil {
|
||||
confirm_overwrite_mutelist = true
|
||||
} else {
|
||||
guard let keypair = ds.keypair.to_full(),
|
||||
let muting
|
||||
let pubkey = muting
|
||||
else {
|
||||
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
|
||||
}
|
||||
|
||||
ds.mutelist_manager.set_mutelist(ev)
|
||||
damus_state?.contacts.set_mutelist(ev)
|
||||
ds.postbox.send(ev)
|
||||
}
|
||||
}
|
||||
}, message: {
|
||||
if case let .user(pubkey, _) = muting {
|
||||
let profile_txn = damus_state?.profiles.lookup(id: pubkey)
|
||||
let profile = profile_txn?.unsafeUnownedValue
|
||||
let name = Profile.displayName(profile: profile, pubkey: pubkey).username.truncate(maxLength: 50)
|
||||
if let pubkey = muting {
|
||||
let name = damus_state?.profiles.lookup(id: pubkey).map({ profile in
|
||||
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.")
|
||||
} else {
|
||||
Text("Could not find user to mute...", comment: "Alert message to indicate that the muted user could not be found.")
|
||||
@@ -667,7 +623,7 @@ struct ContentView: View {
|
||||
|
||||
// out of space or something?? maybe we need a in-memory fallback
|
||||
if mndb == nil {
|
||||
logout(nil)
|
||||
notify(.logout)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -679,14 +635,19 @@ struct ContentView: View {
|
||||
let relay_filters = RelayFilters(our_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
|
||||
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)
|
||||
if let url = RelayURL(relay) {
|
||||
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)
|
||||
|
||||
if let nwc_str = settings.nostr_wallet_connect,
|
||||
@@ -699,7 +660,6 @@ struct ContentView: View {
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
boosts: EventCounter(our_pubkey: pubkey),
|
||||
contacts: Contacts(our_pubkey: pubkey),
|
||||
mutelist_manager: MutelistManager(),
|
||||
profiles: Profiles(ndb: ndb),
|
||||
dms: home.dms,
|
||||
previews: PreviewCache(),
|
||||
@@ -714,22 +674,19 @@ struct ContentView: View {
|
||||
postbox: PostBox(pool: pool),
|
||||
bootstrap_relays: bootstrap_relays,
|
||||
replies: ReplyCounter(our_pubkey: pubkey),
|
||||
muted_threads: MutedThreadsManager(keypair: keypair),
|
||||
wallet: WalletModel(settings: settings),
|
||||
nav: self.navigationCoordinator,
|
||||
music: MusicController(onChange: music_changed),
|
||||
video: VideoController(),
|
||||
ndb: ndb,
|
||||
quote_reposts: .init(our_pubkey: pubkey)
|
||||
ndb: ndb
|
||||
)
|
||||
|
||||
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
|
||||
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 {
|
||||
// Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts
|
||||
@@ -762,22 +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:
|
||||
open_event(ev: target)
|
||||
case .profile_zap:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
@@ -832,12 +773,6 @@ func save_last_event(_ ev: NostrEvent, timeline: Timeline) {
|
||||
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] {
|
||||
|
||||
return filters.map { filter in
|
||||
@@ -889,13 +824,13 @@ func setup_notifications() {
|
||||
|
||||
struct FindEvent {
|
||||
let type: FindEventType
|
||||
let find_from: [RelayURL]?
|
||||
|
||||
static func profile(pubkey: Pubkey, find_from: [RelayURL]? = nil) -> FindEvent {
|
||||
let find_from: [String]?
|
||||
|
||||
static func profile(pubkey: Pubkey, find_from: [String]? = nil) -> FindEvent {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -923,8 +858,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
||||
|
||||
switch query {
|
||||
case .profile(let pubkey):
|
||||
if let profile_txn = state.ndb.lookup_profile(pubkey),
|
||||
let record = profile_txn.unsafeUnownedValue,
|
||||
if let record = state.ndb.lookup_profile(pubkey).unsafeUnownedValue,
|
||||
record.profile != nil
|
||||
{
|
||||
callback(.profile(pubkey))
|
||||
@@ -990,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 {
|
||||
guard let timeline else {
|
||||
return ""
|
||||
@@ -1135,15 +1041,9 @@ enum OpenResult {
|
||||
case event(NostrEvent)
|
||||
case wallet_connect(WalletConnectURL)
|
||||
case script([UInt8])
|
||||
case purple(DamusPurpleURL)
|
||||
}
|
||||
|
||||
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) {
|
||||
result(.wallet_connect(nwc))
|
||||
return
|
||||
@@ -1165,15 +1065,10 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
|
||||
result(.event(ev))
|
||||
}
|
||||
case .hashtag(let ht):
|
||||
result(.filter(.filter_hashtag([ht.hashtag])))
|
||||
result(.filter(.filter_hashtag([ht.string()])))
|
||||
case .param, .quote:
|
||||
// doesn't really make sense here
|
||||
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):
|
||||
result(.filter(filt))
|
||||
@@ -1185,10 +1080,3 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func logout(_ state: DamusState?)
|
||||
{
|
||||
state?.close()
|
||||
notify(.logout)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict/>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
|
||||
@@ -16,12 +16,10 @@ enum Zapped {
|
||||
class ActionBarModel: ObservableObject {
|
||||
@Published var our_like: NostrEvent?
|
||||
@Published var our_boost: NostrEvent?
|
||||
@Published var our_quote_repost: NostrEvent?
|
||||
@Published var our_reply: NostrEvent?
|
||||
@Published var our_zap: Zapping?
|
||||
@Published var likes: Int
|
||||
@Published var boosts: Int
|
||||
@Published var quote_reposts: Int
|
||||
@Published private(set) var zaps: Int
|
||||
@Published var zap_total: Int64
|
||||
@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)
|
||||
}
|
||||
|
||||
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.boosts = boosts
|
||||
self.zaps = zaps
|
||||
@@ -40,8 +38,6 @@ class ActionBarModel: ObservableObject {
|
||||
self.our_boost = our_boost
|
||||
self.our_zap = our_zap
|
||||
self.our_reply = our_reply
|
||||
self.our_quote_repost = our_quote_repost
|
||||
self.quote_reposts = quote_reposts
|
||||
}
|
||||
|
||||
func update(damus: DamusState, evid: NoteId) {
|
||||
@@ -49,13 +45,11 @@ class ActionBarModel: ObservableObject {
|
||||
self.boosts = damus.boosts.counts[evid] ?? 0
|
||||
self.zaps = damus.zaps.event_counts[evid] ?? 0
|
||||
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.our_like = damus.likes.our_events[evid]
|
||||
self.our_boost = damus.boosts.our_events[evid]
|
||||
self.our_zap = damus.zaps.our_zaps[evid]?.first
|
||||
self.our_reply = damus.replies.our_reply(evid)
|
||||
self.our_quote_repost = damus.quote_reposts.our_events[evid]
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
@@ -74,8 +68,4 @@ class ActionBarModel: ObservableObject {
|
||||
var boosted: Bool {
|
||||
return our_boost != nil
|
||||
}
|
||||
|
||||
var quoted: Bool {
|
||||
return our_quote_repost != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
//
|
||||
// Contacts+.swift
|
||||
// damus
|
||||
//
|
||||
// Extra functionality and utilities for `Contacts.swift`
|
||||
//
|
||||
// Created by Daniel D’Aquino 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, _):
|
||||
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
@@ -7,25 +7,57 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
class Contacts {
|
||||
private var friends: Set<Pubkey> = Set()
|
||||
private var friend_of_friends: Set<Pubkey> = Set()
|
||||
/// Tracks which friends are friends of a given pubkey.
|
||||
private var pubkey_to_our_friends = [Pubkey : Set<Pubkey>]()
|
||||
private var muted: Set<Pubkey> = Set()
|
||||
|
||||
let our_pubkey: Pubkey
|
||||
var delegate: ContactsDelegate? = nil
|
||||
var event: NostrEvent? {
|
||||
didSet {
|
||||
guard let event else { return }
|
||||
self.delegate?.latest_contact_event_changed(new_event: event)
|
||||
}
|
||||
}
|
||||
|
||||
var event: NostrEvent?
|
||||
var mutelist: NostrEvent?
|
||||
|
||||
init(our_pubkey: 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) {
|
||||
friends.remove(pubkey)
|
||||
|
||||
@@ -94,7 +126,144 @@ class Contacts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegate protocol for `Contacts`. Use this to listen to significant updates from a `Contacts` instance
|
||||
protocol ContactsDelegate {
|
||||
func latest_contact_event_changed(new_event: NostrEvent)
|
||||
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) -> [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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,8 @@ func nsfw_tag_filter(ev: NostrEvent) -> Bool {
|
||||
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
|
||||
return { ev in
|
||||
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_cached_inner_event(cache: damus_state.events) else { return true }
|
||||
return should_show_event(state: damus_state, ev: inner_ev)
|
||||
guard let inner_ev = ev.get_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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,10 +52,6 @@ struct 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] {
|
||||
var filters = Array<(NostrEvent) -> Bool>()
|
||||
if damus_state.settings.hide_nsfw_tagged_content {
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
import Foundation
|
||||
import LinkPresentation
|
||||
|
||||
class DamusState: HeadlessDamusState {
|
||||
struct DamusState {
|
||||
let pool: RelayPool
|
||||
let keypair: Keypair
|
||||
let likes: EventCounter
|
||||
let boosts: EventCounter
|
||||
let quote_reposts: EventCounter
|
||||
let contacts: Contacts
|
||||
let mutelist_manager: MutelistManager
|
||||
let profiles: Profiles
|
||||
let dms: DirectMessagesModel
|
||||
let previews: PreviewCache
|
||||
@@ -28,22 +26,22 @@ class DamusState: HeadlessDamusState {
|
||||
let events: EventCache
|
||||
let bookmarks: BookmarksManager
|
||||
let postbox: PostBox
|
||||
let bootstrap_relays: [RelayURL]
|
||||
let bootstrap_relays: [String]
|
||||
let replies: ReplyCounter
|
||||
let muted_threads: MutedThreadsManager
|
||||
let wallet: WalletModel
|
||||
let nav: NavigationCoordinator
|
||||
let music: MusicController?
|
||||
let video: VideoController
|
||||
let ndb: Ndb
|
||||
var purple: DamusPurple
|
||||
|
||||
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: VideoController, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter) {
|
||||
|
||||
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) {
|
||||
self.pool = pool
|
||||
self.keypair = keypair
|
||||
self.likes = likes
|
||||
self.boosts = boosts
|
||||
self.contacts = contacts
|
||||
self.mutelist_manager = mutelist_manager
|
||||
self.profiles = profiles
|
||||
self.dms = dms
|
||||
self.previews = previews
|
||||
@@ -58,16 +56,16 @@ class DamusState: HeadlessDamusState {
|
||||
self.postbox = postbox
|
||||
self.bootstrap_relays = bootstrap_relays
|
||||
self.replies = replies
|
||||
self.muted_threads = muted_threads
|
||||
self.wallet = wallet
|
||||
self.nav = nav
|
||||
self.music = music
|
||||
self.video = video
|
||||
self.ndb = ndb
|
||||
self.purple = purple ?? DamusPurple(
|
||||
settings: settings,
|
||||
environment: settings.purple_api_local_test_mode ? .local_test : .production,
|
||||
keypair: keypair
|
||||
)
|
||||
self.quote_reposts = quote_reposts
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -94,13 +92,7 @@ class DamusState: HeadlessDamusState {
|
||||
var is_privkey_user: Bool {
|
||||
keypair.privkey != nil
|
||||
}
|
||||
|
||||
func close() {
|
||||
print("txn: damus close")
|
||||
pool.close()
|
||||
ndb.close()
|
||||
}
|
||||
|
||||
|
||||
static var empty: DamusState {
|
||||
let empty_pub: Pubkey = .empty
|
||||
let empty_sec: Privkey = .empty
|
||||
@@ -112,7 +104,6 @@ class DamusState: HeadlessDamusState {
|
||||
likes: EventCounter(our_pubkey: empty_pub),
|
||||
boosts: EventCounter(our_pubkey: empty_pub),
|
||||
contacts: Contacts(our_pubkey: empty_pub),
|
||||
mutelist_manager: MutelistManager(),
|
||||
profiles: Profiles(ndb: .empty),
|
||||
dms: DirectMessagesModel(our_pubkey: empty_pub),
|
||||
previews: PreviewCache(),
|
||||
@@ -127,12 +118,12 @@ class DamusState: HeadlessDamusState {
|
||||
postbox: PostBox(pool: RelayPool(ndb: .empty)),
|
||||
bootstrap_relays: [],
|
||||
replies: ReplyCounter(our_pubkey: empty_pub),
|
||||
muted_threads: MutedThreadsManager(keypair: kp),
|
||||
wallet: WalletModel(settings: UserSettingsStore()),
|
||||
nav: NavigationCoordinator(),
|
||||
music: nil,
|
||||
video: VideoController(),
|
||||
ndb: .empty,
|
||||
quote_reposts: .init(our_pubkey: empty_pub)
|
||||
ndb: .empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
//
|
||||
// DamusUserDefaults.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,62 +7,25 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
class EventsModel: ObservableObject {
|
||||
let state: DamusState
|
||||
let target: NoteId
|
||||
let kind: QueryKind
|
||||
let kind: NostrKind
|
||||
let sub_id = UUID().uuidString
|
||||
let profiles_id = UUID().uuidString
|
||||
var events: EventHolder
|
||||
@Published var loading: Bool
|
||||
|
||||
enum QueryKind {
|
||||
case kind(NostrKind)
|
||||
case quotes
|
||||
}
|
||||
|
||||
|
||||
@Published var events: [NostrEvent] = []
|
||||
|
||||
init(state: DamusState, target: NoteId, kind: NostrKind) {
|
||||
self.state = state
|
||||
self.target = target
|
||||
self.kind = .kind(kind)
|
||||
self.loading = true
|
||||
self.events = EventHolder(on_queue: { ev in
|
||||
preload_events(state: state, events: [ev])
|
||||
})
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
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 {
|
||||
var filter: NostrFilter
|
||||
switch kind {
|
||||
case .kind(let k):
|
||||
filter = NostrFilter(kinds: [k])
|
||||
filter.referenced_ids = [target]
|
||||
case .quotes:
|
||||
filter = NostrFilter(kinds: [.text])
|
||||
filter.quotes = [target]
|
||||
}
|
||||
var filter = NostrFilter(kinds: [kind])
|
||||
filter.referenced_ids = [target]
|
||||
filter.limit = 500
|
||||
return filter
|
||||
}
|
||||
@@ -76,19 +39,23 @@ class EventsModel: ObservableObject {
|
||||
func unsubscribe() {
|
||||
state.pool.unsubscribe(sub_id: sub_id)
|
||||
}
|
||||
|
||||
private func handle_event(relay_id: RelayURL, ev: NostrEvent) {
|
||||
if events.insert(ev) {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
func handle_nostr_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let nev) = ev, nev.subid == self.sub_id
|
||||
else {
|
||||
|
||||
private func handle_event(relay_id: String, ev: NostrEvent) {
|
||||
guard ev.kind == kind.rawValue,
|
||||
ev.referenced_ids.last == target else {
|
||||
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 {
|
||||
case .event(_, let ev):
|
||||
handle_event(relay_id: relay_id, ev: ev)
|
||||
@@ -99,11 +66,8 @@ class EventsModel: ObservableObject {
|
||||
case .auth:
|
||||
break
|
||||
case .eose:
|
||||
self.loading = false
|
||||
guard let txn = NdbTxn(ndb: self.state.ndb) else {
|
||||
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)
|
||||
let txn = NdbTxn(ndb: self.state.ndb)
|
||||
load_profiles(context: "events_model", profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state, txn: txn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// FollowState.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum FollowState {
|
||||
case follows
|
||||
case following
|
||||
case unfollowing
|
||||
case unfollows
|
||||
}
|
||||
@@ -52,8 +52,8 @@ class FollowersModel: ObservableObject {
|
||||
contacts?.append(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)
|
||||
if authors.isEmpty {
|
||||
return
|
||||
@@ -63,8 +63,8 @@ class FollowersModel: ObservableObject {
|
||||
authors: authors)
|
||||
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 {
|
||||
return
|
||||
}
|
||||
@@ -83,7 +83,7 @@ class FollowersModel: ObservableObject {
|
||||
|
||||
case .eose(let 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)
|
||||
} else if sub_id == self.profiles_id {
|
||||
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
||||
|
||||
@@ -52,8 +52,8 @@ class FollowingModel {
|
||||
print("unsubscribing from following \(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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
//
|
||||
// FriendFilter.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum FriendFilter: String, StringCodable {
|
||||
case all
|
||||
case 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:
|
||||
return contacts.is_in_friendosphere(pubkey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//
|
||||
// HeadlessDamusState.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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
|
||||
}
|
||||
+352
-152
@@ -8,6 +8,21 @@
|
||||
import Foundation
|
||||
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 {
|
||||
case following
|
||||
case unfollowing(FollowRef)
|
||||
@@ -41,20 +56,16 @@ enum HomeResubFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class HomeModel: ContactsDelegate {
|
||||
class HomeModel {
|
||||
// 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 {
|
||||
didSet {
|
||||
self.load_our_stuff_from_damus_state()
|
||||
}
|
||||
}
|
||||
var damus_state: DamusState
|
||||
|
||||
// NDBTODO: let's get rid of this entirely, let nostrdb handle it
|
||||
var has_event: [String: Set<NoteId>] = [:]
|
||||
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 incoming_dms: [NostrEvent] = []
|
||||
let dm_debouncer = Debouncer(interval: 0.5)
|
||||
@@ -112,32 +123,6 @@ class HomeModel: ContactsDelegate {
|
||||
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() {
|
||||
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)
|
||||
damus_state.contacts.delegate = self
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if self.should_debounce_dms {
|
||||
@@ -165,7 +150,7 @@ class HomeModel: ContactsDelegate {
|
||||
}
|
||||
|
||||
@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) {
|
||||
return
|
||||
}
|
||||
@@ -187,10 +172,8 @@ class HomeModel: ContactsDelegate {
|
||||
case .metadata:
|
||||
// profile metadata processing is handled by nostrdb
|
||||
break
|
||||
case .list_deprecated:
|
||||
handle_old_list_event(ev)
|
||||
case .mute_list:
|
||||
handle_mute_list_event(ev)
|
||||
case .list:
|
||||
handle_list_event(ev)
|
||||
case .boost:
|
||||
handle_boost_event(sub_id: sub_id, ev)
|
||||
case .like:
|
||||
@@ -241,7 +224,7 @@ class HomeModel: ContactsDelegate {
|
||||
pdata.status.update_status(st)
|
||||
}
|
||||
|
||||
func handle_nwc_response(_ ev: NostrEvent, relay: RelayURL) {
|
||||
func handle_nwc_response(_ ev: NostrEvent, relay: String) {
|
||||
Task { @MainActor in
|
||||
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
|
||||
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
|
||||
@@ -249,10 +232,10 @@ class HomeModel: ContactsDelegate {
|
||||
let resp = await FullWalletResponse(from: ev, nwc: nwc) else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// since command results are not returned for ephemeral events,
|
||||
// remove the request from the postbox which is likely failing over and over
|
||||
if damus_state.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
|
||||
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)]")
|
||||
} else {
|
||||
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
|
||||
@@ -271,10 +254,10 @@ class HomeModel: ContactsDelegate {
|
||||
|
||||
@MainActor
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -306,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() {
|
||||
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
|
||||
!damus_state.mutelist_manager.is_muted(.user(ev.pubkey, nil))
|
||||
!damus_state.contacts.is_muted(ev.pubkey)
|
||||
}
|
||||
|
||||
notifications.filter { ev in
|
||||
@@ -334,8 +303,7 @@ class HomeModel: ContactsDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
let event_muted = damus_state.mutelist_manager.is_event_muted(ev)
|
||||
return !event_muted
|
||||
return !damus_state.contacts.is_muted(ev.pubkey) && !damus_state.muted_threads.isMutedThread(ev, keypair: damus_state.keypair)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,7 +311,7 @@ class HomeModel: ContactsDelegate {
|
||||
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)
|
||||
|
||||
if sub_id == init_subid {
|
||||
@@ -382,19 +350,12 @@ class HomeModel: ContactsDelegate {
|
||||
case .already_counted:
|
||||
break
|
||||
case .success(let n):
|
||||
let boosted = Counted(event: ev, id: e, total: n)
|
||||
notify(.reposted(boosted))
|
||||
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) {
|
||||
guard let e = ev.last_refid() else {
|
||||
// no id ref? invalid like event
|
||||
@@ -417,7 +378,7 @@ class HomeModel: ContactsDelegate {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handle_event(relay_id: RelayURL, conn_event: NostrConnectionEvent) {
|
||||
func handle_event(relay_id: String, conn_event: NostrConnectionEvent) {
|
||||
switch conn_event {
|
||||
case .ws_event(let ev):
|
||||
switch ev {
|
||||
@@ -435,7 +396,7 @@ class HomeModel: ContactsDelegate {
|
||||
let r = pool.get_relay(relay_id),
|
||||
r.descriptor.variant == .nwc,
|
||||
let nwc = WalletConnectURL(str: nwc_str),
|
||||
nwc.relay == relay_id
|
||||
nwc.relay.id == relay_id
|
||||
{
|
||||
subscribe_to_nwc(url: nwc, pool: pool)
|
||||
}
|
||||
@@ -468,10 +429,8 @@ class HomeModel: ContactsDelegate {
|
||||
print(msg)
|
||||
|
||||
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 {
|
||||
var dms = dms.dms.flatMap { $0.events }
|
||||
dms.append(contentsOf: incoming_dms)
|
||||
@@ -496,14 +455,14 @@ class HomeModel: ContactsDelegate {
|
||||
|
||||
|
||||
/// 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 subscription = NostrSubscribe(filters: [filter], sub_id: init_subid)
|
||||
pool.send(.subscribe(subscription), to: [relay_id])
|
||||
}
|
||||
|
||||
/// 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
|
||||
// perhaps we could mark this in the relay pool somehow
|
||||
|
||||
@@ -515,13 +474,10 @@ class HomeModel: ContactsDelegate {
|
||||
var our_contacts_filter = NostrFilter(kinds: [.contacts, .metadata])
|
||||
our_contacts_filter.authors = [damus_state.pubkey]
|
||||
|
||||
var our_old_blocklist_filter = NostrFilter(kinds: [.list_deprecated])
|
||||
our_old_blocklist_filter.parameter = ["mute"]
|
||||
our_old_blocklist_filter.authors = [damus_state.pubkey]
|
||||
|
||||
var our_blocklist_filter = NostrFilter(kinds: [.mute_list])
|
||||
var our_blocklist_filter = NostrFilter(kinds: [.list])
|
||||
our_blocklist_filter.parameter = ["mute"]
|
||||
our_blocklist_filter.authors = [damus_state.pubkey]
|
||||
|
||||
|
||||
var dms_filter = NostrFilter(kinds: [.dm])
|
||||
|
||||
var our_dms_filter = NostrFilter(kinds: [.dm])
|
||||
@@ -545,7 +501,7 @@ class HomeModel: ContactsDelegate {
|
||||
notifications_filter.limit = 500
|
||||
|
||||
var notifications_filters = [notifications_filter]
|
||||
var contacts_filters = [contacts_filter, our_contacts_filter, our_blocklist_filter, our_old_blocklist_filter]
|
||||
var contacts_filters = [contacts_filter, our_contacts_filter, our_blocklist_filter]
|
||||
var dms_filters = [dms_filter, our_dms_filter]
|
||||
let last_of_kind = get_last_of_kind(relay_id: relay_id)
|
||||
|
||||
@@ -564,7 +520,7 @@ class HomeModel: ContactsDelegate {
|
||||
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] } ?? [:]
|
||||
}
|
||||
|
||||
@@ -578,7 +534,7 @@ class HomeModel: ContactsDelegate {
|
||||
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?
|
||||
var home_filter_kinds: [NostrKind] = [
|
||||
.text, .longform, .boost
|
||||
@@ -614,32 +570,13 @@ class HomeModel: ContactsDelegate {
|
||||
pool.send(.subscribe(sub), to: relay_ids)
|
||||
}
|
||||
|
||||
func handle_mute_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) {
|
||||
func handle_list_event(_ ev: NostrEvent) {
|
||||
// we only care about our lists
|
||||
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 let mutelist = damus_state.contacts.mutelist {
|
||||
if ev.created_at <= mutelist.created_at {
|
||||
return
|
||||
}
|
||||
@@ -649,12 +586,10 @@ class HomeModel: ContactsDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
damus_state.mutelist_manager.set_mutelist(ev)
|
||||
|
||||
migrate_old_muted_threads_to_new_mutelist(keypair: damus_state.keypair, damus_state: damus_state)
|
||||
damus_state.contacts.set_mutelist(ev)
|
||||
}
|
||||
|
||||
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 {
|
||||
last_event_of_kind[relay_id] = [:]
|
||||
return nil
|
||||
@@ -667,7 +602,7 @@ class HomeModel: ContactsDelegate {
|
||||
// don't show notifications from ourselves
|
||||
guard ev.pubkey != 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
|
||||
}
|
||||
|
||||
@@ -682,7 +617,7 @@ class HomeModel: ContactsDelegate {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -705,7 +640,7 @@ class HomeModel: ContactsDelegate {
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -714,10 +649,6 @@ class HomeModel: ContactsDelegate {
|
||||
damus_state.replies.count_replies(ev, keypair: self.damus_state.keypair)
|
||||
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 {
|
||||
insert_home_event(ev)
|
||||
} else if sub_id == notifications_subid {
|
||||
@@ -728,17 +659,15 @@ class HomeModel: ContactsDelegate {
|
||||
func got_new_dm(notifs: NewEventsBits, ev: NostrEvent) {
|
||||
notification_status.new_events = notifs
|
||||
|
||||
guard should_display_notification(state: damus_state, event: ev),
|
||||
let notification_object = generate_local_notification_object(from: ev, state: damus_state)
|
||||
else {
|
||||
return
|
||||
if damus_state.settings.dm_notification && ev.age < HomeModel.event_max_age_for_notification {
|
||||
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")
|
||||
let notify = LocalNotification(type: .dm, event: ev, target: ev, content: convo)
|
||||
create_local_notification(profiles: damus_state.profiles, notify: notify)
|
||||
}
|
||||
|
||||
create_local_notification(profiles: damus_state.profiles, notify: notification_object)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -918,23 +847,23 @@ func process_contact_event(state: DamusState, 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
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
var changed = false
|
||||
|
||||
var new = Set<RelayURL>()
|
||||
|
||||
var new = Set<String>()
|
||||
for key in decoded.keys {
|
||||
new.insert(key)
|
||||
}
|
||||
|
||||
var old = Set<RelayURL>()
|
||||
|
||||
var old = Set<String>()
|
||||
for key in old_decoded.keys {
|
||||
old.insert(key)
|
||||
}
|
||||
@@ -945,8 +874,10 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
|
||||
for d in diff {
|
||||
changed = true
|
||||
if new.contains(d) {
|
||||
let descriptor = RelayDescriptor(url: d, info: decoded[d] ?? .rw)
|
||||
add_new_relay(model_cache: state.relay_model_cache, relay_filters: state.relay_filters, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: state.settings.developer_mode)
|
||||
if let url = RelayURL(d) {
|
||||
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 {
|
||||
state.pool.remove_relay(d)
|
||||
}
|
||||
@@ -962,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) {
|
||||
try? pool.add_relay(descriptor)
|
||||
let url = descriptor.url
|
||||
|
||||
let relay_id = url
|
||||
|
||||
let relay_id = url.id
|
||||
guard model_cache.model(withURL: url) == nil else {
|
||||
return
|
||||
}
|
||||
@@ -989,10 +920,10 @@ func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, po
|
||||
}
|
||||
}
|
||||
|
||||
func fetch_relay_metadata(relay_id: RelayURL) async throws -> RelayMetadata? {
|
||||
var urlString = relay_id.absoluteString.replacingOccurrences(of: "wss://", with: "https://")
|
||||
func fetch_relay_metadata(relay_id: String) async throws -> RelayMetadata? {
|
||||
var urlString = relay_id.replacingOccurrences(of: "wss://", with: "https://")
|
||||
urlString = urlString.replacingOccurrences(of: "ws://", with: "http://")
|
||||
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
return nil
|
||||
}
|
||||
@@ -1143,14 +1074,19 @@ func event_has_our_pubkey(_ ev: NostrEvent, our_pubkey: Pubkey) -> Bool {
|
||||
|
||||
func should_show_event(event: NostrEvent, damus_state: DamusState) -> Bool {
|
||||
return should_show_event(
|
||||
state: damus_state,
|
||||
keypair: damus_state.keypair,
|
||||
hellthreads: damus_state.muted_threads,
|
||||
contacts: damus_state.contacts,
|
||||
ev: event
|
||||
)
|
||||
}
|
||||
|
||||
func should_show_event(state: DamusState, ev: NostrEvent, keypair: Keypair? = nil) -> Bool {
|
||||
let event_muted = state.mutelist_manager.is_event_muted(ev, keypair: keypair)
|
||||
if event_muted {
|
||||
func should_show_event(keypair: Keypair, hellthreads: MutedThreadsManager, contacts: Contacts, ev: NostrEvent) -> Bool {
|
||||
if contacts.is_muted(ev.pubkey) {
|
||||
return false
|
||||
}
|
||||
|
||||
if hellthreads.isMutedThread(ev, keypair: keypair) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1170,11 +1106,39 @@ func zap_vibrate(zap_amount: Int64) {
|
||||
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) {
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
content.title = NotificationFormatter.zap_notification_title(zap)
|
||||
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
||||
content.title = zap_notification_title(zap)
|
||||
content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
||||
content.sound = UNNotificationSound.default
|
||||
content.userInfo = LossyLocalNotification(type: .profile_zap, mention: .pubkey(profile_id)).to_user_info()
|
||||
|
||||
@@ -1194,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) {
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
content.title = NotificationFormatter.zap_notification_title(zap)
|
||||
content.body = NotificationFormatter.zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
||||
content.title = zap_notification_title(zap)
|
||||
content.body = zap_notification_body(profiles: profiles, zap: zap, locale: locale)
|
||||
content.sound = UNNotificationSound.default
|
||||
content.userInfo = LossyLocalNotification(type: .zap, mention: .note(evId)).to_user_info()
|
||||
|
||||
@@ -1211,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
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,6 @@
|
||||
import Foundation
|
||||
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 {
|
||||
case image(URL)
|
||||
@@ -49,32 +42,6 @@ enum MediaUpload {
|
||||
|
||||
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 {
|
||||
@@ -82,20 +49,9 @@ class ImageUploadModel: NSObject, URLSessionTaskDelegate, ObservableObject {
|
||||
|
||||
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)
|
||||
|
||||
switch res {
|
||||
case .success(_):
|
||||
DispatchQueue.main.async {
|
||||
self.progress = nil
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
}
|
||||
case .failed(_):
|
||||
DispatchQueue.main.async {
|
||||
self.progress = nil
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.progress = nil
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
//
|
||||
// MediaUploader.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
|
||||
var id: String { self.rawValue }
|
||||
case nostrBuild
|
||||
case nostrImg
|
||||
|
||||
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\""
|
||||
case .nostrImg:
|
||||
return "\"image\""
|
||||
}
|
||||
}
|
||||
|
||||
var supportsVideo: Bool {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return true
|
||||
case .nostrImg:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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 .nostrImg:
|
||||
return .init(index: 0, tag: "nostrImg", displayName: "nostrimg.com")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var postAPI: String {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return "https://nostr.build/api/v2/upload/files"
|
||||
case .nostrImg:
|
||||
return "https://nostrimg.com/api/upload"
|
||||
}
|
||||
}
|
||||
|
||||
func getMediaURL(from data: Data) -> String? {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
do {
|
||||
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
|
||||
let status = jsonObject["status"] as? String {
|
||||
|
||||
if status == "success", let dataArray = jsonObject["data"] as? [[String: Any]] {
|
||||
|
||||
var urls: [String] = []
|
||||
|
||||
for dataDict in dataArray {
|
||||
if let mainUrl = dataDict["url"] as? String {
|
||||
urls.append(mainUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return urls.joined(separator: "\n")
|
||||
} else if status == "error", let message = jsonObject["message"] as? String {
|
||||
print("Upload Error: \(message)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed JSONSerialization")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case .nostrImg:
|
||||
guard let responseString = String(data: data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue)) else {
|
||||
print("Upload failed getting response string")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let startIndex = responseString.range(of: "https://i.nostrimg.com/")?.lowerBound else {
|
||||
return nil
|
||||
}
|
||||
let stringContainingName = responseString[startIndex..<responseString.endIndex]
|
||||
guard let endIndex = stringContainingName.range(of: "\"")?.lowerBound else {
|
||||
return nil
|
||||
}
|
||||
let nostrBuildImageName = responseString[startIndex..<endIndex]
|
||||
let nostrBuildURL = "\(nostrBuildImageName)"
|
||||
return nostrBuildURL
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-58
@@ -10,8 +10,6 @@ import Foundation
|
||||
enum MentionType: AsciiCharacter, TagKey {
|
||||
case p
|
||||
case e
|
||||
case a
|
||||
case r
|
||||
|
||||
var keychar: AsciiCharacter {
|
||||
self.rawValue
|
||||
@@ -19,26 +17,21 @@ enum MentionType: AsciiCharacter, TagKey {
|
||||
}
|
||||
|
||||
enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
|
||||
case pubkey(Pubkey)
|
||||
case pubkey(Pubkey) // TODO: handle nprofile
|
||||
case note(NoteId)
|
||||
case nevent(NEvent)
|
||||
case nprofile(NProfile)
|
||||
case nrelay(String)
|
||||
case naddr(NAddr)
|
||||
|
||||
var key: MentionType {
|
||||
switch self {
|
||||
case .pubkey: return .p
|
||||
case .note: return .e
|
||||
case .nevent: return .e
|
||||
case .nprofile: return .p
|
||||
case .nrelay: return .r
|
||||
case .naddr: return .a
|
||||
}
|
||||
}
|
||||
|
||||
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? {
|
||||
@@ -53,10 +46,6 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
|
||||
switch self {
|
||||
case .pubkey(let pubkey): return pubkey
|
||||
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 {
|
||||
case .pubkey(let pubkey): return ["p", pubkey.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(),
|
||||
let chr = t0.single_char,
|
||||
let mention_type = MentionType(rawValue: chr),
|
||||
let element = i.next()
|
||||
let id = i.next()?.id()
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch mention_type {
|
||||
case .p:
|
||||
guard let data = element.id() else { return nil }
|
||||
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)
|
||||
case .p: return .pubkey(Pubkey(id))
|
||||
case .e: return .note(NoteId(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,11 +223,8 @@ func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
|
||||
for post_block in post_blocks {
|
||||
switch post_block {
|
||||
case .mention(let mention):
|
||||
switch(mention.ref) {
|
||||
case .note, .nevent:
|
||||
if case .note = mention.ref {
|
||||
continue
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
new_tags.append(mention.ref.tag)
|
||||
@@ -300,3 +251,4 @@ func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? {
|
||||
.joined(separator: "")
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: post.kind.rawValue, tags: post_tags.tags)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
//
|
||||
// MuteManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2024-01-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ fileprivate func getMutedThreadsKey(pubkey: Pubkey) -> String {
|
||||
pk_setting_key(pubkey, key: "muted_threads")
|
||||
}
|
||||
|
||||
func loadOldMutedThreads(pubkey: Pubkey) -> [NoteId] {
|
||||
func loadMutedThreads(pubkey: Pubkey) -> [NoteId] {
|
||||
let key = getMutedThreadsKey(pubkey: pubkey)
|
||||
let xs = UserDefaults.standard.stringArray(forKey: key) ?? []
|
||||
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
|
||||
// So now all it's doing is moving a users muted threads to the new kind:10000 system
|
||||
// It should not be used for any purpose beyond that
|
||||
func migrate_old_muted_threads_to_new_mutelist(keypair: Keypair, damus_state: DamusState) {
|
||||
// Ensure that keypair is fullkeypair
|
||||
guard let fullKeypair = keypair.to_full() else { return }
|
||||
// Load existing muted threads
|
||||
let mutedThreads = loadOldMutedThreads(pubkey: fullKeypair.pubkey)
|
||||
guard !mutedThreads.isEmpty else { return }
|
||||
// Set new muted system for those existing threads
|
||||
let previous_mute_list_event = damus_state.mutelist_manager.event
|
||||
guard let new_mutelist_event = create_or_update_mutelist(keypair: fullKeypair, mprev: previous_mute_list_event, to_add: Set(mutedThreads.map { MuteItem.thread($0, nil) })) else { return }
|
||||
damus_state.mutelist_manager.set_mutelist(new_mutelist_event)
|
||||
damus_state.postbox.send(new_mutelist_event)
|
||||
// Set existing muted threads to an empty array
|
||||
UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey))
|
||||
func saveMutedThreads(pubkey: Pubkey, currentValue: [NoteId], value: [NoteId]) -> Bool {
|
||||
let uniqueMutedThreads = Array(Set(value))
|
||||
|
||||
if uniqueMutedThreads != currentValue {
|
||||
let ids = uniqueMutedThreads.map { note_id in return note_id.hex() }
|
||||
UserDefaults.standard.set(ids, forKey: getMutedThreadsKey(pubkey: pubkey))
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
class MutedThreadsManager: ObservableObject {
|
||||
|
||||
private let keypair: Keypair
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
//
|
||||
// MutelistManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Charlie Fish on 1/28/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class MutelistManager {
|
||||
private(set) var event: NostrEvent? = nil
|
||||
|
||||
var users: Set<MuteItem> = []
|
||||
var hashtags: Set<MuteItem> = []
|
||||
var threads: Set<MuteItem> = []
|
||||
var words: Set<MuteItem> = []
|
||||
|
||||
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 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, keypair: Keypair? = nil) -> Bool {
|
||||
return event_muted_reason(ev, keypair: keypair) != 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(_, _):
|
||||
users.insert(item)
|
||||
case .hashtag(_, _):
|
||||
hashtags.insert(item)
|
||||
case .word(_, _):
|
||||
words.insert(item)
|
||||
case .thread(_, _):
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// 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 event_muted_reason(_ ev: NostrEvent, keypair: Keypair? = nil) -> MuteItem? {
|
||||
// Events from the current user should not be muted.
|
||||
guard 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 keypair, let content: String = ev.maybe_get_content(keypair)?.lowercased() {
|
||||
for word in words {
|
||||
if case .word(let string, _) = word {
|
||||
if content.contains(string.lowercased()) {
|
||||
return word
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
//
|
||||
// NewEventsBits.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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]
|
||||
}
|
||||
@@ -1,358 +0,0 @@
|
||||
//
|
||||
// NoteContent.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
//
|
||||
// NotificationsManager.swift
|
||||
// damus
|
||||
//
|
||||
// Handles several aspects of notification logic (Both local and push notifications)
|
||||
//
|
||||
// Created by Daniel D’Aquino 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) 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) -> Bool {
|
||||
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, keypair: state.keypair) {
|
||||
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: 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: inner_ev, content: content_preview)
|
||||
} else if type == .like,
|
||||
state.settings.like_notification,
|
||||
let evid = ev.referenced_ids.last,
|
||||
let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
|
||||
let liked_event = txn.unsafeUnownedValue?.to_owned()
|
||||
{
|
||||
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .like, event: ev, target: liked_event, content: content_preview)
|
||||
}
|
||||
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: ev, content: convo)
|
||||
}
|
||||
else if type == .zap,
|
||||
state.settings.zap_notification {
|
||||
return LocalNotification(type: .zap, event: ev, target: 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ enum NotificationItem {
|
||||
case profile_zap(ZapGroup)
|
||||
case event_zap(NoteId, ZapGroup)
|
||||
case reply(NostrEvent)
|
||||
case damus_app_notification(DamusAppNotification)
|
||||
|
||||
var is_reply: NostrEvent? {
|
||||
if case .reply(let ev) = self {
|
||||
@@ -34,8 +33,6 @@ enum NotificationItem {
|
||||
return nil
|
||||
case .repost:
|
||||
return nil
|
||||
case .damus_app_notification(_):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +48,6 @@ enum NotificationItem {
|
||||
return zapgrp.last_event_at
|
||||
case .reply(let reply):
|
||||
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)
|
||||
case .reply(let ev):
|
||||
return !isIncluded(ev)
|
||||
case .damus_app_notification(_):
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +79,6 @@ enum NotificationItem {
|
||||
case .reply(let ev):
|
||||
if isIncluded(ev) { return .reply(ev) }
|
||||
return nil
|
||||
case .damus_app_notification(_):
|
||||
return self
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,9 +94,6 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
var reactions: [NoteId: EventGroup] = [:]
|
||||
var reposts: [NoteId: EventGroup] = [:]
|
||||
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_ev = Set<NoteId>()
|
||||
|
||||
@@ -172,10 +160,6 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
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 }
|
||||
return notifs
|
||||
}
|
||||
@@ -270,33 +254,6 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
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 {
|
||||
if should_queue {
|
||||
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
|
||||
}
|
||||
|
||||
for incoming_app_notification in incoming_app_notifications {
|
||||
inserted = insert_app_notification_immediate(notification: incoming_app_notification) || inserted
|
||||
}
|
||||
|
||||
if inserted {
|
||||
self.notifications = build_notifications()
|
||||
}
|
||||
@@ -373,19 +326,3 @@ class NotificationsModel: ObservableObject, ScrollQueue {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,8 @@ import Foundation
|
||||
class ProfileModel: ObservableObject, Equatable {
|
||||
@Published var contacts: NostrEvent? = nil
|
||||
@Published var following: Int = 0
|
||||
@Published var relays: [RelayURL: RelayInfo]? = nil
|
||||
@Published var relays: [String: RelayInfo]? = nil
|
||||
@Published var progress: Int = 0
|
||||
|
||||
private let MAX_SHARE_RELAYS = 4
|
||||
|
||||
var events: EventHolder
|
||||
let pubkey: Pubkey
|
||||
@@ -22,7 +20,6 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
var seen_event: Set<NoteId> = Set()
|
||||
var sub_id = UUID().description
|
||||
var prof_subid = UUID().description
|
||||
var findRelay_subid = UUID().description
|
||||
|
||||
init(pubkey: Pubkey, damus: DamusState) {
|
||||
self.pubkey = pubkey
|
||||
@@ -108,8 +105,8 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
}
|
||||
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 {
|
||||
case .ws_event:
|
||||
return
|
||||
@@ -131,7 +128,7 @@ class ProfileModel: ObservableObject, Equatable {
|
||||
break
|
||||
//notify(.notice, notice)
|
||||
case .eose:
|
||||
guard let txn = NdbTxn(ndb: damus.ndb) else { return }
|
||||
let txn = NdbTxn(ndb: damus.ndb)
|
||||
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)
|
||||
}
|
||||
@@ -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 } ?? []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,62 +6,37 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
class DamusPurple: StoreObserverDelegate {
|
||||
let settings: UserSettingsStore
|
||||
let environment: ServerEnvironment
|
||||
let keypair: Keypair
|
||||
var storekit_manager: StoreKitManager
|
||||
var checkout_ids_in_progress: Set<String> = []
|
||||
var onboarding_status: OnboardingStatus
|
||||
|
||||
@MainActor
|
||||
var account_cache: [Pubkey: Account]
|
||||
@MainActor
|
||||
var account_uuid_cache: [Pubkey: UUID]
|
||||
|
||||
init(settings: UserSettingsStore, keypair: Keypair) {
|
||||
self.settings = settings
|
||||
var starred_profiles_cache: [Pubkey: Bool]
|
||||
|
||||
init(environment: ServerEnvironment, keypair: Keypair) {
|
||||
self.environment = environment
|
||||
self.keypair = keypair
|
||||
self.account_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
|
||||
}
|
||||
}
|
||||
self.starred_profiles_cache = [:]
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
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? {
|
||||
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) {
|
||||
return account_info.pubkey == pubkey.hex()
|
||||
@@ -69,30 +44,29 @@ class DamusPurple: StoreObserverDelegate {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func get_maybe_cached_account(pubkey: Pubkey) async throws -> Account? {
|
||||
if let account = self.account_cache[pubkey] {
|
||||
return account
|
||||
|
||||
func get_account_data(pubkey: Pubkey) async -> Data? {
|
||||
let url = environment.get_base_url().appendingPathComponent("accounts/\(pubkey.hex())")
|
||||
var request = URLRequest(url: url)
|
||||
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)
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func get_account_data(pubkey: Pubkey) async throws -> Data? {
|
||||
let url = environment.api_base_url().appendingPathComponent("accounts/\(pubkey.hex())")
|
||||
func create_account(pubkey: Pubkey) async throws {
|
||||
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(
|
||||
method: .get,
|
||||
method: .post,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: nil,
|
||||
@@ -102,131 +76,59 @@ class DamusPurple: StoreObserverDelegate {
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
return data
|
||||
case 404:
|
||||
return nil
|
||||
Log.info("Created an account with Damus Purple server", for: .damus_purple)
|
||||
default:
|
||||
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
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)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
let account_uuid_info = try JSONDecoder().decode(AccountUUIDInfo.self, from: data)
|
||||
self.account_uuid_cache[self.keypair.pubkey] = account_uuid_info.account_uuid
|
||||
return account_uuid_info.account_uuid
|
||||
return
|
||||
}
|
||||
|
||||
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.
|
||||
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
|
||||
|
||||
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
|
||||
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)
|
||||
try? await create_account_if_not_existing(pubkey: keypair.pubkey)
|
||||
|
||||
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt")
|
||||
|
||||
Log.info("Sending in-app purchase receipt to Damus Purple server: %s", for: .damus_purple, receipt_base64_string)
|
||||
|
||||
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 in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
|
||||
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")
|
||||
throw DamusPurple.PurpleError.iap_receipt_verification_error(status: httpResponse.statusCode, response: data)
|
||||
do {
|
||||
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
|
||||
let url = environment.get_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/app-store-receipt")
|
||||
|
||||
Log.info("Sending in-app purchase receipt to Damus Purple server", for: .damus_purple)
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .post,
|
||||
url: url,
|
||||
payload: receiptData,
|
||||
payload_type: .binary,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Sent in-app purchase receipt to Damus Purple server successfully", for: .damus_purple)
|
||||
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 {
|
||||
var url = environment.api_base_url()
|
||||
var url = environment.get_base_url()
|
||||
url.append(path: "/translate")
|
||||
url.append(queryItems: [
|
||||
.init(name: "source", value: source_language),
|
||||
@@ -254,166 +156,6 @@ class DamusPurple: StoreObserverDelegate {
|
||||
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
|
||||
@@ -425,82 +167,31 @@ extension DamusPurple {
|
||||
let expiry: UInt64?
|
||||
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
|
||||
|
||||
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 {
|
||||
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 checkout_npub_verification_error
|
||||
}
|
||||
|
||||
struct TranslationResult: Codable {
|
||||
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 D’Aquino 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 D’Aquino 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 D’Aquino 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)
|
||||
}
|
||||
@@ -21,14 +21,13 @@ class StoreObserver: NSObject, SKPaymentTransactionObserver {
|
||||
//Observe transaction updates.
|
||||
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
|
||||
//Handle transaction states here.
|
||||
Log.info("StoreObserver received a transaction update. Notifying to delegate.", for: .damus_purple)
|
||||
|
||||
|
||||
Task {
|
||||
try await self.delegate?.send_receipt()
|
||||
await self.delegate?.send_receipt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol StoreObserverDelegate {
|
||||
func send_receipt() async throws
|
||||
func send_receipt() async
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// RepostsModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 1/22/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
final class RepostsModel: EventsModel {
|
||||
|
||||
init(state: DamusState, target: NoteId) {
|
||||
super.init(state: state, target: target, kind: .boost)
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class SearchHomeModel: ObservableObject {
|
||||
}
|
||||
|
||||
func filter_muted() {
|
||||
events.filter { should_show_event(state: damus_state, ev: $0) }
|
||||
events.filter { should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: $0) }
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
|
||||
@@ -45,12 +45,12 @@ class SearchHomeModel: ObservableObject {
|
||||
damus_state.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
func unsubscribe(to: String? = nil) {
|
||||
loading = false
|
||||
damus_state.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||
|
||||
func handle_event(relay_id: String, conn_ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let event) = conn_ev else {
|
||||
return
|
||||
}
|
||||
@@ -60,7 +60,7 @@ class SearchHomeModel: ObservableObject {
|
||||
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
|
||||
return
|
||||
}
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply(damus_state.keypair)
|
||||
if ev.is_textlike && should_show_event(keypair: damus_state.keypair, hellthreads: damus_state.muted_threads, contacts: damus_state.contacts, ev: ev) && !ev.is_reply(damus_state.keypair)
|
||||
{
|
||||
if !damus_state.settings.multiple_events_per_pubkey && seen_pubkey.contains(ev.pubkey) {
|
||||
return
|
||||
@@ -83,7 +83,7 @@ class SearchHomeModel: ObservableObject {
|
||||
// global events are not realtime
|
||||
unsubscribe(to: relay_id)
|
||||
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||
let txn = NdbTxn(ndb: damus_state.ndb)
|
||||
load_profiles(context: "universe", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.all_events), damus_state: damus_state, txn: txn)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ enum PubkeysToLoad {
|
||||
case from_keys([Pubkey])
|
||||
}
|
||||
|
||||
func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayURL, load: PubkeysToLoad, damus_state: DamusState, txn: NdbTxn<Y>) {
|
||||
func load_profiles<Y>(context: String, profiles_subid: String, relay_id: String, load: PubkeysToLoad, damus_state: DamusState, txn: NdbTxn<Y>) {
|
||||
let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events, txn: txn)
|
||||
|
||||
guard !authors.isEmpty else {
|
||||
|
||||
@@ -28,7 +28,7 @@ class SearchModel: ObservableObject {
|
||||
|
||||
func filter_muted() {
|
||||
self.events.filter {
|
||||
should_show_event(state: state, ev: $0)
|
||||
should_show_event(keypair: state.keypair, hellthreads: state.muted_threads, contacts: state.contacts, ev: $0)
|
||||
}
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
@@ -57,7 +57,7 @@ class SearchModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
guard should_show_event(state: state, ev: ev) else {
|
||||
guard should_show_event(keypair: state.keypair, hellthreads: state.muted_threads, contacts: state.contacts, ev: ev) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ class SearchModel: ObservableObject {
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
|
||||
func handle_event(relay_id: String, ev: NostrConnectionEvent) {
|
||||
let (sub_id, done) = handle_subid_event(pool: state.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||
if ev.is_textlike && ev.should_show_event {
|
||||
self.add_event(ev)
|
||||
@@ -80,7 +80,7 @@ class SearchModel: ObservableObject {
|
||||
self.loading = false
|
||||
|
||||
if sub_id == self.sub_id {
|
||||
guard let txn = NdbTxn(ndb: state.ndb) else { return }
|
||||
let txn = NdbTxn(ndb: state.ndb)
|
||||
load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn)
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func event_matches_filter(_ ev: NostrEvent, filter: NostrFilter) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func handle_subid_event(pool: RelayPool, relay_id: RelayURL, ev: NostrConnectionEvent, handle: (String, NostrEvent) -> ()) -> (String?, Bool) {
|
||||
func handle_subid_event(pool: RelayPool, relay_id: String, ev: NostrConnectionEvent, handle: (String, NostrEvent) -> ()) -> (String?, Bool) {
|
||||
switch ev {
|
||||
case .ws_event:
|
||||
return (nil, false)
|
||||
|
||||
@@ -56,7 +56,6 @@ class ThreadModel: ObservableObject {
|
||||
|
||||
func subscribe() {
|
||||
var meta_events = NostrFilter()
|
||||
var quote_events = NostrFilter()
|
||||
var event_filter = NostrFilter()
|
||||
var ref_events = NostrFilter()
|
||||
|
||||
@@ -75,14 +74,11 @@ class ThreadModel: ObservableObject {
|
||||
kinds.append(.like)
|
||||
}
|
||||
meta_events.kinds = kinds
|
||||
|
||||
meta_events.limit = 1000
|
||||
|
||||
quote_events.kinds = [.text]
|
||||
quote_events.quotes = [event.id]
|
||||
quote_events.limit = 1000
|
||||
|
||||
|
||||
let base_filters = [event_filter, ref_events]
|
||||
let meta_filters = [meta_events, quote_events]
|
||||
let meta_filters = [meta_events]
|
||||
|
||||
print("subscribing to thread \(event.id) with sub_id \(base_subid)")
|
||||
damus_state.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event)
|
||||
@@ -94,7 +90,7 @@ class ThreadModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
damus_state.events.upsert(ev)
|
||||
let the_ev = damus_state.events.upsert(ev)
|
||||
damus_state.replies.count_replies(ev, keypair: keypair)
|
||||
damus_state.events.add_replies(ev: ev, keypair: keypair)
|
||||
|
||||
@@ -103,25 +99,19 @@ class ThreadModel: ObservableObject {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||
|
||||
func handle_event(relay_id: String, ev: NostrConnectionEvent) {
|
||||
|
||||
let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in
|
||||
guard subids.contains(sid) else {
|
||||
return
|
||||
}
|
||||
|
||||
if ev.known_kind == .zap {
|
||||
process_zap_event(state: damus_state, ev: ev) { zap in
|
||||
process_zap_event(damus_state: damus_state, ev: ev) { zap in
|
||||
|
||||
}
|
||||
} else if ev.is_textlike {
|
||||
// handle thread quote reposts, we just count them instead of
|
||||
// adding them to the thread
|
||||
if let target = ev.is_quote_repost, target == self.event.id {
|
||||
//let _ = self.damus_state.quote_reposts.add_event(ev, target: target)
|
||||
} else {
|
||||
self.add_event(ev, keypair: damus_state.keypair)
|
||||
}
|
||||
self.add_event(ev, keypair: damus_state.keypair)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +120,7 @@ class ThreadModel: ObservableObject {
|
||||
}
|
||||
|
||||
if sub_id == self.base_subid {
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||
let txn = NdbTxn(ndb: damus_state.ndb)
|
||||
load_profiles(context: "thread", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(Array(event_map)), damus_state: damus_state, txn: txn)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,20 +9,19 @@ import Foundation
|
||||
import UIKit
|
||||
|
||||
let fallback_zap_amount = 1000
|
||||
let default_emoji_reactions = ["🤣", "🤙", "⚡", "💜", "🔥", "😀", "😃", "😄", "🥶"]
|
||||
|
||||
func setting_property_key(key: String) -> String {
|
||||
return pk_setting_key(UserSettingsStore.pubkey ?? .empty, key: key)
|
||||
}
|
||||
|
||||
func setting_get_property_value<T>(key: String, scoped_key: String, default_value: T) -> T {
|
||||
if let loaded = DamusUserDefaults.standard.object(forKey: scoped_key) as? T {
|
||||
if let loaded = UserDefaults.standard.object(forKey: scoped_key) as? T {
|
||||
return loaded
|
||||
} else if let loaded = DamusUserDefaults.standard.object(forKey: key) as? T {
|
||||
} else if let loaded = UserDefaults.standard.object(forKey: key) as? T {
|
||||
// If pubkey-scoped setting does not exist but the deprecated non-pubkey-scoped setting does,
|
||||
// migrate the deprecated setting into the pubkey-scoped one and delete the deprecated one.
|
||||
DamusUserDefaults.standard.set(loaded, forKey: scoped_key)
|
||||
DamusUserDefaults.standard.removeObject(forKey: key)
|
||||
UserDefaults.standard.set(loaded, forKey: scoped_key)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
return loaded
|
||||
} else {
|
||||
return default_value
|
||||
@@ -31,7 +30,7 @@ func setting_get_property_value<T>(key: String, scoped_key: String, default_valu
|
||||
|
||||
func setting_set_property_value<T: Equatable>(scoped_key: String, old_value: T, new_value: T) -> T? {
|
||||
guard old_value != new_value else { return nil }
|
||||
DamusUserDefaults.standard.set(new_value, forKey: scoped_key)
|
||||
UserDefaults.standard.set(new_value, forKey: scoped_key)
|
||||
UserSettingsStore.shared?.objectWillChange.send()
|
||||
return new_value
|
||||
}
|
||||
@@ -65,14 +64,14 @@ func setting_set_property_value<T: Equatable>(scoped_key: String, old_value: T,
|
||||
|
||||
init(key: String, default_value: T) {
|
||||
self.key = pk_setting_key(UserSettingsStore.pubkey ?? .empty, key: key)
|
||||
if let loaded = DamusUserDefaults.standard.string(forKey: self.key), let val = T.init(from: loaded) {
|
||||
if let loaded = UserDefaults.standard.string(forKey: self.key), let val = T.init(from: loaded) {
|
||||
self.value = val
|
||||
} else if let loaded = DamusUserDefaults.standard.string(forKey: key), let val = T.init(from: loaded) {
|
||||
} else if let loaded = UserDefaults.standard.string(forKey: key), let val = T.init(from: loaded) {
|
||||
// If pubkey-scoped setting does not exist but the deprecated non-pubkey-scoped setting does,
|
||||
// migrate the deprecated setting into the pubkey-scoped one and delete the deprecated one.
|
||||
self.value = val
|
||||
DamusUserDefaults.standard.set(val.to_string(), forKey: self.key)
|
||||
DamusUserDefaults.standard.removeObject(forKey: key)
|
||||
UserDefaults.standard.set(val.to_string(), forKey: self.key)
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
} else {
|
||||
self.value = default_value
|
||||
}
|
||||
@@ -85,7 +84,7 @@ func setting_set_property_value<T: Equatable>(scoped_key: String, old_value: T,
|
||||
return
|
||||
}
|
||||
self.value = newValue
|
||||
DamusUserDefaults.standard.set(newValue.to_string(), forKey: key)
|
||||
UserDefaults.standard.set(newValue.to_string(), forKey: key)
|
||||
UserSettingsStore.shared!.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
@@ -96,14 +95,6 @@ class UserSettingsStore: ObservableObject {
|
||||
static var shared: UserSettingsStore? = nil
|
||||
static var bool_options = Set<String>()
|
||||
|
||||
static func globally_load_for(pubkey: Pubkey) -> UserSettingsStore {
|
||||
// dumb stuff needed for property wrappers
|
||||
UserSettingsStore.pubkey = pubkey
|
||||
let settings = UserSettingsStore()
|
||||
UserSettingsStore.shared = settings
|
||||
return settings
|
||||
}
|
||||
|
||||
@StringSetting(key: "default_wallet", default_value: .system_default_wallet)
|
||||
var default_wallet: Wallet
|
||||
|
||||
@@ -213,12 +204,9 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "enable_experimental_purple_api", default_value: false)
|
||||
var enable_experimental_purple_api: Bool
|
||||
|
||||
@StringSetting(key: "purple_environment", default_value: .production)
|
||||
var purple_enviroment: DamusPurpleEnvironment
|
||||
@Setting(key: "purple_api_local_test_mode", default_value: false)
|
||||
var purple_api_local_test_mode: Bool
|
||||
|
||||
@Setting(key: "enable_experimental_purple_iap_support", default_value: false)
|
||||
var enable_experimental_purple_iap_support: Bool
|
||||
|
||||
@Setting(key: "emoji_reactions", default_value: default_emoji_reactions)
|
||||
var emoji_reactions: [String]
|
||||
|
||||
@@ -320,12 +308,6 @@ class UserSettingsStore: ObservableObject {
|
||||
return internal_winetranslate_api_key != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Internal, hidden settings
|
||||
|
||||
@Setting(key: "latest_contact_event_id", default_value: nil)
|
||||
var latest_contact_event_id_hex: String?
|
||||
|
||||
}
|
||||
|
||||
func pk_setting_key(_ pubkey: Pubkey, key: String) -> String {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
//
|
||||
// VideoCache.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D'Aquino on 2024-04-01.
|
||||
//
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// Default expiry time of only 1 day to prevent using too much storage
|
||||
fileprivate let DEFAULT_EXPIRY_TIME: TimeInterval = 60*60*24
|
||||
// Default cache directory is in the system-provided caches directory, so that the operating system can delete files when it needs storage space
|
||||
// (https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html)
|
||||
fileprivate let DEFAULT_CACHE_DIRECTORY_PATH: URL? = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("video_cache")
|
||||
|
||||
struct VideoCache {
|
||||
private let cache_url: URL
|
||||
private let expiry_time: TimeInterval
|
||||
static let standard: VideoCache? = try? VideoCache()
|
||||
|
||||
init?(cache_url: URL? = nil, expiry_time: TimeInterval = DEFAULT_EXPIRY_TIME) throws {
|
||||
guard let cache_url_to_apply = cache_url ?? DEFAULT_CACHE_DIRECTORY_PATH else { return nil }
|
||||
self.cache_url = cache_url_to_apply
|
||||
self.expiry_time = expiry_time
|
||||
|
||||
// Create the cache directory if it doesn't exist
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: self.cache_url, withIntermediateDirectories: true, attributes: nil)
|
||||
} catch {
|
||||
Log.error("Could not create cache directory: %s", for: .storage, error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks for a cached video and returns its URL if available, otherwise downloads and caches the video.
|
||||
func maybe_cached_url_for(video_url: URL) throws -> URL {
|
||||
let cached_url = url_to_cached_url(url: video_url)
|
||||
|
||||
if FileManager.default.fileExists(atPath: cached_url.path) {
|
||||
// Check if the cached video has expired
|
||||
let file_attributes = try FileManager.default.attributesOfItem(atPath: cached_url.path)
|
||||
if let modification_date = file_attributes[.modificationDate] as? Date, Date().timeIntervalSince(modification_date) <= expiry_time {
|
||||
// Video is not expired
|
||||
return cached_url
|
||||
} else {
|
||||
Task {
|
||||
// Video is expired, delete and re-download on the background
|
||||
try FileManager.default.removeItem(at: cached_url)
|
||||
return try await download_and_cache_video(from: video_url)
|
||||
}
|
||||
return video_url
|
||||
}
|
||||
} else {
|
||||
Task {
|
||||
// Video is not cached, download and cache on the background
|
||||
return try await download_and_cache_video(from: video_url)
|
||||
}
|
||||
return video_url
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads video content using URLSession and caches it to disk.
|
||||
private func download_and_cache_video(from url: URL) async throws -> URL {
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
|
||||
guard let http_response = response as? HTTPURLResponse,
|
||||
200..<300 ~= http_response.statusCode else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let destination_url = url_to_cached_url(url: url)
|
||||
|
||||
try data.write(to: destination_url)
|
||||
return destination_url
|
||||
}
|
||||
|
||||
func url_to_cached_url(url: URL) -> URL {
|
||||
let hashed_url = hash_url(url)
|
||||
let file_extension = url.pathExtension
|
||||
return self.cache_url.appendingPathComponent(hashed_url + "." + file_extension)
|
||||
}
|
||||
|
||||
/// Deletes all cached videos older than the expiry time.
|
||||
func periodic_purge(completion: ((Error?) -> Void)? = nil) {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
Log.info("Starting periodic video cache purge", for: .storage)
|
||||
let file_manager = FileManager.default
|
||||
do {
|
||||
let cached_files = try file_manager.contentsOfDirectory(at: self.cache_url, includingPropertiesForKeys: [.contentModificationDateKey], options: .skipsHiddenFiles)
|
||||
|
||||
for file in cached_files {
|
||||
let attributes = try file.resourceValues(forKeys: [.contentModificationDateKey])
|
||||
if let modification_date = attributes.contentModificationDate, Date().timeIntervalSince(modification_date) > self.expiry_time {
|
||||
try file_manager.removeItem(at: file)
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completion?(nil)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion?(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hashes the URL using SHA-256
|
||||
private func hash_url(_ url: URL) -> String {
|
||||
let data = Data(url.absoluteString.utf8)
|
||||
let hashed_data = SHA256.hash(data: data)
|
||||
return hashed_data.compactMap { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//
|
||||
// ZapType.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum ZapType: String, StringCodable {
|
||||
case pub
|
||||
case anon
|
||||
case priv
|
||||
case non_zap
|
||||
|
||||
init?(from string: String) {
|
||||
guard let v = ZapType(rawValue: string) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self = v
|
||||
}
|
||||
|
||||
func to_string() -> String {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
}
|
||||
@@ -39,7 +39,7 @@ class ZapsModel: ObservableObject {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||
func handle_event(relay_id: String, conn_ev: NostrConnectionEvent) {
|
||||
guard case .nostr_event(let resp) = conn_ev else {
|
||||
return
|
||||
}
|
||||
@@ -55,7 +55,7 @@ class ZapsModel: ObservableObject {
|
||||
break
|
||||
case .eose:
|
||||
let events = state.events.lookup_zaps(target: target).map { $0.request.ev }
|
||||
guard let txn = NdbTxn(ndb: state.ndb) else { return }
|
||||
let txn = NdbTxn(ndb: state.ndb)
|
||||
load_profiles(context: "zaps_model", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state, txn: txn)
|
||||
case .event(_, let ev):
|
||||
guard ev.kind == 9735,
|
||||
|
||||
@@ -41,7 +41,7 @@ struct QuoteId: IdType, TagKey, TagConvertible {
|
||||
self.id = data
|
||||
}
|
||||
|
||||
/// The note id being quoted
|
||||
/// Refer to this QuoteId as a NoteId
|
||||
var note_id: NoteId {
|
||||
NoteId(self.id)
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
//
|
||||
// MakeZapRequest.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MakeZapRequest {
|
||||
case priv(ZapRequest, PrivateZapRequest)
|
||||
case normal(ZapRequest)
|
||||
|
||||
var private_inner_request: ZapRequest {
|
||||
switch self {
|
||||
case .priv(_, let pzr):
|
||||
return pzr.req
|
||||
case .normal(let zr):
|
||||
return zr
|
||||
}
|
||||
}
|
||||
|
||||
var potentially_anon_outer_request: ZapRequest {
|
||||
switch self {
|
||||
case .priv(let zr, _):
|
||||
return zr
|
||||
case .normal(let zr):
|
||||
return zr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivateZapRequest {
|
||||
let req: ZapRequest
|
||||
let enc: String
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func make_nip98_authenticated_request(method: HTTPMethod, url: URL, payload: Dat
|
||||
createdAt: UInt32(Date().timeIntervalSince1970)
|
||||
)
|
||||
|
||||
let auth_note_json_data: Data = try encode_json_data(auth_note)
|
||||
let auth_note_json_data: Data = try JSONEncoder().encode(auth_note)
|
||||
let auth_note_base64: String = base64_encode(auth_note_json_data.bytes)
|
||||
|
||||
request.setValue("Nostr " + auth_note_base64, forHTTPHeaderField: "Authorization")
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Foundation
|
||||
|
||||
func make_auth_request(keypair: FullKeypair, challenge_string: String, relay: Relay) -> NostrEvent? {
|
||||
let tags: [[String]] = [["relay", relay.descriptor.url.absoluteString],["challenge", challenge_string]]
|
||||
let tags: [[String]] = [["relay", relay.descriptor.url.id],["challenge", challenge_string]]
|
||||
let event = NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 22242, tags: tags)
|
||||
return event
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import Foundation
|
||||
func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? {
|
||||
var tags = zap_target_to_tags(target)
|
||||
var relay_tag = ["relays"]
|
||||
relay_tag.append(contentsOf: relays.map { $0.url.absoluteString })
|
||||
relay_tag.append(contentsOf: relays.map { $0.url.id })
|
||||
tags.append(relay_tag)
|
||||
|
||||
|
||||
var kp = keypair
|
||||
|
||||
let now = UInt32(Date().timeIntervalSince1970)
|
||||
@@ -62,6 +62,11 @@ func zap_target_to_tags(_ target: ZapTarget) -> [[String]] {
|
||||
}
|
||||
}
|
||||
|
||||
struct PrivateZapRequest {
|
||||
let req: ZapRequest
|
||||
let enc: String
|
||||
}
|
||||
|
||||
func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, target: ZapTarget, message: String) -> PrivateZapRequest? {
|
||||
// target tags must be the same as zap request target tags
|
||||
let tags = zap_target_to_tags(target)
|
||||
@@ -76,11 +81,83 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair,
|
||||
return PrivateZapRequest(req: ZapRequest(ev: note), enc: enc)
|
||||
}
|
||||
|
||||
func decrypt_private_zap(our_privkey: Privkey, zapreq: NostrEvent, target: ZapTarget) -> NostrEvent? {
|
||||
guard let anon_tag = zapreq.tags.first(where: { t in
|
||||
t.count >= 2 && t[0].matches_str("anon")
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let enc_note = anon_tag[1].string()
|
||||
|
||||
var note = decrypt_note(our_privkey: our_privkey, their_pubkey: zapreq.pubkey, enc_note: enc_note, encoding: .bech32)
|
||||
|
||||
// check to see if the private note was from us
|
||||
if note == nil {
|
||||
guard let our_private_keypair = generate_private_keypair(our_privkey: our_privkey, id: NoteId(target.id), created_at: zapreq.created_at) else {
|
||||
return nil
|
||||
}
|
||||
// use our private keypair and their pubkey to get the shared secret
|
||||
note = decrypt_note(our_privkey: our_private_keypair.privkey, their_pubkey: target.pubkey, enc_note: enc_note, encoding: .bech32)
|
||||
}
|
||||
|
||||
guard let note else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard note.kind == 9733 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let zr_etag = zapreq.referenced_ids.first
|
||||
let note_etag = note.referenced_ids.first
|
||||
|
||||
guard zr_etag == note_etag else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let zr_ptag = zapreq.referenced_pubkeys.first
|
||||
let note_ptag = note.referenced_pubkeys.first
|
||||
|
||||
guard let zr_ptag, let note_ptag, zr_ptag == note_ptag else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard validate_event(ev: note) == .ok else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return note
|
||||
}
|
||||
|
||||
enum MakeZapRequest {
|
||||
case priv(ZapRequest, PrivateZapRequest)
|
||||
case normal(ZapRequest)
|
||||
|
||||
var private_inner_request: ZapRequest {
|
||||
switch self {
|
||||
case .priv(_, let pzr):
|
||||
return pzr.req
|
||||
case .normal(let zr):
|
||||
return zr
|
||||
}
|
||||
}
|
||||
|
||||
var potentially_anon_outer_request: ZapRequest {
|
||||
switch self {
|
||||
case .priv(let zr, _):
|
||||
return zr
|
||||
case .normal(let zr):
|
||||
return zr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func make_first_contact_event(keypair: Keypair) -> NostrEvent? {
|
||||
let bootstrap_relays = load_bootstrap_relays(pubkey: keypair.pubkey)
|
||||
let rw_relay_info = RelayInfo(read: true, write: true)
|
||||
var relays: [RelayURL: RelayInfo] = [:]
|
||||
|
||||
var relays: [String: RelayInfo] = [:]
|
||||
|
||||
for relay in bootstrap_relays {
|
||||
relays[relay] = rw_relay_info
|
||||
}
|
||||
|
||||
@@ -325,13 +325,7 @@ func decode_nostr_event(txt: String) -> NostrResponse? {
|
||||
func encode_json<T: Encodable>(_ val: T) -> String? {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .withoutEscapingSlashes
|
||||
return (try? encode_json_data(val)).map { String(decoding: $0, as: UTF8.self) }
|
||||
}
|
||||
|
||||
func encode_json_data<T: Encodable>(_ val: T) throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .withoutEscapingSlashes
|
||||
return try encoder.encode(val)
|
||||
return (try? encoder.encode(val)).map { String(decoding: $0, as: UTF8.self) }
|
||||
}
|
||||
|
||||
func decode_nostr_event_json(json: String) -> NostrEvent? {
|
||||
@@ -745,30 +739,22 @@ func validate_event(ev: NostrEvent) -> ValidationResult {
|
||||
|
||||
func first_eref_mention(ev: NostrEvent, keypair: Keypair) -> Mention<NoteId>? {
|
||||
let blocks = ev.blocks(keypair).blocks.filter { block in
|
||||
guard case .mention(let mention) = block else {
|
||||
return false
|
||||
}
|
||||
|
||||
switch mention.ref {
|
||||
case .note, .nevent:
|
||||
return true
|
||||
default:
|
||||
guard case .mention(let mention) = block,
|
||||
case .note = mention.ref else {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// MARK: - Preview
|
||||
if let firstBlock = blocks.first,
|
||||
case .mention(let mention) = firstBlock {
|
||||
switch mention.ref {
|
||||
case .note(let note_id):
|
||||
return .note(note_id)
|
||||
case .nevent(let nevent):
|
||||
return .note(nevent.noteid)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
case .mention(let mention) = firstBlock,
|
||||
case .note(let note_id) = mention.ref
|
||||
{
|
||||
return .note(note_id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -804,15 +790,3 @@ func to_reaction_emoji(ev: NostrEvent) -> String? {
|
||||
}
|
||||
}
|
||||
|
||||
extension NostrEvent {
|
||||
/// The mutelist for a given event
|
||||
///
|
||||
/// If the event is not a mutelist it will return `nil`.
|
||||
var mute_list: Set<MuteItem>? {
|
||||
if (self.kind == NostrKind.list_deprecated.rawValue && self.referenced_params.contains(where: { p in p.param.matches_str("mute") })) || self.kind == NostrKind.mute_list.rawValue {
|
||||
return Set(self.referenced_mute_items)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ struct NostrFilter: Codable, Equatable {
|
||||
var authors: [Pubkey]?
|
||||
var hashtag: [String]?
|
||||
var parameter: [String]?
|
||||
var quotes: [NoteId]?
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case ids
|
||||
@@ -27,14 +26,13 @@ struct NostrFilter: Codable, Equatable {
|
||||
case pubkeys = "#p"
|
||||
case hashtag = "#t"
|
||||
case parameter = "#d"
|
||||
case quotes = "#q"
|
||||
case since
|
||||
case until
|
||||
case authors
|
||||
case limit
|
||||
}
|
||||
|
||||
init(ids: [NoteId]? = nil, kinds: [NostrKind]? = nil, referenced_ids: [NoteId]? = nil, pubkeys: [Pubkey]? = nil, since: UInt32? = nil, until: UInt32? = nil, limit: UInt32? = nil, authors: [Pubkey]? = nil, hashtag: [String]? = nil, quotes: [NoteId]? = nil) {
|
||||
init(ids: [NoteId]? = nil, kinds: [NostrKind]? = nil, referenced_ids: [NoteId]? = nil, pubkeys: [Pubkey]? = nil, since: UInt32? = nil, until: UInt32? = nil, limit: UInt32? = nil, authors: [Pubkey]? = nil, hashtag: [String]? = nil) {
|
||||
self.ids = ids
|
||||
self.kinds = kinds
|
||||
self.referenced_ids = referenced_ids
|
||||
@@ -44,7 +42,6 @@ struct NostrFilter: Codable, Equatable {
|
||||
self.limit = limit
|
||||
self.authors = authors
|
||||
self.hashtag = hashtag
|
||||
self.quotes = quotes
|
||||
}
|
||||
|
||||
public static func copy(from: NostrFilter) -> NostrFilter {
|
||||
|
||||
@@ -17,8 +17,7 @@ enum NostrKind: UInt32, Codable {
|
||||
case boost = 6
|
||||
case like = 7
|
||||
case chat = 42
|
||||
case mute_list = 10000
|
||||
case list_deprecated = 30000
|
||||
case list = 30000
|
||||
case longform = 30023
|
||||
case zap = 9735
|
||||
case zap_request = 9734
|
||||
|
||||
+44
-19
@@ -55,34 +55,59 @@ func parse_hexstr(_ p: Parser, len: Int) -> String? {
|
||||
return String(substring(p.str, start: start, end: p.pos))
|
||||
}
|
||||
|
||||
func decode_universal_link(_ s: String) -> NostrLink? {
|
||||
var uri = s.replacingOccurrences(of: "https://damus.io/r/", with: "")
|
||||
uri = uri.replacingOccurrences(of: "https://damus.io/", with: "")
|
||||
uri = uri.replacingOccurrences(of: "/", with: "")
|
||||
|
||||
guard let decoded = try? bech32_decode(uri),
|
||||
decoded.data.count == 32
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if decoded.hrp == "note" {
|
||||
return .ref(.event(NoteId(decoded.data)))
|
||||
} else if decoded.hrp == "npub" {
|
||||
return .ref(.pubkey(Pubkey(decoded.data)))
|
||||
}
|
||||
// TODO: handle nprofile, etc
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decode_nostr_bech32_uri(_ s: String) -> NostrLink? {
|
||||
guard let obj = Bech32Object.parse(s) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch obj {
|
||||
case .nsec(let privkey):
|
||||
guard let pubkey = privkey_to_pubkey(privkey: privkey) else { return nil }
|
||||
return .ref(.pubkey(pubkey))
|
||||
case .npub(let pubkey):
|
||||
return .ref(.pubkey(pubkey))
|
||||
case .note(let id):
|
||||
return .ref(.event(id))
|
||||
case .nscript(let data):
|
||||
return .script(data)
|
||||
case .naddr(let naddr):
|
||||
return .ref(.naddr(naddr))
|
||||
case .nevent(let nevent):
|
||||
return .ref(.event(nevent.noteid))
|
||||
case .nprofile(let nprofile):
|
||||
return .ref(.pubkey(nprofile.author))
|
||||
case .nrelay(_):
|
||||
return .none
|
||||
}
|
||||
case .nsec(let privkey):
|
||||
guard let pubkey = privkey_to_pubkey(privkey: privkey) else { return nil }
|
||||
return .ref(.pubkey(pubkey))
|
||||
case .npub(let pubkey):
|
||||
return .ref(.pubkey(pubkey))
|
||||
case .note(let id):
|
||||
return .ref(.event(id))
|
||||
case .nscript(let data):
|
||||
return .script(data)
|
||||
}
|
||||
}
|
||||
|
||||
func decode_nostr_uri(_ s: String) -> NostrLink? {
|
||||
let uri = remove_nostr_uri_prefix(s)
|
||||
if s.starts(with: "https://damus.io/") {
|
||||
return decode_universal_link(s)
|
||||
}
|
||||
|
||||
var uri = s
|
||||
uri = uri.replacingOccurrences(of: "nostr://", with: "")
|
||||
uri = uri.replacingOccurrences(of: "nostr:", with: "")
|
||||
|
||||
// Fix for non-latin characters resulting in second colon being encoded
|
||||
uri = uri.replacingOccurrences(of: "damus:t%3A", with: "t:")
|
||||
|
||||
uri = uri.replacingOccurrences(of: "damus://", with: "")
|
||||
uri = uri.replacingOccurrences(of: "damus:", with: "")
|
||||
|
||||
let parts = uri.split(separator: ":")
|
||||
.reduce(into: Array<String>()) { acc, str in
|
||||
|
||||
@@ -73,27 +73,24 @@ class Profiles {
|
||||
profile_data(pubkey).zapper
|
||||
}
|
||||
|
||||
func lookup_with_timestamp(_ pubkey: Pubkey) -> NdbTxn<ProfileRecord?>? {
|
||||
ndb.lookup_profile(pubkey)
|
||||
func lookup_with_timestamp(_ pubkey: Pubkey) -> NdbTxn<ProfileRecord?> {
|
||||
return ndb.lookup_profile(pubkey)
|
||||
}
|
||||
|
||||
func lookup_by_key(key: ProfileKey) -> NdbTxn<ProfileRecord?>? {
|
||||
ndb.lookup_profile_by_key(key: key)
|
||||
func lookup_by_key(key: ProfileKey) -> NdbTxn<ProfileRecord?> {
|
||||
return ndb.lookup_profile_by_key(key: key)
|
||||
}
|
||||
|
||||
func search<Y>(_ query: String, limit: Int, txn: NdbTxn<Y>) -> [Pubkey] {
|
||||
ndb.search_profile(query, limit: limit, txn: txn)
|
||||
return ndb.search_profile(query, limit: limit, txn: txn)
|
||||
}
|
||||
|
||||
func lookup(id: Pubkey, txn_name: String? = nil) -> NdbTxn<Profile?>? {
|
||||
guard let txn = ndb.lookup_profile(id, txn_name: txn_name) else {
|
||||
return nil
|
||||
}
|
||||
return txn.map({ pr in pr?.profile })
|
||||
func lookup(id: Pubkey) -> NdbTxn<Profile?> {
|
||||
return ndb.lookup_profile(id).map({ pr in pr?.profile })
|
||||
}
|
||||
|
||||
func lookup_key_by_pubkey(_ pubkey: Pubkey) -> ProfileKey? {
|
||||
ndb.lookup_profile_key(pubkey)
|
||||
return ndb.lookup_profile_key(pubkey)
|
||||
}
|
||||
|
||||
func has_fresh_profile<Y>(id: Pubkey, txn: NdbTxn<Y>) -> Bool {
|
||||
|
||||
@@ -119,10 +119,9 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case event(NoteId)
|
||||
case pubkey(Pubkey)
|
||||
case quote(QuoteId)
|
||||
case hashtag(Hashtag)
|
||||
case hashtag(TagElem)
|
||||
case param(TagElem)
|
||||
case naddr(NAddr)
|
||||
|
||||
|
||||
var key: RefKey {
|
||||
switch self {
|
||||
case .event: return .e
|
||||
@@ -130,12 +129,11 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case .quote: return .q
|
||||
case .hashtag: return .t
|
||||
case .param: return .d
|
||||
case .naddr: return .a
|
||||
}
|
||||
}
|
||||
|
||||
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
|
||||
case e, p, t, d, q, a
|
||||
case e, p, t, d, q
|
||||
|
||||
var keychar: AsciiCharacter {
|
||||
self.rawValue
|
||||
@@ -155,10 +153,8 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case .event(let noteId): return noteId.hex()
|
||||
case .pubkey(let pubkey): return pubkey.hex()
|
||||
case .quote(let quote): return quote.hex()
|
||||
case .hashtag(let string): return string.hashtag
|
||||
case .hashtag(let string): return string.string()
|
||||
case .param(let string): return string.string()
|
||||
case .naddr(let naddr):
|
||||
return naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,9 +172,8 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case .e: return t1.id().map({ .event(NoteId($0)) })
|
||||
case .p: return t1.id().map({ .pubkey(Pubkey($0)) })
|
||||
case .q: return t1.id().map({ .quote(QuoteId($0)) })
|
||||
case .t: return .hashtag(Hashtag(hashtag: t1.string()))
|
||||
case .t: return .hashtag(t1)
|
||||
case .d: return .param(t1)
|
||||
case .a: return .naddr(NAddr(identifier: "", author: Pubkey(Data()), relays: [], kind: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-31
@@ -84,33 +84,6 @@ struct Limitations: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
struct Admission: Codable {
|
||||
let amount: Int64
|
||||
let unit: String
|
||||
}
|
||||
|
||||
struct Subscription: Codable {
|
||||
let amount: Int64
|
||||
let unit: String
|
||||
let period: Int
|
||||
}
|
||||
|
||||
struct Publication: Codable {
|
||||
let kinds: [Int]
|
||||
let amount: Int64
|
||||
let unit: String
|
||||
}
|
||||
|
||||
struct Fees: Codable {
|
||||
let admission: [Admission]?
|
||||
let subscription: [Subscription]?
|
||||
let publication: [Publication]?
|
||||
|
||||
static var empty: Fees {
|
||||
Fees(admission: nil, subscription: nil, publication: nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct RelayMetadata: Codable {
|
||||
let name: String?
|
||||
let description: String?
|
||||
@@ -122,7 +95,6 @@ struct RelayMetadata: Codable {
|
||||
let limitation: Limitations?
|
||||
let payments_url: String?
|
||||
let icon: String?
|
||||
let fees: Fees?
|
||||
|
||||
var is_paid: Bool {
|
||||
return limitation?.payment_required ?? false
|
||||
@@ -146,9 +118,9 @@ class Relay: Identifiable {
|
||||
var is_broken: Bool {
|
||||
return (flags & RelayFlags.broken.rawValue) == RelayFlags.broken.rawValue
|
||||
}
|
||||
|
||||
var id: RelayURL {
|
||||
return descriptor.url
|
||||
|
||||
var id: String {
|
||||
return get_relay_id(descriptor.url)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -156,3 +128,15 @@ class Relay: Identifiable {
|
||||
enum RelayError: Error {
|
||||
case RelayAlreadyExists
|
||||
}
|
||||
|
||||
func get_relay_id(_ url: RelayURL) -> String {
|
||||
let trimTrailingSlashes: (String) -> String = { url in
|
||||
var trimmedUrl = url
|
||||
while trimmedUrl.hasSuffix("/") {
|
||||
trimmedUrl.removeLast()
|
||||
}
|
||||
return trimmedUrl
|
||||
}
|
||||
|
||||
return trimTrailingSlashes(url.url.absoluteString)
|
||||
}
|
||||
|
||||
@@ -21,19 +21,19 @@ final class RelayConnection: ObservableObject {
|
||||
private(set) var last_connection_attempt: TimeInterval = 0
|
||||
private(set) var last_pong: Date? = nil
|
||||
private(set) var backoff: TimeInterval = 1.0
|
||||
private lazy var socket = WebSocket(relay_url.url)
|
||||
private lazy var socket = WebSocket(url.url)
|
||||
private var subscriptionToken: AnyCancellable?
|
||||
|
||||
|
||||
private var handleEvent: (NostrConnectionEvent) -> ()
|
||||
private var processEvent: (WebSocketEvent) -> ()
|
||||
private let relay_url: RelayURL
|
||||
private let url: RelayURL
|
||||
var log: RelayLog?
|
||||
|
||||
init(url: RelayURL,
|
||||
handleEvent: @escaping (NostrConnectionEvent) -> (),
|
||||
processEvent: @escaping (WebSocketEvent) -> ())
|
||||
{
|
||||
self.relay_url = url
|
||||
self.url = url
|
||||
self.handleEvent = handleEvent
|
||||
self.processEvent = processEvent
|
||||
}
|
||||
@@ -48,7 +48,7 @@ final class RelayConnection: ObservableObject {
|
||||
self.last_pong = .now
|
||||
self.log?.add("Successful ping")
|
||||
} else {
|
||||
print("pong failed, reconnecting \(self.relay_url.id)")
|
||||
print("pong failed, reconnecting \(self.url.id)")
|
||||
self.isConnected = false
|
||||
self.isConnecting = false
|
||||
self.reconnect_with_backoff()
|
||||
@@ -126,7 +126,7 @@ final class RelayConnection: ObservableObject {
|
||||
self.receive(message: message)
|
||||
case .disconnected(let closeCode, let reason):
|
||||
if closeCode != .normalClosure {
|
||||
print("⚠️ Warning: RelayConnection (\(self.relay_url)) closed with code \(closeCode), reason: \(String(describing: reason))")
|
||||
print("⚠️ Warning: RelayConnection (\(self.url)) closed with code \(closeCode), reason: \(String(describing: reason))")
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.isConnected = false
|
||||
@@ -134,7 +134,7 @@ final class RelayConnection: ObservableObject {
|
||||
self.reconnect()
|
||||
}
|
||||
case .error(let error):
|
||||
print("⚠️ Warning: RelayConnection (\(self.relay_url)) error: \(error)")
|
||||
print("⚠️ Warning: RelayConnection (\(self.url)) error: \(error)")
|
||||
let nserr = error as NSError
|
||||
if nserr.domain == NSPOSIXErrorDomain && nserr.code == 57 {
|
||||
// ignore socket not connected?
|
||||
|
||||
@@ -13,7 +13,7 @@ import UIKit
|
||||
/// will have information to help developers debug issues.
|
||||
final class RelayLog: ObservableObject {
|
||||
private static let line_limit = 250
|
||||
private let relay_url: RelayURL?
|
||||
private let relay_url: URL?
|
||||
private lazy var formatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
@@ -29,9 +29,9 @@ final class RelayLog: ObservableObject {
|
||||
/// - Parameter relay_url: the relay url the log represents. Pass nil for the url to create
|
||||
/// a RelayLog that does nothing. This is required to allow RelayLog to be used as a StateObject,
|
||||
/// because they cannot be Optional.
|
||||
init(_ relay_url: RelayURL? = nil) {
|
||||
init(_ relay_url: URL? = nil) {
|
||||
self.relay_url = relay_url
|
||||
|
||||
|
||||
setUp()
|
||||
}
|
||||
|
||||
|
||||
+59
-69
@@ -10,17 +10,17 @@ import Network
|
||||
|
||||
struct RelayHandler {
|
||||
let sub_id: String
|
||||
let callback: (RelayURL, NostrConnectionEvent) -> ()
|
||||
let callback: (String, NostrConnectionEvent) -> ()
|
||||
}
|
||||
|
||||
struct QueuedRequest {
|
||||
let req: NostrRequestType
|
||||
let relay: RelayURL
|
||||
let relay: String
|
||||
let skip_ephemeral: Bool
|
||||
}
|
||||
|
||||
struct SeenEvent: Hashable {
|
||||
let relay_id: RelayURL
|
||||
let relay_id: String
|
||||
let evid: NoteId
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class RelayPool {
|
||||
var handlers: [RelayHandler] = []
|
||||
var request_queue: [QueuedRequest] = []
|
||||
var seen: Set<SeenEvent> = Set()
|
||||
var counts: [RelayURL: UInt64] = [:]
|
||||
var counts: [String: UInt64] = [:]
|
||||
var ndb: Ndb
|
||||
var keypair: Keypair?
|
||||
var message_received_function: (((String, RelayDescriptor)) -> Void)?
|
||||
@@ -39,16 +39,6 @@ class RelayPool {
|
||||
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
|
||||
private var last_network_status: NWPath.Status = .unsatisfied
|
||||
|
||||
func close() {
|
||||
disconnect()
|
||||
relays = []
|
||||
handlers = []
|
||||
request_queue = []
|
||||
seen.removeAll()
|
||||
counts = [:]
|
||||
keypair = nil
|
||||
}
|
||||
|
||||
init(ndb: Ndb, keypair: Keypair? = nil) {
|
||||
self.ndb = ndb
|
||||
self.keypair = keypair
|
||||
@@ -93,8 +83,8 @@ class RelayPool {
|
||||
relay.connection.ping()
|
||||
}
|
||||
}
|
||||
|
||||
func register_handler(sub_id: String, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
|
||||
|
||||
func register_handler(sub_id: String, handler: @escaping (String, NostrConnectionEvent) -> ()) {
|
||||
for handler in handlers {
|
||||
// don't add duplicate handlers
|
||||
if handler.sub_id == sub_id {
|
||||
@@ -104,10 +94,10 @@ class RelayPool {
|
||||
self.handlers.append(RelayHandler(sub_id: sub_id, callback: handler))
|
||||
print("registering \(sub_id) handler, current: \(self.handlers.count)")
|
||||
}
|
||||
|
||||
func remove_relay(_ relay_id: RelayURL) {
|
||||
|
||||
func remove_relay(_ relay_id: String) {
|
||||
var i: Int = 0
|
||||
|
||||
|
||||
self.disconnect(to: [relay_id])
|
||||
|
||||
for relay in relays {
|
||||
@@ -120,13 +110,14 @@ class RelayPool {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func add_relay(_ desc: RelayDescriptor) throws {
|
||||
let relay_id = desc.url
|
||||
let url = desc.url
|
||||
let relay_id = get_relay_id(url)
|
||||
if get_relay(relay_id) != nil {
|
||||
throw RelayError.RelayAlreadyExists
|
||||
}
|
||||
let conn = RelayConnection(url: desc.url, handleEvent: { event in
|
||||
let conn = RelayConnection(url: url, handleEvent: { event in
|
||||
self.handle_event(relay_id: relay_id, event: event)
|
||||
}, processEvent: { wsev in
|
||||
guard case .message(let msg) = wsev,
|
||||
@@ -139,11 +130,11 @@ class RelayPool {
|
||||
let relay = Relay(descriptor: desc, connection: conn)
|
||||
self.relays.append(relay)
|
||||
}
|
||||
|
||||
func setLog(_ log: RelayLog, for relay_id: RelayURL) {
|
||||
|
||||
func setLog(_ log: RelayLog, for relay_id: String) {
|
||||
// add the current network state to the log
|
||||
log.add("Network state: \(network_monitor.currentPath.status)")
|
||||
|
||||
|
||||
get_relay(relay_id)?.connection.log = log
|
||||
}
|
||||
|
||||
@@ -153,9 +144,9 @@ class RelayPool {
|
||||
let c = relay.connection
|
||||
|
||||
let is_connecting = c.isConnecting
|
||||
|
||||
|
||||
if is_connecting && (Date.now.timeIntervalSince1970 - c.last_connection_attempt) > 5 {
|
||||
print("stale connection detected (\(relay.descriptor.url.absoluteString)). retrying...")
|
||||
print("stale connection detected (\(relay.descriptor.url.url.absoluteString)). retrying...")
|
||||
relay.connection.reconnect()
|
||||
} else if relay.is_broken || is_connecting || c.isConnected {
|
||||
continue
|
||||
@@ -165,8 +156,8 @@ class RelayPool {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func reconnect(to: [RelayURL]? = nil) {
|
||||
|
||||
func reconnect(to: [String]? = nil) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
for relay in relays {
|
||||
// don't try to reconnect to broken relays
|
||||
@@ -174,38 +165,38 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
|
||||
func connect(to: [RelayURL]? = nil) {
|
||||
func connect(to: [String]? = nil) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
for relay in relays {
|
||||
relay.connection.connect()
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(to: [RelayURL]? = nil) {
|
||||
func disconnect(to: [String]? = nil) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
for relay in relays {
|
||||
relay.connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribe(sub_id: String, to: [RelayURL]? = nil) {
|
||||
|
||||
func unsubscribe(sub_id: String, to: [String]? = nil) {
|
||||
if to == nil {
|
||||
self.remove_handler(sub_id: sub_id)
|
||||
}
|
||||
self.send(.unsubscribe(sub_id), to: to)
|
||||
}
|
||||
|
||||
func subscribe(sub_id: String, filters: [NostrFilter], handler: @escaping (RelayURL, NostrConnectionEvent) -> (), to: [RelayURL]? = nil) {
|
||||
|
||||
func subscribe(sub_id: String, filters: [NostrFilter], handler: @escaping (String, NostrConnectionEvent) -> (), to: [String]? = nil) {
|
||||
register_handler(sub_id: sub_id, handler: handler)
|
||||
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
|
||||
}
|
||||
|
||||
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) {
|
||||
|
||||
func subscribe_to(sub_id: String, filters: [NostrFilter], to: [String]?, handler: @escaping (String, NostrConnectionEvent) -> ()) {
|
||||
register_handler(sub_id: sub_id, handler: handler)
|
||||
send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to)
|
||||
}
|
||||
|
||||
func count_queued(relay: RelayURL) -> Int {
|
||||
|
||||
func count_queued(relay: String) -> Int {
|
||||
var c = 0
|
||||
for request in request_queue {
|
||||
if request.relay == relay {
|
||||
@@ -215,8 +206,8 @@ class RelayPool {
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func queue_req(r: NostrRequestType, relay: RelayURL, skip_ephemeral: Bool) {
|
||||
|
||||
func queue_req(r: NostrRequestType, relay: String, skip_ephemeral: Bool) {
|
||||
let count = count_queued(relay: relay)
|
||||
guard count <= 10 else {
|
||||
print("can't queue, too many queued events for \(relay)")
|
||||
@@ -226,23 +217,19 @@ class RelayPool {
|
||||
print("queueing request for \(relay)")
|
||||
request_queue.append(QueuedRequest(req: r, relay: relay, skip_ephemeral: skip_ephemeral))
|
||||
}
|
||||
|
||||
func send_raw_to_local_ndb(_ req: NostrRequestType) {
|
||||
|
||||
func send_raw(_ req: NostrRequestType, to: [String]? = nil, skip_ephemeral: Bool = true) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
|
||||
// send to local relay (nostrdb)
|
||||
switch req {
|
||||
case .typical(let r):
|
||||
if case .event = r, let rstr = make_nostr_req(r) {
|
||||
let _ = ndb.process_client_event(rstr)
|
||||
}
|
||||
case .custom(let string):
|
||||
let _ = ndb.process_client_event(string)
|
||||
case .typical(let r):
|
||||
if case .event = r, let rstr = make_nostr_req(r) {
|
||||
let _ = ndb.process_client_event(rstr)
|
||||
}
|
||||
case .custom(let string):
|
||||
let _ = ndb.process_client_event(string)
|
||||
}
|
||||
}
|
||||
|
||||
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
|
||||
let relays = to.map{ get_relays($0) } ?? self.relays
|
||||
|
||||
self.send_raw_to_local_ndb(req)
|
||||
|
||||
for relay in relays {
|
||||
if req.is_read && !(relay.descriptor.info.read ?? true) {
|
||||
@@ -267,21 +254,21 @@ class RelayPool {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ req: NostrRequest, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) {
|
||||
|
||||
func send(_ req: NostrRequest, to: [String]? = nil, skip_ephemeral: Bool = true) {
|
||||
send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral)
|
||||
}
|
||||
|
||||
func get_relays(_ ids: [RelayURL]) -> [Relay] {
|
||||
|
||||
func get_relays(_ ids: [String]) -> [Relay] {
|
||||
// don't include ephemeral relays in the default list to query
|
||||
relays.filter { ids.contains($0.id) }
|
||||
}
|
||||
|
||||
func get_relay(_ id: RelayURL) -> Relay? {
|
||||
|
||||
func get_relay(_ id: String) -> Relay? {
|
||||
relays.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func run_queue(_ relay_id: RelayURL) {
|
||||
|
||||
func run_queue(_ relay_id: String) {
|
||||
self.request_queue = request_queue.reduce(into: Array<QueuedRequest>()) { (q, req) in
|
||||
guard req.relay == relay_id else {
|
||||
q.append(req)
|
||||
@@ -292,8 +279,8 @@ class RelayPool {
|
||||
self.send_raw(req.req, to: [relay_id], skip_ephemeral: false)
|
||||
}
|
||||
}
|
||||
|
||||
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
|
||||
|
||||
func record_seen(relay_id: String, event: NostrConnectionEvent) {
|
||||
if case .nostr_event(let ev) = event {
|
||||
if case .event(_, let nev) = ev {
|
||||
let k = SeenEvent(relay_id: relay_id, evid: nev.id)
|
||||
@@ -308,10 +295,10 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) {
|
||||
|
||||
func handle_event(relay_id: String, event: NostrConnectionEvent) {
|
||||
record_seen(relay_id: relay_id, event: event)
|
||||
|
||||
|
||||
// run req queue when we reconnect
|
||||
if case .ws_event(let ws) = event {
|
||||
if case .connected = ws {
|
||||
@@ -352,7 +339,10 @@ class RelayPool {
|
||||
}
|
||||
}
|
||||
|
||||
func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) {
|
||||
func add_rw_relay(_ pool: RelayPool, _ url: String) {
|
||||
guard let url = RelayURL(url) else {
|
||||
return
|
||||
}
|
||||
try? pool.add_relay(RelayDescriptor(url: url, info: .rw))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,31 +7,18 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable, Identifiable, Comparable, CustomStringConvertible {
|
||||
public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable {
|
||||
private(set) var url: URL
|
||||
|
||||
public var id: URL {
|
||||
return url
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
return self.absoluteString
|
||||
}
|
||||
|
||||
public var absoluteString: String {
|
||||
|
||||
var id: String {
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
|
||||
init?(_ str: String) {
|
||||
var trimmed_url_str = str
|
||||
while trimmed_url_str.hasSuffix("/") {
|
||||
trimmed_url_str.removeLast()
|
||||
}
|
||||
|
||||
guard let url = URL(string: trimmed_url_str) else {
|
||||
guard let url = URL(string: str) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
guard let scheme = url.scheme else {
|
||||
return nil
|
||||
}
|
||||
@@ -80,12 +67,7 @@ public struct RelayURL: Hashable, Equatable, Codable, CodingKeyRepresentable, Id
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(self.url)
|
||||
}
|
||||
|
||||
// MARK: - Comparable
|
||||
public static func < (lhs: RelayURL, rhs: RelayURL) -> Bool {
|
||||
return lhs.url.absoluteString < rhs.url.absoluteString
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
private struct StringKey: CodingKey {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
import Foundation
|
||||
|
||||
struct MuteNotify: Notify {
|
||||
typealias Payload = MuteItem
|
||||
var payload: MuteItem
|
||||
typealias Payload = Pubkey
|
||||
var payload: Payload
|
||||
}
|
||||
|
||||
extension NotifyHandler {
|
||||
@@ -19,7 +19,7 @@ extension NotifyHandler {
|
||||
}
|
||||
|
||||
extension Notifications {
|
||||
static func mute(_ target: MuteItem) -> Notifications<MuteNotify> {
|
||||
static func mute(_ target: Pubkey) -> Notifications<MuteNotify> {
|
||||
.init(.init(payload: target))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Foundation
|
||||
|
||||
struct NewMutesNotify: Notify {
|
||||
typealias Payload = Set<MuteItem>
|
||||
typealias Payload = Set<Pubkey>
|
||||
var payload: Payload
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ extension NotifyHandler {
|
||||
}
|
||||
|
||||
extension Notifications {
|
||||
static func new_mutes(_ pubkeys: Set<MuteItem>) -> Notifications<NewMutesNotify> {
|
||||
static func new_mutes(_ pubkeys: Set<Pubkey>) -> Notifications<NewMutesNotify> {
|
||||
.init(.init(payload: pubkeys))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import Foundation
|
||||
|
||||
struct NewUnmutesNotify: Notify {
|
||||
typealias Payload = Set<MuteItem>
|
||||
typealias Payload = Set<Pubkey>
|
||||
var payload: Payload
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ extension NotifyHandler {
|
||||
}
|
||||
|
||||
extension Notifications {
|
||||
static func new_unmutes(_ pubkeys: Set<MuteItem>) -> Notifications<NewUnmutesNotify> {
|
||||
static func new_unmutes(_ pubkeys: Set<Pubkey>) -> Notifications<NewUnmutesNotify> {
|
||||
.init(.init(payload: pubkeys))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//
|
||||
// PurpleAccountUpdateNotify.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2024-01-29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PurpleAccountUpdateNotify: Notify {
|
||||
typealias Payload = DamusPurple.Account
|
||||
var payload: DamusPurple.Account
|
||||
}
|
||||
|
||||
extension NotifyHandler {
|
||||
static var purple_account_update: NotifyHandler<PurpleAccountUpdateNotify> {
|
||||
.init()
|
||||
}
|
||||
}
|
||||
|
||||
extension Notifications {
|
||||
static func purple_account_update(_ result: DamusPurple.Account) -> Notifications<PurpleAccountUpdateNotify> {
|
||||
.init(.init(payload: result))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user