Compare commits

..

6 Commits

Author SHA1 Message Date
8ed2395865 Export strings for translation
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:16 -05:00
274d1035e0 Fix suggested users category titles to be localizable
Changelog-Fixed: Fixed suggested users category titles to be localizable

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:16 -05:00
b41205729e Fix GradientFollowButton to have consistent width and autoscale text limited to 1 line
Changelog-Fixed: Fixed GradientFollowButton to have consistent width and autoscale text limited to 1 line

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:16 -05:00
2d7b77a7e0 Fix right-to-left localization issues
Changelog-Fixed: Fixed right-to-left localization issues

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:16 -05:00
0ed2b4edec Fix AddMuteItemView to trim leading and trailing whitespaces from mute text and disallow adding text with only whitespaces
Changelog-Fixed: Fixed AddMuteItemView to trim leading and trailing whitespaces from mute text and disallow adding text with only whitespaces

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-10 21:06:08 -05:00
9a5fabfee5 Fix SideMenuView text to autoscale and limit to 1 line
Changelog-Fixed: Fixed SideMenuView text to autoscale and limit to 1 line

Signed-off-by: Terry Yiu <git@tyiu.xyz>
2024-12-09 18:36:43 -05:00
360 changed files with 3564 additions and 19571 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

@@ -6,7 +6,6 @@ _[Please provide a summary of the changes in this PR.]_
- [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md) - [ ] I have read (or I am familiar with) the [Contribution Guidelines](../docs/CONTRIBUTING.md)
- [ ] I have tested the changes in this PR - [ ] 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 - [ ] 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 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 have added appropriate changelog entries for the changes in this PR. See [Adding changelog entries](../docs/CONTRIBUTING.md#add-changelog-changed-changelog-fixed-etc)

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,163 +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 ## [v1.11(10)](https://github.com/damus-io/damus/releases/tag/v1.11-10) - 2024-11-18
### 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,5 +1,5 @@
{ {
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc", "originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
"pins" : [ "pins" : [
{ {
"identity" : "codescanner", "identity" : "codescanner",
@@ -9,21 +9,13 @@
"revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c" "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 +23,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 +41,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,23 +97,6 @@
"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",

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "coinos.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

View File

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

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

@@ -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

@@ -162,7 +162,6 @@ class CarouselModel: ObservableObject {
// Upon updating information, update the carousel fill size if the size for the current url has changed // 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] { if oldValue[current_url] != media_size_information[current_url] {
self.refresh_current_item_fill() self.refresh_current_item_fill()
self.refresh_first_item_height()
} }
} }
} }
@@ -187,13 +186,6 @@ class CarouselModel: ObservableObject {
/// and is automatically updated upon changes to these properties. /// and is automatically updated upon changes to these properties.
@Published private(set) var current_item_fill: ImageFill? @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 // MARK: Initialization and de-initialization
@@ -215,7 +207,6 @@ class CarouselModel: ObservableObject {
self.observe_video_sizes() self.observe_video_sizes()
Task { Task {
self.refresh_current_item_fill() self.refresh_current_item_fill()
self.refresh_first_item_height()
} }
} }
@@ -250,17 +241,10 @@ class CarouselModel: ObservableObject {
/// **Usage note:** This is private, do not call this directly from outside the class. /// **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 /// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
private func refresh_current_item_fill() { private func refresh_current_item_fill() {
self.current_item_fill = self.compute_item_fill(url: current_url) if let current_url,
} let item_size = self.media_size_information[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 { let geo_size {
return ImageFill.calculate_image_fill( self.current_item_fill = ImageFill.calculate_image_fill(
geo_size: geo_size, geo_size: geo_size,
img_size: item_size, img_size: item_size,
maxHeight: self.max_height, maxHeight: self.max_height,
@@ -268,26 +252,9 @@ class CarouselModel: ObservableObject {
) )
} }
else { else {
return nil // Not enough information to compute the proper fill. Default to nil self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
} }
} }
/// This function refreshes the first item height based on the current state of the model
/// **Usage note:** This is private, do not call this directly from outside the class.
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the height.
/// When the first image dimensions change, this ensures the carousel maintains consistent dimensions.
private func refresh_first_item_height() {
self.first_image_fill = self.compute_first_item_fill()
}
/// Computes the first item fill with no side-effects.
/// **Usage note:** Not to be used outside the class. Use the `first_image_fill` property instead.
/// **Implementation note:** This retrieves the first URL from the carousel and computes its fill properties
/// to establish a consistent height for the entire carousel.
private func compute_first_item_fill() -> ImageFill? {
guard let first_url = urls[safe: 0] else { return nil }
return self.compute_item_fill(url: first_url.url)
}
} }
// MARK: - Image Carousel // MARK: - Image Carousel
@@ -319,15 +286,13 @@ struct ImageCarousel<Content: View>: View {
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.current_item_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 // Use the calculated fill height if available, otherwise use the default fill height
// This prioritization ensures consistent carousel height regardless of which image is currently displayed model.current_item_fill?.height ?? model.default_fill_height
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 {
@@ -411,7 +376,6 @@ struct ImageCarousel<Content: View>: View {
} }
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.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
} }

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)
} }
} }

View File

@@ -63,7 +63,7 @@ struct SelectableText: View {
})) { })) {
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState { if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
PostView( PostView(
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event.id))), action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
damus_state: damus_state damus_state: damus_state
) )
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
@@ -94,12 +94,12 @@ struct SelectableText: View {
case show_mute_word_view(highlighted_text: String) case show_mute_word_view(highlighted_text: String)
func should_show_highlight_post_view() -> Bool { func should_show_highlight_post_view() -> Bool {
guard case .show_highlight_post_view = self else { return false } guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
return true return true
} }
func should_show_mute_word_view() -> Bool { func should_show_mute_word_view() -> Bool {
guard case .show_mute_word_view = self else { return false } guard case .show_mute_word_view(let highlighted_text) = self else { return false }
return true return true
} }
@@ -119,23 +119,16 @@ struct SelectableText: View {
fileprivate class TextView: UITextView { fileprivate class TextView: UITextView {
var postHighlight: (String) -> Void var postHighlight: (String) -> Void
var muteWord: (String) -> Void var muteWord: (String) -> Void
private let enableHighlighting: Bool
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void, enableHighlighting: Bool) { init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
self.postHighlight = postHighlight self.postHighlight = postHighlight
self.muteWord = muteWord self.muteWord = muteWord
self.enableHighlighting = enableHighlighting
super.init(frame: frame, textContainer: textContainer) super.init(frame: frame, textContainer: textContainer)
if enableHighlighting {
self.delegate = self
}
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(highlightText(_:)) { if action == #selector(highlightText(_:)) {
@@ -149,44 +142,23 @@ fileprivate class TextView: UITextView {
return super.canPerformAction(action, withSender: sender) return super.canPerformAction(action, withSender: sender)
} }
private func getSelectedText() -> String? { func getSelectedText() -> String? {
guard let selectedRange = self.selectedTextRange else { return nil } guard let selectedRange = self.selectedTextRange else { return nil }
return self.text(in: selectedRange) return self.text(in: selectedRange)
} }
@objc private func highlightText(_ sender: Any?) { @objc public func highlightText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return } guard let selectedText = self.getSelectedText() else { return }
self.postHighlight(selectedText) self.postHighlight(selectedText)
} }
@objc private func muteText(_ sender: Any?) { @objc public func muteText(_ sender: Any?) {
guard let selectedText = self.getSelectedText() else { return } guard let selectedText = self.getSelectedText() else { return }
self.muteWord(selectedText) self.muteWord(selectedText)
} }
} }
extension TextView: UITextViewDelegate {
func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? {
guard enableHighlighting,
let selectedTextRange = self.selectedTextRange,
let selectedText = self.text(in: selectedTextRange),
!selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let highlightAction = UIAction(title: NSLocalizedString("Highlight", comment: "Context menu action to highlight the selected text as context to draft a new note."), image: UIImage(systemName: "highlighter")) { [weak self] _ in
self?.postHighlight(selectedText)
}
let muteAction = UIAction(title: NSLocalizedString("Mute", comment: "Context menu action to mute the selected word."), image: UIImage(systemName: "speaker.slash")) { [weak self] _ in
self?.muteWord(selectedText)
}
return UIMenu(children: suggestedActions + [highlightAction, muteAction])
}
}
fileprivate struct TextViewRepresentable: UIViewRepresentable { fileprivate struct TextViewRepresentable: UIViewRepresentable {
let attributedString: AttributedString let attributedString: AttributedString
@@ -200,7 +172,7 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
@Binding var height: CGFloat @Binding var height: CGFloat
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView { func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord, enableHighlighting: enableHighlighting) let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
view.isEditable = false view.isEditable = false
view.dataDetectorTypes = .all view.dataDetectorTypes = .all
view.isSelectable = true view.isSelectable = true
@@ -211,6 +183,11 @@ fileprivate struct TextViewRepresentable: UIViewRepresentable {
view.textContainerInset.right = 1.0 view.textContainerInset.right = 1.0
view.textAlignment = textAlignment view.textAlignment = textAlignment
let menuController = UIMenuController.shared
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
return view return view
} }

View File

@@ -213,6 +213,6 @@ struct UserStatusSheet: View {
struct UserStatusSheet_Previews: PreviewProvider { struct UserStatusSheet_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.nostrNetwork.postbox, keypair: test_keypair, status: .init()) UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.postbox, keypair: test_keypair, status: .init())
} }
} }

View File

@@ -12,14 +12,6 @@ struct SupporterBadge: View {
let purple_account: DamusPurple.Account? let purple_account: DamusPurple.Account?
let style: Style let style: Style
let text_color: Color let text_color: Color
var badge_variant: BadgeVariant {
if purple_account?.attributes.contains(.memberForMoreThanOneYear) == true {
return .oneYearSpecial
}
else {
return .normal
}
}
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style, text_color: Color = .secondary) { init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style, text_color: Color = .secondary) {
self.percent = percent self.percent = percent
@@ -34,18 +26,13 @@ struct SupporterBadge: View {
HStack { HStack {
if let purple_account, purple_account.active == true { if let purple_account, purple_account.active == true {
HStack(spacing: 1) { HStack(spacing: 1) {
switch self.badge_variant { Image("star.fill")
case .normal: .resizable()
StarShape() .frame(width:size, height:size)
.frame(width:size, height:size) .foregroundStyle(GoldGradient)
.foregroundStyle(GoldGradient) if self.style == .full {
case .oneYearSpecial: let date = format_date(date: purple_account.created_at, time_style: .none)
DoubleStar(size: size) Text(date)
}
if self.style == .full,
let ordinal = self.purple_account?.ordinal() {
Text(ordinal)
.foregroundStyle(text_color) .foregroundStyle(text_color)
.font(.caption) .font(.caption)
} }
@@ -69,102 +56,8 @@ struct SupporterBadge: View {
case full // Shows the entire badge with a purple subscriber number if present 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) case compact // Does not show purple subscriber number. Only shows the star (if applicable)
} }
enum BadgeVariant {
/// A normal badge that people are used to
case normal
/// A special badge for users who have been members for more than a year
case oneYearSpecial
}
} }
struct StarShape: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius: CGFloat = min(rect.width, rect.height) / 2
let points = 5
let adjustment: CGFloat = .pi / 2
for i in 0..<points * 2 {
let angle = (CGFloat(i) * .pi / CGFloat(points)) - adjustment
let pointRadius = i % 2 == 0 ? radius : radius * 0.4
let point = CGPoint(x: center.x + pointRadius * cos(angle), y: center.y + pointRadius * sin(angle))
if i == 0 {
path.move(to: point)
} else {
path.addLine(to: point)
}
}
path.closeSubpath()
return path
}
}
struct DoubleStar: View {
let size: CGFloat
var starOffset: CGFloat = 5
var body: some View {
if #available(iOS 17.0, *) {
DoubleStarShape(starOffset: starOffset)
.frame(width: size, height: size)
.foregroundStyle(GoldGradient)
.padding(.trailing, starOffset)
} else {
Fallback(size: size, starOffset: starOffset)
}
}
@available(iOS 17.0, *)
struct DoubleStarShape: Shape {
var strokeSize: CGFloat = 3
var starOffset: CGFloat
func path(in rect: CGRect) -> Path {
let normalSizedStarPath = StarShape().path(in: rect)
let largerStarPath = StarShape().path(in: rect.insetBy(dx: -strokeSize, dy: -strokeSize))
let finalPath = normalSizedStarPath
.subtracting(
largerStarPath.offsetBy(dx: starOffset, dy: 0)
)
.union(
normalSizedStarPath.offsetBy(dx: starOffset, dy: 0)
)
return finalPath
}
}
/// A fallback view for those who cannot run iOS 17
struct Fallback: View {
var size: CGFloat
var starOffset: CGFloat
var body: some View {
HStack {
StarShape()
.frame(width: size, height: size)
.foregroundStyle(GoldGradient)
StarShape()
.fill(GoldGradient)
.overlay(
StarShape()
.stroke(Color.damusAdaptableWhite, lineWidth: 1)
)
.frame(width: size + 1, height: size + 1)
.padding(.leading, -size - starOffset)
}
.padding(.trailing, -3)
}
}
}
func support_level_color(_ percent: Int) -> Color { func support_level_color(_ percent: Int) -> Color {
if percent == 0 { if percent == 0 {
return .gray return .gray
@@ -193,7 +86,7 @@ struct SupporterBadge_Previews: PreviewProvider {
HStack(alignment: .center) { HStack(alignment: .center) {
SupporterBadge( SupporterBadge(
percent: nil, percent: nil,
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: subscriber_number, active: true, attributes: []), purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: subscriber_number, active: true),
style: .full style: .full
) )
.frame(width: 100) .frame(width: 100)
@@ -225,52 +118,4 @@ struct SupporterBadge_Previews: PreviewProvider {
} }
} }
#Preview("1 yr badge") {
VStack {
HStack(alignment: .center) {
SupporterBadge(
percent: nil,
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: 3, active: true, attributes: []),
style: .full
)
.frame(width: 100)
}
HStack(alignment: .center) {
SupporterBadge(
percent: nil,
purple_account: DamusPurple.Account(pubkey: test_pubkey, created_at: .now, expiry: .now.addingTimeInterval(10000), subscriber_number: 3, active: true, attributes: [.memberForMoreThanOneYear]),
style: .full
)
.frame(width: 100)
}
Text(verbatim: "Double star (just shape itself, with alt background color, to show it adapts to background well)")
.multilineTextAlignment(.center)
if #available(iOS 17.0, *) {
HStack(alignment: .center) {
DoubleStar.DoubleStarShape(starOffset: 5)
.frame(width: 17, height: 17)
.padding(.trailing, -8)
}
.background(Color.blue)
}
Text(verbatim: "Double star (fallback for iOS 16 and below)")
HStack(alignment: .center) {
DoubleStar.Fallback(size: 17, starOffset: 5)
}
Text(verbatim: "Double star (fallback for iOS 16 and below, with alt color limitation shown)")
.multilineTextAlignment(.center)
HStack(alignment: .center) {
DoubleStar.Fallback(size: 17, starOffset: 5)
}
.background(Color.blue)
}
}

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