Compare commits

..

1 Commits

Author SHA1 Message Date
e456ac864d Add Apple translation popovers for notes for iOS 17.4+ and macOS 14.4+
Changelog-Added: Add Apple translation popovers for notes for iOS 17.4+ and macOS 14.4+
2024-09-22 00:38:15 -04:00
424 changed files with 5332 additions and 29748 deletions

View File

@@ -1,52 +0,0 @@
---
name: App release process
about: Begin preparing for a new app release
title: 'Release: '
labels: release-tasks
assignees: ''
---
A new version release. Please attempt to follow the release process steps below in the order they are shown.
## TestFlight release candidates
### Release candidate 1
**Version:** _[Enter full build information for the release candidate, including major and minor version number, build number, and commit hash]_
1. [ ] Merge in all needed changes to `master`
2. [ ] Check CI, make sure it is passing
3. [ ] Prepare preliminary changelog as a draft PR: _[Enter PR link to changelog here]_
4. [ ] Make a _release_ build and submit to the internal TestFlight group via our new Release candidate workflow in Xcode Cloud.
5. [ ] Prepare short screencast style video with main changes for the announcement
6. [ ] Publish release build to these TestFlight groups:
- [ ] Alpha testers group
- [ ] Translators group
- [ ] Purple group
7. [ ] Publish announcement on Nostr
_[Duplicate this release candidate section if there is more than one release candidate]_
## App Store release
1. [ ] Release candidate checks:
- [ ] Release candidate has been on Purple TestFlight for at least one week
- [ ] No blocker issues came from feedback from Purple users (double-check)
- [ ] Check with stakeholders
- [ ] Check with developers & product for any release showstoppers (e.g., critical newfound bugs)
2. [ ] Thorough check on release notes
3. [ ] Submit to App Store review (with manual publishing setting enabled)
4. [ ] Get App Store approval from Apple
5. [ ] Prepare announcement
7. [ ] Publish on the App Store and make announcement
8. [ ] Publish changelog and tag commit hash corresponding to the release
9. [ ] Perform a version bump on the repository, in preparation for the next release
## Notes/others
_Enter any relevant notes here_

View File

@@ -1,37 +0,0 @@
## Summary
_[Please provide a summary of the changes in this PR.]_
## Checklist
- [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md)
- [ ] I have tested the changes in this PR
- [ ] I have opened or referred to an existing github issue related to this change.
- [ ] My PR is either small, or I have split it into smaller logical commits that are easier to review
- [ ] I have added the signoff line to all my commits. See [Signing off your work](../docs/CONTRIBUTING.md#sign-your-work---the-developers-certificate-of-origin)
- [ ] I have added appropriate changelog entries for the changes in this PR. See [Adding changelog entries](../docs/CONTRIBUTING.md#add-changelog-changed-changelog-fixed-etc)
- [ ] I do not need to add a changelog entry. Reason: _[Please provide a reason]_
- [ ] I have added appropriate `Closes:` or `Fixes:` tags in the commit messages wherever applicable, or made sure those are not needed. See [Submitting patches](https://github.com/damus-io/damus/blob/master/docs/CONTRIBUTING.md#submitting-patches)
## Test report
_Please provide a test report for the changes in this PR. You can use the template below, but feel free to modify it as needed._
**Device:** _[Please specify the device you used for testing]_
**iOS:** _[Please specify the iOS version you used for testing]_
**Damus:** _[Please specify the Damus version or commit hash you used for testing]_
**Setup:** _[Please provide a brief description of the setup you used for testing, if applicable]_
**Steps:** _[Please provide a list of steps you took to test the changes in this PR]_
**Results:**
- [ ] PASS
- [ ] Partial PASS
- Details: _[Please provide details of the partial pass]_
## Other notes
_[Please provide any other information that you think is relevant to this PR.]_

1
.gitignore vendored
View File

@@ -6,4 +6,3 @@ damus.xcodeproj/xcshareddata/xcbaselines
TODO.bak TODO.bak
tags tags
build-git-hash.txt build-git-hash.txt
.build

View File

@@ -1,5 +0,0 @@
### Acknowledgements and licenses
1. This product contains code derived from [Nostr SDK iOS](https://github.com/nostr-sdk/nostr-sdk-ios). [License](https://github.com/nostr-sdk/nostr-sdk-ios/blob/40df800c6749d7ce0b6fd7328e76cbc0dc71c87b/LICENSE)
2. This product includes software developed by the "Marcin Krzyzanowski" (http://krzyzanowskim.com/). [License](https://github.com/krzyzanowskim/CryptoSwift/blob/e74bbbfbef939224b242ae7c342a90e60b88b5ce/LICENSE)

View File

@@ -1,267 +1,3 @@
## [1.14] - 2025-05-25
### Added
- Added safety reminder to wallets with higher balance (Daniel DAquino)
- Added one-click Coinos wallet setup (Daniel DAquino)
- Add notification setting to hide hellthreads (Terry Yiu)
- Added separated first aid option for relay lists that does not need a contact list reset (Daniel DAquino)
- Added NIP-65 relay list support (Daniel DAquino)
- Added Unicode 16 emoji reactions for iOS 18.4+ by upgrading EmojiPicker (Terry Yiu)
- Added a search interface to the settings screen (SanjaySiddharth)
- Added view introducing users to Zaps (ericholguin)
- Added new wallet view with balance and transactions list (ericholguin)
- Added copy technical info button to user visible errors, so that users can more easily share errors with developers (Daniel DAquino)
- Add dismiss button to wallet high balance reminders (Daniel DAquino)
- Zap receiver information now included for outgoing zaps (Daniel DAquino)
- Added inline note rendering of invoices to pull up wallet selector sheet (Terry Yiu)
- Added route to profile page from wallet tx list (ericholguin)
### Changed
- Added additional information on top of blurred images (SanjaySiddharth)
- Improved robustness of relay list handling (Daniel DAquino)
- Updated image cache for better stability (Daniel DAquino)
- Improved integration with Nostr Wallet Connect wallets (ericholguin)
- Added relay connectivity information to NWC settings (Daniel DAquino)
- Improved handling around NWC responses (Daniel DAquino)
- Added more human visible errors on NWC wallets to aid with troubleshooting (Daniel DAquino)
- Re-enabled note zaps as permitted by the new App Store guidelines (Daniel DAquino)
### Fixed
- Hide future notes from timeline (Terry Yiu)
- Fixed issue where profiles with a NIP-65 relay list would not display on Damus (Daniel DAquino)
- Fix quote notes to include missing q tag (Terry Yiu)
- Fixed issue where the side menu would close when copying the npub (SanjaySiddharth)
- Fixed issue where cached images would be backed up to iCloud (Daniel DAquino)
- Optimized classify_url function (Terry Yiu)
- Fixed note rendering for those that contain previewable items or leading and trailing whitespaces (Terry Yiu)
- Fixed issue where some videos would become unplayable after some time using the app (Daniel DAquino)
[1.14]: https://github.com/damus-io/damus/releases/tag/v1.14
## [1.13.1] - 2025-03-21
### Fixed
- Fixed an issue where threads would not load properly (Daniel DAquino)
[1.13.1]: https://github.com/damus-io/damus/releases/tag/v1.13.1
## [1.13] - 2025-03-14
### Added
- Added local persistence of note drafts (Daniel DAquino)
- Added user-friendly error view for errors around the app that would not fit in other places (Daniel DAquino)
- Coinos connection button in Wallet view (ericholguin)
- Added Alby Go to mobile wallets selection menu (Tomek ⚡ K)
- Minor accessibility improvements around picture editing and onboarding (Daniel DAquino)
- Profile image cropping tools (Daniel DAquino)
- Added Conversations tab to profiles (Terry Yiu)
- Added profile pictures to push notifications (William Casarin)
### Changed
- Don't show reposts for the same note more than once in your home feed (William Casarin)
- Improved profile image bandwidth optimization (Daniel DAquino)
- Improved reliability of picture selector (Daniel DAquino)
- Changed spaces to newlines in new posts to provide cleaner separation between text, uploaded media, and quoted notes (Terry Yiu)
### Fixed
- Fixed issue where some push notifications would not open in the app and leave users confused (Daniel DAquino)
- Fixed issue where app would need a restart for new NWC wallets to work (Daniel DAquino)
- Fixed overly sensitive horizontal swipe on thread chat view (Daniel DAquino)
- Trim whitespaces from Lightning addresses (Terry Yiu)
- Fixed translation export script by upgrading nostr-sdk-swift dependency to support Mac Catalyst (Terry Yiu)
- Fixed issue where users continue to receive push notifications after logout (Daniel DAquino)
- Fixed an issue where events on a thread view would occasionally disappear (Daniel DAquino)
- Improved robustness of the URL handler (Daniel DAquino)
- Translate notes even if they are in a preferred language but not the current language as that is what users expect (Terry Yiu)
- Cancel ongoing uploading operations after the user cancels the post (Swift Coder)
- Fixed link and photo sharing support on macOS (Swift Coder)
- Fix bug where profile view was showing more than just the notes and replies on the notes / notes & replies tabs (Terry Yiu)
- Fixed reposts banner to be localizable (Terry Yiu)
### Removed
- Removed language filtering from Universe feed because language detection can be inaccurate (Terry Yiu)
- Removed mystery tabs meant to fix tab switching bug that no longer exists (Terry Yiu)
[1.13](https://github.com/damus-io/damus/releases/tag/v1.13): https://github.com/damus-io/damus/releases/tag/v1.13
## [1.12.3] - 2025-02-06
### Added
- Purple members who have been active for more than a year now get a special badge (Daniel DAquino)
### Changed
- Improved clarity of the mute button to indicate it can be used for blocking a user (Daniel DAquino)
- Made the microphone access request message more clear to users (Daniel DAquino)
[v1.12.3]: https://github.com/damus-io/damus/releases/tag/v1.12.3
## [1.12](https://github.com/damus-io/damus/releases/tag/v1.12) - 2024-12-20
### Added
- Render Gif and video files while composing posts (Swift Coder)
- Add profile info text in stretchable banner with follow button (Swift Coder)
- Paste Gif image similar to jpeg and png files (Swift Coder)
### Changed
- Improved UX around the label for searching words (Daniel DAquino)
- Improved accessibility support on some elements (Daniel DAquino)
### Fixed
- Fixed issue where the "next" button would appear hidden and hard to click on the create account view (Daniel DAquino)
- Fix non scrollable wallet screen (Swift Coder)
- Fixed suggested users category titles to be localizable (Terry Yiu)
- Fixed GradientFollowButton to have consistent width and autoscale text limited to 1 line (Terry Yiu)
- Fixed right-to-left localization issues (Terry Yiu)
- Fixed AddMuteItemView to trim leading and trailing whitespaces from mute text and disallow adding text with only whitespaces (Terry Yiu)
- Fixed SideMenuView text to autoscale and limit to 1 line (Terry Yiu)
- Fixed an issue where a profile would need to be input twice in the search to be found (Daniel DAquino)
- Fixed non-breaking spaces in localized strings (Terry Yiu)
- Fixed localization issue on Add mute item button (Terry Yiu)
- Replace non-breaking spaces with regular spaces as Apple's NSLocalizedString macro does not seem to work with it (Terry Yiu)
- Fixed localization issues in RelayConfigView (Terry Yiu)
- Fix duplicate uploads (Swift Coder)
- Remove duplicate pubkey from Follow Suggestion list (Swift Coder)
- Fix Page control indicator (Swift Coder)
- Fix damus sharing issues (Swift Coder)
- Fixed issue where banner edit button is unclickable (Daniel DAquino)
- Handle empty notification pages by displaying suitable text (Swift Coder)
[v1.12](https://github.com/damus-io/damus/releases/tag/v1.12): [https://github.com/damus-io/damus/releases/tag/v1.12]
## [v1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10) - 2024-11-18
### Added
- Add Damus Share Feature (Swift)
- Added new easy to use video controls for full screen video (Daniel DAquino)
- Add Edit, Share, and Tap-gesture in Profile pic image viewer (Swift Coder)
- Disappearing header, tabbar, and post button on scroll (ericholguin)
- Add Apple translation popovers for notes for iOS 17.4+ and macOS 14.4+ (Terry Yiu)
- Added NDB search functionality to the universe view (ericholguin)
- Added mute button to ProfileActionSheet (chungwwei)
- Added mute action to selected text menu (ericholguin)
- Added support for pasting images from the clipboard to the post composer (Swift Coder)
### Changed
- Improved image carousel image fill behavior (Daniel DAquino)
- Improved video syncing and bandwidth usage when switching between timeline video and full screen mode (Daniel DAquino)
- Swipe to dismiss on full screen carousel now shows an opacity effect for improved UX (Daniel DAquino)
- Removed event contents from full screen media carousel for cleaner view (Daniel DAquino)
- Add share button for images on full screen image carousel view (Swift)
- Changed boldness of font in side menu labels. (ericholguin)
- Changed search notes button with searched keyword (ericholguin)
- Changed opacity of tabbar and post button (ericholguin)
- Allow multiple images to be uploaded at the same time (swiftcoder) (William Casarin)
- Changed side menu design (ericholguin)
- Truncate fulltext search results (William Casarin)
- Expanded profile search results to 128 (William Casarin)
- Expand nostrdb text search results to 128 items (William Casarin)
- Use LazyVStack in text search results (William Casarin)
### Fixed
- Fixed missing tab bar on navigation (Swift Coder)
- Fixed some issues where QR code would not work, and improved UX (Daniel DAquino)
- Fixed iOS 18 gesture issues that would take user to the thread view when clicking on a video or unmuting it (Daniel DAquino)
- Fixed several issues that would cause video to automatically play or pause incorrectly (Daniel DAquino)
- Fixed issue where full screen video would disappear when going to landscape mode (Daniel DAquino)
- Fixed portrait video size on full screen carousel (Daniel DAquino)
- Fix avatar image on qrcode view (Swift Coder)
- Fix banner image upload (Swift Coder)
- Fix dismiss button visibility (Swift Coder)
- Fix quote repost counting (William Casarin)
- Fixed overlapping text in Universe View (ericholguin)
- Fixed localization issues and exported strings (Terry Yiu)
- Fix sensitive long-press gesture on event chat bubble in iOS 18 (Daniel DAquino)
- Fixed bottom padding for tabbar (ericholguin)
- Fixed localization build failures (Terry Yiu)
- Fixed back nav button placement in profile edit view (ericholguin)
- Friend profiles will now more likely show up in profile search (William Casarin)
- Fix broken QR code scanner and fix landscape mode (Terry Yiu)
[1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10): https://github.com/damus-io/damus/releases/tag/v1.11-10
## [1.10.1] - 2024-09-22
### Added
- Push notification support (Daniel DAquino)
- Added profile edit safe guards (Eric Holguin)
- Tor relay icon (ericholguin)
- Add highlighter for web pages (Daniel DAquino)
- Add support for adding comments when creating a highlight (Daniel DAquino)
- Add support for rendering highlights with comments (Daniel DAquino)
- Ability to create highlights (ericholguin)
- Highlights (NIP-84) (ericholguin)
- Revamp emoji picker to be less error-prone and add search, frequently used, and multiple skin tone support capabilities (Terry Yiu)
### Changed
- Improve notification view filtering UX (Daniel DAquino)
- Improve visibility of friends filter button (Daniel DAquino)
- Changed the default banner from ostriches to damoose (Eric Holguin)
- Changed image and banner url text fields to new sheet view (Eric Holguin)
- Onboarding design (ericholguin)
### Fixed
- Fix items that became unclickable on iOS 18 (Daniel DAquino)
- Fix many reconnection issues (William Casarin)
- Fixed issue where theme would be changed to black and can't be switched back on iOS 18 (cr0bar)
- Fixed some scenarios where the contact list would never be saved locally and cause issues when switching relays. (Daniel DAquino)
- Fix albyhub zaps not appearing (William Casarin)
- Fix inadvertent escape from mention suggestion menu when typing a space character (Daniel DAquino)
- Fix profile view toolbar alignment bug in iOS 18 (Terry Yiu)
- Create Account model now uses correct metadata (ericholguin)
- Restore localization for custom tabs (William Casarin)
- Fix iOS 18 reflection runtime error for custom picker (William Casarin)
[1.10.1]: https://github.com/damus-io/damus/releases/tag/v1.10.1
## [1.9.1 (4)] - 2024-08-13
### Fixed
- Fix crash when viewing notes with invalid image dimension metadata (Daniel DAquino)
[1.9.1 (4)]: https://github.com/damus-io/damus/releases/tag/v1.9.1-4
## [1.9 (14)] - 2024-07-14 ## [1.9 (14)] - 2024-07-14
### Added ### Added

View File

@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>com.apple.developer.usernotifications.communication</key>
<true/>
<key>com.apple.developer.kernel.extended-virtual-addressing</key> <key>com.apple.developer.kernel.extended-virtual-addressing</key>
<true/> <true/>
<key>com.apple.security.app-sandbox</key> <key>com.apple.security.app-sandbox</key>

View File

@@ -18,7 +18,7 @@ struct NotificationExtensionState: HeadlessDamusState {
let lnurls: LNUrls let lnurls: LNUrls
init?() { init?() {
guard let ndb = Ndb(owns_db_file: false) else { return nil } guard let ndb = try? Ndb(owns_db_file: false) else { return nil }
self.ndb = ndb self.ndb = ndb
guard let keypair = get_saved_keypair() else { return nil } guard let keypair = get_saved_keypair() else { return nil }

View File

@@ -5,32 +5,15 @@
// Created by Daniel DAquino on 2023-11-10. // Created by Daniel DAquino on 2023-11-10.
// //
import Kingfisher
import ImageIO
import UserNotifications import UserNotifications
import Foundation import Foundation
import UniformTypeIdentifiers
import Intents
class NotificationService: UNNotificationServiceExtension { class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)? var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptContent: UNMutableNotificationContent? var bestAttemptContent: UNMutableNotificationContent?
private func configureKingfisherCache() {
guard let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER) else {
return
}
let cachePath = groupURL.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
if let cache = try? ImageCache(name: "sharedCache", cacheDirectoryURL: cachePath) {
KingfisherManager.shared.cache = cache
}
}
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
configureKingfisherCache()
self.contentHandler = contentHandler self.contentHandler = contentHandler
guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String, guard let nostr_event_json = request.content.userInfo["nostr_event"] as? String,
@@ -57,16 +40,9 @@ class NotificationService: UNNotificationServiceExtension {
return return
} }
let sender_profile = { let txn = state.ndb.lookup_profile(nostr_event.pubkey)
let txn = state.ndb.lookup_profile(nostr_event.pubkey) let profile = txn?.unsafeUnownedValue?.profile
let profile = txn?.unsafeUnownedValue?.profile let name = Profile.displayName(profile: profile, pubkey: nostr_event.pubkey).displayName
let picture = ((profile?.picture.map { URL(string: $0) }) ?? URL(string: robohash(nostr_event.pubkey)))!
return ProfileBuf(picture: picture,
name: profile?.name,
display_name: profile?.display_name,
nip05: profile?.nip05)
}()
let sender_pubkey = nostr_event.pubkey
// Don't show notification details that match mute list. // 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 // TODO: Remove this code block once we get notification suppression entitlement from Apple. It will be covered by the `guard should_display_notification` block
@@ -80,7 +56,7 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(content) contentHandler(content)
return return
} }
guard should_display_notification(state: state, event: nostr_event, mode: .push) else { guard should_display_notification(state: state, event: nostr_event, mode: .push) else {
Log.debug("should_display_notification failed", for: .push_notifications) Log.debug("should_display_notification failed", for: .push_notifications)
// We should not display notification for this event. Suppress notification. // We should not display notification for this event. Suppress notification.
@@ -89,7 +65,7 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content) contentHandler(request.content)
return return
} }
guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else { guard let notification_object = generate_local_notification_object(from: nostr_event, state: state) else {
Log.debug("generate_local_notification_object failed", for: .push_notifications) Log.debug("generate_local_notification_object failed", for: .push_notifications)
// We could not process this notification. Probably an unsupported nostr event kind. Suppress. // We could not process this notification. Probably an unsupported nostr event kind. Suppress.
@@ -98,58 +74,15 @@ class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content) contentHandler(request.content)
return return
} }
Task { Task {
let sender_dn = DisplayName(name: sender_profile.name, display_name: sender_profile.display_name, pubkey: sender_pubkey) guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: sender_dn.displayName, notify: notification_object, state: state) else {
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications) Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
return return
} }
do { contentHandler(improvedContent)
var options: [AnyHashable: Any] = [:]
if let imageSource = CGImageSourceCreateWithURL(sender_profile.picture as CFURL, nil),
let uti = CGImageSourceGetType(imageSource) {
options[UNNotificationAttachmentOptionsTypeHintKey] = uti
}
let attachment = try UNNotificationAttachment(identifier: sender_profile.picture.absoluteString, url: sender_profile.picture, options: options)
improvedContent.attachments = [attachment]
} catch {
Log.error("failed to get notification attachment: %s", for: .push_notifications, error.localizedDescription)
}
let kind = nostr_event.known_kind
// these aren't supported yet
if !(kind == .text || kind == .dm) {
contentHandler(improvedContent)
return
}
// rich communication notifications for kind1, dms, etc
let message_intent = await message_intent_from_note(ndb: state.ndb,
sender_profile: sender_profile,
content: improvedContent.body,
note: nostr_event,
our_pubkey: state.keypair.pubkey)
improvedContent.threadIdentifier = nostr_event.thread_id().hex()
improvedContent.categoryIdentifier = "COMMUNICATION"
let interaction = INInteraction(intent: message_intent, response: nil)
interaction.direction = .incoming
do {
try await interaction.donate()
let updated = try improvedContent.updating(from: message_intent)
contentHandler(updated)
} catch {
Log.error("failed to donate interaction: %s", for: .push_notifications, error.localizedDescription)
contentHandler(improvedContent)
}
} }
} }
@@ -162,162 +95,3 @@ class NotificationService: UNNotificationServiceExtension {
} }
} }
struct ProfileBuf {
let picture: URL
let name: String?
let display_name: String?
let nip05: String?
}
func message_intent_from_note(ndb: Ndb, sender_profile: ProfileBuf, content: String, note: NdbNote, our_pubkey: Pubkey) async -> INSendMessageIntent {
let sender_pk = note.pubkey
let sender = await profile_to_inperson(name: sender_profile.name,
display_name: sender_profile.display_name,
picture: sender_profile.picture.absoluteString,
nip05: sender_profile.nip05,
pubkey: sender_pk,
our_pubkey: our_pubkey)
let conversationIdentifier = note.thread_id().hex()
var recipients: [INPerson] = []
var pks: [Pubkey] = []
let meta = INSendMessageIntentDonationMetadata()
// gather recipients
if let recipient_note_id = note.direct_replies() {
let replying_to = ndb.lookup_note(recipient_note_id)
if let replying_to_pk = replying_to?.unsafeUnownedValue?.pubkey {
meta.isReplyToCurrentUser = replying_to_pk == our_pubkey
if replying_to_pk != sender_pk {
// we push the actual person being replied to first
pks.append(replying_to_pk)
}
}
}
let pubkeys = Array(note.referenced_pubkeys)
meta.recipientCount = pubkeys.count
if pubkeys.contains(sender_pk) {
meta.recipientCount -= 1
}
for pk in pubkeys.prefix(3) {
if pk == sender_pk || pks.contains(pk) {
continue
}
if !meta.isReplyToCurrentUser && pk == our_pubkey {
meta.mentionsCurrentUser = true
}
pks.append(pk)
}
for pk in pks {
let recipient = await pubkey_to_inperson(ndb: ndb, pubkey: pk, our_pubkey: our_pubkey)
recipients.append(recipient)
}
// we enable default formatting this way
var groupName = INSpeakableString(spokenPhrase: "")
// otherwise we just say its a DM
if note.known_kind == .dm {
groupName = INSpeakableString(spokenPhrase: "DM")
}
let intent = INSendMessageIntent(recipients: recipients,
outgoingMessageType: .outgoingMessageText,
content: content,
speakableGroupName: groupName,
conversationIdentifier: conversationIdentifier,
serviceName: "kind\(note.kind)",
sender: sender,
attachments: nil)
intent.donationMetadata = meta
// this is needed for recipients > 0
if let img = sender.image {
intent.setImage(img, forParameterNamed: \.speakableGroupName)
}
return intent
}
func pubkey_to_inperson(ndb: Ndb, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
let profile_txn = ndb.lookup_profile(pubkey)
let profile = profile_txn?.unsafeUnownedValue?.profile
let name = profile?.name
let display_name = profile?.display_name
let nip05 = profile?.nip05
let picture = profile?.picture
return await profile_to_inperson(name: name,
display_name: display_name,
picture: picture,
nip05: nip05,
pubkey: pubkey,
our_pubkey: our_pubkey)
}
func fetch_pfp(picture: URL) async throws -> RetrieveImageResult {
try await withCheckedThrowingContinuation { continuation in
KingfisherManager.shared.retrieveImage(with: Kingfisher.ImageResource(downloadURL: picture)) { result in
switch result {
case .success(let img):
continuation.resume(returning: img)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
func profile_to_inperson(name: String?, display_name: String?, picture: String?, nip05: String?, pubkey: Pubkey, our_pubkey: Pubkey) async -> INPerson {
let npub = pubkey.npub
let handle = INPersonHandle(value: npub, type: .unknown)
var aliases: [INPersonHandle] = []
if let nip05 {
aliases.append(INPersonHandle(value: nip05, type: .emailAddress))
}
let nostrName = DisplayName(name: name, display_name: display_name, pubkey: pubkey)
let nameComponents = nostrName.nameComponents()
let displayName = nostrName.displayName
let contactIdentifier = npub
let customIdentifier = npub
let suggestionType = INPersonSuggestionType.socialProfile
var image: INImage? = nil
if let picture,
let url = URL(string: picture),
let img = try? await fetch_pfp(picture: url),
let imgdata = img.data()
{
image = INImage(imageData: imgdata)
} else {
Log.error("Failed to fetch pfp (%s) for %s", for: .push_notifications, picture ?? "nil", displayName)
}
let person = INPerson(personHandle: handle,
nameComponents: nameComponents,
displayName: displayName,
image: image,
contactIdentifier: contactIdentifier,
customIdentifier: customIdentifier,
isMe: pubkey == our_pubkey,
suggestionType: suggestionType
)
return person
}
func robohash(_ pk: Pubkey) -> String {
return "https://robohash.org/" + pk.hex()
}

View File

@@ -1,32 +1,3 @@
// swift-tools-version: 6.0 dependencies: [
// The swift-tools-version declares the minimum version of Swift required to build this package. .Package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
]
import PackageDescription
let package = Package(
name: "damus",
platforms: [
.iOS(.v16),
.macOS(.v12)
],
products: [
.library(
name: "damus",
targets: ["damus"]),
],
dependencies: [
.package(url: "https://github.com/jb55/secp256k1.swift.git", branch: "main")
],
targets: [
.target(
name: "damus",
dependencies: [
.product(name: "secp256k1", package: "secp256k1.swift")
],
path: "damus"),
.testTarget(
name: "damusTests",
dependencies: ["damus"],
path: "damusTests"),
]
)

View File

@@ -1,30 +1,14 @@
<div align="center"> [![Run Test Suite](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml/badge.svg?branch=master)](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
<img src="./damus/Assets.xcassets/damus-home.imageset/damus-home@2x.png" alt="Damus Logo" title="Damus logo" width=""/> # damus
# Damus
The social network you control
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS. A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
[![License: GPL-3.0](https://img.shields.io/github/license/damus-io/damus?labelColor=27303D&color=0877d2)](/LICENSE) <img src="./ss.png" width="50%" height="50%" />
## Download and Install
[![Apple](https://img.shields.io/badge/Apple-%23000000.svg?style=for-the-badge&logo=apple&logoColor=white)](https://apps.apple.com/us/app/damus/id1628663131)
## Supported Platforms
iOS 16.0+ • macOS 13.0+
<img src="./demo1.png" width="70%" height="50%" />
</div>
[nostr]: https://github.com/fiatjaf/nostr [nostr]: https://github.com/fiatjaf/nostr
## How is Damus better than X/Twitter? ## How is Damus better than twitter?
There are no toxic algorithms.\ There are no toxic algorithms.\
You can send or receive zaps (satoshis) without asking for permission.\ 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 is no central database](https://fiatjaf.com/nostr.html). Therefore, Damus is censorship resistant.\

1
TODO
View File

@@ -1 +0,0 @@
Fix q tags

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,13 @@
{ {
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc", "originHash" : "babaf4d5748afecf49bbb702530d8e9576460692f478b0a50ee43195dd4440e2",
"pins" : [ "pins" : [
{
"identity" : "codescanner",
"kind" : "remoteSourceControl",
"location" : "https://github.com/twostraws/CodeScanner.git",
"state" : {
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
}
},
{
"identity" : "cryptoswift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/krzyzanowskim/CryptoSwift.git",
"state" : {
"revision" : "e74bbbfbef939224b242ae7c342a90e60b88b5ce"
}
},
{ {
"identity" : "emojikit", "identity" : "emojikit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/EmojiKit", "location" : "https://github.com/tyiu/EmojiKit",
"state" : { "state" : {
"revision" : "47a4b1402de26be0299dcb4d667c1faaf21a7874", "revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
"version" : "0.2.0" "version" : "0.1.2"
} }
}, },
{ {
@@ -31,17 +15,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/EmojiPicker.git", "location" : "https://github.com/tyiu/EmojiPicker.git",
"state" : { "state" : {
"revision" : "3f48903721eae223238ff0af17c22d6373d33813", "revision" : "0c28b4a1a6b8840cf2580bda59517f6d0a733dc8",
"version" : "0.2.0" "version" : "0.1.1"
}
},
{
"identity" : "faviconfinder",
"kind" : "remoteSourceControl",
"location" : "https://github.com/will-lumley/FaviconFinder.git",
"state" : {
"revision" : "9279f4371f4877ca302ba3bf1015f3f58ae4a56c",
"version" : "5.1.4"
} }
}, },
{ {
@@ -58,8 +33,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher", "location" : "https://github.com/onevcat/Kingfisher",
"state" : { "state" : {
"revision" : "4c6b067f96953ee19526e49e4189403a2be21fb3", "revision" : "415b1d97fb38bda1e5a6b2dde63354720832110b",
"version" : "8.3.1" "version" : "7.6.1"
} }
}, },
{ {
@@ -114,29 +89,13 @@
"version" : "0.1.2" "version" : "0.1.2"
} }
}, },
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
"location" : "https://github.com/scinfu/SwiftSoup.git",
"state" : {
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
"version" : "2.8.7"
}
},
{
"identity" : "swiftycrop",
"kind" : "remoteSourceControl",
"location" : "https://github.com/benedom/SwiftyCrop",
"state" : {
"revision" : "454d0a0d4faf6f3a19c8d817ab9d7d27524bd79f"
}
},
{ {
"identity" : "swipeactions", "identity" : "swipeactions",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/damus-io/SwipeActions.git", "location" : "https://github.com/aheze/SwipeActions",
"state" : { "state" : {
"revision" : "33d99756c3112e1a07c1732e3cddc5ad5bd0c5f4" "revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab",
"version" : "1.1.0"
} }
} }
], ],

View File

@@ -40,7 +40,7 @@
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
<TestableReference <TestableReference
skipped = "NO"> skipped = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEFC27F7A08200C66700" BlueprintIdentifier = "4CE6DEFC27F7A08200C66700"

View File

@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -1,23 +0,0 @@
{
"images" : [
{
"filename" : "alby.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "alby.svg",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "alby.svg",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -1,12 +0,0 @@
{
"images" : [
{
"filename" : "coinos.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "alby-go.png", "filename" : "profile-banner.jpeg",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,23 @@
{
"images": [
{
"filename": "alby.svg",
"idiom": "universal",
"scale": "1x"
},
{
"idiom": "universal",
"scale": "2x",
"filename": "alby.svg"
},
{
"idiom": "universal",
"scale": "3x",
"filename": "alby.svg"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 300 KiB

View File

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "blink.png", "filename" : "bbw.jpg",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

View File

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 215 KiB

View File

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 511 KiB

After

Width:  |  Height:  |  Size: 511 KiB

View File

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View File

Before

Width:  |  Height:  |  Size: 216 B

After

Width:  |  Height:  |  Size: 216 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 547 KiB

After

Width:  |  Height:  |  Size: 547 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 78 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -46,6 +46,7 @@ struct CustomPicker<SelectionValue: Hashable>: View {
.accentColor(tag == selection ? textColor() : .gray) .accentColor(tag == selection ? textColor() : .gray)
} }
} }
.background(Color(UIColor.systemBackground))
} }
func textColor() -> Color { func textColor() -> Color {

View File

@@ -20,7 +20,6 @@ struct DamusBackground: View {
.resizable() .resizable()
.frame(maxWidth: .infinity, maxHeight: maxHeight, alignment: .center) .frame(maxWidth: .infinity, maxHeight: maxHeight, alignment: .center)
.ignoresSafeArea() .ignoresSafeArea()
.accessibilityHidden(true)
} }
} }

View File

@@ -0,0 +1,15 @@
//
// 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)

View File

@@ -7,7 +7,6 @@
import SwiftUI import SwiftUI
import Kingfisher import Kingfisher
import Combine
// TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16 // TODO: all this ShareSheet complexity can be replaced with ShareLink once we update to iOS 16
struct ShareSheet: UIViewControllerRepresentable { struct ShareSheet: UIViewControllerRepresentable {
@@ -96,238 +95,64 @@ enum ImageShape {
} }
} }
/// The `CarouselModel` helps `ImageCarousel` with some state management logic, keeping track of media sizes, and the ideal display size
///
/// This model is necessary because the state management logic required to keep track of media sizes for each one of the carousel items,
/// and the ideal display size at each moment is not a trivial task.
///
/// The rules for the media fill are as follows:
/// 1. The media item should generally have a width that completely fills the width of its parent view
/// 2. The height of the carousel should be adjusted accordingly
/// 3. The only exception to rules 1 and 2 is when the total height would be 20% larger than the height of the device
/// 4. If none of the above can be computed (e.g. due to missing information), default to a reasonable height, where the media item will fit into.
///
/// ## Usage notes
///
/// The view is has the following state management responsibilities:
/// 1. Watching the size of the images (via the `.observe_image_size` modifier)
/// 2. Notifying this class of geometry reader changes, by setting `geo_size`
///
/// ## Implementation notes
///
/// This class is organized in a way to reduce stateful behavior and the transiency bugs it can cause.
///
/// This is accomplished through the following pattern:
/// 1. The `current_item_fill` is a published property so that any updates instantly re-render the view
/// 2. However, `current_item_fill` has a mathematical dependency on other members of this class
/// 3. Therefore, the members on which the fill property depends on all have `didSet` observers that will cause the `current_item_fill` to be recalculated and published.
///
/// This pattern helps ensure that the state is always consistent and that the view is always up-to-date.
///
/// This class is marked as `@MainActor` since most of its properties are published and should be accessed from the main thread to avoid inconsistent SwiftUI state during renders
@MainActor
class CarouselModel: ObservableObject { class CarouselModel: ObservableObject {
// MARK: Immutable object attributes var current_url: URL?
// These are some attributes that are not expected to change throughout the lifecycle of this object var fillHeight: CGFloat
// These should not be modified after initialization to avoid state inconsistency var maxHeight: CGFloat
var firstImageHeight: CGFloat?
/// The state of the app
let damus_state: DamusState
/// All urls in the carousel
let urls: [MediaUrl]
/// The default fill height for the carousel, if we cannot calculate a more appropriate height
/// **Usage note:** Default to this when `current_item_fill` is nil
let default_fill_height: CGFloat
/// The maximum height for any carousel item
let max_height: CGFloat
// MARK: Miscellaneous
/// Holds items that allows us to cancel video size observers during de-initialization
private var all_cancellables: [AnyCancellable] = []
// MARK: State management properties
/// Properties relevant to state management.
/// These should be made into computed/functional properties when possible to avoid stateful behavior
/// When that is not possible (e.g. when dealing with an observed published property), establish its mathematical dependencies,
/// and use `didSet` observers to ensure that the state is always re-computed when necessary.
/// Stores information about the size of each media item in `urls`. @Published var open_sheet: Bool
/// **Usage note:** The view is responsible for setting the size of image urls @Published var selectedIndex: Int
var media_size_information: [URL: CGSize] { @Published var video_size: CGSize?
didSet { @Published var image_fill: ImageFill?
guard let current_url else { return }
// Upon updating information, update the carousel fill size if the size for the current url has changed
if oldValue[current_url] != media_size_information[current_url] {
self.refresh_current_item_fill()
self.refresh_first_item_height()
}
}
}
/// Stores information about the geometry reader
/// **Usage note:** The view is responsible for setting this value
var geo_size: CGSize? {
didSet { self.refresh_current_item_fill() }
}
/// The index of the currently selected item
/// **Usage note:** The view is responsible for setting this value
@Published var selectedIndex: Int {
didSet { self.refresh_current_item_fill() }
}
/// The current fill for the media item.
/// **Usage note:** This property is read-only and should not be set directly. Update `selectedIndex` to update the current item being viewed.
var current_url: URL? {
return urls[safe: selectedIndex]?.url
}
/// Holds the ideal fill dimensions for the current item.
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly
/// **Implementation note:** This property is mathematically dependent on geo_size, media_size_information, and `selectedIndex`,
/// and is automatically updated upon changes to these properties.
@Published private(set) var current_item_fill: ImageFill?
/// Holds the ideal fill dimensions for the first item in the carousel.
/// This is used to maintain a consistent height for the carousel when swiping between images.
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly.
/// **Implementation note:** This property ensures the carousel maintains a consistent height based on the first image,
/// preventing the UI from "jumping" when swiping between images of different aspect ratios.
@Published private(set) var first_image_fill: ImageFill?
// MARK: Initialization and de-initialization
/// Initializes the `CarouselModel` with the given `DamusState` and `MediaUrl` array init(image_fill: ImageFill?) {
init(damus_state: DamusState, urls: [MediaUrl]) { self.current_url = nil
// Immutable object attributes self.fillHeight = 350
self.damus_state = damus_state self.maxHeight = UIScreen.main.bounds.height * 1.2 // 1.2
self.urls = urls self.firstImageHeight = nil
self.default_fill_height = 350 self.open_sheet = false
self.max_height = UIScreen.main.bounds.height * 1.2 // 1.2
// State management properties
self.selectedIndex = 0 self.selectedIndex = 0
self.current_item_fill = nil self.video_size = nil
self.geo_size = nil self.image_fill = image_fill
self.media_size_information = [:]
// Setup the rest of the state management logic
self.observe_video_sizes()
Task {
self.refresh_current_item_fill()
self.refresh_first_item_height()
}
}
/// This private function observes the video sizes for all videos
private func observe_video_sizes() {
for media_url in urls {
switch media_url {
case .video(let url):
let video_player = damus_state.video.get_player(for: url)
if let video_size = video_player.video_size {
self.media_size_information[url] = video_size // Set the initial size if available
}
let observer_cancellable = video_player.$video_size.sink(receiveValue: { new_size in
self.media_size_information[url] = new_size // Update the size when it changes
})
all_cancellables.append(observer_cancellable) // Store the cancellable to cancel it later
case .image(_):
break; // Observing an image size needs to be done on the view directly, through the `.observe_image_size` modifier
}
}
}
deinit {
for cancellable_item in all_cancellables {
cancellable_item.cancel()
}
}
// MARK: State management and logic
/// This function refreshes the current item fill based on the current state of the model
/// **Usage note:** This is private, do not call this directly from outside the class.
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
private func refresh_current_item_fill() {
self.current_item_fill = self.compute_item_fill(url: current_url)
}
/// Computes the image fill properties for a given URL without side effects.
/// This is a pure function that calculates the appropriate fill dimensions based on image size and container constraints.
/// **Usage note:** This is a helper method used by both `refresh_current_item_fill` and `refresh_first_item_height`.
private func compute_item_fill(url: URL?) -> ImageFill? {
if let url,
let item_size = self.media_size_information[url],
let geo_size {
return ImageFill.calculate_image_fill(
geo_size: geo_size,
img_size: item_size,
maxHeight: self.max_height,
fillHeight: self.default_fill_height
)
}
else {
return nil // Not enough information to compute the proper fill. Default to nil
}
}
/// This function refreshes the first item height based on the current state of the model
/// **Usage note:** This is private, do not call this directly from outside the class.
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the height.
/// When the first image dimensions change, this ensures the carousel maintains consistent dimensions.
private func refresh_first_item_height() {
self.first_image_fill = self.compute_first_item_fill()
}
/// Computes the first item fill with no side-effects.
/// **Usage note:** Not to be used outside the class. Use the `first_image_fill` property instead.
/// **Implementation note:** This retrieves the first URL from the carousel and computes its fill properties
/// to establish a consistent height for the entire carousel.
private func compute_first_item_fill() -> ImageFill? {
guard let first_url = urls[safe: 0] else { return nil }
return self.compute_item_fill(url: first_url.url)
} }
} }
// MARK: - Image Carousel // MARK: - Image Carousel
/// A carousel that displays images and videos
///
/// ## Implementation notes
///
/// - State management logic is mostly handled by `CarouselModel`, as it is complex, and becomes difficult to manage in a view
///
@MainActor @MainActor
struct ImageCarousel<Content: View>: View { struct ImageCarousel<Content: View>: View {
/// The event id of the note that this carousel is displaying var urls: [MediaUrl]
let evid: NoteId let evid: NoteId
/// The model that holds information and state of this carousel
/// This is observed to update the view when the model changes let state: DamusState
@ObservedObject var model: CarouselModel @ObservedObject var model: CarouselModel
let content: ((_ dismiss: @escaping (() -> Void)) -> Content)? let content: ((_ dismiss: @escaping (() -> Void)) -> Content)?
init(state: DamusState, evid: NoteId, urls: [MediaUrl]) { init(state: DamusState, evid: NoteId, urls: [MediaUrl]) {
self.urls = urls
self.evid = evid self.evid = evid
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls)) 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 self.content = nil
} }
init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) { init(state: DamusState, evid: NoteId, urls: [MediaUrl], @ViewBuilder content: @escaping (_ dismiss: @escaping (() -> Void)) -> Content) {
self.urls = urls
self.evid = evid self.evid = evid
self._model = ObservedObject(initialValue: CarouselModel(damus_state: state, urls: urls)) 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 self.content = content
} }
/// Determines if the image should fill its container. var filling: Bool {
/// Always returns true to ensure images consistently fill the width of the container. model.image_fill?.filling == true
/// This simplifies the layout behavior and prevents inconsistent sizing between carousel items. }
var filling: Bool { true }
var height: CGFloat { var height: CGFloat {
// Use the first image height (to prevent height from jumping when swiping), then default to the default fill height model.firstImageHeight ?? model.image_fill?.height ?? model.fillHeight
// This prioritization ensures consistent carousel height regardless of which image is currently displayed
model.first_image_fill?.height ?? model.default_fill_height
} }
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View { func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
@@ -335,7 +160,7 @@ struct ImageCarousel<Content: View>: View {
if num_urls > 1 { if num_urls > 1 {
// jb55: quick hack since carousel with multiple images looks horrible with blurhash background // jb55: quick hack since carousel with multiple images looks horrible with blurhash background
Color.clear Color.clear
} else if let meta = model.damus_state.events.lookup_img_metadata(url: url), } else if let meta = state.events.lookup_img_metadata(url: url),
case .processed(let blurhash) = meta.state { case .processed(let blurhash) = meta.state {
Image(uiImage: blurhash) Image(uiImage: blurhash)
.resizable() .resizable()
@@ -344,6 +169,12 @@ struct ImageCarousel<Content: View>: View {
Color.clear Color.clear
} }
} }
.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
}
}
} }
func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View { func Media(geo: GeometryProxy, url: MediaUrl, index: Int) -> some View {
@@ -352,17 +183,24 @@ struct ImageCarousel<Content: View>: View {
case .image(let url): case .image(let url):
Img(geo: geo, url: url, index: index) Img(geo: geo, url: url, index: index)
.onTapGesture { .onTapGesture {
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex)) model.open_sheet = true
} }
case .video(let url): case .video(let url):
let video_model = model.damus_state.video.get_player(for: url) DamusVideoPlayer(url: url, video_size: $model.video_size, controller: state.video, style: .preview(on_tap: { model.open_sheet = true }))
DamusVideoPlayerView( .onChange(of: model.video_size) { size in
model: video_model, guard let size else { return }
coordinator: model.damus_state.video,
style: .preview(on_tap: { let fill = ImageFill.calculate_image_fill(geo_size: geo.size, img_size: size, maxHeight: model.maxHeight, fillHeight: model.fillHeight)
present(full_screen_item: .full_screen_carousel(urls: model.urls, selectedIndex: $model.selectedIndex))
}) print("video_size changed \(size)")
) if self.model.image_fill == nil {
print("video_size firstImageHeight \(fill.height)")
self.model.firstImageHeight = fill.height
state.events.get_cache_data(evid).media_metadata_model.fill = fill
}
self.model.image_fill = fill
}
} }
} }
} }
@@ -371,18 +209,31 @@ struct ImageCarousel<Content: View>: View {
KFAnimatedImage(url) KFAnimatedImage(url)
.callbackQueue(.dispatch(.global(qos:.background))) .callbackQueue(.dispatch(.global(qos:.background)))
.backgroundDecode(true) .backgroundDecode(true)
.imageContext(.note, disable_animation: model.damus_state.settings.disable_animation) .imageContext(.note, disable_animation: state.settings.disable_animation)
.image_fade(duration: 0.25) .image_fade(duration: 0.25)
.cancelOnDisappear(true) .cancelOnDisappear(true)
.configure { view in .configure { view in
view.framePreloadCount = 3 view.framePreloadCount = 3
} }
.observe_image_size(size_changed: { size in .imageFill(for: geo.size, max: model.maxHeight, fill: model.fillHeight) { fill in
// Observe the image size to update the model when the size changes, so we can calculate the fill state.events.get_cache_data(evid).media_metadata_model.fill = fill
model.media_size_information[url] = size // blur hash can be discarded when we have the url
}) // NOTE: this is the wrong place for this... we need to remove
// it when the image is loaded in memory. This may happen
// earlier than this (by the preloader, etc)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
state.events.lookup_img_metadata(url: url)?.state = .not_needed
}
self.model.image_fill = fill
if index == 0 {
self.model.firstImageHeight = fill.height
//maxHeight = firstImageHeight ?? maxHeight
} else {
//maxHeight = firstImageHeight ?? fill.height
}
}
.background { .background {
Placeholder(url: url, geo_size: geo.size, num_urls: model.urls.count) Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
} }
.aspectRatio(contentMode: filling ? .fill : .fit) .aspectRatio(contentMode: filling ? .fill : .fit)
.kfClickable() .kfClickable()
@@ -397,21 +248,26 @@ struct ImageCarousel<Content: View>: View {
var Medias: some View { var Medias: some View {
TabView(selection: $model.selectedIndex) { TabView(selection: $model.selectedIndex) {
ForEach(model.urls.indices, id: \.self) { index in ForEach(urls.indices, id: \.self) { index in
GeometryReader { geo in GeometryReader { geo in
Media(geo: geo, url: model.urls[index], index: index) Media(geo: geo, url: urls[index], index: index)
.onChange(of: geo.size, perform: { new_size in
model.geo_size = new_size
})
.onAppear {
model.geo_size = geo.size
}
} }
} }
} }
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.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)
}
}
.frame(height: height) .frame(height: height)
.clipped() // Prevents content from overflowing the frame, ensuring clean edges in the carousel
.onChange(of: model.selectedIndex) { value in .onChange(of: model.selectedIndex) { value in
model.selectedIndex = value model.selectedIndex = value
} }
@@ -428,8 +284,8 @@ struct ImageCarousel<Content: View>: View {
} }
if model.urls.count > 1 { if urls.count > 1 {
PageControlView(currentPage: $model.selectedIndex, numberOfPages: model.urls.count) PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
.frame(maxWidth: 0, maxHeight: 0) .frame(maxWidth: 0, maxHeight: 0)
.padding(.top, 5) .padding(.top, 5)
} }
@@ -437,6 +293,27 @@ struct ImageCarousel<Content: View>: View {
} }
} }
// MARK: - Image Modifier
extension KFOptionSetter {
/// Sets a block to get image size
///
/// - Parameter block: The block which is used to read the image object.
/// - Returns: `Self` value after read size
public func imageFill(for size: CGSize, max: CGFloat, fill: CGFloat, block: @escaping (ImageFill) throws -> Void) -> Self {
let modifier = AnyImageModifier { image -> KFCrossPlatformImage in
let img_size = image.size
let geo_size = size
let fill = ImageFill.calculate_image_fill(geo_size: geo_size, img_size: img_size, maxHeight: max, fillHeight: fill)
DispatchQueue.main.async { [block, fill] in
try? block(fill)
}
return image
}
options.imageModifier = modifier
return self
}
}
public struct ImageFill { public struct ImageFill {
let filling: Bool? let filling: Bool?
@@ -473,3 +350,4 @@ struct ImageCarousel_Previews: PreviewProvider {
.environmentObject(OrientationTracker()) .environmentObject(OrientationTracker())
} }
} }

View File

@@ -94,30 +94,26 @@ enum OpenWalletError: Error {
} }
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws { func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
let url = try getUrlToOpen(invoice: invoice, with: wallet)
this_app.open(url)
}
func getUrlToOpen(invoice: String, with wallet: Wallet.Model) throws(OpenWalletError) -> URL {
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) { if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
return url this_app.open(url)
} else { } else {
guard let store_link = wallet.appStoreLink else { guard let store_link = wallet.appStoreLink else {
throw .no_wallet_to_open throw OpenWalletError.no_wallet_to_open
} }
guard let url = URL(string: store_link) else { guard let url = URL(string: store_link) else {
throw .store_link_invalid throw OpenWalletError.store_link_invalid
} }
guard this_app.canOpenURL(url) else { guard this_app.canOpenURL(url) else {
throw .system_cannot_open_store_link throw OpenWalletError.system_cannot_open_store_link
} }
return url this_app.open(url)
} }
} }
let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119) let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
struct InvoiceView_Previews: PreviewProvider { struct InvoiceView_Previews: PreviewProvider {

View File

@@ -5,27 +5,27 @@
// Created by William Casarin on 2023-01-11. // Created by William Casarin on 2023-01-11.
// //
import FaviconFinder
import Kingfisher
import SwiftUI import SwiftUI
struct NIP05Badge: View { struct NIP05Badge: View {
let nip05: NIP05 let nip05: NIP05
let pubkey: Pubkey let pubkey: Pubkey
let damus_state: DamusState let contacts: Contacts
let show_domain: Bool let show_domain: Bool
let nip05_domain_favicon: FaviconURL? let profiles: Profiles
init(nip05: NIP05, pubkey: Pubkey, damus_state: DamusState, show_domain: Bool, nip05_domain_favicon: FaviconURL?) { @Environment(\.openURL) var openURL
init(nip05: NIP05, pubkey: Pubkey, contacts: Contacts, show_domain: Bool, profiles: Profiles) {
self.nip05 = nip05 self.nip05 = nip05
self.pubkey = pubkey self.pubkey = pubkey
self.damus_state = damus_state self.contacts = contacts
self.show_domain = show_domain self.show_domain = show_domain
self.nip05_domain_favicon = nip05_domain_favicon self.profiles = profiles
} }
var nip05_color: Bool { var nip05_color: Bool {
return use_nip05_color(pubkey: pubkey, contacts: damus_state.contacts) return use_nip05_color(pubkey: pubkey, contacts: contacts)
} }
var Seal: some View { var Seal: some View {
@@ -44,23 +44,8 @@ struct NIP05Badge: View {
} }
} }
var domainBadge: some View {
Group {
if let nip05_domain_favicon {
KFImage(nip05_domain_favicon.source)
.imageContext(.favicon, disable_animation: true)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18, height: 18)
.clipped()
} else {
EmptyView()
}
}
}
var username_matches_nip05: Bool { var username_matches_nip05: Bool {
guard let name = damus_state.profiles.lookup(id: pubkey)?.map({ p in p?.name }).value guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
else { else {
return false return false
} }
@@ -80,18 +65,14 @@ struct NIP05Badge: View {
HStack(spacing: 2) { HStack(spacing: 2) {
Seal Seal
Group { if show_domain {
if show_domain { Text(nip05_string)
Text(nip05_string) .nip05_colorized(gradient: nip05_color)
.nip05_colorized(gradient: nip05_color) .onTapGesture {
} if let nip5url = nip05.siteUrl {
openURL(nip5url)
if nip05_domain_favicon != nil { }
domainBadge }
}
}
.onTapGesture {
damus_state.nav.push(route: Route.NIP05DomainEvents(events: NIP05DomainEventsModel(state: damus_state, domain: nip05.host), nip05_domain_favicon: nip05_domain_favicon))
} }
} }
@@ -117,9 +98,13 @@ struct NIP05Badge_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let test_state = test_damus_state let test_state = test_damus_state
VStack { VStack {
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil) NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil) NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: Contacts(our_pubkey: test_pubkey), show_domain: true, profiles: test_state.profiles)
} }
} }
} }

View File

@@ -84,7 +84,7 @@ struct NoteZapButton: View {
print("cancel_zap: we already have a real zap, can't cancel") print("cancel_zap: we already have a real zap, can't cancel")
break break
case .pending(let pzap): case .pending(let pzap):
guard let res = cancel_zap(zap: pzap, box: damus_state.nostrNetwork.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else { guard let res = cancel_zap(zap: pzap, box: damus_state.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
UIImpactFeedbackGenerator(style: .soft).impactOccurred() UIImpactFeedbackGenerator(style: .soft).impactOccurred()
return return
@@ -179,7 +179,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
} }
// Only take the first 10 because reasons // Only take the first 10 because reasons
let relays = Array(damus_state.nostrNetwork.pool.our_descriptors.prefix(10)) let relays = Array(damus_state.pool.our_descriptors.prefix(10))
let content = comment ?? "" let content = comment ?? ""
guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else { guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else {
@@ -232,7 +232,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
flusher = .once({ pe in flusher = .once({ pe in
// send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation // send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation
Task { @MainActor in Task { @MainActor in
await WalletConnect.send_donation_zap(pool: damus_state.nostrNetwork.pool, postbox: damus_state.nostrNetwork.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat) await send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
} }
}) })
} }
@@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
// we don't have a delay on one-tap nozaps (since this will be from customize zap view) // we don't have a delay on one-tap nozaps (since this will be from customize zap view)
let delay = damus_state.settings.nozaps ? nil : 5.0 let delay = damus_state.settings.nozaps ? nil : 5.0
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, zap_request: zapreq, delay: delay, on_flush: flusher) let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher)
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else { guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)") print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")

View File

@@ -10,68 +10,22 @@ import SwiftUI
struct Reposted: View { struct Reposted: View {
let damus: DamusState let damus: DamusState
let pubkey: Pubkey let pubkey: Pubkey
let target: NostrEvent
@State var reposts: Int
init(damus: DamusState, pubkey: Pubkey, target: NostrEvent) {
self.damus = damus
self.pubkey = pubkey
self.target = target
self.reposts = damus.boosts.counts[target.id] ?? 1
}
var body: some View { var body: some View {
HStack(alignment: .center) { HStack(alignment: .center) {
Image("repost") Image("repost")
.foregroundColor(Color.gray) .foregroundColor(Color.gray)
ProfileName(pubkey: pubkey, damus: damus, show_nip5_domain: false)
// Show profile picture of the reposter only if the reposter is not the author of the reposted note. .foregroundColor(Color.gray)
if pubkey != target.pubkey { Text("Reposted", comment: "Text indicating that the note was reposted (i.e. re-shared).")
ProfilePicView(pubkey: pubkey, size: eventview_pfp_size(.small), highlight: .none, profiles: damus.profiles, disable_animation: damus.settings.disable_animation) .foregroundColor(Color.gray)
.onTapGesture {
show_profile_action_sheet_if_enabled(damus_state: damus, pubkey: pubkey)
}
.onLongPressGesture(minimumDuration: 0.1) {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
damus.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
}
}
NavigationLink(value: Route.Reposts(reposts: .reposts(state: damus, target: target.id))) {
Text(people_reposted_text(profiles: damus.profiles, pubkey: pubkey, reposts: reposts))
.font(.subheadline)
.foregroundColor(.gray)
}
} }
.onReceive(handle_notify(.update_stats), perform: { note_id in
guard note_id == target.id else { return }
let repost_count = damus.boosts.counts[target.id]
if let repost_count, reposts != repost_count {
reposts = repost_count
}
})
}
}
func people_reposted_text(profiles: Profiles, pubkey: Pubkey, reposts: Int, locale: Locale = Locale.current) -> String {
guard reposts > 0 else {
return ""
}
let bundle = bundleForLocale(locale: locale)
let other_reposts = reposts - 1
let display_name = event_author_name(profiles: profiles, pubkey: pubkey)
if other_reposts == 0 {
return String(format: NSLocalizedString("%@ reposted", bundle: bundle, comment: "Text indicating that the note was reposted (i.e. re-shared)."), locale: locale, display_name)
} else {
return String(format: localizedStringFormat(key: "people_reposted_count", locale: locale), locale: locale, other_reposts, display_name)
} }
} }
struct Reposted_Previews: PreviewProvider { struct Reposted_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let test_state = test_damus_state let test_state = test_damus_state
Reposted(damus: test_state, pubkey: test_state.pubkey, target: test_note) Reposted(damus: test_state, pubkey: test_state.pubkey)
} }
} }

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