Compare commits
1 Commits
remove-unu
...
tyiu/local
| Author | SHA1 | Date | |
|---|---|---|---|
|
b6c811dedf
|
35
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,35 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: 'Bug: '
|
||||
labels: bug, Needs recreation
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**What happens**
|
||||
When I perform action ___, _____ happens.
|
||||
|
||||
**What I expect to happen**
|
||||
I expect _______ to happen.
|
||||
|
||||
**Link to noteID, npub**
|
||||
Provide link to relevant noteID, npub etc.
|
||||
|
||||
**Screenshots/video recording**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
|
||||
** Versions **
|
||||
Damus version: [e.g. 1.7.2 (1()]
|
||||
Operating system version: [e.g. iOS 17.2.1]
|
||||
Device: e.g. iPhone 13 Pro
|
||||
|
||||
**Steps To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Open Damus
|
||||
2. Tap on ___
|
||||
3. Action ____
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -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.
|
||||
32
.github/workflows/run-tests.yaml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Run Test Suite
|
||||
run-name: Testing ${{ github.ref }} by @${{ github.actor }}
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
- "ci"
|
||||
pull_request:
|
||||
branches:
|
||||
- "*"
|
||||
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
runs-on: macos-12
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- xcode: "14.2"
|
||||
ios: "16.2"
|
||||
|
||||
name: Test iOS (${{ matrix.ios }})
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v1
|
||||
- name: Select Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: ${{ matrix.xcode }}
|
||||
- name: Run Tests
|
||||
run: xcodebuild test -scheme damus -project damus.xcodeproj -destination 'platform=iOS Simulator,name=iPhone 14,OS=${{ matrix.ios }}' | xcpretty && exit ${PIPESTATUS[0]}
|
||||
1
.mailmap
@@ -4,4 +4,3 @@ Suhail Saqan <suhail.saqan@gmail.com> <43693074+suhailsaqan@users.noreply.github
|
||||
cr0bar <cr0bar@cr0.bar> <cr0bar@users.noreply.github.com>
|
||||
Swift <scoder1747@gmail.com> <120697811+scoder1747@users.noreply.github.com>
|
||||
Daniel D'Aquino <daniel@daquino.me> <patches@damus.io>
|
||||
Transifex <transifex@transifex.com> <43880903+transifex-integration[bot]@users.noreply.github.com>
|
||||
|
||||
187
CHANGELOG.md
@@ -1,188 +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
|
||||
|
||||
- Tap to dismiss keyboard on user status view (ericholguin)
|
||||
- Add setting that allows users to optionally disable the new profile action sheet feature (Daniel D’Aquino)
|
||||
- Add follow button to profile action sheet (Daniel D’Aquino)
|
||||
- Added reaction counters to nostrdb (William Casarin)
|
||||
- Record when profile is last fetched in nostrdb (William Casarin)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Automatically load extra regional Japanese relays during account creation if user's region is set to Japan. (Daniel D’Aquino)
|
||||
- Updated customize zap view (ericholguin)
|
||||
- Users are now notified when you quote repost them (William Casarin)
|
||||
- Save bandwidth by only fetching new profiles after a certain amount of time (William Casarin)
|
||||
- Zap button on profile action sheet now zaps with a single click, while a long press brings custom zap view (Daniel D’Aquino)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use white font color in qrcode view (ericholguin)
|
||||
- Fixed an issue where zapping would silently fail on default settings if the user does not have a lightning wallet preinstalled on their device. (Daniel D’Aquino)
|
||||
|
||||
|
||||
[1.6-25]: https://github.com/damus-io/damus/releases/tag/v1.6-25
|
||||
## [1.6-24] - 2023-10-22 - AppStore Rejection Cope
|
||||
|
||||
### Added
|
||||
|
||||
- Improve discoverability of profile zaps with zappability badges and profile action sheets (Daniel D’Aquino)
|
||||
- Add suggested hashtags to universe view (Daniel D’Aquino)
|
||||
- Suggest first post during onboarding (Daniel D’Aquino)
|
||||
- Add expiry date for images in cache to be auto-deleted after a preset time to save space on storage (Daniel D’Aquino)
|
||||
- Add QR scan nsec logins. (Jericho Hasselbush)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved status view design (ericholguin)
|
||||
- Improve clear cache functionality (Daniel D’Aquino)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduce size of event menu hitbox (William Casarin)
|
||||
- Do not show DMs from muted users (Daniel D’Aquino)
|
||||
- Add more spacing between display name and username, and prefix username with `@` character (Daniel D’Aquino)
|
||||
- Broadcast quoted notes when posting a note with quotes (Daniel D’Aquino)
|
||||
|
||||
|
||||
[1.6-24]: https://github.com/damus-io/damus/releases/tag/v1.6-24
|
||||
|
||||
## [1.6-23] - 2023-10-06 - Appstore Release
|
||||
|
||||
### Added
|
||||
|
||||
- Added merch store button to sidebar menu (Daniel D’Aquino)
|
||||
|
||||
### Changed
|
||||
|
||||
- Damus icon now opens sidebar (Daniel D’Aquino)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stop tab buttons from causing the root view to scroll to the top unless user is coming from another tab or already at the root view (Daniel D’Aquino)
|
||||
- Fix profiles not updating (William Casarin)
|
||||
- Fix issue where relays with trailing slashes cannot be removed (#1531) (Daniel D’Aquino)
|
||||
|
||||
|
||||
[1.6-23]: https://github.com/damus-io/damus/releases/tag/v1.6-23
|
||||
|
||||
## [1.6-20] - 2023-10-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Improve UX around clearing cache (Daniel D’Aquino)
|
||||
- Show muted thread replies at the bottom of the thread view (#1522) (Daniel D’Aquino)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix situations where the note composer cursor gets stuck in one place after tagging a user (Daniel D’Aquino)
|
||||
- Fix some note composer issues, such as when copying/pasting larger text, and make the post composer more robust. (Daniel D’Aquino)
|
||||
- Apply filters to hashtag search timeline view (Daniel D’Aquino)
|
||||
- Hide quoted or reposted notes from people whom the user has muted. (#1216) (Daniel D’Aquino)
|
||||
- Fix profile not updating (William Casarin)
|
||||
- Fix small graphical toolbar bug when scrolling profiles (Daniel D’Aquino)
|
||||
- Fix localization issues and export strings for translation (Terry Yiu)
|
||||
|
||||
|
||||
[1.6-20]: https://github.com/damus-io/damus/releases/tag/v1.6-20
|
||||
|
||||
## [1.6-18] - 2023-09-21
|
||||
|
||||
### Added
|
||||
@@ -1743,3 +1558,5 @@
|
||||
|
||||
|
||||
[0.1.2]: https://github.com/damus-io/damus/releases/tag/v0.1.2
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.damus</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,13 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.usernotifications.service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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(user_keypair: keypair)
|
||||
self.keypair = keypair
|
||||
self.profiles = Profiles(ndb: ndb)
|
||||
self.zaps = Zaps(our_pubkey: keypair.pubkey)
|
||||
self.lnurls = LNUrls()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func add_zap(zap: Zapping) -> Bool {
|
||||
// store generic zap mapping
|
||||
self.zaps.add_zap(zap: zap)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
//
|
||||
// NotificationFormatter.swift
|
||||
// DamusNotificationService
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-13.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
struct NotificationFormatter {
|
||||
static var shared = NotificationFormatter()
|
||||
|
||||
// MARK: - Formatting with NdbNote
|
||||
|
||||
func format_message(event: NdbNote) -> UNMutableNotificationContent? {
|
||||
let content = UNMutableNotificationContent()
|
||||
if let event_json_data = try? JSONEncoder().encode(event), // Must be encoded, as the notification completion handler requires this object to conform to `NSSecureCoding`
|
||||
let event_json_string = String(data: event_json_data, encoding: .utf8) {
|
||||
content.userInfo = [
|
||||
NDB_NOTE_JSON_USER_INFO_KEY: event_json_string
|
||||
]
|
||||
}
|
||||
switch event.known_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
|
||||
break
|
||||
case .dm:
|
||||
content.title = NSLocalizedString("New message", comment: "Title label for push notifications where a direct message was sent to the user")
|
||||
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 {
|
||||
content.title = NSLocalizedString("Someone reacted to your note", comment: "Generic title label for push notifications where someone reacted to the user's post")
|
||||
break
|
||||
}
|
||||
content.title = NSLocalizedString("New note reaction", comment: "Title label for push notifications where someone reacted to the user's post with a specific emoji")
|
||||
content.body = String(format: NSLocalizedString("Someone reacted to your note with %@", comment: "Body label for push notifications where someone reacted to the user's post with a specific emoji"), reactionEmoji)
|
||||
break
|
||||
case .zap:
|
||||
content.title = NSLocalizedString("Someone zapped you ⚡️", comment: "Title label for a push notification where someone zapped the user")
|
||||
break
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//
|
||||
// NotificationService.swift
|
||||
// DamusNotificationService
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-11-10.
|
||||
//
|
||||
|
||||
import UserNotifications
|
||||
import Foundation
|
||||
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
var bestAttemptContent: UNMutableNotificationContent?
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// Don't show notification details that match mute list.
|
||||
// TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
|
||||
if state.mutelist_manager.is_event_muted(nostr_event) {
|
||||
// We cannot really suppress muted notifications until we have the notification supression entitlement.
|
||||
// The best we can do if we ever get those muted notifications (which we generally won't due to server-side processing) is to obscure the details
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = NSLocalizedString("Muted event", comment: "Title for a push notification which has been muted")
|
||||
content.body = NSLocalizedString("This is an event that has been muted according to your mute list rules. We cannot suppress this notification, but we obscured the details to respect your preferences", comment: "Description for a push notification which has been muted, and explanation that we cannot suppress it")
|
||||
content.sound = UNNotificationSound.default
|
||||
contentHandler(content)
|
||||
return
|
||||
}
|
||||
|
||||
guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
|
||||
// We should not display notification for this event. Suppress notification.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
|
||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
contentHandler(request.content)
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
if let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object, state: state) {
|
||||
contentHandler(improvedContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func serviceExtensionTimeWillExpire() {
|
||||
// Called just before the extension will be terminated by the system.
|
||||
// Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used.
|
||||
if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent {
|
||||
contentHandler(bestAttemptContent)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>1C8F.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
125
Purple.storekit
@@ -1,125 +0,0 @@
|
||||
{
|
||||
"identifier" : "64C21A2D",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "1628663131",
|
||||
"_developerTeamID" : "XK7H4JAB3D",
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 704848066.26849198,
|
||||
"_locale" : "en_US",
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Load Products"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Purchase"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Verification"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Store Sync"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Subscription Status"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "App Transaction"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Manage Subscriptions Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Refund Request Sheet"
|
||||
},
|
||||
{
|
||||
"current" : null,
|
||||
"enabled" : false,
|
||||
"name" : "Offer Code Redeem Sheet"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "21283177",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "Purple",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "6.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6446591615",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Support damus development with Damus Purple!",
|
||||
"displayName" : "Damus Purple",
|
||||
"locale" : "en_CA"
|
||||
}
|
||||
],
|
||||
"productID" : "purple",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "Purple",
|
||||
"subscriptionGroupID" : "21283177",
|
||||
"type" : "RecurringSubscription"
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "69.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 2,
|
||||
"internalID" : "6448764101",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"productID" : "purpleyearly",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "Purple Yearly",
|
||||
"subscriptionGroupID" : "21283177",
|
||||
"type" : "RecurringSubscription"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 3,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
52
README.md
@@ -2,56 +2,26 @@
|
||||
|
||||
# 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 +32,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 +46,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 +76,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 +117,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 +127,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
|
||||
|
||||
@@ -96,16 +96,6 @@ static inline void copy_cursor(struct cursor *src, struct cursor *dest)
|
||||
dest->end = src->end;
|
||||
}
|
||||
|
||||
static inline int cursor_skip(struct cursor *cursor, int n)
|
||||
{
|
||||
if (cursor->p + n >= cursor->end)
|
||||
return 0;
|
||||
|
||||
cursor->p += n;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static inline int pull_byte(struct cursor *cursor, u8 *c)
|
||||
{
|
||||
if (unlikely(cursor->p >= cursor->end))
|
||||
@@ -369,20 +359,6 @@ static inline int push_sized_str(struct cursor *cursor, const char *str, int len
|
||||
return cursor_push(cursor, (u8*)str, len);
|
||||
}
|
||||
|
||||
static inline int cursor_push_lowercase(struct cursor *cur, const char *str, int len)
|
||||
{
|
||||
int i;
|
||||
|
||||
if (unlikely(cur->p + len >= cur->end))
|
||||
return 0;
|
||||
|
||||
for (i = 0; i < len; i++)
|
||||
cur->p[i] = tolower(str[i]);
|
||||
|
||||
cur->p += len;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static inline int cursor_push_str(struct cursor *cursor, const char *str)
|
||||
{
|
||||
return cursor_push(cursor, (u8*)str, (int)strlen(str));
|
||||
@@ -485,37 +461,11 @@ static inline int parse_str(struct cursor *cur, const char *str) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
static inline int is_whitespace(int c) {
|
||||
static inline int is_whitespace(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\v' || c == '\f' || c == '\r';
|
||||
}
|
||||
|
||||
|
||||
static inline int next_char_is_whitespace(unsigned char *curChar, unsigned char *endChar) {
|
||||
unsigned char * next = curChar + 1;
|
||||
if(next > endChar) return 0;
|
||||
else if(next == endChar) return 1;
|
||||
return is_whitespace(*next);
|
||||
}
|
||||
|
||||
static int char_disallowed_at_end_url(char c){
|
||||
return c == '.' || c == ',';
|
||||
}
|
||||
|
||||
static inline int is_final_url_char(unsigned char *curChar, unsigned char *endChar){
|
||||
if(is_whitespace(*curChar)){
|
||||
return 1;
|
||||
}
|
||||
else if(next_char_is_whitespace(curChar, endChar)) {
|
||||
// next char is whitespace so this char could be the final char in the url
|
||||
return char_disallowed_at_end_url(*curChar);
|
||||
}
|
||||
else{
|
||||
// next char isn't whitespace so it can't be a final char
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static inline int is_underscore(int c) {
|
||||
static inline int is_underscore(char c) {
|
||||
return c == '_';
|
||||
}
|
||||
|
||||
@@ -696,23 +646,6 @@ static inline int consume_until_whitespace(struct cursor *cur, int or_end) {
|
||||
return or_end;
|
||||
}
|
||||
|
||||
static inline int consume_until_end_url(struct cursor *cur, int or_end) {
|
||||
char c;
|
||||
int consumedAtLeastOne = 0;
|
||||
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
|
||||
if (is_final_url_char(cur->p, cur->end))
|
||||
return consumedAtLeastOne;
|
||||
|
||||
cur->p++;
|
||||
consumedAtLeastOne = 1;
|
||||
}
|
||||
|
||||
return or_end;
|
||||
}
|
||||
|
||||
static inline int consume_until_non_alphanumeric(struct cursor *cur, int or_end) {
|
||||
char c;
|
||||
int consumedAtLeastOne = 0;
|
||||
@@ -730,17 +663,4 @@ static inline int consume_until_non_alphanumeric(struct cursor *cur, int or_end)
|
||||
return or_end;
|
||||
}
|
||||
|
||||
|
||||
static inline int cursor_memset(struct cursor *cursor, unsigned char c, int n)
|
||||
{
|
||||
if (cursor->p + n >= cursor->end)
|
||||
return 0;
|
||||
|
||||
memset(cursor->p, c, n);
|
||||
cursor->p += n;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
#endif
|
||||
|
||||
@@ -117,7 +117,7 @@ static int consume_url_fragment(struct cursor *cur)
|
||||
|
||||
cur->p++;
|
||||
|
||||
return consume_until_end_url(cur, 1);
|
||||
return consume_until_whitespace(cur, 1);
|
||||
}
|
||||
|
||||
static int consume_url_path(struct cursor *cur)
|
||||
@@ -134,7 +134,7 @@ static int consume_url_path(struct cursor *cur)
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
|
||||
if (c == '?' || c == '#' || is_final_url_char(cur->p, cur->end)) {
|
||||
if (c == '?' || c == '#' || is_whitespace(c)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ static int consume_url_host(struct cursor *cur)
|
||||
while (cur->p < cur->end) {
|
||||
c = *cur->p;
|
||||
// TODO: handle IDNs
|
||||
if ((is_alphanumeric(c) || c == '.' || c == '-') && !is_final_url_char(cur->p, cur->end))
|
||||
if (is_alphanumeric(c) || c == '.' || c == '-')
|
||||
{
|
||||
count++;
|
||||
cur->p++;
|
||||
@@ -169,9 +169,6 @@ static int consume_url_host(struct cursor *cur)
|
||||
|
||||
static int parse_url(struct cursor *cur, struct note_block *block) {
|
||||
u8 *start = cur->p;
|
||||
u8 *host;
|
||||
int host_len;
|
||||
struct cursor path_cur;
|
||||
|
||||
if (!parse_str(cur, "http"))
|
||||
return 0;
|
||||
@@ -188,32 +185,12 @@ static int parse_url(struct cursor *cur, struct note_block *block) {
|
||||
}
|
||||
}
|
||||
|
||||
// make sure to save the hostname. We will use this to detect damus.io links
|
||||
host = cur->p;
|
||||
|
||||
if (!consume_url_host(cur)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// get the length of the host string
|
||||
host_len = (int)(cur->p - host);
|
||||
|
||||
// save the current parse state so that we can continue from here when
|
||||
// parsing the bech32 in the damus.io link if we have it
|
||||
copy_cursor(cur, &path_cur);
|
||||
|
||||
// skip leading /
|
||||
cursor_skip(&path_cur, 1);
|
||||
|
||||
if (!consume_url_path(cur)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!consume_url_fragment(cur)) {
|
||||
cur->p = start;
|
||||
return 0;
|
||||
if (!(consume_url_host(cur) &&
|
||||
consume_url_path(cur) &&
|
||||
consume_url_fragment(cur)))
|
||||
{
|
||||
cur->p = start;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// smart parens
|
||||
@@ -226,19 +203,6 @@ static int parse_url(struct cursor *cur, struct note_block *block) {
|
||||
cur->p--;
|
||||
}
|
||||
|
||||
// save the bech32 string pos in case we hit a damus.io link
|
||||
block->block.str.start = (const char *)path_cur.p;
|
||||
|
||||
// if we have a damus link, make it a mention
|
||||
if (host_len == 8
|
||||
&& !strncmp((const char *)host, "damus.io", 8)
|
||||
&& parse_nostr_bech32(&path_cur, &block->block.mention_bech32.bech32))
|
||||
{
|
||||
block->block.str.end = (const char *)path_cur.p;
|
||||
block->type = BLOCK_MENTION_BECH32;
|
||||
return 1;
|
||||
}
|
||||
|
||||
block->type = BLOCK_URL;
|
||||
block->block.str.start = (const char *)start;
|
||||
block->block.str.end = (const char *)cur->p;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
@@ -42,24 +33,6 @@
|
||||
"state" : {
|
||||
"revision" : "76bb7971da7fbf429de1c84f1244adf657242fee"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-snapshot-testing",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
|
||||
"state" : {
|
||||
"revision" : "5b356adceabff6ca027f6574aac79e9fee145d26",
|
||||
"version" : "1.14.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-syntax",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-syntax.git",
|
||||
"state" : {
|
||||
"revision" : "74203046135342e4a4a627476dd6caf8b28fe11b",
|
||||
"version" : "509.0.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1520"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D79C4C132AFEB061003A41B4"
|
||||
BuildableName = "DamusNotificationService.appex"
|
||||
BlueprintName = "DamusNotificationService"
|
||||
ReferencedContainer = "container:damus.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
|
||||
BuildableName = "damus.app"
|
||||
BlueprintName = "damus"
|
||||
ReferencedContainer = "container:damus.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<RemoteRunnable
|
||||
runnableDebuggingMode = "1"
|
||||
BundleIdentifier = "com.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">
|
||||
</RemoteRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
|
||||
BuildableName = "damus.app"
|
||||
BlueprintName = "damus"
|
||||
ReferencedContainer = "container:damus.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
|
||||
BuildableName = "damus.app"
|
||||
BlueprintName = "damus"
|
||||
ReferencedContainer = "container:damus.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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"
|
||||
@@ -71,9 +71,6 @@
|
||||
ReferencedContainer = "container:damus.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../Purple.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x1A",
|
||||
"green" : "0x93",
|
||||
"red" : "0xF7"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFF"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x00",
|
||||
"green" : "0x00",
|
||||
"red" : "0x00"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Damus dark-gray.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 122 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Damus dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 66 KiB |
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "special-features.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "special-features.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "special-features.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "stars-bg.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 262 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
BIN
damus/Assets.xcassets/atproto.imageset/atproto.png
vendored
|
Before Width: | Height: | Size: 300 KiB |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadow-2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.0 MiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "shadow.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 511 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
|
||||
}
|
||||
}
|
||||
BIN
damus/Assets.xcassets/mutiny.imageset/mutiny.png
vendored
|
Before Width: | Height: | Size: 1.6 KiB |
12
damus/Assets.xcassets/rss.imageset/Contents.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "rss.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
damus/Assets.xcassets/rss.imageset/rss.png
vendored
|
Before Width: | Height: | Size: 16 KiB |
BIN
damus/Assets.xcassets/zeusln.imageset/zeus.png
vendored
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 109 KiB |
@@ -11,12 +11,10 @@ import SwiftUI
|
||||
class DamusColors {
|
||||
static let adaptableGrey = Color("DamusAdaptableGrey")
|
||||
static let adaptableBlack = Color("DamusAdaptableBlack")
|
||||
static let adaptableWhite = Color("DamusAdaptableWhite")
|
||||
static let white = Color("DamusWhite")
|
||||
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 +22,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")
|
||||
@@ -43,15 +40,5 @@ class DamusColors {
|
||||
static let neutral1 = Color("DamusNeutral1")
|
||||
static let neutral3 = Color("DamusNeutral3")
|
||||
static let neutral6 = Color("DamusNeutral6")
|
||||
static let pink = Color(red: 211/255.0, green: 76/255.0, blue: 217/255.0)
|
||||
static let lighterPink = Color(red: 248/255.0, green: 105/255.0, blue: 182/255.0)
|
||||
static let lightBackgroundPink = Color(red: 0xF8/255.0, green: 0xE7/255.0, blue: 0xF8/255.0)
|
||||
}
|
||||
|
||||
func hex_col(r: UInt8, g: UInt8, b: UInt8) -> Color {
|
||||
return Color(.sRGB,
|
||||
red: Double(r) / Double(0xff),
|
||||
green: Double(g) / Double(0xff),
|
||||
blue: Double(b) / Double(0xff),
|
||||
opacity: 1.0)
|
||||
}
|
||||
|
||||
@@ -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, disable_animation: state.settings.disable_animation)
|
||||
}
|
||||
.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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,12 +39,7 @@ struct InvoiceView: View {
|
||||
if settings.show_wallet_selector {
|
||||
present_sheet(.select_wallet(invoice: invoice.string))
|
||||
} else {
|
||||
do {
|
||||
try open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string)
|
||||
}
|
||||
catch {
|
||||
present_sheet(.select_wallet(invoice: invoice.string))
|
||||
}
|
||||
open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string)
|
||||
}
|
||||
} label: {
|
||||
RoundedRectangle(cornerRadius: 20, style: .circular)
|
||||
@@ -87,26 +82,21 @@ struct InvoiceView: View {
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenWalletError: Error {
|
||||
case no_wallet_to_open
|
||||
case store_link_invalid
|
||||
case system_cannot_open_store_link
|
||||
}
|
||||
|
||||
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
||||
func open_with_wallet(wallet: Wallet.Model, invoice: String) {
|
||||
if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
} else {
|
||||
guard let store_link = wallet.appStoreLink else {
|
||||
throw OpenWalletError.no_wallet_to_open
|
||||
// TODO: do something here if we don't have an appstore link
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = URL(string: store_link) else {
|
||||
throw OpenWalletError.store_link_invalid
|
||||
return
|
||||
}
|
||||
|
||||
guard UIApplication.shared.canOpenURL(url) else {
|
||||
throw OpenWalletError.system_cannot_open_store_link
|
||||
return
|
||||
}
|
||||
|
||||
UIApplication.shared.open(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
|
||||
}
|
||||
|
||||
@@ -7,49 +7,23 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum NeutralButtonShape {
|
||||
case rounded, capsule, circle
|
||||
|
||||
var style: NeutralButtonStyle {
|
||||
switch self {
|
||||
case .rounded:
|
||||
return NeutralButtonStyle(padding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), cornerRadius: 12)
|
||||
case .capsule:
|
||||
return NeutralButtonStyle(padding: EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15), cornerRadius: 20)
|
||||
case .circle:
|
||||
return NeutralButtonStyle(padding: EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20), cornerRadius: 9999)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NeutralButtonStyle: ButtonStyle {
|
||||
let padding: EdgeInsets
|
||||
let cornerRadius: CGFloat
|
||||
let scaleEffect: CGFloat
|
||||
|
||||
init(padding: EdgeInsets = EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0), cornerRadius: CGFloat = 15, scaleEffect: CGFloat = 0.95) {
|
||||
self.padding = padding
|
||||
self.cornerRadius = cornerRadius
|
||||
self.scaleEffect = scaleEffect
|
||||
}
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.padding(padding)
|
||||
func makeBody(configuration: Self.Configuration) -> some View {
|
||||
return configuration.label
|
||||
.background(DamusColors.neutral1)
|
||||
.cornerRadius(cornerRadius)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||
)
|
||||
.scaleEffect(configuration.isPressed ? scaleEffect : 1)
|
||||
.scaleEffect(configuration.isPressed ? 0.95 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct NeutralButtonStyle_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
|
||||
Button(action: {
|
||||
print("dynamic size")
|
||||
}) {
|
||||
@@ -57,7 +31,8 @@ struct NeutralButtonStyle_Previews: PreviewProvider {
|
||||
.padding()
|
||||
}
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
|
||||
|
||||
|
||||
Button(action: {
|
||||
print("infinite width")
|
||||
}) {
|
||||
@@ -69,17 +44,6 @@ struct NeutralButtonStyle_Previews: PreviewProvider {
|
||||
}
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
.padding()
|
||||
|
||||
Button(String(stringLiteral: "Rounded Button"), action: {})
|
||||
.buttonStyle(NeutralButtonShape.rounded.style)
|
||||
.padding()
|
||||
|
||||
Button(String(stringLiteral: "Capsule Button"), action: {})
|
||||
.buttonStyle(NeutralButtonShape.capsule.style)
|
||||
.padding()
|
||||
|
||||
Button(action: {}, label: {Image("messages")})
|
||||
.buttonStyle(NeutralButtonShape.circle.style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ struct NonImageAvatar<Content: View>: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(DamusColors.lightBackgroundPink)
|
||||
.fill(Color(red: 0xF8/255.0, green: 0xE7/255.0, blue: 0xF8/255.0))
|
||||
.frame(width: 54, height: 54)
|
||||
|
||||
content
|
||||
|
||||
@@ -11,19 +11,12 @@ import SwiftUI
|
||||
struct SelectableText: View {
|
||||
|
||||
let attributedString: AttributedString
|
||||
let textAlignment: NSTextAlignment
|
||||
|
||||
@State private var selectedTextHeight: CGFloat = .zero
|
||||
@State private var selectedTextWidth: CGFloat = .zero
|
||||
|
||||
let size: EventViewKind
|
||||
|
||||
init(attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
|
||||
self.attributedString = attributedString
|
||||
self.textAlignment = textAlignment ?? NSTextAlignment.natural
|
||||
self.size = size
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
TextViewRepresentable(
|
||||
@@ -31,7 +24,6 @@ struct SelectableText: View {
|
||||
textColor: UIColor.label,
|
||||
font: eventviewsize_to_uifont(size),
|
||||
fixedWidth: selectedTextWidth,
|
||||
textAlignment: self.textAlignment,
|
||||
height: $selectedTextHeight
|
||||
)
|
||||
.padding([.leading, .trailing], -1.0)
|
||||
@@ -56,7 +48,6 @@ struct SelectableText: View {
|
||||
let textColor: UIColor
|
||||
let font: UIFont
|
||||
let fixedWidth: CGFloat
|
||||
let textAlignment: NSTextAlignment
|
||||
|
||||
@Binding var height: CGFloat
|
||||
|
||||
@@ -70,14 +61,12 @@ struct SelectableText: View {
|
||||
view.textContainerInset = .zero
|
||||
view.textContainerInset.left = 1.0
|
||||
view.textContainerInset.right = 1.0
|
||||
view.textAlignment = textAlignment
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) {
|
||||
let mutableAttributedString = createNSAttributedString()
|
||||
uiView.attributedText = mutableAttributedString
|
||||
uiView.textAlignment = self.textAlignment
|
||||
|
||||
let newHeight = mutableAttributedString.height(containerWidth: fixedWidth)
|
||||
|
||||
|
||||
@@ -52,21 +52,13 @@ enum StatusDuration: CustomStringConvertible, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
enum Fields{
|
||||
case status
|
||||
case link
|
||||
}
|
||||
|
||||
struct UserStatusSheet: View {
|
||||
let damus_state: DamusState
|
||||
let postbox: PostBox
|
||||
let keypair: Keypair
|
||||
|
||||
@State var duration: StatusDuration = .never
|
||||
@State var show_link: Bool = false
|
||||
|
||||
@ObservedObject var status: UserStatusModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var status_binding: Binding<String> {
|
||||
@@ -94,125 +86,75 @@ struct UserStatusSheet: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
// This is needed to prevent the view from being moved when the keyboard is shown
|
||||
GeometryReader { geometry in
|
||||
VStack {
|
||||
HStack {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Cancel", comment: "Cancel button text for dismissing profile status settings view.")
|
||||
.padding(10)
|
||||
})
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
guard let status = self.status.general,
|
||||
let kp = keypair.to_full(),
|
||||
let ev = make_user_status_note(status: status, keypair: kp, expiry: duration.expiration)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
postbox.send(ev)
|
||||
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Share", comment: "Save button text for saving profile status settings.")
|
||||
})
|
||||
.buttonStyle(GradientButtonStyle(padding: 10))
|
||||
}
|
||||
.padding(5)
|
||||
|
||||
Divider()
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
ProfilePicView(pubkey: keypair.pubkey, size: 120.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
.padding(.top, 30)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
TextField(NSLocalizedString("Staying humble...", comment: "Placeholder as an example of what the user could set as their profile status."), text: status_binding, axis: .vertical)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
.lineLimit(3)
|
||||
.frame(width: 175)
|
||||
|
||||
}
|
||||
.padding(10)
|
||||
.background(colorScheme == .light ? .white : DamusColors.neutral3)
|
||||
.cornerRadius(15)
|
||||
.shadow(color: colorScheme == .light ? DamusColors.neutral3 : .clear, radius: 15)
|
||||
|
||||
Circle()
|
||||
.fill(colorScheme == .light ? .white : DamusColors.neutral3)
|
||||
.frame(width: 12, height: 12)
|
||||
.padding(.trailing, 140)
|
||||
|
||||
Circle()
|
||||
.fill(colorScheme == .light ? .white : DamusColors.neutral3)
|
||||
.frame(width: 7, height: 7)
|
||||
.padding(.trailing, 120)
|
||||
|
||||
}
|
||||
.padding(.leading, 60)
|
||||
}
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Image("link")
|
||||
.foregroundColor(DamusColors.neutral3)
|
||||
|
||||
TextField(text: url_binding, label: {
|
||||
Text("Add an external link", comment: "Placeholder as an example of what the user could set so that the link is opened when the status is tapped.")
|
||||
})
|
||||
.autocorrectionDisabled(true)
|
||||
}
|
||||
.padding(10)
|
||||
.cornerRadius(12)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(DamusColors.neutral3, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
|
||||
Toggle(isOn: $status.playing_enabled, label: {
|
||||
Text("Broadcast music playing on Apple Music", comment: "Toggle to enable or disable broadcasting what music is being played on Apple Music in their profile status.")
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Set Status", comment: "Title of view that allows the user to set their profile status (e.g. working, studying, coding)")
|
||||
.font(.largeTitle)
|
||||
|
||||
TextField(text: status_binding, label: {
|
||||
Text("📋 Working", comment: "Placeholder as an example of what the user could set as their profile status.")
|
||||
})
|
||||
|
||||
HStack {
|
||||
Image("link")
|
||||
|
||||
TextField(text: url_binding, label: {
|
||||
Text("https://example.com", comment: "Placeholder as an example of what the user could set so that the link is opened when the status is tapped.")
|
||||
})
|
||||
.tint(DamusColors.purple)
|
||||
.padding(.horizontal)
|
||||
|
||||
HStack {
|
||||
Text("Clear status", comment: "Label to prompt user to select an expiration time for the profile status to clear.")
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) {
|
||||
ForEach(StatusDuration.allCases, id: \.self) { d in
|
||||
Text(d.description)
|
||||
.tag(d)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Clear status", comment: "Label to prompt user to select an expiration time for the profile status to clear.")
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker(NSLocalizedString("Duration", comment: "Label for profile status expiration duration picker."), selection: $duration) {
|
||||
ForEach(StatusDuration.allCases, id: \.self) { d in
|
||||
Text(verbatim: d.description)
|
||||
.tag(d)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
|
||||
}
|
||||
.padding(.top)
|
||||
.background(DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all))
|
||||
|
||||
Toggle(isOn: $status.playing_enabled, label: {
|
||||
Text("Broadcast music playing on Apple Music", comment: "Toggle to enable or disable broadcasting what music is being played on Apple Music in their profile status.")
|
||||
})
|
||||
|
||||
HStack(alignment: .center) {
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Cancel", comment: "Cancel button text for dismissing profile status settings view.")
|
||||
})
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
guard let status = self.status.general,
|
||||
let kp = keypair.to_full(),
|
||||
let ev = make_user_status_note(status: status, keypair: kp, expiry: duration.expiration)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
postbox.send(ev)
|
||||
|
||||
dismiss()
|
||||
}, label: {
|
||||
Text("Save", comment: "Save button text for saving profile status settings.")
|
||||
})
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
}
|
||||
.padding([.top], 30)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.dismissKeyboardOnTap()
|
||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||
.padding(30)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct UserStatusSheet_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.postbox, keypair: test_keypair, status: .init())
|
||||
UserStatusSheet(postbox: test_damus_state.postbox, keypair: test_keypair, status: .init())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -66,13 +64,21 @@ struct TranslateView: View {
|
||||
guard let note_language = translations_model.note_language else {
|
||||
return
|
||||
}
|
||||
let res = await translate_note(profiles: damus_state.profiles, keypair: damus_state.keypair, event: event, settings: damus_state.settings, note_lang: note_language, purple: damus_state.purple)
|
||||
let res = await translate_note(profiles: damus_state.profiles, keypair: damus_state.keypair, event: event, settings: damus_state.settings, note_lang: note_language)
|
||||
DispatchQueue.main.async {
|
||||
self.translations_model.state = res
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,10 +125,10 @@ struct TranslateView_Previews: PreviewProvider {
|
||||
}
|
||||
}
|
||||
|
||||
func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, settings: UserSettingsStore, note_lang: String, purple: DamusPurple) async -> TranslateStatus {
|
||||
func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, settings: UserSettingsStore, note_lang: String) async -> TranslateStatus {
|
||||
|
||||
// If the note language is different from our preferred languages, send a translation request.
|
||||
let translator = Translator(settings, purple: purple)
|
||||
let translator = Translator(settings)
|
||||
let originalContent = event.get_content(keypair)
|
||||
let translated_note = try? await translator.translate(originalContent, from: note_lang, to: current_language())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -9,57 +9,33 @@ import SwiftUI
|
||||
|
||||
struct WebsiteLink: View {
|
||||
let url: URL
|
||||
let style: StyleVariant
|
||||
@Environment(\.openURL) var openURL
|
||||
|
||||
init(url: URL, style: StyleVariant? = nil) {
|
||||
self.url = url
|
||||
self.style = style ?? .normal
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image("link")
|
||||
.resizable()
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(self.style == .accent ? .white : .gray)
|
||||
.padding(.vertical, 5)
|
||||
.padding([.leading], 10)
|
||||
.foregroundColor(.gray)
|
||||
.font(.footnote)
|
||||
|
||||
Button(action: {
|
||||
openURL(url)
|
||||
}, label: {
|
||||
Text(link_text)
|
||||
.font(.footnote)
|
||||
.foregroundColor(self.style == .accent ? .white : .accentColor)
|
||||
.foregroundColor(.accentColor)
|
||||
.truncationMode(.tail)
|
||||
.lineLimit(1)
|
||||
})
|
||||
.padding(.vertical, 5)
|
||||
.padding([.trailing], 10)
|
||||
}
|
||||
.background(
|
||||
self.style == .accent ?
|
||||
AnyView(RoundedRectangle(cornerRadius: 50).fill(PinkGradient))
|
||||
: AnyView(Color.clear)
|
||||
)
|
||||
}
|
||||
|
||||
var link_text: String {
|
||||
url.host ?? url.absoluteString
|
||||
}
|
||||
|
||||
enum StyleVariant {
|
||||
case normal
|
||||
case accent
|
||||
}
|
||||
}
|
||||
|
||||
struct WebsiteLink_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
WebsiteLink(url: URL(string: "https://jb55.com")!)
|
||||
.previewDisplayName("Normal")
|
||||
WebsiteLink(url: URL(string: "https://jb55.com")!, style: .accent)
|
||||
.previewDisplayName("Accent")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// NoteZapButton.swift
|
||||
// ZapButton.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2023-01-17.
|
||||
@@ -18,19 +18,6 @@ enum ZappingError {
|
||||
case bad_lnurl
|
||||
case canceled
|
||||
case send_failed
|
||||
|
||||
func humanReadableMessage() -> String {
|
||||
switch self {
|
||||
case .fetching_invoice:
|
||||
return NSLocalizedString("Error fetching lightning invoice", comment: "Message to display when there was an error fetching a lightning invoice while attempting to zap.")
|
||||
case .bad_lnurl:
|
||||
return NSLocalizedString("Invalid lightning address", comment: "Message to display when there was an error attempting to zap due to an invalid lightning address.")
|
||||
case .canceled:
|
||||
return NSLocalizedString("Zap attempt from connected wallet was canceled.", comment: "Message to display when a zap from the user's connected wallet was canceled.")
|
||||
case .send_failed:
|
||||
return NSLocalizedString("Zap attempt from connected wallet failed.", comment: "Message to display when sending a zap from the user's connected wallet failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ZappingEvent {
|
||||
@@ -39,7 +26,7 @@ struct ZappingEvent {
|
||||
let target: ZapTarget
|
||||
}
|
||||
|
||||
struct NoteZapButton: View {
|
||||
struct ZapButton: View {
|
||||
let damus_state: DamusState
|
||||
let target: ZapTarget
|
||||
let lnurl: String
|
||||
@@ -157,7 +144,7 @@ struct ZapButton_Previews: PreviewProvider {
|
||||
let pending_zap = PendingZap(amount_msat: 1000, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), request: .normal(test_zap_request), type: .pub, state: .external(.init(state: .fetching_invoice)))
|
||||
let zaps = ZapsDataModel([.pending(pending_zap)])
|
||||
|
||||
NoteZapButton(damus_state: test_damus_state, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), lnurl: "lnurl", zaps: zaps)
|
||||
ZapButton(damus_state: test_damus_state, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), lnurl: "lnurl", zaps: zaps)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,57 +59,66 @@ func parse_note_content(content: NoteContent) -> Blocks {
|
||||
}
|
||||
}
|
||||
|
||||
func interpret_event_refs(tags: TagsSequence) -> ThreadReply? {
|
||||
// migration is long over, lets just do this to fix tests
|
||||
return interpret_event_refs_ndb(tags: tags)
|
||||
}
|
||||
|
||||
func interpret_event_refs_ndb(tags: TagsSequence) -> ThreadReply? {
|
||||
func interpret_event_refs_ndb(blocks: [Block], tags: TagsSequence) -> [EventRef] {
|
||||
if tags.count == 0 {
|
||||
return nil
|
||||
return []
|
||||
}
|
||||
|
||||
/// build a set of indices for each event mention
|
||||
let mention_indices = build_mention_indices(blocks, type: .e)
|
||||
|
||||
return interp_event_refs_without_mentions_ndb(References<NoteRef>(tags: tags))
|
||||
/// simpler case with no mentions
|
||||
if mention_indices.count == 0 {
|
||||
return interp_event_refs_without_mentions_ndb(tags.note.referenced_noterefs)
|
||||
}
|
||||
|
||||
return interp_event_refs_with_mentions_ndb(tags: tags, mention_indices: mention_indices)
|
||||
}
|
||||
|
||||
func interp_event_refs_without_mentions_ndb(_ ev_tags: References<NoteRef>) -> ThreadReply? {
|
||||
func interp_event_refs_without_mentions_ndb(_ ev_tags: References<NoteRef>) -> [EventRef] {
|
||||
|
||||
var count = 0
|
||||
var evrefs: [EventRef] = []
|
||||
var first: Bool = true
|
||||
var root_id: NoteRef? = nil
|
||||
var reply_id: NoteRef? = nil
|
||||
var mention: NoteRef? = nil
|
||||
var any_marker: Bool = false
|
||||
var first_ref: NoteRef? = nil
|
||||
|
||||
for ref in ev_tags {
|
||||
if let marker = ref.marker {
|
||||
any_marker = true
|
||||
switch marker {
|
||||
case .root: root_id = ref
|
||||
case .reply: reply_id = ref
|
||||
case .mention: mention = ref
|
||||
}
|
||||
// deprecated form, only activate if we don't have any markers set
|
||||
} else if !any_marker {
|
||||
if first {
|
||||
root_id = ref
|
||||
first = false
|
||||
if first {
|
||||
first_ref = ref
|
||||
evrefs.append(.thread_id(ref))
|
||||
first = false
|
||||
} else {
|
||||
|
||||
evrefs.append(.reply(ref))
|
||||
}
|
||||
count += 1
|
||||
}
|
||||
|
||||
if let first_ref, count == 1 {
|
||||
let r = first_ref
|
||||
return [.reply_to_root(r)]
|
||||
}
|
||||
|
||||
return evrefs
|
||||
}
|
||||
|
||||
func interp_event_refs_with_mentions_ndb(tags: TagsSequence, mention_indices: Set<Int>) -> [EventRef] {
|
||||
var mentions: [EventRef] = []
|
||||
var ev_refs: [NoteRef] = []
|
||||
var i: Int = 0
|
||||
|
||||
for tag in tags {
|
||||
if let note_id = NoteRef.from_tag(tag: tag) {
|
||||
if mention_indices.contains(i) {
|
||||
mentions.append(.mention(.noteref(note_id, index: i)))
|
||||
} else {
|
||||
reply_id = ref
|
||||
ev_refs.append(note_id)
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
// If either reply or root_id is blank while the other is not, then this is
|
||||
// considered reply-to-root. We should always have a root and reply tag, if they
|
||||
// are equal this is reply-to-root
|
||||
if reply_id == nil && root_id != nil {
|
||||
reply_id = root_id
|
||||
} else if root_id == nil && reply_id != nil {
|
||||
root_id = reply_id
|
||||
}
|
||||
|
||||
guard let reply_id, let root_id else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ThreadReply(root: root_id, reply: reply_id, mention: mention.map { m in .noteref(m) })
|
||||
|
||||
var replies = interp_event_refs_without_mentions(ev_refs)
|
||||
replies.append(contentsOf: mentions)
|
||||
return replies
|
||||
}
|
||||
|
||||
@@ -22,14 +22,11 @@ enum Sheets: Identifiable {
|
||||
case post(PostAction)
|
||||
case report(ReportTarget)
|
||||
case event(NostrEvent)
|
||||
case profile_action(Pubkey)
|
||||
case zap(ZapSheet)
|
||||
case select_wallet(SelectWallet)
|
||||
case filter
|
||||
case user_status
|
||||
case onboardingSuggestions
|
||||
case purple(DamusPurpleURL)
|
||||
case purple_onboarding
|
||||
case suggestedUsers
|
||||
|
||||
static func zap(target: ZapTarget, lnurl: String) -> Sheets {
|
||||
return .zap(ZapSheet(target: target, lnurl: lnurl))
|
||||
@@ -45,20 +42,16 @@ enum Sheets: Identifiable {
|
||||
case .user_status: return "user_status"
|
||||
case .post(let action): return "post-" + (action.ev?.id.hex() ?? "")
|
||||
case .event(let ev): return "event-" + ev.id.hex()
|
||||
case .profile_action(let pubkey): return "profile-action-" + pubkey.npub
|
||||
case .zap(let sheet): return "zap-" + hex_encode(sheet.target.id)
|
||||
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"
|
||||
case .suggestedUsers: return "suggested-users"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
let keypair: Keypair
|
||||
let appDelegate: AppDelegate?
|
||||
|
||||
var pubkey: Pubkey {
|
||||
return keypair.pubkey
|
||||
@@ -71,18 +64,17 @@ struct ContentView: View {
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
|
||||
@State var active_sheet: Sheets? = nil
|
||||
@State var damus_state: DamusState!
|
||||
@State var damus_state: DamusState? = nil
|
||||
@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
|
||||
@State var confirm_overwrite_mutelist: Bool = false
|
||||
@SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
|
||||
@State private var isSideBarOpened = false
|
||||
var home: HomeModel = HomeModel()
|
||||
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
||||
@AppStorage("has_seen_suggested_users") private var hasSeenSuggestedUsers = false
|
||||
let sub_id = UUID().description
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@@ -96,7 +88,7 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
|
||||
var filters = ContentFilters.defaults(damus_state: damus_state!)
|
||||
var filters = ContentFilters.defaults(damus_state!.settings)
|
||||
filters.append(fstate.filter)
|
||||
return ContentFilters(filters: filters).filter
|
||||
}
|
||||
@@ -138,15 +130,13 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
|
||||
TimelineView(events: home.events, loading: .constant(false), damus: damus_state, show_friend_icon: false, filter: filter) {
|
||||
PullDownSearchView(state: damus_state, on_cancel: {})
|
||||
ZStack {
|
||||
if let damus = self.damus_state {
|
||||
TimelineView<AnyView>(events: home.events, loading: .constant(false), damus: damus, show_friend_icon: false, filter: filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func navIsAtRoot() -> Bool {
|
||||
return navigationCoordinator.isAtRoot()
|
||||
}
|
||||
|
||||
func popToRoot() {
|
||||
navigationCoordinator.popToRoot()
|
||||
isSideBarOpened = false
|
||||
@@ -190,9 +180,6 @@ struct ContentView: View {
|
||||
.shadow(color: DamusColors.purple, radius: 2)
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
.animation(isSideBarOpened ? .none : .default, value: isSideBarOpened)
|
||||
.onTapGesture {
|
||||
isSideBarOpened.toggle()
|
||||
}
|
||||
} else {
|
||||
timelineNavItem
|
||||
.opacity(isSideBarOpened ? 0 : 1)
|
||||
@@ -205,8 +192,12 @@ struct ContentView: View {
|
||||
|
||||
func MaybeReportView(target: ReportTarget) -> some View {
|
||||
Group {
|
||||
if let keypair = damus_state.keypair.to_full() {
|
||||
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
|
||||
if let damus_state {
|
||||
if let keypair = damus_state.keypair.to_full() {
|
||||
ReportView(postbox: damus_state.postbox, target: target, keypair: keypair)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -264,13 +255,16 @@ struct ContentView: View {
|
||||
|
||||
// maybe expand this to other timelines in the future
|
||||
if selected_timeline == .search {
|
||||
|
||||
Button(action: {
|
||||
//isFilterVisible.toggle()
|
||||
present_sheet(.filter)
|
||||
}, label: {
|
||||
Image("filter")
|
||||
}) {
|
||||
// checklist, checklist.checked, lisdt.bullet, list.bullet.circle, line.3.horizontal.decrease..., line.3.horizontail.decrease
|
||||
Label(NSLocalizedString("Filter", comment: "Button label text for filtering relay servers."), image: "filter")
|
||||
.foregroundColor(.gray)
|
||||
})
|
||||
//.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,7 +272,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!)
|
||||
@@ -289,26 +283,20 @@ struct ContentView: View {
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
|
||||
if !hide_bar {
|
||||
TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
|
||||
.padding([.bottom], 8)
|
||||
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
|
||||
} else {
|
||||
Text("")
|
||||
}
|
||||
TabBar(nstatus: home.notification_status, selected: $selected_timeline, settings: damus.settings, action: switch_timeline)
|
||||
.padding([.bottom], 8)
|
||||
.background(Color(uiColor: .systemBackground).ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.edgesIgnoringSafeArea(hide_bar ? [.bottom] : [])
|
||||
.onAppear() {
|
||||
self.connect()
|
||||
try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers)
|
||||
setup_notifications()
|
||||
if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions {
|
||||
active_sheet = .onboardingSuggestions
|
||||
hasSeenOnboardingSuggestions = true
|
||||
if !hasSeenSuggestedUsers {
|
||||
active_sheet = .suggestedUsers
|
||||
hasSeenSuggestedUsers = true
|
||||
}
|
||||
self.appDelegate?.state = damus_state
|
||||
}
|
||||
.sheet(item: $active_sheet) { item in
|
||||
switch item {
|
||||
@@ -317,27 +305,24 @@ struct ContentView: View {
|
||||
case .post(let action):
|
||||
PostView(action: action, damus_state: damus_state!)
|
||||
case .user_status:
|
||||
UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
|
||||
.presentationDragIndicator(.visible)
|
||||
UserStatusSheet(postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status)
|
||||
case .event:
|
||||
EventDetailView()
|
||||
case .profile_action(let pubkey):
|
||||
ProfileActionSheetView(damus_state: damus_state!, pubkey: pubkey)
|
||||
case .zap(let zapsheet):
|
||||
CustomizeZapView(state: damus_state!, target: zapsheet.target, lnurl: zapsheet.lnurl)
|
||||
case .select_wallet(let select):
|
||||
SelectWalletView(default_wallet: damus_state!.settings.default_wallet, active_sheet: $active_sheet, our_pubkey: damus_state!.pubkey, invoice: select.invoice)
|
||||
case .filter:
|
||||
let timeline = selected_timeline
|
||||
RelayFilterView(state: damus_state!, timeline: timeline)
|
||||
.presentationDetents([.height(550)])
|
||||
.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)
|
||||
if #available(iOS 16.0, *) {
|
||||
RelayFilterView(state: damus_state!, timeline: timeline)
|
||||
.presentationDetents([.height(550)])
|
||||
.presentationDragIndicator(.visible)
|
||||
} else {
|
||||
RelayFilterView(state: damus_state!, timeline: timeline)
|
||||
}
|
||||
case .suggestedUsers:
|
||||
SuggestedUsersView(model: SuggestedUsersViewModel(damus_state: damus_state!))
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
@@ -347,36 +332,17 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.compose)) { action in
|
||||
self.active_sheet = .post(action)
|
||||
}
|
||||
.onReceive(handle_notify(.display_tabbar)) { display in
|
||||
let show = display
|
||||
self.hide_bar = !show
|
||||
}
|
||||
.onReceive(timer) { n in
|
||||
self.damus_state?.postbox.try_flushing_events()
|
||||
self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire()
|
||||
@@ -384,8 +350,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 +359,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
|
||||
}
|
||||
@@ -465,65 +436,24 @@ struct ContentView: View {
|
||||
present_sheet(.select_wallet(invoice: inv))
|
||||
} else {
|
||||
let wallet = damus_state!.settings.default_wallet.model
|
||||
do {
|
||||
try open_with_wallet(wallet: wallet, invoice: inv)
|
||||
}
|
||||
catch {
|
||||
present_sheet(.select_wallet(invoice: inv))
|
||||
}
|
||||
open_with_wallet(wallet: wallet, invoice: inv)
|
||||
}
|
||||
case .sent_from_nwc:
|
||||
break
|
||||
}
|
||||
}
|
||||
.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 +466,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 +488,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 +507,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 +525,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 +550,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.")
|
||||
@@ -644,12 +581,11 @@ struct ContentView: View {
|
||||
|
||||
func switch_timeline(_ timeline: Timeline) {
|
||||
self.isSideBarOpened = false
|
||||
let navWasAtRoot = self.navIsAtRoot()
|
||||
self.popToRoot()
|
||||
|
||||
notify(.switched_timeline(timeline))
|
||||
|
||||
if timeline == self.selected_timeline && navWasAtRoot {
|
||||
if timeline == self.selected_timeline {
|
||||
notify(.scroll_to_top)
|
||||
return
|
||||
}
|
||||
@@ -659,34 +595,26 @@ struct ContentView: View {
|
||||
|
||||
func connect() {
|
||||
// nostrdb
|
||||
var mndb = Ndb()
|
||||
if mndb == nil {
|
||||
// try recovery
|
||||
print("DB ISSUE! RECOVERING")
|
||||
mndb = Ndb.safemode()
|
||||
let ndb = Ndb()!
|
||||
|
||||
// out of space or something?? maybe we need a in-memory fallback
|
||||
if mndb == nil {
|
||||
logout(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let ndb = mndb else { return }
|
||||
|
||||
let pool = RelayPool(ndb: ndb, keypair: keypair)
|
||||
let pool = RelayPool(ndb: ndb)
|
||||
let model_cache = RelayModelCache()
|
||||
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
||||
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
|
||||
|
||||
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
||||
|
||||
// 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,
|
||||
@@ -694,12 +622,13 @@ struct ContentView: View {
|
||||
try? pool.add_relay(.nwc(url: nwc.relay))
|
||||
}
|
||||
|
||||
|
||||
let user_search_cache = UserSearchCache()
|
||||
self.damus_state = DamusState(pool: pool,
|
||||
keypair: keypair,
|
||||
likes: EventCounter(our_pubkey: pubkey),
|
||||
boosts: EventCounter(our_pubkey: pubkey),
|
||||
contacts: Contacts(our_pubkey: pubkey),
|
||||
mutelist_manager: MutelistManager(user_keypair: keypair),
|
||||
profiles: Profiles(ndb: ndb),
|
||||
dms: home.dms,
|
||||
previews: PreviewCache(),
|
||||
@@ -714,27 +643,15 @@ 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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
pool.connect()
|
||||
}
|
||||
|
||||
@@ -762,27 +679,11 @@ 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 {
|
||||
static var previews: some View {
|
||||
ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil), appDelegate: nil)
|
||||
ContentView(keypair: Keypair(pubkey: test_pubkey, privkey: nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -832,12 +733,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 +784,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 +818,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))
|
||||
@@ -983,41 +877,11 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St
|
||||
}
|
||||
case .notice:
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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 ""
|
||||
@@ -1115,12 +979,6 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
|
||||
postbox.send(ev)
|
||||
}
|
||||
}
|
||||
for qref in new_ev.referenced_quote_ids.prefix(3) {
|
||||
// also broadcast at most 3 referenced quoted events
|
||||
if let ev = events.lookup(qref.note_id) {
|
||||
postbox.send(ev)
|
||||
}
|
||||
}
|
||||
return true
|
||||
case .cancel:
|
||||
print("post cancelled")
|
||||
@@ -1135,15 +993,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 +1017,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 +1032,3 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func logout(_ state: DamusState?)
|
||||
{
|
||||
state?.close()
|
||||
notify(.logout)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,10 +67,10 @@
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Damus needs access to your camera in order to upload photos and scan QR codes.</string>
|
||||
<string>Damus needs access to your camera if you want to upload photos from it</string>
|
||||
<key>NSAppleMusicUsageDescription</key>
|
||||
<string>Damus needs access to your media library for playback statuses</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Damus needs access to your microphone for creating video recording posts</string>
|
||||
<string>Damus needs access to your microphone if you want to upload recorded videos from it</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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,122 +0,0 @@
|
||||
//
|
||||
// CameraModel.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Suhail Saqan on 8/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Combine
|
||||
|
||||
final class CameraModel: ObservableObject {
|
||||
private let service = CameraService()
|
||||
|
||||
@Published var showAlertError = false
|
||||
|
||||
@Published var isFlashOn = false
|
||||
|
||||
@Published var willCapturePhoto = false
|
||||
|
||||
@Published var isCameraButtonDisabled = false
|
||||
|
||||
@Published var isPhotoProcessing = false
|
||||
|
||||
@Published var isRecording = false
|
||||
|
||||
@Published var captureMode: CameraMediaType = .image
|
||||
|
||||
@Published public var mediaItems: [MediaItem] = []
|
||||
|
||||
@Published var thumbnail: Thumbnail!
|
||||
|
||||
var alertError: AlertError!
|
||||
|
||||
var session: AVCaptureSession
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
self.session = service.session
|
||||
|
||||
service.$shouldShowAlertView.sink { [weak self] (val) in
|
||||
self?.alertError = self?.service.alertError
|
||||
self?.showAlertError = val
|
||||
}
|
||||
.store(in: &self.subscriptions)
|
||||
|
||||
service.$flashMode.sink { [weak self] (mode) in
|
||||
self?.isFlashOn = mode == .on
|
||||
}
|
||||
.store(in: &self.subscriptions)
|
||||
|
||||
service.$willCapturePhoto.sink { [weak self] (val) in
|
||||
self?.willCapturePhoto = val
|
||||
}
|
||||
.store(in: &self.subscriptions)
|
||||
|
||||
service.$isCameraButtonDisabled.sink { [weak self] (val) in
|
||||
self?.isCameraButtonDisabled = val
|
||||
}
|
||||
.store(in: &self.subscriptions)
|
||||
|
||||
service.$isPhotoProcessing.sink { [weak self] (val) in
|
||||
self?.isPhotoProcessing = val
|
||||
}
|
||||
.store(in: &self.subscriptions)
|
||||
|
||||
service.$isRecording.sink { [weak self] (val) in
|
||||
self?.isRecording = val
|
||||
}
|
||||
.store(in: &self.subscriptions)
|
||||
|
||||
service.$captureMode.sink { [weak self] (mode) in
|
||||
self?.captureMode = mode
|
||||
}
|
||||
.store(in: &self.subscriptions)
|
||||
|
||||
service.$mediaItems.sink { [weak self] (mode) in
|
||||
self?.mediaItems = mode
|
||||
}
|
||||
.store(in: &self.subscriptions)
|
||||
|
||||
service.$thumbnail.sink { [weak self] (thumbnail) in
|
||||
guard let pic = thumbnail else { return }
|
||||
self?.thumbnail = pic
|
||||
}
|
||||
.store(in: &self.subscriptions)
|
||||
}
|
||||
|
||||
func configure() {
|
||||
service.checkForPermissions()
|
||||
service.configure()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
service.stop()
|
||||
}
|
||||
|
||||
func capturePhoto() {
|
||||
service.capturePhoto()
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
service.startRecording()
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
service.stopRecording()
|
||||
}
|
||||
|
||||
func flipCamera() {
|
||||
service.changeCamera()
|
||||
}
|
||||
|
||||
func zoom(with factor: CGFloat) {
|
||||
service.set(zoom: factor)
|
||||
}
|
||||
|
||||
func switchFlash() {
|
||||
service.flashMode = service.flashMode == .on ? .off : .on
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
//
|
||||
// CameraService+Extensions.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Suhail Saqan on 8/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
extension AVCaptureVideoOrientation {
|
||||
init?(deviceOrientation: UIDeviceOrientation) {
|
||||
switch deviceOrientation {
|
||||
case .portrait: self = .portrait
|
||||
case .portraitUpsideDown: self = .portraitUpsideDown
|
||||
case .landscapeLeft: self = .landscapeRight
|
||||
case .landscapeRight: self = .landscapeLeft
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
init?(interfaceOrientation: UIInterfaceOrientation) {
|
||||
switch interfaceOrientation {
|
||||
case .portrait: self = .portrait
|
||||
case .portraitUpsideDown: self = .portraitUpsideDown
|
||||
case .landscapeLeft: self = .landscapeLeft
|
||||
case .landscapeRight: self = .landscapeRight
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,693 +0,0 @@
|
||||
//
|
||||
// CameraService.swift
|
||||
// Campus
|
||||
//
|
||||
// Created by Suhail Saqan on 8/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import AVFoundation
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
public struct Thumbnail: Identifiable, Equatable {
|
||||
public var id: String
|
||||
public var type: CameraMediaType
|
||||
public var url: URL
|
||||
|
||||
public init(id: String = UUID().uuidString, type: CameraMediaType, url: URL) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.url = url
|
||||
}
|
||||
|
||||
public var thumbnailImage: UIImage? {
|
||||
switch type {
|
||||
case .image:
|
||||
return ImageResizer(targetWidth: 100).resize(at: url)
|
||||
case .video:
|
||||
return generateVideoThumbnail(for: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AlertError {
|
||||
public var title: String = ""
|
||||
public var message: String = ""
|
||||
public var primaryButtonTitle = "Accept"
|
||||
public var secondaryButtonTitle: String?
|
||||
public var primaryAction: (() -> ())?
|
||||
public var secondaryAction: (() -> ())?
|
||||
|
||||
public init(title: String = "", message: String = "", primaryButtonTitle: String = "Accept", secondaryButtonTitle: String? = nil, primaryAction: (() -> ())? = nil, secondaryAction: (() -> ())? = nil) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.primaryAction = primaryAction
|
||||
self.primaryButtonTitle = primaryButtonTitle
|
||||
self.secondaryAction = secondaryAction
|
||||
}
|
||||
}
|
||||
|
||||
func generateVideoThumbnail(for videoURL: URL) -> UIImage? {
|
||||
let asset = AVAsset(url: videoURL)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
|
||||
do {
|
||||
let cgImage = try imageGenerator.copyCGImage(at: .zero, actualTime: nil)
|
||||
return UIImage(cgImage: cgImage)
|
||||
} catch {
|
||||
print("Error generating thumbnail: \(error)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public enum CameraMediaType {
|
||||
case image
|
||||
case video
|
||||
}
|
||||
|
||||
public struct MediaItem {
|
||||
let url: URL
|
||||
let type: CameraMediaType
|
||||
}
|
||||
|
||||
public class CameraService: NSObject, Identifiable {
|
||||
public let session = AVCaptureSession()
|
||||
|
||||
public var isSessionRunning = false
|
||||
public var isConfigured = false
|
||||
var setupResult: SessionSetupResult = .success
|
||||
|
||||
public var alertError: AlertError = AlertError()
|
||||
|
||||
@Published public var flashMode: AVCaptureDevice.FlashMode = .off
|
||||
@Published public var shouldShowAlertView = false
|
||||
@Published public var isPhotoProcessing = false
|
||||
@Published public var captureMode: CameraMediaType = .image
|
||||
@Published public var isRecording: Bool = false
|
||||
|
||||
@Published public var willCapturePhoto = false
|
||||
@Published public var isCameraButtonDisabled = false
|
||||
@Published public var isCameraUnavailable = false
|
||||
@Published public var thumbnail: Thumbnail?
|
||||
@Published public var mediaItems: [MediaItem] = []
|
||||
|
||||
public let sessionQueue = DispatchQueue(label: "io.damus.camera")
|
||||
|
||||
@objc dynamic public var videoDeviceInput: AVCaptureDeviceInput!
|
||||
@objc dynamic public var audioDeviceInput: AVCaptureDeviceInput!
|
||||
|
||||
public let videoDeviceDiscoverySession = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera, .builtInDualCamera, .builtInTrueDepthCamera], mediaType: .video, position: .unspecified)
|
||||
|
||||
public let photoOutput = AVCapturePhotoOutput()
|
||||
|
||||
public let movieOutput = AVCaptureMovieFileOutput()
|
||||
|
||||
var videoCaptureProcessor: VideoCaptureProcessor?
|
||||
var photoCaptureProcessor: PhotoCaptureProcessor?
|
||||
|
||||
public var keyValueObservations = [NSKeyValueObservation]()
|
||||
|
||||
override public init() {
|
||||
super.init()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isCameraButtonDisabled = true
|
||||
self.isCameraUnavailable = true
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionSetupResult {
|
||||
case success
|
||||
case notAuthorized
|
||||
case configurationFailed
|
||||
}
|
||||
|
||||
public func configure() {
|
||||
if !self.isSessionRunning && !self.isConfigured {
|
||||
sessionQueue.async {
|
||||
self.configureSession()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func checkForPermissions() {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
break
|
||||
case .notDetermined:
|
||||
sessionQueue.suspend()
|
||||
AVCaptureDevice.requestAccess(for: .video, completionHandler: { granted in
|
||||
if !granted {
|
||||
self.setupResult = .notAuthorized
|
||||
}
|
||||
self.sessionQueue.resume()
|
||||
})
|
||||
|
||||
default:
|
||||
setupResult = .notAuthorized
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!,
|
||||
options: [:], completionHandler: nil)
|
||||
|
||||
}, secondaryAction: nil)
|
||||
self.shouldShowAlertView = true
|
||||
self.isCameraUnavailable = true
|
||||
self.isCameraButtonDisabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureSession() {
|
||||
if setupResult != .success {
|
||||
return
|
||||
}
|
||||
|
||||
session.beginConfiguration()
|
||||
|
||||
session.sessionPreset = .high
|
||||
|
||||
// Add video input.
|
||||
do {
|
||||
var defaultVideoDevice: AVCaptureDevice?
|
||||
|
||||
if let backCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||
// If a rear dual camera is not available, default to the rear wide angle camera.
|
||||
defaultVideoDevice = backCameraDevice
|
||||
} else if let frontCameraDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) {
|
||||
// If the rear wide angle camera isn't available, default to the front wide angle camera.
|
||||
defaultVideoDevice = frontCameraDevice
|
||||
}
|
||||
|
||||
guard let videoDevice = defaultVideoDevice else {
|
||||
print("Default video device is unavailable.")
|
||||
setupResult = .configurationFailed
|
||||
session.commitConfiguration()
|
||||
return
|
||||
}
|
||||
|
||||
let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||
|
||||
if session.canAddInput(videoDeviceInput) {
|
||||
session.addInput(videoDeviceInput)
|
||||
self.videoDeviceInput = videoDeviceInput
|
||||
} else {
|
||||
print("Couldn't add video device input to the session.")
|
||||
setupResult = .configurationFailed
|
||||
session.commitConfiguration()
|
||||
return
|
||||
}
|
||||
|
||||
let audioDevice = AVCaptureDevice.default(for: .audio)
|
||||
let audioDeviceInput = try AVCaptureDeviceInput(device: audioDevice!)
|
||||
|
||||
if session.canAddInput(audioDeviceInput) {
|
||||
session.addInput(audioDeviceInput)
|
||||
self.audioDeviceInput = audioDeviceInput
|
||||
} else {
|
||||
print("Couldn't add audio device input to the session.")
|
||||
setupResult = .configurationFailed
|
||||
session.commitConfiguration()
|
||||
return
|
||||
}
|
||||
|
||||
// Add video output
|
||||
if session.canAddOutput(movieOutput) {
|
||||
session.addOutput(movieOutput)
|
||||
} else {
|
||||
print("Could not add movie output to the session")
|
||||
setupResult = .configurationFailed
|
||||
session.commitConfiguration()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
print("Couldn't create video device input: \(error)")
|
||||
setupResult = .configurationFailed
|
||||
session.commitConfiguration()
|
||||
return
|
||||
}
|
||||
|
||||
// Add the photo output.
|
||||
if session.canAddOutput(photoOutput) {
|
||||
session.addOutput(photoOutput)
|
||||
|
||||
photoOutput.maxPhotoQualityPrioritization = .quality
|
||||
|
||||
} else {
|
||||
print("Could not add photo output to the session")
|
||||
setupResult = .configurationFailed
|
||||
session.commitConfiguration()
|
||||
return
|
||||
}
|
||||
|
||||
session.commitConfiguration()
|
||||
self.isConfigured = true
|
||||
|
||||
self.start()
|
||||
}
|
||||
|
||||
private func resumeInterruptedSession() {
|
||||
sessionQueue.async {
|
||||
self.session.startRunning()
|
||||
self.isSessionRunning = self.session.isRunning
|
||||
if !self.session.isRunning {
|
||||
DispatchQueue.main.async {
|
||||
self.alertError = AlertError(title: "Camera Error", message: "Unable to resume camera", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil)
|
||||
self.shouldShowAlertView = true
|
||||
self.isCameraUnavailable = true
|
||||
self.isCameraButtonDisabled = true
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.isCameraUnavailable = false
|
||||
self.isCameraButtonDisabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func changeCamera() {
|
||||
DispatchQueue.main.async {
|
||||
self.isCameraButtonDisabled = true
|
||||
}
|
||||
|
||||
sessionQueue.async {
|
||||
let currentVideoDevice = self.videoDeviceInput.device
|
||||
let currentPosition = currentVideoDevice.position
|
||||
|
||||
let preferredPosition: AVCaptureDevice.Position
|
||||
let preferredDeviceType: AVCaptureDevice.DeviceType
|
||||
|
||||
switch currentPosition {
|
||||
case .unspecified, .front:
|
||||
preferredPosition = .back
|
||||
preferredDeviceType = .builtInWideAngleCamera
|
||||
|
||||
case .back:
|
||||
preferredPosition = .front
|
||||
preferredDeviceType = .builtInWideAngleCamera
|
||||
|
||||
@unknown default:
|
||||
print("Unknown capture position. Defaulting to back, dual-camera.")
|
||||
preferredPosition = .back
|
||||
preferredDeviceType = .builtInWideAngleCamera
|
||||
}
|
||||
let devices = self.videoDeviceDiscoverySession.devices
|
||||
var newVideoDevice: AVCaptureDevice? = nil
|
||||
|
||||
if let device = devices.first(where: { $0.position == preferredPosition && $0.deviceType == preferredDeviceType }) {
|
||||
newVideoDevice = device
|
||||
} else if let device = devices.first(where: { $0.position == preferredPosition }) {
|
||||
newVideoDevice = device
|
||||
}
|
||||
|
||||
if let videoDevice = newVideoDevice {
|
||||
do {
|
||||
let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice)
|
||||
|
||||
self.session.beginConfiguration()
|
||||
|
||||
self.session.removeInput(self.videoDeviceInput)
|
||||
|
||||
if self.session.canAddInput(videoDeviceInput) {
|
||||
NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: currentVideoDevice)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(self.subjectAreaDidChange), name: .AVCaptureDeviceSubjectAreaDidChange, object: videoDeviceInput.device)
|
||||
|
||||
self.session.addInput(videoDeviceInput)
|
||||
self.videoDeviceInput = videoDeviceInput
|
||||
} else {
|
||||
self.session.addInput(self.videoDeviceInput)
|
||||
}
|
||||
|
||||
if let connection = self.photoOutput.connection(with: .video) {
|
||||
if connection.isVideoStabilizationSupported {
|
||||
connection.preferredVideoStabilizationMode = .auto
|
||||
}
|
||||
}
|
||||
|
||||
self.photoOutput.maxPhotoQualityPrioritization = .quality
|
||||
|
||||
self.session.commitConfiguration()
|
||||
} catch {
|
||||
print("Error occurred while creating video device input: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isCameraButtonDisabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func focus(with focusMode: AVCaptureDevice.FocusMode, exposureMode: AVCaptureDevice.ExposureMode, at devicePoint: CGPoint, monitorSubjectAreaChange: Bool) {
|
||||
sessionQueue.async {
|
||||
guard let device = self.videoDeviceInput?.device else { return }
|
||||
do {
|
||||
try device.lockForConfiguration()
|
||||
|
||||
if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) {
|
||||
device.focusPointOfInterest = devicePoint
|
||||
device.focusMode = focusMode
|
||||
}
|
||||
|
||||
if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
|
||||
device.exposurePointOfInterest = devicePoint
|
||||
device.exposureMode = exposureMode
|
||||
}
|
||||
|
||||
device.isSubjectAreaChangeMonitoringEnabled = monitorSubjectAreaChange
|
||||
device.unlockForConfiguration()
|
||||
} catch {
|
||||
print("Could not lock device for configuration: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func focus(at focusPoint: CGPoint) {
|
||||
let device = self.videoDeviceInput.device
|
||||
do {
|
||||
try device.lockForConfiguration()
|
||||
if device.isFocusPointOfInterestSupported {
|
||||
device.focusPointOfInterest = focusPoint
|
||||
device.exposurePointOfInterest = focusPoint
|
||||
device.exposureMode = .continuousAutoExposure
|
||||
device.focusMode = .continuousAutoFocus
|
||||
device.unlockForConfiguration()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func stop(completion: (() -> ())? = nil) {
|
||||
sessionQueue.async {
|
||||
if self.isSessionRunning {
|
||||
if self.setupResult == .success {
|
||||
self.session.stopRunning()
|
||||
self.isSessionRunning = self.session.isRunning
|
||||
print("CAMERA STOPPED")
|
||||
self.removeObservers()
|
||||
|
||||
if !self.session.isRunning {
|
||||
DispatchQueue.main.async {
|
||||
self.isCameraButtonDisabled = true
|
||||
self.isCameraUnavailable = true
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc public func start() {
|
||||
sessionQueue.async {
|
||||
if !self.isSessionRunning && self.isConfigured {
|
||||
switch self.setupResult {
|
||||
case .success:
|
||||
self.addObservers()
|
||||
self.session.startRunning()
|
||||
print("CAMERA RUNNING")
|
||||
self.isSessionRunning = self.session.isRunning
|
||||
|
||||
if self.session.isRunning {
|
||||
DispatchQueue.main.async {
|
||||
self.isCameraButtonDisabled = false
|
||||
self.isCameraUnavailable = false
|
||||
}
|
||||
}
|
||||
|
||||
case .notAuthorized:
|
||||
print("Application not authorized to use camera")
|
||||
DispatchQueue.main.async {
|
||||
self.isCameraButtonDisabled = true
|
||||
self.isCameraUnavailable = true
|
||||
}
|
||||
|
||||
case .configurationFailed:
|
||||
DispatchQueue.main.async {
|
||||
self.alertError = AlertError(title: "Camera Error", message: "Camera configuration failed. Either your device camera is not available or other application is using it", primaryButtonTitle: "Accept", secondaryButtonTitle: nil, primaryAction: nil, secondaryAction: nil)
|
||||
self.shouldShowAlertView = true
|
||||
self.isCameraButtonDisabled = true
|
||||
self.isCameraUnavailable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func set(zoom: CGFloat) {
|
||||
let factor = zoom < 1 ? 1 : zoom
|
||||
let device = self.videoDeviceInput.device
|
||||
|
||||
do {
|
||||
try device.lockForConfiguration()
|
||||
device.videoZoomFactor = factor
|
||||
device.unlockForConfiguration()
|
||||
}
|
||||
catch {
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public func capturePhoto() {
|
||||
if self.setupResult != .configurationFailed {
|
||||
let videoPreviewLayerOrientation: AVCaptureVideoOrientation = .portrait
|
||||
self.isCameraButtonDisabled = true
|
||||
|
||||
sessionQueue.async {
|
||||
if let photoOutputConnection = self.photoOutput.connection(with: .video) {
|
||||
photoOutputConnection.videoOrientation = videoPreviewLayerOrientation
|
||||
}
|
||||
var photoSettings = AVCapturePhotoSettings()
|
||||
|
||||
// Capture HEIF photos when supported. Enable according to user settings and high-resolution photos.
|
||||
if (self.photoOutput.availablePhotoCodecTypes.contains(.hevc)) {
|
||||
photoSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.hevc])
|
||||
}
|
||||
|
||||
if self.videoDeviceInput.device.isFlashAvailable {
|
||||
photoSettings.flashMode = self.flashMode
|
||||
}
|
||||
|
||||
if !photoSettings.__availablePreviewPhotoPixelFormatTypes.isEmpty {
|
||||
photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: photoSettings.__availablePreviewPhotoPixelFormatTypes.first!]
|
||||
}
|
||||
|
||||
photoSettings.photoQualityPrioritization = .speed
|
||||
|
||||
if self.photoCaptureProcessor == nil {
|
||||
self.photoCaptureProcessor = PhotoCaptureProcessor(with: photoSettings, photoOutput: self.photoOutput, willCapturePhotoAnimation: {
|
||||
DispatchQueue.main.async {
|
||||
self.willCapturePhoto.toggle()
|
||||
self.willCapturePhoto.toggle()
|
||||
}
|
||||
}, completionHandler: { (photoCaptureProcessor) in
|
||||
if let data = photoCaptureProcessor.photoData {
|
||||
let url = self.savePhoto(data: data)
|
||||
if let unwrappedURL = url {
|
||||
self.thumbnail = Thumbnail(type: .image, url: unwrappedURL)
|
||||
}
|
||||
} else {
|
||||
print("Data for photo not found")
|
||||
}
|
||||
|
||||
self.isCameraButtonDisabled = false
|
||||
}, photoProcessingHandler: { animate in
|
||||
self.isPhotoProcessing = animate
|
||||
})
|
||||
}
|
||||
|
||||
self.photoCaptureProcessor?.capturePhoto(settings: photoSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func startRecording() {
|
||||
if self.setupResult != .configurationFailed {
|
||||
let videoPreviewLayerOrientation: AVCaptureVideoOrientation = .portrait
|
||||
self.isCameraButtonDisabled = true
|
||||
|
||||
sessionQueue.async {
|
||||
if let videoOutputConnection = self.movieOutput.connection(with: .video) {
|
||||
videoOutputConnection.videoOrientation = videoPreviewLayerOrientation
|
||||
|
||||
var videoSettings = [String: Any]()
|
||||
|
||||
if self.movieOutput.availableVideoCodecTypes.contains(.hevc) == true {
|
||||
videoSettings[AVVideoCodecKey] = AVVideoCodecType.hevc
|
||||
self.movieOutput.setOutputSettings(videoSettings, for: videoOutputConnection)
|
||||
}
|
||||
}
|
||||
|
||||
if self.videoCaptureProcessor == nil {
|
||||
self.videoCaptureProcessor = VideoCaptureProcessor(movieOutput: self.movieOutput, beginHandler: {
|
||||
self.isRecording = true
|
||||
}, completionHandler: { (videoCaptureProcessor, outputFileURL) in
|
||||
self.isCameraButtonDisabled = false
|
||||
self.captureMode = .image
|
||||
|
||||
self.mediaItems.append(MediaItem(url: outputFileURL, type: .video))
|
||||
self.thumbnail = Thumbnail(type: .video, url: outputFileURL)
|
||||
}, videoProcessingHandler: { animate in
|
||||
self.isPhotoProcessing = animate
|
||||
})
|
||||
}
|
||||
|
||||
self.videoCaptureProcessor?.startCapture(session: self.session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
if let videoCaptureProcessor = self.videoCaptureProcessor {
|
||||
isRecording = false
|
||||
videoCaptureProcessor.stopCapture()
|
||||
}
|
||||
}
|
||||
|
||||
func savePhoto(imageType: String = "jpeg", data: Data) -> URL? {
|
||||
guard let uiImage = UIImage(data: data) else {
|
||||
print("Error converting media data to UIImage")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let compressedData = uiImage.jpegData(compressionQuality: 0.8) else {
|
||||
print("Error converting UIImage to JPEG data")
|
||||
return nil
|
||||
}
|
||||
|
||||
let temporaryDirectory = NSTemporaryDirectory()
|
||||
let tempFileName = "\(UUID().uuidString).\(imageType)"
|
||||
let tempFileURL = URL(fileURLWithPath: temporaryDirectory).appendingPathComponent(tempFileName)
|
||||
|
||||
do {
|
||||
try compressedData.write(to: tempFileURL)
|
||||
self.mediaItems.append(MediaItem(url: tempFileURL, type: .image))
|
||||
return tempFileURL
|
||||
} catch {
|
||||
print("Error saving image data to temporary URL: \(error.localizedDescription)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func addObservers() {
|
||||
let systemPressureStateObservation = observe(\.videoDeviceInput.device.systemPressureState, options: .new) { _, change in
|
||||
guard let systemPressureState = change.newValue else { return }
|
||||
self.setRecommendedFrameRateRangeForPressureState(systemPressureState: systemPressureState)
|
||||
}
|
||||
keyValueObservations.append(systemPressureStateObservation)
|
||||
|
||||
// NotificationCenter.default.addObserver(self, selector: #selector(self.onOrientationChange), name: UIDevice.orientationDidChangeNotification, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(subjectAreaDidChange),
|
||||
name: .AVCaptureDeviceSubjectAreaDidChange,
|
||||
object: videoDeviceInput.device)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(uiRequestedNewFocusArea), name: .init(rawValue: "UserDidRequestNewFocusPoint"), object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(sessionRuntimeError),
|
||||
name: .AVCaptureSessionRuntimeError,
|
||||
object: session)
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(sessionWasInterrupted),
|
||||
name: .AVCaptureSessionWasInterrupted,
|
||||
object: session)
|
||||
|
||||
NotificationCenter.default.addObserver(self,
|
||||
selector: #selector(sessionInterruptionEnded),
|
||||
name: .AVCaptureSessionInterruptionEnded,
|
||||
object: session)
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
|
||||
for keyValueObservation in keyValueObservations {
|
||||
keyValueObservation.invalidate()
|
||||
}
|
||||
keyValueObservations.removeAll()
|
||||
}
|
||||
|
||||
@objc private func uiRequestedNewFocusArea(notification: NSNotification) {
|
||||
guard let userInfo = notification.userInfo as? [String: Any], let devicePoint = userInfo["devicePoint"] as? CGPoint else { return }
|
||||
self.focus(at: devicePoint)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func subjectAreaDidChange(notification: NSNotification) {
|
||||
let devicePoint = CGPoint(x: 0.5, y: 0.5)
|
||||
focus(with: .continuousAutoFocus, exposureMode: .continuousAutoExposure, at: devicePoint, monitorSubjectAreaChange: false)
|
||||
}
|
||||
|
||||
@objc
|
||||
private func sessionRuntimeError(notification: NSNotification) {
|
||||
guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else { return }
|
||||
|
||||
print("Capture session runtime error: \(error)")
|
||||
|
||||
if error.code == .mediaServicesWereReset {
|
||||
sessionQueue.async {
|
||||
if self.isSessionRunning {
|
||||
self.session.startRunning()
|
||||
self.isSessionRunning = self.session.isRunning
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setRecommendedFrameRateRangeForPressureState(systemPressureState: AVCaptureDevice.SystemPressureState) {
|
||||
let pressureLevel = systemPressureState.level
|
||||
if pressureLevel == .serious || pressureLevel == .critical {
|
||||
do {
|
||||
try self.videoDeviceInput.device.lockForConfiguration()
|
||||
print("WARNING: Reached elevated system pressure level: \(pressureLevel). Throttling frame rate.")
|
||||
self.videoDeviceInput.device.activeVideoMinFrameDuration = CMTime(value: 1, timescale: 20)
|
||||
self.videoDeviceInput.device.activeVideoMaxFrameDuration = CMTime(value: 1, timescale: 15)
|
||||
self.videoDeviceInput.device.unlockForConfiguration()
|
||||
} catch {
|
||||
print("Could not lock device for configuration: \(error)")
|
||||
}
|
||||
} else if pressureLevel == .shutdown {
|
||||
print("Session stopped running due to shutdown system pressure level.")
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func sessionWasInterrupted(notification: NSNotification) {
|
||||
DispatchQueue.main.async {
|
||||
self.isCameraUnavailable = true
|
||||
}
|
||||
|
||||
if let userInfoValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as AnyObject?,
|
||||
let reasonIntegerValue = userInfoValue.integerValue,
|
||||
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonIntegerValue) {
|
||||
print("Capture session was interrupted with reason \(reason)")
|
||||
|
||||
if reason == .audioDeviceInUseByAnotherClient || reason == .videoDeviceInUseByAnotherClient {
|
||||
print("Session stopped running due to video devies in use by another client.")
|
||||
} else if reason == .videoDeviceNotAvailableWithMultipleForegroundApps {
|
||||
print("Session stopped running due to video devies is not available with multiple foreground apps.")
|
||||
} else if reason == .videoDeviceNotAvailableDueToSystemPressure {
|
||||
print("Session stopped running due to shutdown system pressure level.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
private func sessionInterruptionEnded(notification: NSNotification) {
|
||||
print("Capture session interruption ended")
|
||||
DispatchQueue.main.async {
|
||||
self.isCameraUnavailable = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// ImageResizer.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Suhail Saqan on 8/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public enum ImageResizingError: Error {
|
||||
case cannotRetrieveFromURL
|
||||
case cannotRetrieveFromData
|
||||
}
|
||||
|
||||
public struct ImageResizer {
|
||||
public var targetWidth: CGFloat
|
||||
|
||||
public init(targetWidth: CGFloat) {
|
||||
self.targetWidth = targetWidth
|
||||
}
|
||||
|
||||
public func resize(at url: URL) -> UIImage? {
|
||||
guard let image = UIImage(contentsOfFile: url.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return self.resize(image: image)
|
||||
}
|
||||
|
||||
public func resize(image: UIImage) -> UIImage {
|
||||
let originalSize = image.size
|
||||
let targetSize = CGSize(width: targetWidth, height: targetWidth*originalSize.height/originalSize.width)
|
||||
let renderer = UIGraphicsImageRenderer(size: targetSize)
|
||||
return renderer.image { (context) in
|
||||
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//
|
||||
// PhotoCaptureProcessor.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Suhail Saqan on 8/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Photos
|
||||
|
||||
class PhotoCaptureProcessor: NSObject {
|
||||
private(set) var requestedPhotoSettings: AVCapturePhotoSettings
|
||||
private(set) var photoOutput: AVCapturePhotoOutput?
|
||||
|
||||
lazy var context = CIContext()
|
||||
var photoData: Data?
|
||||
private var maxPhotoProcessingTime: CMTime?
|
||||
|
||||
private let willCapturePhotoAnimation: () -> Void
|
||||
private let completionHandler: (PhotoCaptureProcessor) -> Void
|
||||
private let photoProcessingHandler: (Bool) -> Void
|
||||
|
||||
init(with requestedPhotoSettings: AVCapturePhotoSettings,
|
||||
photoOutput: AVCapturePhotoOutput?,
|
||||
willCapturePhotoAnimation: @escaping () -> Void,
|
||||
completionHandler: @escaping (PhotoCaptureProcessor) -> Void,
|
||||
photoProcessingHandler: @escaping (Bool) -> Void) {
|
||||
self.requestedPhotoSettings = requestedPhotoSettings
|
||||
self.willCapturePhotoAnimation = willCapturePhotoAnimation
|
||||
self.completionHandler = completionHandler
|
||||
self.photoProcessingHandler = photoProcessingHandler
|
||||
self.photoOutput = photoOutput
|
||||
}
|
||||
|
||||
func capturePhoto(settings: AVCapturePhotoSettings) {
|
||||
if let photoOutput = self.photoOutput {
|
||||
photoOutput.capturePhoto(with: settings, delegate: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PhotoCaptureProcessor: AVCapturePhotoCaptureDelegate {
|
||||
func photoOutput(_ output: AVCapturePhotoOutput, willBeginCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings) {
|
||||
maxPhotoProcessingTime = resolvedSettings.photoProcessingTimeRange.start + resolvedSettings.photoProcessingTimeRange.duration
|
||||
}
|
||||
|
||||
func photoOutput(_ output: AVCapturePhotoOutput, willCapturePhotoFor resolvedSettings: AVCaptureResolvedPhotoSettings) {
|
||||
DispatchQueue.main.async {
|
||||
self.willCapturePhotoAnimation()
|
||||
}
|
||||
|
||||
guard let maxPhotoProcessingTime = maxPhotoProcessingTime else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.photoProcessingHandler(true)
|
||||
}
|
||||
|
||||
let oneSecond = CMTime(seconds: 2, preferredTimescale: 1)
|
||||
if maxPhotoProcessingTime > oneSecond {
|
||||
DispatchQueue.main.async {
|
||||
self.photoProcessingHandler(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
|
||||
DispatchQueue.main.async {
|
||||
self.photoProcessingHandler(false)
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
print("Error capturing photo: \(error)")
|
||||
} else {
|
||||
photoData = photo.fileDataRepresentation()
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func photoOutput(_ output: AVCapturePhotoOutput, didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, error: Error?) {
|
||||
if let error = error {
|
||||
print("Error capturing photo: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.completionHandler(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//
|
||||
// VideoCaptureProcessor.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Suhail Saqan on 8/5/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Photos
|
||||
|
||||
class VideoCaptureProcessor: NSObject {
|
||||
private(set) var movieOutput: AVCaptureMovieFileOutput?
|
||||
|
||||
private let beginHandler: () -> Void
|
||||
private let completionHandler: (VideoCaptureProcessor, URL) -> Void
|
||||
private let videoProcessingHandler: (Bool) -> Void
|
||||
private var session: AVCaptureSession?
|
||||
|
||||
init(movieOutput: AVCaptureMovieFileOutput?,
|
||||
beginHandler: @escaping () -> Void,
|
||||
completionHandler: @escaping (VideoCaptureProcessor, URL) -> Void,
|
||||
videoProcessingHandler: @escaping (Bool) -> Void) {
|
||||
self.beginHandler = beginHandler
|
||||
self.completionHandler = completionHandler
|
||||
self.videoProcessingHandler = videoProcessingHandler
|
||||
self.movieOutput = movieOutput
|
||||
}
|
||||
|
||||
func startCapture(session: AVCaptureSession) {
|
||||
if let movieOutput = self.movieOutput, session.isRunning {
|
||||
let outputFileURL = uniqueOutputFileURL()
|
||||
movieOutput.startRecording(to: outputFileURL, recordingDelegate: self)
|
||||
}
|
||||
}
|
||||
|
||||
func stopCapture() {
|
||||
if let movieOutput = self.movieOutput {
|
||||
if movieOutput.isRecording {
|
||||
movieOutput.stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func uniqueOutputFileURL() -> URL {
|
||||
let tempDirectory = FileManager.default.temporaryDirectory
|
||||
let fileName = UUID().uuidString + ".mov"
|
||||
return tempDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
extension VideoCaptureProcessor: AVCaptureFileOutputRecordingDelegate {
|
||||
|
||||
func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) {
|
||||
DispatchQueue.main.async {
|
||||
self.beginHandler()
|
||||
}
|
||||
}
|
||||
|
||||
func fileOutput(_ output: AVCaptureFileOutput, willFinishRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) {
|
||||
DispatchQueue.main.async {
|
||||
self.videoProcessingHandler(true)
|
||||
}
|
||||
}
|
||||
|
||||
func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) {
|
||||
if let error = error {
|
||||
print("Error capturing video: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.completionHandler(self, outputFileURL)
|
||||
self.videoProcessingHandler(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,127 @@ 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 remove_relay(ev: NostrEvent, current_relays: [RelayDescriptor], keypair: FullKeypair, relay: String) -> 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: String, 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 ensure_relay_info(relays: [RelayDescriptor], content: String) -> [String: RelayInfo] {
|
||||
guard let relay_info = decode_json_relays(content) else {
|
||||
return make_contact_relays(relays)
|
||||
}
|
||||
return relay_info
|
||||
}
|
||||
|
||||
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]) -> [String: RelayInfo] {
|
||||
return relays.reduce(into: [:]) { acc, relay in
|
||||
acc[relay.url.url.absoluteString] = relay.info
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ enum FilterState : Int {
|
||||
func filter(ev: NostrEvent) -> Bool {
|
||||
switch self {
|
||||
case .posts:
|
||||
return ev.known_kind == .boost || !ev.is_reply()
|
||||
return ev.known_kind == .boost || !ev.is_reply(.empty)
|
||||
case .posts_and_replies:
|
||||
return true
|
||||
}
|
||||
@@ -25,16 +25,7 @@ enum FilterState : Int {
|
||||
|
||||
/// Simple filter to determine whether to show posts with #nsfw tags
|
||||
func nsfw_tag_filter(ev: NostrEvent) -> Bool {
|
||||
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
|
||||
}
|
||||
|
||||
/// Generic filter with various tweakable settings
|
||||
@@ -53,16 +44,11 @@ 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] {
|
||||
static func defaults(_ settings: UserSettingsStore) -> [(NostrEvent) -> Bool] {
|
||||
var filters = Array<(NostrEvent) -> Bool>()
|
||||
if damus_state.settings.hide_nsfw_tagged_content {
|
||||
if settings.hide_nsfw_tagged_content {
|
||||
filters.append(nsfw_tag_filter)
|
||||
}
|
||||
filters.append(get_repost_of_muted_user_filter(damus_state: damus_state))
|
||||
return filters
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
//
|
||||
// DamusCacheManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2023-10-04.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Kingfisher
|
||||
|
||||
struct DamusCacheManager {
|
||||
static var shared: DamusCacheManager = DamusCacheManager()
|
||||
|
||||
func clear_cache(damus_state: DamusState, completion: (() -> Void)? = nil) {
|
||||
Log.info("Clearing all caches", for: .storage)
|
||||
clear_kingfisher_cache(completion: {
|
||||
clear_cache_folder(completion: {
|
||||
Log.info("All caches cleared", for: .storage)
|
||||
completion?()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func clear_kingfisher_cache(completion: (() -> Void)? = nil) {
|
||||
Log.info("Clearing Kingfisher cache", for: .storage)
|
||||
KingfisherManager.shared.cache.clearMemoryCache()
|
||||
KingfisherManager.shared.cache.clearDiskCache {
|
||||
Log.info("Kingfisher cache cleared", for: .storage)
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
func clear_cache_folder(completion: (() -> Void)? = nil) {
|
||||
Log.info("Clearing entire cache folder", for: .storage)
|
||||
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
|
||||
do {
|
||||
let fileNames = try FileManager.default.contentsOfDirectory(atPath: cacheURL.path)
|
||||
|
||||
for fileName in fileNames {
|
||||
let filePath = cacheURL.appendingPathComponent(fileName)
|
||||
|
||||
// Prevent issues by double-checking if files are in use, and do not delete them if they are.
|
||||
// This is not perfect. There is still a small chance for a race condition if a file is opened between this check and the file removal.
|
||||
let isBusy = (!(access(filePath.path, F_OK) == -1 && errno == ETXTBSY))
|
||||
if isBusy {
|
||||
continue
|
||||
}
|
||||
|
||||
try FileManager.default.removeItem(at: filePath)
|
||||
}
|
||||
|
||||
Log.info("Cache folder cleared successfully.", for: .storage)
|
||||
completion?()
|
||||
} catch {
|
||||
Log.error("Could not clear cache folder", for: .storage)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,49 +26,14 @@ 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
|
||||
var push_notification_client: PushNotificationClient
|
||||
|
||||
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) {
|
||||
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
|
||||
self.zaps = zaps
|
||||
self.lnurls = lnurls
|
||||
self.settings = settings
|
||||
self.relay_filters = relay_filters
|
||||
self.relay_model_cache = relay_model_cache
|
||||
self.drafts = drafts
|
||||
self.events = events
|
||||
self.bookmarks = bookmarks
|
||||
self.postbox = postbox
|
||||
self.bootstrap_relays = bootstrap_relays
|
||||
self.replies = replies
|
||||
self.wallet = wallet
|
||||
self.nav = nav
|
||||
self.music = music
|
||||
self.video = video
|
||||
self.ndb = ndb
|
||||
self.purple = purple ?? DamusPurple(
|
||||
settings: settings,
|
||||
keypair: keypair
|
||||
)
|
||||
self.quote_reposts = quote_reposts
|
||||
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func add_zap(zap: Zapping) -> Bool {
|
||||
@@ -96,13 +59,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
|
||||
@@ -114,7 +71,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(user_keypair: kp),
|
||||
profiles: Profiles(ndb: .empty),
|
||||
dms: DirectMessagesModel(our_pubkey: empty_pub),
|
||||
previews: PreviewCache(),
|
||||
@@ -129,12 +85,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,7 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class DraftArtifacts: Equatable {
|
||||
class DraftArtifacts {
|
||||
var content: NSMutableAttributedString
|
||||
var media: [UploadedMedia]
|
||||
|
||||
@@ -15,13 +15,6 @@ class DraftArtifacts: Equatable {
|
||||
self.content = content
|
||||
self.media = media
|
||||
}
|
||||
|
||||
static func == (lhs: DraftArtifacts, rhs: DraftArtifacts) -> Bool {
|
||||
return (
|
||||
lhs.media == rhs.media &&
|
||||
lhs.content.string == rhs.content.string // Comparing the text content is not perfect but acceptable in this case because attributes for our post editor are determined purely from text content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Drafts: ObservableObject {
|
||||
|
||||
148
damus/Models/EventRef.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// EventRef.swift
|
||||
// damus
|
||||
//
|
||||
// Created by William Casarin on 2022-05-08.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum EventRef: Equatable {
|
||||
case mention(Mention<NoteRef>)
|
||||
case thread_id(NoteRef)
|
||||
case reply(NoteRef)
|
||||
case reply_to_root(NoteRef)
|
||||
|
||||
var is_mention: NoteRef? {
|
||||
if case .mention(let m) = self { return m.ref }
|
||||
return nil
|
||||
}
|
||||
|
||||
var is_direct_reply: NoteRef? {
|
||||
switch self {
|
||||
case .mention:
|
||||
return nil
|
||||
case .thread_id:
|
||||
return nil
|
||||
case .reply(let refid):
|
||||
return refid
|
||||
case .reply_to_root(let refid):
|
||||
return refid
|
||||
}
|
||||
}
|
||||
|
||||
var is_thread_id: NoteRef? {
|
||||
switch self {
|
||||
case .mention:
|
||||
return nil
|
||||
case .thread_id(let referencedId):
|
||||
return referencedId
|
||||
case .reply:
|
||||
return nil
|
||||
case .reply_to_root(let referencedId):
|
||||
return referencedId
|
||||
}
|
||||
}
|
||||
|
||||
var is_reply: NoteRef? {
|
||||
switch self {
|
||||
case .mention:
|
||||
return nil
|
||||
case .thread_id:
|
||||
return nil
|
||||
case .reply(let refid):
|
||||
return refid
|
||||
case .reply_to_root(let refid):
|
||||
return refid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func build_mention_indices(_ blocks: [Block], type: MentionType) -> Set<Int> {
|
||||
return blocks.reduce(into: []) { acc, block in
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
if m.ref.key == type, let idx = m.index {
|
||||
acc.insert(idx)
|
||||
}
|
||||
case .relay:
|
||||
return
|
||||
case .text:
|
||||
return
|
||||
case .hashtag:
|
||||
return
|
||||
case .url:
|
||||
return
|
||||
case .invoice:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func interp_event_refs_without_mentions(_ refs: [NoteRef]) -> [EventRef] {
|
||||
if refs.count == 0 {
|
||||
return []
|
||||
}
|
||||
|
||||
if refs.count == 1 {
|
||||
return [.reply_to_root(refs[0])]
|
||||
}
|
||||
|
||||
var evrefs: [EventRef] = []
|
||||
var first: Bool = true
|
||||
for ref in refs {
|
||||
if first {
|
||||
evrefs.append(.thread_id(ref))
|
||||
first = false
|
||||
} else {
|
||||
evrefs.append(.reply(ref))
|
||||
}
|
||||
}
|
||||
return evrefs
|
||||
}
|
||||
|
||||
func interp_event_refs_with_mentions(tags: Tags, mention_indices: Set<Int>) -> [EventRef] {
|
||||
var mentions: [EventRef] = []
|
||||
var ev_refs: [NoteRef] = []
|
||||
var i: Int = 0
|
||||
|
||||
for tag in tags {
|
||||
if let ref = NoteRef.from_tag(tag: tag) {
|
||||
if mention_indices.contains(i) {
|
||||
let mention = Mention<NoteRef>(index: i, ref: ref)
|
||||
mentions.append(.mention(mention))
|
||||
} else {
|
||||
ev_refs.append(ref)
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
var replies = interp_event_refs_without_mentions(ev_refs)
|
||||
replies.append(contentsOf: mentions)
|
||||
return replies
|
||||
}
|
||||
|
||||
func interpret_event_refs(blocks: [Block], tags: Tags) -> [EventRef] {
|
||||
if tags.count == 0 {
|
||||
return []
|
||||
}
|
||||
|
||||
/// build a set of indices for each event mention
|
||||
let mention_indices = build_mention_indices(blocks, type: .e)
|
||||
|
||||
/// simpler case with no mentions
|
||||
if mention_indices.count == 0 {
|
||||
return interp_event_refs_without_mentions_ndb(References<NoteRef>(tags: tags))
|
||||
}
|
||||
|
||||
return interp_event_refs_with_mentions(tags: tags, mention_indices: mention_indices)
|
||||
}
|
||||
|
||||
|
||||
func event_is_reply(_ refs: [EventRef]) -> Bool {
|
||||
return refs.contains { evref in
|
||||
return evref.is_reply != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -96,14 +63,8 @@ class EventsModel: ObservableObject {
|
||||
break
|
||||
case .ok:
|
||||
break
|
||||
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)
|
||||
load_profiles(profiles_subid: profiles_id, relay_id: relay_id, load: .from_events(events), damus_state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +52,9 @@ class FollowersModel: ObservableObject {
|
||||
contacts?.append(ev.pubkey)
|
||||
has_contact.insert(ev.pubkey)
|
||||
}
|
||||
|
||||
func load_profiles<Y>(relay_id: RelayURL, txn: NdbTxn<Y>) {
|
||||
let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [], txn: txn)
|
||||
|
||||
func load_profiles(relay_id: String) {
|
||||
let authors = find_profiles_to_fetch_from_keys(profiles: damus_state.profiles, pks: contacts ?? [])
|
||||
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,16 +83,13 @@ 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 }
|
||||
load_profiles(relay_id: relay_id, txn: txn)
|
||||
load_profiles(relay_id: relay_id)
|
||||
} else if sub_id == self.profiles_id {
|
||||
damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id])
|
||||
}
|
||||
|
||||
case .ok:
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,11 +22,11 @@ class FollowingModel {
|
||||
self.hashtags = hashtags
|
||||
}
|
||||
|
||||
func get_filter<Y>(txn: NdbTxn<Y>) -> NostrFilter {
|
||||
func get_filter() -> NostrFilter {
|
||||
var f = NostrFilter(kinds: [.metadata])
|
||||
f.authors = self.contacts.reduce(into: Array<Pubkey>()) { acc, pk in
|
||||
// don't fetch profiles we already have
|
||||
if damus_state.profiles.has_fresh_profile(id: pk, txn: txn) {
|
||||
if damus_state.profiles.has_fresh_profile(id: pk) {
|
||||
return
|
||||
}
|
||||
acc.append(pk)
|
||||
@@ -34,8 +34,8 @@ class FollowingModel {
|
||||
return f
|
||||
}
|
||||
|
||||
func subscribe<Y>(txn: NdbTxn<Y>) {
|
||||
let filter = get_filter(txn: txn)
|
||||
func subscribe() {
|
||||
let filter = get_filter()
|
||||
if (filter.authors?.count ?? 0) == 0 {
|
||||
needs_sub = false
|
||||
return
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,24 +56,16 @@ enum HomeResubFilter {
|
||||
}
|
||||
}
|
||||
|
||||
class HomeModel: ContactsDelegate {
|
||||
// The maximum amount of contacts placed on a home feed subscription filter.
|
||||
// If the user has more contacts, chunking or other techniques will be used to avoid sending huge filters
|
||||
let MAX_CONTACTS_ON_FILTER = 500
|
||||
|
||||
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)
|
||||
@@ -116,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 {
|
||||
@@ -169,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
|
||||
}
|
||||
@@ -191,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:
|
||||
@@ -245,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,
|
||||
@@ -253,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)]")
|
||||
@@ -275,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
|
||||
}
|
||||
|
||||
@@ -310,27 +289,13 @@ class HomeModel: ContactsDelegate {
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handle_damus_app_notification(_ notification: DamusAppNotification) async {
|
||||
if self.notifications.insert_app_notification(notification: notification) {
|
||||
let last_notification = get_last_event(.notifications)
|
||||
if last_notification == nil || last_notification!.created_at < notification.last_event_at {
|
||||
save_last_event(NoteId.empty, created_at: notification.last_event_at, timeline: .notifications)
|
||||
// If we successfully inserted a new Damus App notification, switch ON the Damus App notification bit on our NewsEventsBits
|
||||
// This will cause the bell icon on the tab bar to display the purple dot indicating there is an unread notification
|
||||
self.notification_status.new_events = NewEventsBits(rawValue: self.notification_status.new_events.rawValue | NewEventsBits.damus_app_notifications.rawValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func filter_events() {
|
||||
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
|
||||
@@ -338,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,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 {
|
||||
@@ -386,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
|
||||
@@ -421,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 {
|
||||
@@ -439,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)
|
||||
}
|
||||
@@ -472,18 +429,15 @@ class HomeModel: ContactsDelegate {
|
||||
print(msg)
|
||||
|
||||
case .eose(let sub_id):
|
||||
guard let txn = NdbTxn(ndb: damus_state.ndb) else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if sub_id == dms_subid {
|
||||
var dms = dms.dms.flatMap { $0.events }
|
||||
dms.append(contentsOf: incoming_dms)
|
||||
load_profiles(context: "dms", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state, txn: txn)
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(dms), damus_state: damus_state)
|
||||
} else if sub_id == notifications_subid {
|
||||
load_profiles(context: "notifications", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state, txn: txn)
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state)
|
||||
} else if sub_id == home_subid {
|
||||
load_profiles(context: "home", profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus_state, txn: txn)
|
||||
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events.events), damus_state: damus_state)
|
||||
}
|
||||
|
||||
self.loading = false
|
||||
@@ -491,8 +445,6 @@ class HomeModel: ContactsDelegate {
|
||||
|
||||
case .ok:
|
||||
break
|
||||
case .auth:
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
@@ -500,14 +452,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
|
||||
|
||||
@@ -519,13 +471,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])
|
||||
@@ -549,8 +498,7 @@ class HomeModel: ContactsDelegate {
|
||||
notifications_filter.limit = 500
|
||||
|
||||
var notifications_filters = [notifications_filter]
|
||||
let contacts_filter_chunks = contacts_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_FILTER)
|
||||
var contacts_filters = contacts_filter_chunks + [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)
|
||||
|
||||
@@ -569,7 +517,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] } ?? [:]
|
||||
}
|
||||
|
||||
@@ -583,7 +531,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
|
||||
@@ -603,7 +551,7 @@ class HomeModel: ContactsDelegate {
|
||||
home_filter.authors = friends
|
||||
home_filter.limit = 500
|
||||
|
||||
var home_filters = home_filter.chunked(on: .authors, into: MAX_CONTACTS_ON_FILTER)
|
||||
var home_filters = [home_filter]
|
||||
|
||||
let followed_hashtags = Array(damus_state.contacts.get_followed_hashtags())
|
||||
if followed_hashtags.count != 0 {
|
||||
@@ -619,32 +567,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
|
||||
}
|
||||
@@ -654,12 +583,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
|
||||
@@ -672,7 +599,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
|
||||
}
|
||||
|
||||
@@ -687,7 +614,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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -710,7 +637,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
|
||||
}
|
||||
|
||||
@@ -719,10 +646,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 {
|
||||
@@ -733,17 +656,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, mode: .local),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -923,23 +844,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 = 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)
|
||||
}
|
||||
@@ -950,8 +871,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)
|
||||
}
|
||||
@@ -967,8 +890,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
|
||||
}
|
||||
@@ -994,10 +917,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
|
||||
}
|
||||
@@ -1146,16 +1069,13 @@ func event_has_our_pubkey(_ ev: NostrEvent, our_pubkey: Pubkey) -> Bool {
|
||||
return ev.referenced_pubkeys.contains(our_pubkey)
|
||||
}
|
||||
|
||||
func should_show_event(event: NostrEvent, damus_state: DamusState) -> Bool {
|
||||
return should_show_event(
|
||||
state: damus_state,
|
||||
ev: event
|
||||
)
|
||||
}
|
||||
|
||||
func should_show_event(state: DamusState, ev: NostrEvent) -> Bool {
|
||||
let event_muted = state.mutelist_manager.is_event_muted(ev)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1175,11 +1095,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()
|
||||
|
||||
@@ -1199,8 +1147,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()
|
||||
|
||||
@@ -1216,3 +1164,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
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,16 @@ enum LibreTranslateServer: String, CaseIterable, Identifiable, StringCodable {
|
||||
self = libreTranslateServer
|
||||
}
|
||||
|
||||
case argosopentech
|
||||
case terraprint
|
||||
case custom
|
||||
|
||||
var model: Model {
|
||||
switch self {
|
||||
case .argosopentech:
|
||||
return .init(tag: self.rawValue, displayName: "translate.argosopentech.com", url: "https://translate.argosopentech.com")
|
||||
case .terraprint:
|
||||
return .init(tag: self.rawValue, displayName: "translate.terraprint.co", url: "https://translate.terraprint.co")
|
||||
case .custom:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("Custom", comment: "Dropdown option for selecting a custom translation server."), url: nil)
|
||||
}
|
||||
|
||||
@@ -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,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +123,11 @@ struct LightningInvoice<T> {
|
||||
}
|
||||
}
|
||||
|
||||
struct Blocks: Equatable {
|
||||
let words: Int
|
||||
let blocks: [Block]
|
||||
}
|
||||
|
||||
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
|
||||
guard p != nil else {
|
||||
return nil
|
||||
@@ -269,11 +228,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)
|
||||
@@ -292,10 +248,12 @@ func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
|
||||
}
|
||||
|
||||
func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? {
|
||||
let tags = post.references.map({ r in r.tag }) + post.tags
|
||||
let post_blocks = parse_post_blocks(content: post.content)
|
||||
let post_tags = make_post_tags(post_blocks: post_blocks, tags: post.tags)
|
||||
let post_tags = make_post_tags(post_blocks: post_blocks, tags: tags)
|
||||
let content = post_tags.blocks
|
||||
.map(\.asString)
|
||||
.joined(separator: "")
|
||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: post.kind.rawValue, tags: post_tags.tags)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,207 +0,0 @@
|
||||
//
|
||||
// MutelistManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Charlie Fish on 1/28/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
class MutelistManager {
|
||||
let user_keypair: Keypair
|
||||
private(set) var event: NostrEvent? = nil
|
||||
|
||||
var users: Set<MuteItem> = [] {
|
||||
didSet { self.reset_cache() }
|
||||
}
|
||||
var hashtags: Set<MuteItem> = [] {
|
||||
didSet { self.reset_cache() }
|
||||
}
|
||||
var threads: Set<MuteItem> = [] {
|
||||
didSet { self.reset_cache() }
|
||||
}
|
||||
var words: Set<MuteItem> = [] {
|
||||
didSet { self.reset_cache() }
|
||||
}
|
||||
|
||||
var muted_notes_cache: [NoteId: EventMuteStatus] = [:]
|
||||
|
||||
init(user_keypair: Keypair) {
|
||||
self.user_keypair = user_keypair
|
||||
}
|
||||
|
||||
func refresh_sets() {
|
||||
guard let referenced_mute_items = event?.referenced_mute_items else { return }
|
||||
|
||||
var new_users: Set<MuteItem> = []
|
||||
var new_hashtags: Set<MuteItem> = []
|
||||
var new_threads: Set<MuteItem> = []
|
||||
var new_words: Set<MuteItem> = []
|
||||
|
||||
for mute_item in referenced_mute_items {
|
||||
switch mute_item {
|
||||
case .user:
|
||||
new_users.insert(mute_item)
|
||||
case .hashtag:
|
||||
new_hashtags.insert(mute_item)
|
||||
case .word:
|
||||
new_words.insert(mute_item)
|
||||
case .thread:
|
||||
new_threads.insert(mute_item)
|
||||
}
|
||||
}
|
||||
|
||||
users = new_users
|
||||
hashtags = new_hashtags
|
||||
threads = new_threads
|
||||
words = new_words
|
||||
}
|
||||
|
||||
func reset_cache() {
|
||||
self.muted_notes_cache = [:]
|
||||
}
|
||||
|
||||
func is_muted(_ item: MuteItem) -> Bool {
|
||||
switch item {
|
||||
case .user(_, _):
|
||||
return users.contains(item)
|
||||
case .hashtag(_, _):
|
||||
return hashtags.contains(item)
|
||||
case .word(_, _):
|
||||
return words.contains(item)
|
||||
case .thread(_, _):
|
||||
return threads.contains(item)
|
||||
}
|
||||
}
|
||||
|
||||
func is_event_muted(_ ev: NostrEvent) -> Bool {
|
||||
return self.event_muted_reason(ev) != nil
|
||||
}
|
||||
|
||||
func set_mutelist(_ ev: NostrEvent) {
|
||||
let oldlist = self.event
|
||||
self.event = ev
|
||||
|
||||
let old: Set<MuteItem> = oldlist?.mute_list ?? Set<MuteItem>()
|
||||
let new: Set<MuteItem> = ev.mute_list ?? Set<MuteItem>()
|
||||
let diff = old.symmetricDifference(new)
|
||||
|
||||
var new_mutes = Set<MuteItem>()
|
||||
var new_unmutes = Set<MuteItem>()
|
||||
|
||||
for d in diff {
|
||||
if new.contains(d) {
|
||||
add_mute_item(d)
|
||||
new_mutes.insert(d)
|
||||
} else {
|
||||
remove_mute_item(d)
|
||||
new_unmutes.insert(d)
|
||||
}
|
||||
}
|
||||
|
||||
if new_mutes.count > 0 {
|
||||
notify(.new_mutes(new_mutes))
|
||||
}
|
||||
|
||||
if new_unmutes.count > 0 {
|
||||
notify(.new_unmutes(new_unmutes))
|
||||
}
|
||||
}
|
||||
|
||||
private func add_mute_item(_ item: MuteItem) {
|
||||
switch item {
|
||||
case .user(_, _):
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func event_muted_reason(_ ev: NostrEvent) -> MuteItem? {
|
||||
if let cached_mute_status = self.muted_notes_cache[ev.id] {
|
||||
return cached_mute_status.mute_reason()
|
||||
}
|
||||
if let reason = self.compute_event_muted_reason(ev) {
|
||||
self.muted_notes_cache[ev.id] = .muted(reason: reason)
|
||||
return reason
|
||||
}
|
||||
self.muted_notes_cache[ev.id] = .not_muted
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
/// Check if an event is muted given a collection of ``MutedItem``.
|
||||
///
|
||||
/// - Parameter ev: The ``NostrEvent`` that you want to check the muted reason for.
|
||||
/// - Returns: The ``MuteItem`` that matched the event. Or `nil` if the event is not muted.
|
||||
func compute_event_muted_reason(_ ev: NostrEvent) -> MuteItem? {
|
||||
// Events from the current user should not be muted.
|
||||
guard self.user_keypair.pubkey != ev.pubkey else { return nil }
|
||||
|
||||
// Check if user is muted
|
||||
let check_user_item = MuteItem.user(ev.pubkey, nil)
|
||||
if users.contains(check_user_item) {
|
||||
return check_user_item
|
||||
}
|
||||
|
||||
// Check if hashtag is muted
|
||||
for hashtag in ev.referenced_hashtags {
|
||||
let check_hashtag_item = MuteItem.hashtag(hashtag, nil)
|
||||
if hashtags.contains(check_hashtag_item) {
|
||||
return check_hashtag_item
|
||||
}
|
||||
}
|
||||
|
||||
// Check if thread is muted
|
||||
for thread_id in ev.referenced_ids {
|
||||
let check_thread_item = MuteItem.thread(thread_id, nil)
|
||||
if threads.contains(check_thread_item) {
|
||||
return check_thread_item
|
||||
}
|
||||
}
|
||||
|
||||
// Check if word is muted
|
||||
if let content: String = ev.maybe_get_content(self.user_keypair)?.lowercased() {
|
||||
for word in words {
|
||||
if case .word(let string, _) = word {
|
||||
if content.contains(string.lowercased()) {
|
||||
return word
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
enum EventMuteStatus {
|
||||
case muted(reason: MuteItem)
|
||||
case not_muted
|
||||
|
||||
func mute_reason() -> MuteItem? {
|
||||
switch self {
|
||||
case .muted(reason: let reason):
|
||||
return reason
|
||||
case .not_muted:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,19 +0,0 @@
|
||||
//
|
||||
// NostrFilter+Hashable.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Davide De Rosa on 10/21/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// FIXME: fine-tune here what's involved in comparing search filters
|
||||
extension NostrFilter: Hashable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.hashtag == rhs.hashtag
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(hashtag)
|
||||
}
|
||||
}
|
||||