Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
f28b15e84a
|
|||
|
e2cf6ffab2
|
|||
| 2a19d5d831 | |||
| 51ee4046a0 | |||
| 1e85bb946d | |||
| 6639c002ed | |||
| 2a61440aed | |||
| 823c2565da | |||
| b5a81e2586 | |||
| 6254cea600 | |||
| ce63f6a96b | |||
| 6fa2e8b5c6 | |||
| 2278ab09a4 | |||
| dfa72fceb1 | |||
| 9e0b9debb4 | |||
| 3902fe7b30 | |||
| 471bb4638a | |||
| 379de6ff8e | |||
| cb241741e3 | |||
| 1dbf7101b9 | |||
| d9bbca1005 | |||
| d2acf61e5a | |||
| d6898c77d8 | |||
| dd1fdf159b | |||
| 51b1b81c0e | |||
| da7af491d0 | |||
| 90b284fb6e | |||
| c1a89bd617 | |||
| a20f3ab2ab | |||
| 7b9d0edef4 | |||
| c22fc8613d | |||
| f61308e573 | |||
| d93b04a54c | |||
| 4b881e6839 | |||
| 63b0661728 | |||
| 46a66bc69d | |||
| c09018be48 | |||
| d71d448ac8 | |||
| 5834e1ee9b | |||
| d51179189c | |||
| b01243b101 | |||
| d2a80cce4e | |||
| 0cc9fc1670 | |||
| 1279791d65 | |||
| 5d2fc0ed54 | |||
| dcafcd9184 | |||
| cf16a9cd10 | |||
| 3a9dda5eb3 | |||
| c69ddd7241 | |||
| bfcb3e4c88 | |||
| 27083669fa | |||
| aaddbd847a | |||
| 1537501127 | |||
| 8b020e2bd6 | |||
| ad614f3e42 | |||
| 01497d0288 | |||
| eaad552273 | |||
|
83ecc3142e
|
|||
| ef4afbc720 | |||
| a5cc3aec92 | |||
| 2b140d4279 | |||
| b43dcd2bc7 | |||
| c67a75d740 | |||
| 7f00ef5d9d | |||
| d663155941 | |||
| abfe0f642f | |||
| f0b5162205 | |||
| a9bb2ef98b | |||
| eff4525720 | |||
| 858d9dc6f0 | |||
| 55090bc102 | |||
| 40d3d273f0 | |||
| f9271da11c | |||
| 4f881a5667 | |||
| 9d97886e3f | |||
| e70cfbbe63 | |||
| 8a75537ea3 | |||
| 49c8d63d0b | |||
| 6480023c96 | |||
| 774da239b9 | |||
| 90c80645ec | |||
| 613ec23f7f | |||
| 1d73ae1d32 | |||
|
63e364ce5b
|
|||
|
ee5f53e4eb
|
|||
|
9de21a730a
|
|||
|
36c09c8657
|
|||
|
e8ac143192
|
|||
|
93f44939e3
|
|||
|
48078b9b6a
|
|||
|
d6d6858e0b
|
|||
|
0187ff1dc0
|
|||
|
4f9fef8515
|
|||
|
1ebadd42f0
|
|||
|
4fb4f3a2de
|
|||
|
f49169c03c
|
|||
| 740c10c9b2 | |||
| 653f9fbcbe | |||
|
1767a677bb
|
|||
| dba1799df0 | |||
| 2db3d7310f | |||
| b2ba1e0e3b | |||
| 10b1cf64ae | |||
| afdd3f1d43 | |||
| 1b8e3fe184 | |||
| 8ab1c6a899 | |||
| e8fae19b97 | |||
| 63e70605fc | |||
| 35df9f7ab7 | |||
| 605d88add1 | |||
| 2b0a7d126d | |||
| 6e2c133faa | |||
| 9885ff1912 | |||
| abb818bbd4 | |||
| f1dc023e18 | |||
| 4a332c7ffa | |||
| 616f730ae5 | |||
| 164cea96f3 | |||
| fa70c376b1 | |||
|
847f31f5a6
|
|||
| fd130b78e7 | |||
| 0be0273121 | |||
| b349de22b7 | |||
| cc2d196705 | |||
| 53be29efc2 | |||
| 529ee63f29 | |||
| 490e8ec1fb | |||
| df267ffd04 | |||
| b771e8f49a | |||
| a88e80a346 | |||
|
8ac9863765
|
|||
| 4a851501a1 | |||
| 4ccfe81558 | |||
| e7ed9dfe86 | |||
| 0dce7aea45 | |||
| 6376c61bad | |||
|
bdd1403a7d
|
|||
| 23c3130a82 | |||
|
9172102f4d
|
|||
| 8bcd8317f1 |
@@ -1,3 +1,79 @@
|
|||||||
|
## [1.9 (14)] - 2024-07-14
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Completely new threads experience that is easier and more pleasant to use (Daniel D’Aquino)
|
||||||
|
- Add emoji search to emoji picker (Terry Yiu)
|
||||||
|
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added first aid contact damus support email (alltheseas)
|
||||||
|
- Disable mutiny wallet button (William Casarin)
|
||||||
|
- Make friends show up first when searching for profiles (Terry Yiu)
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix crash on profile page when there are profile updates (William Casarin)
|
||||||
|
- Fix crash when adding duplicate mute items (William Casarin)
|
||||||
|
- Fix pretty bad crash when building flatbuffer profiles (William Casarin)
|
||||||
|
- Fix reactions view to not show reactions from replies on parent note (Terry Yiu)
|
||||||
|
- Fix missing Mute button in profile view menu (Terry Yiu)
|
||||||
|
- Fixed wallet not disconnecting when a user logs out (ericholguin)
|
||||||
|
- Fix stale feed issue when follow list is too big (Daniel D’Aquino)
|
||||||
|
|
||||||
|
[1.9 (14)]: https://github.com/damus-io/damus/releases/tag/v1.9-14
|
||||||
|
|
||||||
|
## [1.8] - 2024-05-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added nip10 marker replies (William Casarin)
|
||||||
|
- Add marker nip10 support when reading notes (William Casarin)
|
||||||
|
- Added title image and tags to longform events (ericholguin)
|
||||||
|
- Add First Aid solution for users who do not have a contact list created for their account (Daniel D’Aquino)
|
||||||
|
- Relay fees metadata (ericholguin)
|
||||||
|
- Added callbackuri for a better ux when connecting mutiny wallet nwc (ericholguin)
|
||||||
|
- Add event content preview to the full screen carousel (Daniel D’Aquino)
|
||||||
|
- Show list of quoted reposts in threads (William Casarin)
|
||||||
|
- Proxy Tags are now viewable on Selected Events (ericholguin)
|
||||||
|
- Connect to Mutiny Wallet Button (ericholguin)
|
||||||
|
- Add ability to mute words, add new mutelist interface (Charlie) (William Casarin)
|
||||||
|
- Add ability to mute hashtag from SearchView (Charlie Fish)
|
||||||
|
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change reactions to use a native looking emoji picker (Terry Yiu)
|
||||||
|
- Relay detail design (ericholguin)
|
||||||
|
- Updated Zeus logo (ericholguin)
|
||||||
|
- Improve UX around video playback (Daniel D’Aquino)
|
||||||
|
- Moved paste nwc button to main wallet view (ericholguin)
|
||||||
|
- Errors with an NWC will show as an alert (ericholguin)
|
||||||
|
- Relay config view user interface (ericholguin)
|
||||||
|
- Always strip GPS data from images (kernelkind)
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix thread bug where a quote isn't picked up as a reply (William Casarin)
|
||||||
|
- Fixed threads not loading sometimes (William Casarin)
|
||||||
|
- Fixed issue where some replies were including the q tag (William Casarin)
|
||||||
|
- Fixed issue where timeline was scrolling when it isn't supposed to (William Casarin)
|
||||||
|
- Fix issue where bootstrap relays would inadvertently be added to the user's list on connectivity issues (Daniel D’Aquino)
|
||||||
|
- Fix broken GIF uploads (Daniel D’Aquino)
|
||||||
|
- Fix ghost notifications caused by Purple impending expiration notifications (Daniel D’Aquino)
|
||||||
|
- Improve reliability of contact list creation during onboarding (Daniel D’Aquino)
|
||||||
|
- Fix emoji reactions being cut off (ericholguin)
|
||||||
|
- Fix image indicators to limit number of dots to not spill screen beyond visible margins (ericholguin)
|
||||||
|
- Fix bug that would cause connection issues with relays defined with a trailing slash URL, and an inability to delete them. (Daniel D’Aquino)
|
||||||
|
- Issue where NWC Scanner view would not dismiss after a failed scan/paste (ericholguin)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[1.8]: https://github.com/damus-io/damus/releases/tag/v1.8
|
||||||
|
|
||||||
## [1.7-rc2] - 2024-02-28
|
## [1.7-rc2] - 2024-02-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
||||||
|
<true/>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
@@ -10,5 +12,9 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.jb55.damus2</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ struct NotificationFormatter {
|
|||||||
var identifier = ""
|
var identifier = ""
|
||||||
|
|
||||||
switch notify.type {
|
switch notify.type {
|
||||||
|
case .tagged:
|
||||||
|
title = String(format: NSLocalizedString("Tagged by %@", comment: "Tagged by heading in local notification"), displayName)
|
||||||
|
identifier = "myMentionNotification"
|
||||||
case .mention:
|
case .mention:
|
||||||
title = String(format: NSLocalizedString("Mentioned by %@", comment: "Mentioned by heading in local notification"), displayName)
|
title = String(format: NSLocalizedString("Mentioned by %@", comment: "Mentioned by heading in local notification"), displayName)
|
||||||
identifier = "myMentionNotification"
|
identifier = "myMentionNotification"
|
||||||
@@ -70,6 +73,9 @@ struct NotificationFormatter {
|
|||||||
case .zap, .profile_zap:
|
case .zap, .profile_zap:
|
||||||
// not handled here. Try `format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)?`
|
// not handled here. Try `format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)?`
|
||||||
return nil
|
return nil
|
||||||
|
case .reply:
|
||||||
|
title = String(format: NSLocalizedString("%@ replied to your note", comment: "Heading for local notification indicating a new reply"), displayName)
|
||||||
|
identifier = "myReplyNotification"
|
||||||
}
|
}
|
||||||
content.title = title
|
content.title = title
|
||||||
content.body = notify.content
|
content.body = notify.content
|
||||||
@@ -87,10 +93,11 @@ struct NotificationFormatter {
|
|||||||
|
|
||||||
// If it does not work, try async formatting methods
|
// If it does not work, try async formatting methods
|
||||||
let content = UNMutableNotificationContent()
|
let content = UNMutableNotificationContent()
|
||||||
|
|
||||||
switch notify.type {
|
switch notify.type {
|
||||||
case .zap, .profile_zap:
|
case .zap, .profile_zap:
|
||||||
guard let zap = await get_zap(from: notify.event, state: state) else {
|
guard let zap = await get_zap(from: notify.event, state: state) else {
|
||||||
|
Log.debug("format_message: async get_zap failed", for: .push_notifications)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content.title = Self.zap_notification_title(zap)
|
content.title = Self.zap_notification_title(zap)
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
// Log that we got a push notification
|
// Log that we got a push notification
|
||||||
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex())
|
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex())
|
||||||
|
|
||||||
guard let state = NotificationExtensionState(),
|
guard let state = NotificationExtensionState() else {
|
||||||
let display_name = state.ndb.lookup_profile(nostr_event.pubkey)?.unsafeUnownedValue?.profile?.display_name // We are not holding the txn here.
|
Log.debug("Failed to open nostrdb", for: .push_notifications)
|
||||||
else {
|
|
||||||
// Something failed to initialize so let's go for the next best thing
|
// Something failed to initialize so let's go for the next best thing
|
||||||
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
|
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
|
||||||
// We cannot format this nostr event. Suppress notification.
|
// We cannot format this nostr event. Suppress notification.
|
||||||
@@ -39,7 +39,11 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
contentHandler(improved_content)
|
contentHandler(improved_content)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let txn = state.ndb.lookup_profile(nostr_event.pubkey)
|
||||||
|
let profile = txn?.unsafeUnownedValue?.profile
|
||||||
|
let name = Profile.displayName(profile: profile, pubkey: nostr_event.pubkey).displayName
|
||||||
|
|
||||||
// Don't show notification details that match mute list.
|
// 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
|
||||||
if state.mutelist_manager.is_event_muted(nostr_event) {
|
if state.mutelist_manager.is_event_muted(nostr_event) {
|
||||||
@@ -54,6 +58,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
// We should not display notification for this event. Suppress notification.
|
// We should not display notification for this event. Suppress notification.
|
||||||
// contentHandler(UNNotificationContent())
|
// contentHandler(UNNotificationContent())
|
||||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||||
@@ -62,6 +67,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
// 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.
|
||||||
// contentHandler(UNNotificationContent())
|
// contentHandler(UNNotificationContent())
|
||||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||||
@@ -70,9 +76,13 @@ class NotificationService: UNNotificationServiceExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
if let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object, state: state) {
|
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
|
||||||
contentHandler(improvedContent)
|
|
||||||
|
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contentHandler(improvedContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1315
-24
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,24 @@
|
|||||||
{
|
{
|
||||||
|
"originHash" : "babaf4d5748afecf49bbb702530d8e9576460692f478b0a50ee43195dd4440e2",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "emojikit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/tyiu/EmojiKit",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "05805f72d63a6d6a2d7dc7fe14abd37c1317b11a",
|
||||||
|
"version" : "0.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "emojipicker",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/tyiu/EmojiPicker.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "0c28b4a1a6b8840cf2580bda59517f6d0a733dc8",
|
||||||
|
"version" : "0.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "gsplayer",
|
"identity" : "gsplayer",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -18,15 +37,6 @@
|
|||||||
"version" : "7.6.1"
|
"version" : "7.6.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "mcemojipicker",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/izyumkin/MCEmojiPicker",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "e0b4903b75ae1cc418d276d84d1cb946b8a1d73c",
|
|
||||||
"version" : "1.2.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "secp256k1.swift",
|
"identity" : "secp256k1.swift",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -35,6 +45,15 @@
|
|||||||
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
|
"revision" : "40b4b38b3b1c83f7088c76189a742870e0ca06a9"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-collections",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/apple/swift-collections.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "ee97538f5b81ae89698fd95938896dec5217b148",
|
||||||
|
"version" : "1.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swift-markdown-ui",
|
"identity" : "swift-markdown-ui",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -60,7 +79,25 @@
|
|||||||
"revision" : "74203046135342e4a4a627476dd6caf8b28fe11b",
|
"revision" : "74203046135342e4a4a627476dd6caf8b28fe11b",
|
||||||
"version" : "509.0.0"
|
"version" : "509.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swift-trie",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/tyiu/swift-trie",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "4c50bff6c168f74425f70476be62a072980d2da7",
|
||||||
|
"version" : "0.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swipeactions",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/aheze/SwipeActions",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab",
|
||||||
|
"version" : "1.1.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : 2
|
"version" : 3
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1540"
|
||||||
|
wasCreatedForAppExtension = "YES"
|
||||||
|
version = "2.0">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "D703D7162C66E47100A400EA"
|
||||||
|
BuildableName = "HighlighterActionExtension.appex"
|
||||||
|
BlueprintName = "HighlighterActionExtension"
|
||||||
|
ReferencedContainer = "container:damus.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
|
||||||
|
BuildableName = "damus.app"
|
||||||
|
BlueprintName = "damus"
|
||||||
|
ReferencedContainer = "container:damus.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = ""
|
||||||
|
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||||
|
launchStyle = "0"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<RemoteRunnable
|
||||||
|
runnableDebuggingMode = "1"
|
||||||
|
BundleIdentifier = "com.apple.mobilesafari"
|
||||||
|
RemotePath = "/Library/Developer/CoreSimulator/Volumes/iOS_21F79/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.5.simruntime/Contents/Resources/RuntimeRoot/Applications/MobileSafari.app">
|
||||||
|
</RemoteRunnable>
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
|
||||||
|
BuildableName = "damus.app"
|
||||||
|
BlueprintName = "damus"
|
||||||
|
ReferencedContainer = "container:damus.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
|
||||||
|
BuildableName = "damus.app"
|
||||||
|
BlueprintName = "damus"
|
||||||
|
ReferencedContainer = "container:damus.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xD7",
|
||||||
|
"green" : "0xD1",
|
||||||
|
"red" : "0xD1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x13",
|
||||||
|
"green" : "0x11",
|
||||||
|
"red" : "0x11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xF9",
|
||||||
|
"green" : "0xF3",
|
||||||
|
"red" : "0xF3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x25",
|
||||||
|
"green" : "0x22",
|
||||||
|
"red" : "0x22"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "244",
|
||||||
|
"green" : "218",
|
||||||
|
"red" : "244"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "92",
|
||||||
|
"green" : "45",
|
||||||
|
"red" : "93"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "236",
|
||||||
|
"green" : "194",
|
||||||
|
"red" : "238"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "109",
|
||||||
|
"green" : "49",
|
||||||
|
"red" : "111"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "197",
|
||||||
|
"green" : "67",
|
||||||
|
"red" : "204"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "255",
|
||||||
|
"green" : "194",
|
||||||
|
"red" : "255"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0xF2",
|
||||||
|
"green" : "0xD8",
|
||||||
|
"red" : "0xF4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0x45",
|
||||||
|
"green" : "0x17",
|
||||||
|
"red" : "0x47"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "damoose.jpeg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "tor.svg.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -12,31 +12,25 @@ let RECTANGLE_GRADIENT = LinearGradient(gradient: Gradient(colors: [
|
|||||||
DamusColors.blue
|
DamusColors.blue
|
||||||
]), startPoint: .leading, endPoint: .trailing)
|
]), startPoint: .leading, endPoint: .trailing)
|
||||||
|
|
||||||
struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
|
struct CustomPicker<SelectionValue: Hashable>: View {
|
||||||
|
|
||||||
|
let tabs: [(String, SelectionValue)]
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@Namespace var picker
|
@Namespace var picker
|
||||||
@Binding var selection: SelectionValue
|
@Binding var selection: SelectionValue
|
||||||
@ViewBuilder let content: Content
|
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
let contentMirror = Mirror(reflecting: content)
|
|
||||||
let blocksCount = Mirror(reflecting: contentMirror.descendant("value")!).children.count
|
|
||||||
HStack {
|
HStack {
|
||||||
ForEach(0..<blocksCount, id: \.self) { index in
|
ForEach(tabs, id: \.1) { (text, tag) in
|
||||||
let tupleBlock = contentMirror.descendant("value", ".\(index)")
|
|
||||||
let text = Mirror(reflecting: tupleBlock!).descendant("content") as! Text
|
|
||||||
let tag = Mirror(reflecting: tupleBlock!).descendant("modifier", "value", "tagged") as! SelectionValue
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
withAnimation(.spring()) {
|
withAnimation(.spring()) {
|
||||||
selection = tag
|
selection = tag
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
text
|
Text(text).padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
|
||||||
.padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
|
|
||||||
.font(.system(size: 14, weight: .heavy))
|
.font(.system(size: 14, weight: .heavy))
|
||||||
|
.tag(tag)
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
Group {
|
Group {
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import SwiftUI
|
|||||||
|
|
||||||
class DamusColors {
|
class DamusColors {
|
||||||
static let adaptableGrey = Color("DamusAdaptableGrey")
|
static let adaptableGrey = Color("DamusAdaptableGrey")
|
||||||
|
static let adaptableGrey2 = Color("DamusAdaptableGrey 2")
|
||||||
|
static let adaptableLighterGrey = Color("DamusAdaptableLighterGrey")
|
||||||
|
static let adaptablePurpleBackground = Color("DamusAdaptablePurpleBackground 1")
|
||||||
|
static let adaptablePurpleBackground2 = Color("DamusAdaptablePurpleBackground 2")
|
||||||
|
static let adaptablePurpleForeground = Color("DamusAdaptablePurpleForeground")
|
||||||
static let adaptableBlack = Color("DamusAdaptableBlack")
|
static let adaptableBlack = Color("DamusAdaptableBlack")
|
||||||
static let adaptableWhite = Color("DamusAdaptableWhite")
|
static let adaptableWhite = Color("DamusAdaptableWhite")
|
||||||
static let white = Color("DamusWhite")
|
static let white = Color("DamusWhite")
|
||||||
@@ -23,6 +28,7 @@ class DamusColors {
|
|||||||
static let green = Color("DamusGreen")
|
static let green = Color("DamusGreen")
|
||||||
static let purple = Color("DamusPurple")
|
static let purple = Color("DamusPurple")
|
||||||
static let deepPurple = Color("DamusDeepPurple")
|
static let deepPurple = Color("DamusDeepPurple")
|
||||||
|
static let highlight = Color("DamusHighlight")
|
||||||
static let blue = Color("DamusBlue")
|
static let blue = Color("DamusBlue")
|
||||||
static let bitcoin = Color("Bitcoin")
|
static let bitcoin = Color("Bitcoin")
|
||||||
static let success = Color("DamusSuccessPrimary")
|
static let success = Color("DamusSuccessPrimary")
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
|
Placeholder(url: url, geo_size: geo.size, num_urls: urls.count)
|
||||||
}
|
}
|
||||||
.aspectRatio(contentMode: filling ? .fill : .fit)
|
.aspectRatio(contentMode: filling ? .fill : .fit)
|
||||||
|
.kfClickable()
|
||||||
.position(x: geo.size.width / 2, y: geo.size.height / 2)
|
.position(x: geo.size.width / 2, y: geo.size.height / 2)
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Text(url.absoluteString)
|
Text(url.absoluteString)
|
||||||
@@ -274,8 +275,14 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
Medias
|
if #available(iOS 18.0, *) {
|
||||||
.onTapGesture { }
|
Medias
|
||||||
|
} else {
|
||||||
|
// An empty tap gesture recognizer is needed on iOS 17 and below to suppress other overlapping tap recognizers
|
||||||
|
// Otherwise it will both open the carousel and go to a note at the same time
|
||||||
|
Medias.onTapGesture { }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
if urls.count > 1 {
|
if urls.count > 1 {
|
||||||
PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
|
PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ enum OpenWalletError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
||||||
if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) {
|
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
|
||||||
UIApplication.shared.open(url)
|
this_app.open(url)
|
||||||
} else {
|
} else {
|
||||||
guard let store_link = wallet.appStoreLink else {
|
guard let store_link = wallet.appStoreLink else {
|
||||||
throw OpenWalletError.no_wallet_to_open
|
throw OpenWalletError.no_wallet_to_open
|
||||||
@@ -105,11 +105,11 @@ func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
|||||||
throw OpenWalletError.store_link_invalid
|
throw OpenWalletError.store_link_invalid
|
||||||
}
|
}
|
||||||
|
|
||||||
guard UIApplication.shared.canOpenURL(url) else {
|
guard this_app.canOpenURL(url) else {
|
||||||
throw OpenWalletError.system_cannot_open_store_link
|
throw OpenWalletError.system_cannot_open_store_link
|
||||||
}
|
}
|
||||||
|
|
||||||
UIApplication.shared.open(url)
|
this_app.open(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,8 +122,3 @@ struct InvoiceView_Previews: PreviewProvider {
|
|||||||
.frame(width: 300, height: 200)
|
.frame(width: 300, height: 200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func present_sheet(_ sheet: Sheets) {
|
|
||||||
notify(.present_sheet(sheet))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//
|
||||||
|
// OfflineTranslateView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 9/29/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import NaturalLanguage
|
||||||
|
import Translation
|
||||||
|
|
||||||
|
fileprivate let MIN_UNIQUE_CHARS = 2
|
||||||
|
|
||||||
|
@available(iOS 18.0, macOS 15.0, *)
|
||||||
|
@available(macCatalyst, unavailable)
|
||||||
|
struct OfflineTranslateView: View {
|
||||||
|
let damus_state: DamusState
|
||||||
|
let event: NostrEvent
|
||||||
|
let size: EventViewKind
|
||||||
|
|
||||||
|
@ObservedObject var translations_model: TranslationModel
|
||||||
|
|
||||||
|
@State private var translationConfiguration: TranslationSession.Configuration?
|
||||||
|
|
||||||
|
// @State private var languageStatus: LanguageAvailability.Status?
|
||||||
|
|
||||||
|
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) {
|
||||||
|
self.damus_state = damus_state
|
||||||
|
self.event = event
|
||||||
|
self.size = size
|
||||||
|
self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
var TranslateButton: some View {
|
||||||
|
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
|
||||||
|
translate()
|
||||||
|
}
|
||||||
|
.translate_button_style()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TranslatedView(lang: String?, artifacts: NoteArtifactsSeparated, font_size: Double) -> some View {
|
||||||
|
return VStack(alignment: .leading) {
|
||||||
|
let translatedFromLanguageString = String(format: NSLocalizedString("Translated from %@", comment: "Button to indicate that the note has been translated from a different language."), lang ?? "ja")
|
||||||
|
Text(translatedFromLanguageString)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.font(.footnote)
|
||||||
|
.padding([.top, .bottom], 10)
|
||||||
|
|
||||||
|
if self.size == .selected {
|
||||||
|
SelectableText(damus_state: damus_state, event: event, attributedString: artifacts.content.attributed, size: self.size)
|
||||||
|
} else {
|
||||||
|
artifacts.content.text
|
||||||
|
.font(eventviewsize_to_font(self.size, font_size: font_size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func translate() {
|
||||||
|
guard /*let languageStatus, */translations_model.state == .havent_tried && damus_state.settings.translation_service == .none && damus_state.settings.translate_offline/* && languageStatus != .unsupported*/, let note_language = translations_model.note_language else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard translationConfiguration == nil else {
|
||||||
|
translationConfiguration?.invalidate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
translationConfiguration = TranslationSession.Configuration(
|
||||||
|
source: Locale.Language(identifier: note_language))
|
||||||
|
}
|
||||||
|
|
||||||
|
// func setLanguageStatus() async {
|
||||||
|
// guard languageStatus == nil else {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// guard let note_language = translations_model.note_language else {
|
||||||
|
// languageStatus = .unsupported
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// let languageAvailability = LanguageAvailability()
|
||||||
|
// let language = Locale.Language(identifier: note_language)
|
||||||
|
// languageStatus = await languageAvailability.status(from: language, to: nil)
|
||||||
|
// }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let note_lang = translations_model.note_language, damus_state.settings.translation_service == .none && damus_state.settings.translate_offline && should_translate(event: event, our_keypair: damus_state.keypair, note_lang: note_lang) {
|
||||||
|
Group {
|
||||||
|
switch self.translations_model.state {
|
||||||
|
case .havent_tried:
|
||||||
|
if damus_state.settings.auto_translate/* && languageStatus == .installed*/ {
|
||||||
|
Text("")
|
||||||
|
} else {
|
||||||
|
TranslateButton
|
||||||
|
}
|
||||||
|
case .translating:
|
||||||
|
Text("")
|
||||||
|
case .translated(let translated):
|
||||||
|
let languageName = Locale.current.localizedString(forLanguageCode: translated.language)
|
||||||
|
TranslatedView(lang: languageName, artifacts: translated.artifacts, font_size: damus_state.settings.font_size)
|
||||||
|
case .not_needed:
|
||||||
|
Text("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
// Task { @MainActor in
|
||||||
|
// await setLanguageStatus()
|
||||||
|
// }
|
||||||
|
translate()
|
||||||
|
}
|
||||||
|
.translationTask(translationConfiguration) { translationSession in
|
||||||
|
Task { @MainActor in
|
||||||
|
do {
|
||||||
|
guard let note_language = translations_model.note_language, translations_model.state == .havent_tried/*, languageStatus != .unsupported*/ else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
translations_model.state = .translating
|
||||||
|
|
||||||
|
let originalContent = event.get_content(damus_state.keypair)
|
||||||
|
let response = try await translationSession.translate(originalContent)
|
||||||
|
let translated_note = response.targetText
|
||||||
|
|
||||||
|
guard originalContent != translated_note else {
|
||||||
|
// if its the same, give up and don't retry
|
||||||
|
translations_model.state = .not_needed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard translationMeetsStringDistanceRequirements(original: originalContent, translated: translated_note) else {
|
||||||
|
translations_model.state = .not_needed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render translated note
|
||||||
|
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
|
||||||
|
let artifacts = render_blocks(blocks: translated_blocks, profiles: damus_state.profiles)
|
||||||
|
|
||||||
|
// and cache it
|
||||||
|
translations_model.state = .translated(Translated(artifacts: artifacts, language: note_language))
|
||||||
|
} catch {
|
||||||
|
// code to handle error
|
||||||
|
print("Error translating note: \(error.localizedDescription)")
|
||||||
|
translations_model.state = .not_needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func translationMeetsStringDistanceRequirements(original: String, translated: String) -> Bool {
|
||||||
|
return levenshteinDistanceIsGreaterThanOrEqualTo(from: original, to: translated, threshold: MIN_UNIQUE_CHARS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(iOS 18.0, macOS 15.0, *)
|
||||||
|
@available(macCatalyst, unavailable)
|
||||||
|
struct OfflineTranslateView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let ds = test_damus_state
|
||||||
|
OfflineTranslateView(damus_state: ds, event: test_note, size: .normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,16 +9,19 @@ import UIKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SelectableText: View {
|
struct SelectableText: View {
|
||||||
|
let damus_state: DamusState
|
||||||
|
let event: NostrEvent?
|
||||||
let attributedString: AttributedString
|
let attributedString: AttributedString
|
||||||
let textAlignment: NSTextAlignment
|
let textAlignment: NSTextAlignment
|
||||||
|
@State private var selectedTextActionState: SelectedTextActionState = .hide
|
||||||
@State private var selectedTextHeight: CGFloat = .zero
|
@State private var selectedTextHeight: CGFloat = .zero
|
||||||
@State private var selectedTextWidth: CGFloat = .zero
|
@State private var selectedTextWidth: CGFloat = .zero
|
||||||
|
|
||||||
let size: EventViewKind
|
let size: EventViewKind
|
||||||
|
|
||||||
init(attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
|
init(damus_state: DamusState, event: NostrEvent?, attributedString: AttributedString, textAlignment: NSTextAlignment? = nil, size: EventViewKind) {
|
||||||
|
self.damus_state = damus_state
|
||||||
|
self.event = event
|
||||||
self.attributedString = attributedString
|
self.attributedString = attributedString
|
||||||
self.textAlignment = textAlignment ?? NSTextAlignment.natural
|
self.textAlignment = textAlignment ?? NSTextAlignment.natural
|
||||||
self.size = size
|
self.size = size
|
||||||
@@ -32,6 +35,13 @@ struct SelectableText: View {
|
|||||||
font: eventviewsize_to_uifont(size),
|
font: eventviewsize_to_uifont(size),
|
||||||
fixedWidth: selectedTextWidth,
|
fixedWidth: selectedTextWidth,
|
||||||
textAlignment: self.textAlignment,
|
textAlignment: self.textAlignment,
|
||||||
|
enableHighlighting: self.enableHighlighting(),
|
||||||
|
postHighlight: { selectedText in
|
||||||
|
self.selectedTextActionState = .show_highlight_post_view(highlighted_text: selectedText)
|
||||||
|
},
|
||||||
|
muteWord: { selectedText in
|
||||||
|
self.selectedTextActionState = .show_mute_word_view(highlighted_text: selectedText)
|
||||||
|
},
|
||||||
height: $selectedTextHeight
|
height: $selectedTextHeight
|
||||||
)
|
)
|
||||||
.padding([.leading, .trailing], -1.0)
|
.padding([.leading, .trailing], -1.0)
|
||||||
@@ -46,22 +56,123 @@ struct SelectableText: View {
|
|||||||
self.selectedTextWidth = newSize.width
|
self.selectedTextWidth = newSize.width
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: Binding(get: {
|
||||||
|
return self.selectedTextActionState.should_show_highlight_post_view()
|
||||||
|
}, set: { newValue in
|
||||||
|
self.selectedTextActionState = newValue ? .show_highlight_post_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
|
||||||
|
})) {
|
||||||
|
if let event, case .show_highlight_post_view(let highlighted_text) = self.selectedTextActionState {
|
||||||
|
PostView(
|
||||||
|
action: .highlighting(.init(selected_text: highlighted_text, source: .event(event))),
|
||||||
|
damus_state: damus_state
|
||||||
|
)
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationDetents([.height(selectedTextHeight + 450), .medium, .large])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: Binding(get: {
|
||||||
|
return self.selectedTextActionState.should_show_mute_word_view()
|
||||||
|
}, set: { newValue in
|
||||||
|
self.selectedTextActionState = newValue ? .show_mute_word_view(highlighted_text: self.selectedTextActionState.highlighted_text() ?? "") : .hide
|
||||||
|
})) {
|
||||||
|
if case .show_mute_word_view(let highlighted_text) = selectedTextActionState {
|
||||||
|
AddMuteItemView(state: damus_state, new_text: .constant(highlighted_text))
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationDetents([.height(300), .medium, .large])
|
||||||
|
}
|
||||||
|
}
|
||||||
.frame(height: selectedTextHeight)
|
.frame(height: selectedTextHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func enableHighlighting() -> Bool {
|
||||||
|
self.event != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SelectedTextActionState {
|
||||||
|
case hide
|
||||||
|
case show_highlight_post_view(highlighted_text: String)
|
||||||
|
case show_mute_word_view(highlighted_text: String)
|
||||||
|
|
||||||
|
func should_show_highlight_post_view() -> Bool {
|
||||||
|
guard case .show_highlight_post_view(let highlighted_text) = self else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func should_show_mute_word_view() -> Bool {
|
||||||
|
guard case .show_mute_word_view(let highlighted_text) = self else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func highlighted_text() -> String? {
|
||||||
|
switch self {
|
||||||
|
case .hide:
|
||||||
|
return nil
|
||||||
|
case .show_mute_word_view(highlighted_text: let highlighted_text):
|
||||||
|
return highlighted_text
|
||||||
|
case .show_highlight_post_view(highlighted_text: let highlighted_text):
|
||||||
|
return highlighted_text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
fileprivate class TextView: UITextView {
|
||||||
|
var postHighlight: (String) -> Void
|
||||||
|
var muteWord: (String) -> Void
|
||||||
|
|
||||||
|
init(frame: CGRect, textContainer: NSTextContainer?, postHighlight: @escaping (String) -> Void, muteWord: @escaping (String) -> Void) {
|
||||||
|
self.postHighlight = postHighlight
|
||||||
|
self.muteWord = muteWord
|
||||||
|
super.init(frame: frame, textContainer: textContainer)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||||
|
if action == #selector(highlightText(_:)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == #selector(muteText(_:)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.canPerformAction(action, withSender: sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSelectedText() -> String? {
|
||||||
|
guard let selectedRange = self.selectedTextRange else { return nil }
|
||||||
|
return self.text(in: selectedRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public func highlightText(_ sender: Any?) {
|
||||||
|
guard let selectedText = self.getSelectedText() else { return }
|
||||||
|
self.postHighlight(selectedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc public func muteText(_ sender: Any?) {
|
||||||
|
guard let selectedText = self.getSelectedText() else { return }
|
||||||
|
self.muteWord(selectedText)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct TextViewRepresentable: UIViewRepresentable {
|
||||||
|
|
||||||
let attributedString: AttributedString
|
let attributedString: AttributedString
|
||||||
let textColor: UIColor
|
let textColor: UIColor
|
||||||
let font: UIFont
|
let font: UIFont
|
||||||
let fixedWidth: CGFloat
|
let fixedWidth: CGFloat
|
||||||
let textAlignment: NSTextAlignment
|
let textAlignment: NSTextAlignment
|
||||||
|
let enableHighlighting: Bool
|
||||||
|
let postHighlight: (String) -> Void
|
||||||
|
let muteWord: (String) -> Void
|
||||||
@Binding var height: CGFloat
|
@Binding var height: CGFloat
|
||||||
|
|
||||||
func makeUIView(context: UIViewRepresentableContext<Self>) -> UITextView {
|
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
|
||||||
let view = UITextView()
|
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
|
||||||
@@ -71,10 +182,16 @@ struct SelectableText: View {
|
|||||||
view.textContainerInset.left = 1.0
|
view.textContainerInset.left = 1.0
|
||||||
view.textContainerInset.right = 1.0
|
view.textContainerInset.right = 1.0
|
||||||
view.textAlignment = textAlignment
|
view.textAlignment = textAlignment
|
||||||
|
|
||||||
|
let menuController = UIMenuController.shared
|
||||||
|
let highlightItem = UIMenuItem(title: "Highlight", action: #selector(view.highlightText(_:)))
|
||||||
|
let muteItem = UIMenuItem(title: "Mute", action: #selector(view.muteText(_:)))
|
||||||
|
menuController.menuItems = self.enableHighlighting ? [highlightItem, muteItem] : []
|
||||||
|
|
||||||
return view
|
return view
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) {
|
func updateUIView(_ uiView: TextView, context: UIViewRepresentableContext<Self>) {
|
||||||
let mutableAttributedString = createNSAttributedString()
|
let mutableAttributedString = createNSAttributedString()
|
||||||
uiView.attributedText = mutableAttributedString
|
uiView.attributedText = mutableAttributedString
|
||||||
uiView.textAlignment = self.textAlignment
|
uiView.textAlignment = self.textAlignment
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ struct SupporterBadge: View {
|
|||||||
let percent: Int?
|
let percent: Int?
|
||||||
let purple_account: DamusPurple.Account?
|
let purple_account: DamusPurple.Account?
|
||||||
let style: Style
|
let style: Style
|
||||||
|
let text_color: Color
|
||||||
|
|
||||||
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style) {
|
init(percent: Int?, purple_account: DamusPurple.Account? = nil, style: Style, text_color: Color = .secondary) {
|
||||||
self.percent = percent
|
self.percent = percent
|
||||||
self.purple_account = purple_account
|
self.purple_account = purple_account
|
||||||
self.style = style
|
self.style = style
|
||||||
|
self.text_color = text_color
|
||||||
}
|
}
|
||||||
|
|
||||||
let size: CGFloat = 17
|
let size: CGFloat = 17
|
||||||
@@ -31,7 +33,7 @@ struct SupporterBadge: View {
|
|||||||
if self.style == .full {
|
if self.style == .full {
|
||||||
let date = format_date(date: purple_account.created_at, time_style: .none)
|
let date = format_date(date: purple_account.created_at, time_style: .none)
|
||||||
Text(date)
|
Text(date)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(text_color)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,19 +27,26 @@ struct TranslateView: View {
|
|||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
let event: NostrEvent
|
let event: NostrEvent
|
||||||
let size: EventViewKind
|
let size: EventViewKind
|
||||||
|
|
||||||
|
@Binding var isAppleTranslationPopoverPresented: Bool
|
||||||
|
|
||||||
@ObservedObject var translations_model: TranslationModel
|
@ObservedObject var translations_model: TranslationModel
|
||||||
|
|
||||||
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind) {
|
init(damus_state: DamusState, event: NostrEvent, size: EventViewKind, isAppleTranslationPopoverPresented: Binding<Bool>) {
|
||||||
self.damus_state = damus_state
|
self.damus_state = damus_state
|
||||||
self.event = event
|
self.event = event
|
||||||
self.size = size
|
self.size = size
|
||||||
|
self._isAppleTranslationPopoverPresented = isAppleTranslationPopoverPresented
|
||||||
self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
|
self._translations_model = ObservedObject(wrappedValue: damus_state.events.get_cache_data(event.id).translations_model)
|
||||||
}
|
}
|
||||||
|
|
||||||
var TranslateButton: some View {
|
var TranslateButton: some View {
|
||||||
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
|
Button(NSLocalizedString("Translate Note", comment: "Button to translate note from different language.")) {
|
||||||
translate()
|
if damus_state.settings.translation_service == .none {
|
||||||
|
isAppleTranslationPopoverPresented = true
|
||||||
|
} else {
|
||||||
|
translate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.translate_button_style()
|
.translate_button_style()
|
||||||
}
|
}
|
||||||
@@ -51,9 +58,9 @@ struct TranslateView: View {
|
|||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.padding([.top, .bottom], 10)
|
.padding([.top, .bottom], 10)
|
||||||
|
|
||||||
if self.size == .selected {
|
if self.size == .selected {
|
||||||
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
|
SelectableText(damus_state: damus_state, event: event, attributedString: artifacts.content.attributed, size: self.size)
|
||||||
} else {
|
} else {
|
||||||
artifacts.content.text
|
artifacts.content.text
|
||||||
.font(eventviewsize_to_font(self.size, font_size: font_size))
|
.font(eventviewsize_to_font(self.size, font_size: font_size))
|
||||||
@@ -74,17 +81,25 @@ struct TranslateView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func should_transl(_ note_lang: String) -> Bool {
|
func should_transl(_ note_lang: String) -> Bool {
|
||||||
should_translate(event: event, our_keypair: damus_state.keypair, settings: damus_state.settings, note_lang: note_lang)
|
guard should_translate(event: event, our_keypair: damus_state.keypair, note_lang: note_lang) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if TranslationService.isAppleTranslationSupported {
|
||||||
|
return damus_state.settings.translation_service == .none || damus_state.settings.can_translate
|
||||||
|
} else {
|
||||||
|
return damus_state.settings.can_translate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
switch self.translations_model.state {
|
switch self.translations_model.state {
|
||||||
case .havent_tried:
|
case .havent_tried:
|
||||||
if damus_state.settings.auto_translate {
|
if damus_state.settings.auto_translate && damus_state.settings.translation_service != .none {
|
||||||
Text("")
|
Text("")
|
||||||
} else if let note_lang = translations_model.note_language, should_transl(note_lang) {
|
} else if let note_lang = translations_model.note_language, should_transl(note_lang) {
|
||||||
TranslateButton
|
TranslateButton
|
||||||
} else {
|
} else {
|
||||||
Text("")
|
Text("")
|
||||||
}
|
}
|
||||||
@@ -114,9 +129,11 @@ extension View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct TranslateView_Previews: PreviewProvider {
|
struct TranslateView_Previews: PreviewProvider {
|
||||||
|
@State static var isAppleTranslationPopoverPresented: Bool = false
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let ds = test_damus_state
|
let ds = test_damus_state
|
||||||
TranslateView(damus_state: ds, event: test_note, size: .normal)
|
TranslateView(damus_state: ds, event: test_note, size: .normal, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import SwiftUI
|
|||||||
struct TruncatedText: View {
|
struct TruncatedText: View {
|
||||||
let text: CompatibleText
|
let text: CompatibleText
|
||||||
let maxChars: Int
|
let maxChars: Int
|
||||||
|
let show_show_more_button: Bool
|
||||||
|
|
||||||
init(text: CompatibleText, maxChars: Int = 280) {
|
init(text: CompatibleText, maxChars: Int = 280, show_show_more_button: Bool) {
|
||||||
self.text = text
|
self.text = text
|
||||||
self.maxChars = maxChars
|
self.maxChars = maxChars
|
||||||
|
self.show_show_more_button = show_show_more_button
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -29,8 +31,10 @@ struct TruncatedText: View {
|
|||||||
|
|
||||||
if truncatedAttributedString != nil {
|
if truncatedAttributedString != nil {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
|
if self.show_show_more_button {
|
||||||
.allowsHitTesting(false)
|
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,10 +42,10 @@ struct TruncatedText: View {
|
|||||||
struct TruncatedText_Previews: PreviewProvider {
|
struct TruncatedText_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
VStack(spacing: 100) {
|
VStack(spacing: 100) {
|
||||||
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"))
|
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven"), show_show_more_button: true)
|
||||||
.frame(width: 200, height: 200)
|
.frame(width: 200, height: 200)
|
||||||
|
|
||||||
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"))
|
TruncatedText(text: CompatibleText(stringLiteral: "hello\nthere\none\ntwo\nthree\nfour"), show_show_more_button: true)
|
||||||
.frame(width: 200, height: 200)
|
.frame(width: 200, height: 200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ enum NoteContent {
|
|||||||
case content(String, TagsSequence?)
|
case content(String, TagsSequence?)
|
||||||
|
|
||||||
init(note: NostrEvent, keypair: Keypair) {
|
init(note: NostrEvent, keypair: Keypair) {
|
||||||
if note.known_kind == .dm {
|
if note.known_kind == .dm || note.known_kind == .highlight {
|
||||||
self = .content(note.get_content(keypair), note.tags)
|
self = .content(note.get_content(keypair), note.tags)
|
||||||
} else {
|
} else {
|
||||||
self = .note(note)
|
self = .note(note)
|
||||||
|
|||||||
+29
-67
@@ -8,6 +8,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVKit
|
import AVKit
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
|
import EmojiPicker
|
||||||
|
|
||||||
struct ZapSheet {
|
struct ZapSheet {
|
||||||
let target: ZapTarget
|
let target: ZapTarget
|
||||||
@@ -56,6 +57,10 @@ enum Sheets: Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func present_sheet(_ sheet: Sheets) {
|
||||||
|
notify(.present_sheet(sheet))
|
||||||
|
}
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
let keypair: Keypair
|
let keypair: Keypair
|
||||||
let appDelegate: AppDelegate?
|
let appDelegate: AppDelegate?
|
||||||
@@ -72,77 +77,26 @@ struct ContentView: View {
|
|||||||
|
|
||||||
@State var active_sheet: Sheets? = nil
|
@State var active_sheet: Sheets? = nil
|
||||||
@State var damus_state: DamusState!
|
@State var damus_state: DamusState!
|
||||||
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
|
@State var menu_subtitle: String? = nil
|
||||||
|
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home {
|
||||||
|
willSet {
|
||||||
|
self.menu_subtitle = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@State var muting: MuteItem? = nil
|
@State var muting: MuteItem? = nil
|
||||||
@State var confirm_mute: Bool = false
|
@State var confirm_mute: Bool = false
|
||||||
@State var hide_bar: Bool = false
|
@State var hide_bar: Bool = false
|
||||||
@State var user_muted_confirm: Bool = false
|
@State var user_muted_confirm: Bool = false
|
||||||
@State var confirm_overwrite_mutelist: Bool = false
|
@State var confirm_overwrite_mutelist: Bool = false
|
||||||
@SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
|
|
||||||
@State private var isSideBarOpened = false
|
@State private var isSideBarOpened = false
|
||||||
var home: HomeModel = HomeModel()
|
var home: HomeModel = HomeModel()
|
||||||
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||||
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
||||||
let sub_id = UUID().description
|
let sub_id = UUID().description
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
|
||||||
|
|
||||||
// connect retry timer
|
// connect retry timer
|
||||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var mystery: some View {
|
|
||||||
Text("Are you lost?", comment: "Text asking the user if they are lost in the app.")
|
|
||||||
.id("what")
|
|
||||||
}
|
|
||||||
|
|
||||||
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
|
|
||||||
var filters = ContentFilters.defaults(damus_state: damus_state!)
|
|
||||||
filters.append(fstate.filter)
|
|
||||||
return ContentFilters(filters: filters).filter
|
|
||||||
}
|
|
||||||
|
|
||||||
var PostingTimelineView: some View {
|
|
||||||
VStack {
|
|
||||||
ZStack {
|
|
||||||
TabView(selection: $filter_state) {
|
|
||||||
// This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why.
|
|
||||||
mystery
|
|
||||||
|
|
||||||
contentTimelineView(filter: content_filter(.posts))
|
|
||||||
.tag(FilterState.posts)
|
|
||||||
.id(FilterState.posts)
|
|
||||||
contentTimelineView(filter: content_filter(.posts_and_replies))
|
|
||||||
.tag(FilterState.posts_and_replies)
|
|
||||||
.id(FilterState.posts_and_replies)
|
|
||||||
}
|
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
|
||||||
|
|
||||||
if privkey != nil {
|
|
||||||
PostButtonContainer(is_left_handed: damus_state?.settings.left_handed ?? false) {
|
|
||||||
self.active_sheet = .post(.posting(.none))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
CustomPicker(selection: $filter_state, content: {
|
|
||||||
Text("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies).").tag(FilterState.posts)
|
|
||||||
Text("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes).").tag(FilterState.posts_and_replies)
|
|
||||||
})
|
|
||||||
Divider()
|
|
||||||
.frame(height: 1)
|
|
||||||
}
|
|
||||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contentTimelineView(filter: (@escaping (NostrEvent) -> Bool)) -> some View {
|
|
||||||
TimelineView(events: home.events, loading: .constant(false), damus: damus_state, show_friend_icon: false, filter: filter) {
|
|
||||||
PullDownSearchView(state: damus_state, on_cancel: {})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func navIsAtRoot() -> Bool {
|
func navIsAtRoot() -> Bool {
|
||||||
return navigationCoordinator.isAtRoot()
|
return navigationCoordinator.isAtRoot()
|
||||||
}
|
}
|
||||||
@@ -152,9 +106,16 @@ struct ContentView: View {
|
|||||||
isSideBarOpened = false
|
isSideBarOpened = false
|
||||||
}
|
}
|
||||||
|
|
||||||
var timelineNavItem: Text {
|
var timelineNavItem: some View {
|
||||||
return Text(timeline_name(selected_timeline))
|
VStack {
|
||||||
.bold()
|
Text(timeline_name(selected_timeline))
|
||||||
|
.bold()
|
||||||
|
if let menu_subtitle {
|
||||||
|
Text(menu_subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func MainContent(damus: DamusState) -> some View {
|
func MainContent(damus: DamusState) -> some View {
|
||||||
@@ -170,10 +131,10 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case .home:
|
case .home:
|
||||||
PostingTimelineView
|
PostingTimelineView(damus_state: damus_state!, home: home, active_sheet: $active_sheet)
|
||||||
|
|
||||||
case .notifications:
|
case .notifications:
|
||||||
NotificationsView(state: damus, notifications: home.notifications)
|
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||||
|
|
||||||
case .dms:
|
case .dms:
|
||||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||||
@@ -719,7 +680,8 @@ struct ContentView: View {
|
|||||||
music: MusicController(onChange: music_changed),
|
music: MusicController(onChange: music_changed),
|
||||||
video: VideoController(),
|
video: VideoController(),
|
||||||
ndb: ndb,
|
ndb: ndb,
|
||||||
quote_reposts: .init(our_pubkey: pubkey)
|
quote_reposts: .init(our_pubkey: pubkey),
|
||||||
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||||
)
|
)
|
||||||
|
|
||||||
home.damus_state = self.damus_state!
|
home.damus_state = self.damus_state!
|
||||||
@@ -772,7 +734,7 @@ struct ContentView: View {
|
|||||||
selected_timeline = .dms
|
selected_timeline = .dms
|
||||||
damus_state.dms.set_active_dm(target.pubkey)
|
damus_state.dms.set_active_dm(target.pubkey)
|
||||||
navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
|
navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
|
||||||
case .like, .zap, .mention, .repost:
|
case .like, .zap, .mention, .repost, .reply, .tagged:
|
||||||
open_event(ev: target)
|
open_event(ev: target)
|
||||||
case .profile_zap:
|
case .profile_zap:
|
||||||
break
|
break
|
||||||
@@ -873,7 +835,7 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos
|
|||||||
|
|
||||||
|
|
||||||
func setup_notifications() {
|
func setup_notifications() {
|
||||||
UIApplication.shared.registerForRemoteNotifications()
|
this_app.registerForRemoteNotifications()
|
||||||
let center = UNUserNotificationCenter.current()
|
let center = UNUserNotificationCenter.current()
|
||||||
|
|
||||||
center.getNotificationSettings { settings in
|
center.getNotificationSettings { settings in
|
||||||
@@ -1105,7 +1067,7 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
|
|||||||
//let post = tup.0
|
//let post = tup.0
|
||||||
//let to_relays = tup.1
|
//let to_relays = tup.1
|
||||||
print("post \(post.content)")
|
print("post \(post.content)")
|
||||||
guard let new_ev = post_to_event(post: post, keypair: keypair) else {
|
guard let new_ev = post.to_event(keypair: keypair) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
postbox.send(new_ev)
|
postbox.send(new_ev)
|
||||||
@@ -1166,7 +1128,7 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
|
|||||||
}
|
}
|
||||||
case .hashtag(let ht):
|
case .hashtag(let ht):
|
||||||
result(.filter(.filter_hashtag([ht.hashtag])))
|
result(.filter(.filter_hashtag([ht.hashtag])))
|
||||||
case .param, .quote:
|
case .param, .quote, .reference:
|
||||||
// doesn't really make sense here
|
// doesn't really make sense here
|
||||||
break
|
break
|
||||||
case .naddr(let naddr):
|
case .naddr(let naddr):
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ public class CameraService: NSObject, Identifiable {
|
|||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: {
|
self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: {
|
||||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!,
|
this_app.open(URL(string: UIApplication.openSettingsURLString)!,
|
||||||
options: [:], completionHandler: nil)
|
options: [:], completionHandler: nil)
|
||||||
|
|
||||||
}, secondaryAction: nil)
|
}, secondaryAction: nil)
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// CommentItem.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2024-08-14.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CommentItem: TagConvertible {
|
||||||
|
static let TAG_KEY: String = "comment"
|
||||||
|
let content: String
|
||||||
|
var tag: [String] {
|
||||||
|
return [Self.TAG_KEY, content]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from_tag(tag: TagSequence) -> CommentItem? {
|
||||||
|
guard tag.count == 2 else { return nil }
|
||||||
|
guard tag[0].string() == Self.TAG_KEY else { return nil }
|
||||||
|
|
||||||
|
return CommentItem(content: tag[1].string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,7 +109,7 @@ func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool {
|
|||||||
case let (.pubkey(pk), .pubkey(follow_pk)):
|
case let (.pubkey(pk), .pubkey(follow_pk)):
|
||||||
return pk == follow_pk
|
return pk == follow_pk
|
||||||
case (.hashtag, .pubkey), (.pubkey, .hashtag),
|
case (.hashtag, .pubkey), (.pubkey, .hashtag),
|
||||||
(.event, _), (.quote, _), (.param, _), (.naddr, _):
|
(.event, _), (.quote, _), (.param, _), (.naddr, _), (.reference(_), _):
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ enum FilterState : Int {
|
|||||||
func filter(ev: NostrEvent) -> Bool {
|
func filter(ev: NostrEvent) -> Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .posts:
|
case .posts:
|
||||||
return ev.known_kind == .boost || !ev.is_reply()
|
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply()
|
||||||
case .posts_and_replies:
|
case .posts_and_replies:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,31 +9,31 @@ import Foundation
|
|||||||
|
|
||||||
|
|
||||||
class CreateAccountModel: ObservableObject {
|
class CreateAccountModel: ObservableObject {
|
||||||
@Published var real_name: String = ""
|
@Published var display_name: String = ""
|
||||||
@Published var nick_name: String = ""
|
@Published var name: String = ""
|
||||||
@Published var about: String = ""
|
@Published var about: String = ""
|
||||||
@Published var pubkey: Pubkey = .empty
|
@Published var pubkey: Pubkey = .empty
|
||||||
@Published var privkey: Privkey = .empty
|
@Published var privkey: Privkey = .empty
|
||||||
@Published var profile_image: URL? = nil
|
@Published var profile_image: URL? = nil
|
||||||
|
|
||||||
var rendered_name: String {
|
var rendered_name: String {
|
||||||
if real_name.isEmpty {
|
if display_name.isEmpty {
|
||||||
return nick_name
|
return name
|
||||||
}
|
}
|
||||||
return real_name
|
return display_name
|
||||||
}
|
}
|
||||||
|
|
||||||
var keypair: Keypair {
|
var keypair: Keypair {
|
||||||
return Keypair(pubkey: self.pubkey, privkey: self.privkey)
|
return Keypair(pubkey: self.pubkey, privkey: self.privkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(real: String = "", nick: String = "", about: String = "") {
|
init(display_name: String = "", name: String = "", about: String = "") {
|
||||||
let keypair = generate_new_keypair()
|
let keypair = generate_new_keypair()
|
||||||
self.pubkey = keypair.pubkey
|
self.pubkey = keypair.pubkey
|
||||||
self.privkey = keypair.privkey
|
self.privkey = keypair.privkey
|
||||||
|
|
||||||
self.real_name = real
|
self.display_name = display_name
|
||||||
self.nick_name = nick
|
self.name = name
|
||||||
self.about = about
|
self.about = about
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import LinkPresentation
|
import LinkPresentation
|
||||||
|
import EmojiPicker
|
||||||
|
|
||||||
class DamusState: HeadlessDamusState {
|
class DamusState: HeadlessDamusState {
|
||||||
let pool: RelayPool
|
let pool: RelayPool
|
||||||
@@ -37,8 +38,9 @@ class DamusState: HeadlessDamusState {
|
|||||||
let ndb: Ndb
|
let ndb: Ndb
|
||||||
var purple: DamusPurple
|
var purple: DamusPurple
|
||||||
var push_notification_client: PushNotificationClient
|
var push_notification_client: PushNotificationClient
|
||||||
|
let emoji_provider: EmojiProvider
|
||||||
|
|
||||||
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: VideoController, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter) {
|
init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: VideoController, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
|
||||||
self.pool = pool
|
self.pool = pool
|
||||||
self.keypair = keypair
|
self.keypair = keypair
|
||||||
self.likes = likes
|
self.likes = likes
|
||||||
@@ -70,6 +72,80 @@ class DamusState: HeadlessDamusState {
|
|||||||
)
|
)
|
||||||
self.quote_reposts = quote_reposts
|
self.quote_reposts = quote_reposts
|
||||||
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
||||||
|
self.emoji_provider = emoji_provider
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
convenience init?(keypair: Keypair) {
|
||||||
|
// nostrdb
|
||||||
|
var mndb = Ndb()
|
||||||
|
if mndb == nil {
|
||||||
|
// try recovery
|
||||||
|
print("DB ISSUE! RECOVERING")
|
||||||
|
mndb = Ndb.safemode()
|
||||||
|
|
||||||
|
// out of space or something?? maybe we need a in-memory fallback
|
||||||
|
if mndb == nil {
|
||||||
|
logout(nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||||
|
let home: HomeModel = HomeModel()
|
||||||
|
let sub_id = UUID().uuidString
|
||||||
|
|
||||||
|
guard let ndb = mndb else { return nil }
|
||||||
|
let pubkey = keypair.pubkey
|
||||||
|
|
||||||
|
let pool = RelayPool(ndb: ndb, keypair: keypair)
|
||||||
|
let model_cache = RelayModelCache()
|
||||||
|
let relay_filters = RelayFilters(our_pubkey: pubkey)
|
||||||
|
let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey)
|
||||||
|
|
||||||
|
let settings = UserSettingsStore.globally_load_for(pubkey: pubkey)
|
||||||
|
|
||||||
|
let new_relay_filters = load_relay_filters(pubkey) == nil
|
||||||
|
for relay in bootstrap_relays {
|
||||||
|
let descriptor = RelayDescriptor(url: relay, info: .rw)
|
||||||
|
add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||||
|
|
||||||
|
if let nwc_str = settings.nostr_wallet_connect,
|
||||||
|
let nwc = WalletConnectURL(str: nwc_str) {
|
||||||
|
try? pool.add_relay(.nwc(url: nwc.relay))
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
pool: pool,
|
||||||
|
keypair: keypair,
|
||||||
|
likes: EventCounter(our_pubkey: pubkey),
|
||||||
|
boosts: EventCounter(our_pubkey: pubkey),
|
||||||
|
contacts: Contacts(our_pubkey: pubkey),
|
||||||
|
mutelist_manager: MutelistManager(user_keypair: keypair),
|
||||||
|
profiles: Profiles(ndb: ndb),
|
||||||
|
dms: home.dms,
|
||||||
|
previews: PreviewCache(),
|
||||||
|
zaps: Zaps(our_pubkey: pubkey),
|
||||||
|
lnurls: LNUrls(),
|
||||||
|
settings: settings,
|
||||||
|
relay_filters: relay_filters,
|
||||||
|
relay_model_cache: model_cache,
|
||||||
|
drafts: Drafts(),
|
||||||
|
events: EventCache(ndb: ndb),
|
||||||
|
bookmarks: BookmarksManager(pubkey: pubkey),
|
||||||
|
postbox: PostBox(pool: pool),
|
||||||
|
bootstrap_relays: bootstrap_relays,
|
||||||
|
replies: ReplyCounter(our_pubkey: pubkey),
|
||||||
|
wallet: WalletModel(settings: settings),
|
||||||
|
nav: navigationCoordinator,
|
||||||
|
music: MusicController(onChange: { _ in }),
|
||||||
|
video: VideoController(),
|
||||||
|
ndb: ndb,
|
||||||
|
quote_reposts: .init(our_pubkey: pubkey),
|
||||||
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -99,6 +175,7 @@ class DamusState: HeadlessDamusState {
|
|||||||
|
|
||||||
func close() {
|
func close() {
|
||||||
print("txn: damus close")
|
print("txn: damus close")
|
||||||
|
wallet.disconnect()
|
||||||
pool.close()
|
pool.close()
|
||||||
ndb.close()
|
ndb.close()
|
||||||
}
|
}
|
||||||
@@ -134,7 +211,8 @@ class DamusState: HeadlessDamusState {
|
|||||||
music: nil,
|
music: nil,
|
||||||
video: VideoController(),
|
video: VideoController(),
|
||||||
ndb: .empty,
|
ndb: .empty,
|
||||||
quote_reposts: .init(our_pubkey: empty_pub)
|
quote_reposts: .init(our_pubkey: empty_pub),
|
||||||
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,4 +28,5 @@ class Drafts: ObservableObject {
|
|||||||
@Published var post: DraftArtifacts? = nil
|
@Published var post: DraftArtifacts? = nil
|
||||||
@Published var replies: [NostrEvent: DraftArtifacts] = [:]
|
@Published var replies: [NostrEvent: DraftArtifacts] = [:]
|
||||||
@Published var quotes: [NostrEvent: DraftArtifacts] = [:]
|
@Published var quotes: [NostrEvent: DraftArtifacts] = [:]
|
||||||
|
@Published var highlights: [HighlightSource: DraftArtifacts] = [:]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import Foundation
|
|||||||
|
|
||||||
enum FriendFilter: String, StringCodable {
|
enum FriendFilter: String, StringCodable {
|
||||||
case all
|
case all
|
||||||
case friends
|
case friends_of_friends
|
||||||
|
|
||||||
init?(from string: String) {
|
init?(from string: String) {
|
||||||
guard let ff = FriendFilter(rawValue: string) else {
|
guard let ff = FriendFilter(rawValue: string) else {
|
||||||
@@ -27,8 +27,17 @@ enum FriendFilter: String, StringCodable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .all:
|
case .all:
|
||||||
return true
|
return true
|
||||||
case .friends:
|
case .friends_of_friends:
|
||||||
return contacts.is_in_friendosphere(pubkey)
|
return contacts.is_in_friendosphere(pubkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func description() -> String {
|
||||||
|
switch self {
|
||||||
|
case .all:
|
||||||
|
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
|
||||||
|
case .friends_of_friends:
|
||||||
|
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//
|
||||||
|
// HighlightEvent.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/22/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct HighlightEvent {
|
||||||
|
let event: NostrEvent
|
||||||
|
|
||||||
|
var event_ref: String? = nil
|
||||||
|
var url_ref: URL? = nil
|
||||||
|
var context: String? = nil
|
||||||
|
|
||||||
|
// MARK: - Initializers and parsers
|
||||||
|
|
||||||
|
static func parse(from ev: NostrEvent) -> HighlightEvent {
|
||||||
|
var highlight = HighlightEvent(event: ev)
|
||||||
|
|
||||||
|
var best_url_source: (url: URL, tagged_as_source: Bool)? = nil
|
||||||
|
|
||||||
|
for tag in ev.tags {
|
||||||
|
guard tag.count >= 2 else { continue }
|
||||||
|
switch tag[0].string() {
|
||||||
|
case "e": highlight.event_ref = tag[1].string()
|
||||||
|
case "a": highlight.event_ref = tag[1].string()
|
||||||
|
case "r":
|
||||||
|
if tag.count >= 3,
|
||||||
|
tag[2].string() == HighlightSource.TAG_SOURCE_ELEMENT,
|
||||||
|
let url = URL(string: tag[1].string()) {
|
||||||
|
// URL marked as source. Very good candidate
|
||||||
|
best_url_source = (url: url, tagged_as_source: true)
|
||||||
|
}
|
||||||
|
else if tag.count >= 3 && tag[2].string() != HighlightSource.TAG_SOURCE_ELEMENT {
|
||||||
|
// URL marked as something else (not source). Not the source we are after
|
||||||
|
}
|
||||||
|
else if let url = URL(string: tag[1].string()), tag.count == 2 {
|
||||||
|
// Unmarked URL. This might be what we are after (For NIP-84 backwards compatibility)
|
||||||
|
if (best_url_source?.tagged_as_source ?? false) == false {
|
||||||
|
// No URL candidates marked as the source. Mark this as the best option we have
|
||||||
|
best_url_source = (url: url, tagged_as_source: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "context": highlight.context = tag[1].string()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let best_url_source {
|
||||||
|
highlight.url_ref = best_url_source.url
|
||||||
|
}
|
||||||
|
|
||||||
|
return highlight
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Getting information about source
|
||||||
|
|
||||||
|
func source_description_info(highlighted_event: NostrEvent?) -> ReplyDesc {
|
||||||
|
var others_count = 0
|
||||||
|
var highlighted_authors: [Pubkey] = []
|
||||||
|
var i = event.tags.count
|
||||||
|
|
||||||
|
if let highlighted_event {
|
||||||
|
highlighted_authors.append(highlighted_event.pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
for tag in event.tags {
|
||||||
|
if let pubkey_with_role = PubkeyWithRole.from_tag(tag: tag) {
|
||||||
|
others_count += 1
|
||||||
|
if highlighted_authors.count < 2 {
|
||||||
|
if let highlighted_event, pubkey_with_role.pubkey == highlighted_event.pubkey {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
switch pubkey_with_role.role {
|
||||||
|
case .author:
|
||||||
|
highlighted_authors.append(pubkey_with_role.pubkey)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReplyDesc(pubkeys: highlighted_authors, others: others_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func source_description_text(ndb: Ndb, highlighted_event: NostrEvent?, locale: Locale = Locale.current) -> String {
|
||||||
|
let description_info = self.source_description_info(highlighted_event: highlighted_event)
|
||||||
|
let pubkeys = description_info.pubkeys
|
||||||
|
|
||||||
|
let bundle = bundleForLocale(locale: locale)
|
||||||
|
|
||||||
|
if pubkeys.count == 0 {
|
||||||
|
return NSLocalizedString("Highlighted", bundle: bundle, comment: "Label to indicate that the user is highlighting their own post.")
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let profile_txn = NdbTxn(ndb: ndb) else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let names: [String] = pubkeys.map { pk in
|
||||||
|
let prof = ndb.lookup_profile_with_txn(pk, txn: profile_txn)
|
||||||
|
|
||||||
|
return Profile.displayName(profile: prof?.profile, pubkey: pk).username.truncate(maxLength: 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
let uniqueNames: [String] = Array(Set(names))
|
||||||
|
return String(format: NSLocalizedString("Highlighted %@", bundle: bundle, comment: "Label to indicate that the user is highlighting 1 user."), locale: locale, uniqueNames.first ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper structures
|
||||||
|
|
||||||
|
extension HighlightEvent {
|
||||||
|
struct PubkeyWithRole: TagKey, TagConvertible {
|
||||||
|
let pubkey: Pubkey
|
||||||
|
let role: Role
|
||||||
|
|
||||||
|
var tag: [String] {
|
||||||
|
if let role_text = self.role.rawValue {
|
||||||
|
return [keychar.description, self.pubkey.hex(), role_text]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return [keychar.description, self.pubkey.hex()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var keychar: AsciiCharacter { "p" }
|
||||||
|
|
||||||
|
static func from_tag(tag: TagSequence) -> HighlightEvent.PubkeyWithRole? {
|
||||||
|
var i = tag.makeIterator()
|
||||||
|
|
||||||
|
guard tag.count >= 2,
|
||||||
|
let t0 = i.next(),
|
||||||
|
let key = t0.single_char,
|
||||||
|
key == "p",
|
||||||
|
let t1 = i.next(),
|
||||||
|
let pubkey = t1.id().map(Pubkey.init)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
let t3: String? = i.next()?.string()
|
||||||
|
let role = Role(rawValue: t3)
|
||||||
|
return PubkeyWithRole(pubkey: pubkey, role: role)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role: RawRepresentable {
|
||||||
|
case author
|
||||||
|
case editor
|
||||||
|
case mention
|
||||||
|
case other(String)
|
||||||
|
case no_role
|
||||||
|
|
||||||
|
typealias RawValue = String?
|
||||||
|
var rawValue: String? {
|
||||||
|
switch self {
|
||||||
|
case .author: "author"
|
||||||
|
case .editor: "editor"
|
||||||
|
case .mention: "mention"
|
||||||
|
case .other(let role): role
|
||||||
|
case .no_role: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(rawValue: String?) {
|
||||||
|
switch rawValue {
|
||||||
|
case "author": self = .author
|
||||||
|
case "editor": self = .editor
|
||||||
|
case "mention": self = .mention
|
||||||
|
default:
|
||||||
|
if let rawValue {
|
||||||
|
self = .other(rawValue)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self = .no_role
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HighlightContentDraft: Hashable {
|
||||||
|
let selected_text: String
|
||||||
|
let source: HighlightSource
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HighlightSource: Hashable {
|
||||||
|
static let TAG_SOURCE_ELEMENT = "source"
|
||||||
|
case event(NostrEvent)
|
||||||
|
case external_url(URL)
|
||||||
|
|
||||||
|
func tags() -> [[String]] {
|
||||||
|
switch self {
|
||||||
|
case .event(let event):
|
||||||
|
return [ ["e", "\(event.id)", HighlightSource.TAG_SOURCE_ELEMENT] ]
|
||||||
|
case .external_url(let url):
|
||||||
|
return [ ["r", "\(url)", HighlightSource.TAG_SOURCE_ELEMENT] ]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ref() -> RefId {
|
||||||
|
switch self {
|
||||||
|
case .event(let event):
|
||||||
|
return .event(event.id)
|
||||||
|
case .external_url(let url):
|
||||||
|
return .reference(url.absoluteString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,11 +127,11 @@ class HomeModel: ContactsDelegate {
|
|||||||
/// This loads the latest contact event we have on file from NostrDB. This should be called as soon as we get the new DamusState
|
/// This loads the latest contact event we have on file from NostrDB. This should be called as soon as we get the new DamusState
|
||||||
/// Loading the latest contact list event into our `Contacts` instance from storage is important to avoid getting into weird states when the network is unreliable or when relays delete such information
|
/// Loading the latest contact list event into our `Contacts` instance from storage is important to avoid getting into weird states when the network is unreliable or when relays delete such information
|
||||||
func load_latest_contact_event_from_damus_state() {
|
func load_latest_contact_event_from_damus_state() {
|
||||||
|
damus_state.contacts.delegate = self
|
||||||
guard let latest_contact_event_id_hex = damus_state.settings.latest_contact_event_id_hex else { return }
|
guard let latest_contact_event_id_hex = damus_state.settings.latest_contact_event_id_hex else { return }
|
||||||
guard let latest_contact_event_id = NoteId(hex: latest_contact_event_id_hex) else { return }
|
guard let latest_contact_event_id = NoteId(hex: latest_contact_event_id_hex) else { return }
|
||||||
guard let latest_contact_event: NdbNote = damus_state.ndb.lookup_note( latest_contact_event_id)?.unsafeUnownedValue?.to_owned() else { return }
|
guard let latest_contact_event: NdbNote = damus_state.ndb.lookup_note( latest_contact_event_id)?.unsafeUnownedValue?.to_owned() else { return }
|
||||||
process_contact_event(state: damus_state, ev: latest_contact_event)
|
process_contact_event(state: damus_state, ev: latest_contact_event)
|
||||||
damus_state.contacts.delegate = self
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ContactsDelegate functions
|
// MARK: - ContactsDelegate functions
|
||||||
@@ -184,7 +184,7 @@ class HomeModel: ContactsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch kind {
|
switch kind {
|
||||||
case .chat, .longform, .text:
|
case .chat, .longform, .text, .highlight:
|
||||||
handle_text_event(sub_id: sub_id, ev)
|
handle_text_event(sub_id: sub_id, ev)
|
||||||
case .contacts:
|
case .contacts:
|
||||||
handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
|
handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev)
|
||||||
@@ -586,7 +586,7 @@ class HomeModel: ContactsDelegate {
|
|||||||
func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: RelayURL? = nil) {
|
func subscribe_to_home_filters(friends fs: [Pubkey]? = nil, relay_id: RelayURL? = nil) {
|
||||||
// TODO: separate likes?
|
// TODO: separate likes?
|
||||||
var home_filter_kinds: [NostrKind] = [
|
var home_filter_kinds: [NostrKind] = [
|
||||||
.text, .longform, .boost
|
.text, .longform, .boost, .highlight
|
||||||
]
|
]
|
||||||
if !damus_state.settings.onlyzaps_mode {
|
if !damus_state.settings.onlyzaps_mode {
|
||||||
home_filter_kinds.append(.like)
|
home_filter_kinds.append(.like)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import Foundation
|
|||||||
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
|
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
|
||||||
var id: String { self.rawValue }
|
var id: String { self.rawValue }
|
||||||
case nostrBuild
|
case nostrBuild
|
||||||
case nostrImg
|
case nostrcheck
|
||||||
|
|
||||||
init?(from string: String) {
|
init?(from string: String) {
|
||||||
guard let mu = MediaUploader(rawValue: string) else {
|
guard let mu = MediaUploader(rawValue: string) else {
|
||||||
@@ -23,95 +23,73 @@ enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
|
|||||||
func to_string() -> String {
|
func to_string() -> String {
|
||||||
return rawValue
|
return rawValue
|
||||||
}
|
}
|
||||||
|
|
||||||
var nameParam: String {
|
var nameParam: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .nostrBuild:
|
case .nostrBuild:
|
||||||
return "\"fileToUpload\""
|
return "\"fileToUpload\""
|
||||||
case .nostrImg:
|
default:
|
||||||
return "\"image\""
|
return "\"file\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var supportsVideo: Bool {
|
var supportsVideo: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .nostrBuild:
|
case .nostrBuild:
|
||||||
return true
|
return true
|
||||||
case .nostrImg:
|
case .nostrcheck:
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Model: Identifiable, Hashable {
|
struct Model: Identifiable, Hashable {
|
||||||
var id: String { self.tag }
|
var id: String { self.tag }
|
||||||
var index: Int
|
var index: Int
|
||||||
var tag: String
|
var tag: String
|
||||||
var displayName : String
|
var displayName : String
|
||||||
}
|
}
|
||||||
|
|
||||||
var model: Model {
|
var model: Model {
|
||||||
switch self {
|
switch self {
|
||||||
case .nostrBuild:
|
case .nostrBuild:
|
||||||
return .init(index: -1, tag: "nostrBuild", displayName: "nostr.build")
|
return .init(index: -1, tag: "nostrBuild", displayName: "nostr.build")
|
||||||
case .nostrImg:
|
case .nostrcheck:
|
||||||
return .init(index: 0, tag: "nostrImg", displayName: "nostrimg.com")
|
return .init(index: 0, tag: "nostrcheck", displayName: "nostrcheck.me")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
var postAPI: String {
|
var postAPI: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .nostrBuild:
|
case .nostrBuild:
|
||||||
return "https://nostr.build/api/v2/upload/files"
|
return "https://nostr.build/api/v2/nip96/upload"
|
||||||
case .nostrImg:
|
case .nostrcheck:
|
||||||
return "https://nostrimg.com/api/upload"
|
return "https://nostrcheck.me/api/v2/media"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMediaURL(from data: Data) -> String? {
|
func getMediaURL(from data: Data) -> String? {
|
||||||
switch self {
|
do {
|
||||||
case .nostrBuild:
|
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
|
||||||
do {
|
let status = jsonObject["status"] as? String {
|
||||||
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
|
|
||||||
let status = jsonObject["status"] as? String {
|
if status == "success", let nip94Event = jsonObject["nip94_event"] as? [String: Any] {
|
||||||
|
|
||||||
if status == "success", let dataArray = jsonObject["data"] as? [[String: Any]] {
|
if let tags = nip94Event["tags"] as? [[String]] {
|
||||||
|
for tagArray in tags {
|
||||||
var urls: [String] = []
|
if tagArray.count > 1, tagArray[0] == "url" {
|
||||||
|
return tagArray[1]
|
||||||
for dataDict in dataArray {
|
|
||||||
if let mainUrl = dataDict["url"] as? String {
|
|
||||||
urls.append(mainUrl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return urls.joined(separator: "\n")
|
|
||||||
} else if status == "error", let message = jsonObject["message"] as? String {
|
|
||||||
print("Upload Error: \(message)")
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
} else if status == "error", let message = jsonObject["message"] as? String {
|
||||||
} catch {
|
print("Upload Error: \(message)")
|
||||||
print("Failed JSONSerialization")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case .nostrImg:
|
|
||||||
guard let responseString = String(data: data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue)) else {
|
|
||||||
print("Upload failed getting response string")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let startIndex = responseString.range(of: "https://i.nostrimg.com/")?.lowerBound else {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let stringContainingName = responseString[startIndex..<responseString.endIndex]
|
|
||||||
guard let endIndex = stringContainingName.range(of: "\"")?.lowerBound else {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
let nostrBuildImageName = responseString[startIndex..<endIndex]
|
} catch {
|
||||||
let nostrBuildURL = "\(nostrBuildImageName)"
|
print("Failed JSONSerialization")
|
||||||
return nostrBuildURL
|
return nil
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,46 +256,3 @@ func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PostTags {
|
|
||||||
let blocks: [Block]
|
|
||||||
let tags: [[String]]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert
|
|
||||||
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
|
|
||||||
var new_tags = tags
|
|
||||||
|
|
||||||
for post_block in post_blocks {
|
|
||||||
switch post_block {
|
|
||||||
case .mention(let mention):
|
|
||||||
switch(mention.ref) {
|
|
||||||
case .note, .nevent:
|
|
||||||
continue
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
new_tags.append(mention.ref.tag)
|
|
||||||
case .hashtag(let hashtag):
|
|
||||||
new_tags.append(["t", hashtag.lowercased()])
|
|
||||||
case .text: break
|
|
||||||
case .invoice: break
|
|
||||||
case .relay: break
|
|
||||||
case .url(let url):
|
|
||||||
new_tags.append(["r", url.absoluteString])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return PostTags(blocks: post_blocks, tags: new_tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
func post_to_event(post: NostrPost, keypair: FullKeypair) -> NostrEvent? {
|
|
||||||
let post_blocks = parse_post_blocks(content: post.content)
|
|
||||||
let post_tags = make_post_tags(post_blocks: post_blocks, tags: post.tags)
|
|
||||||
let content = post_tags.blocks
|
|
||||||
.map(\.asString)
|
|
||||||
.joined(separator: "")
|
|
||||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: post.kind.rawValue, tags: post_tags.tags)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -111,12 +111,16 @@ class MutelistManager {
|
|||||||
private func add_mute_item(_ item: MuteItem) {
|
private func add_mute_item(_ item: MuteItem) {
|
||||||
switch item {
|
switch item {
|
||||||
case .user(_, _):
|
case .user(_, _):
|
||||||
|
guard !users.contains(item) else { return }
|
||||||
users.insert(item)
|
users.insert(item)
|
||||||
case .hashtag(_, _):
|
case .hashtag(_, _):
|
||||||
|
guard !hashtags.contains(item) else { return }
|
||||||
hashtags.insert(item)
|
hashtags.insert(item)
|
||||||
case .word(_, _):
|
case .word(_, _):
|
||||||
|
guard !words.contains(item) else { return }
|
||||||
words.insert(item)
|
words.insert(item)
|
||||||
case .thread(_, _):
|
case .thread(_, _):
|
||||||
|
guard !threads.contains(item) else { return }
|
||||||
threads.insert(item)
|
threads.insert(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func process_local_notification(state: HeadlessDamusState, event ev: NostrEvent)
|
|||||||
|
|
||||||
func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent, mode: UserSettingsStore.NotificationsMode) -> Bool {
|
func should_display_notification(state: HeadlessDamusState, event ev: NostrEvent, mode: UserSettingsStore.NotificationsMode) -> Bool {
|
||||||
// Do not show notification if it's coming from a mode different from the one selected by our user
|
// Do not show notification if it's coming from a mode different from the one selected by our user
|
||||||
guard state.settings.notifications_mode == mode else {
|
guard state.settings.notification_mode == mode else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,36 +61,55 @@ func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamu
|
|||||||
|
|
||||||
if type == .text, state.settings.mention_notification {
|
if type == .text, state.settings.mention_notification {
|
||||||
let blocks = ev.blocks(state.keypair).blocks
|
let blocks = ev.blocks(state.keypair).blocks
|
||||||
|
|
||||||
for case .mention(let mention) in blocks {
|
for case .mention(let mention) in blocks {
|
||||||
guard case .pubkey(let pk) = mention.ref, pk == state.keypair.pubkey else {
|
guard case .pubkey(let pk) = mention.ref, pk == state.keypair.pubkey else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||||
return LocalNotification(type: .mention, event: ev, target: ev, content: content_preview)
|
return LocalNotification(type: .mention, event: ev, target: .note(ev), content: content_preview)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ev.referenced_ids.contains(where: { note_id in
|
||||||
|
guard let note_author: Pubkey = state.ndb.lookup_note(note_id)?.unsafeUnownedValue?.pubkey else { return false }
|
||||||
|
guard note_author == state.keypair.pubkey else { return false }
|
||||||
|
return true
|
||||||
|
}) {
|
||||||
|
// This is a reply to one of our posts
|
||||||
|
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||||
|
return LocalNotification(type: .reply, event: ev, target: .note(ev), content: content_preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev.referenced_pubkeys.contains(state.keypair.pubkey) {
|
||||||
|
// not mentioned or replied to, just tagged
|
||||||
|
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||||
|
return LocalNotification(type: .tagged, event: ev, target: .note(ev), content: content_preview)
|
||||||
|
}
|
||||||
|
|
||||||
} else if type == .boost,
|
} else if type == .boost,
|
||||||
state.settings.repost_notification,
|
state.settings.repost_notification,
|
||||||
let inner_ev = ev.get_inner_event()
|
let inner_ev = ev.get_inner_event()
|
||||||
{
|
{
|
||||||
let content_preview = render_notification_content_preview(ev: inner_ev, profiles: state.profiles, keypair: state.keypair)
|
let content_preview = render_notification_content_preview(ev: inner_ev, profiles: state.profiles, keypair: state.keypair)
|
||||||
return LocalNotification(type: .repost, event: ev, target: inner_ev, content: content_preview)
|
return LocalNotification(type: .repost, event: ev, target: .note(inner_ev), content: content_preview)
|
||||||
} else if type == .like,
|
} else if type == .like, state.settings.like_notification, let evid = ev.referenced_ids.last {
|
||||||
state.settings.like_notification,
|
if let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
|
||||||
let evid = ev.referenced_ids.last,
|
let liked_event = txn.unsafeUnownedValue
|
||||||
let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
|
{
|
||||||
let liked_event = txn.unsafeUnownedValue?.to_owned()
|
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
|
||||||
{
|
return LocalNotification(type: .like, event: ev, target: .note(liked_event), content: content_preview)
|
||||||
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
|
} else {
|
||||||
return LocalNotification(type: .like, event: ev, target: liked_event, content: content_preview)
|
return LocalNotification(type: .like, event: ev, target: .note_id(evid), content: "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if type == .dm,
|
else if type == .dm,
|
||||||
state.settings.dm_notification {
|
state.settings.dm_notification {
|
||||||
let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
|
let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
|
||||||
return LocalNotification(type: .dm, event: ev, target: ev, content: convo)
|
return LocalNotification(type: .dm, event: ev, target: .note(ev), content: convo)
|
||||||
}
|
}
|
||||||
else if type == .zap,
|
else if type == .zap,
|
||||||
state.settings.zap_notification {
|
state.settings.zap_notification {
|
||||||
return LocalNotification(type: .zap, event: ev, target: ev, content: ev.content)
|
return LocalNotification(type: .zap, event: ev, target: .note(ev), content: ev.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+77
-1
@@ -17,10 +17,86 @@ struct NostrPost {
|
|||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.tags = tags
|
self.tags = tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func to_event(keypair: FullKeypair) -> NostrEvent? {
|
||||||
|
let post_blocks = self.parse_blocks()
|
||||||
|
let post_tags = self.make_post_tags(post_blocks: post_blocks, tags: self.tags)
|
||||||
|
let content = post_tags.blocks
|
||||||
|
.map(\.asString)
|
||||||
|
.joined(separator: "")
|
||||||
|
|
||||||
|
if self.kind == .highlight {
|
||||||
|
var new_tags = post_tags.tags.filter({ $0[safe: 0] != "comment" })
|
||||||
|
if content.count > 0 {
|
||||||
|
new_tags.append(["comment", content])
|
||||||
|
}
|
||||||
|
return NostrEvent(content: self.content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: new_tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: self.kind.rawValue, tags: post_tags.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse_blocks() -> [Block] {
|
||||||
|
guard let content_for_parsing = self.default_content_for_block_parsing() else { return [] }
|
||||||
|
return parse_post_blocks(content: content_for_parsing)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func default_content_for_block_parsing() -> String? {
|
||||||
|
switch kind {
|
||||||
|
case .highlight:
|
||||||
|
return tags.filter({ $0[safe: 0] == "comment" }).first?[safe: 1]
|
||||||
|
default:
|
||||||
|
return self.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the post's contents to find more tags to apply to the final nostr event
|
||||||
|
func make_post_tags(post_blocks: [Block], tags: [[String]]) -> PostTags {
|
||||||
|
var new_tags = tags
|
||||||
|
|
||||||
|
for post_block in post_blocks {
|
||||||
|
switch post_block {
|
||||||
|
case .mention(let mention):
|
||||||
|
switch(mention.ref) {
|
||||||
|
case .note, .nevent:
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.kind == .highlight, case .pubkey(_) = mention.ref {
|
||||||
|
var new_tag = mention.ref.tag
|
||||||
|
new_tag.append("mention")
|
||||||
|
new_tags.append(new_tag)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
new_tags.append(mention.ref.tag)
|
||||||
|
}
|
||||||
|
case .hashtag(let hashtag):
|
||||||
|
new_tags.append(["t", hashtag.lowercased()])
|
||||||
|
case .text: break
|
||||||
|
case .invoice: break
|
||||||
|
case .relay: break
|
||||||
|
case .url(let url):
|
||||||
|
new_tags.append(self.kind == .highlight ? ["r", url.absoluteString, "mention"] : ["r", url.absoluteString])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PostTags(blocks: post_blocks, tags: new_tags)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Helper structures and functions
|
||||||
|
|
||||||
|
extension NostrPost {
|
||||||
|
/// A struct used for temporarily holding tag information that was parsed from a post contents to aid in building a nostr event
|
||||||
|
struct PostTags {
|
||||||
|
let blocks: [Block]
|
||||||
|
let tags: [[String]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return a list of tags
|
|
||||||
func parse_post_blocks(content: String) -> [Block] {
|
func parse_post_blocks(content: String) -> [Block] {
|
||||||
return parse_note_content(content: .content(content, nil)).blocks
|
return parse_note_content(content: .content(content, nil)).blocks
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
damus.pool.unsubscribe(sub_id: sub_id)
|
damus.pool.unsubscribe(sub_id: sub_id)
|
||||||
damus.pool.unsubscribe(sub_id: prof_subid)
|
damus.pool.unsubscribe(sub_id: prof_subid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func subscribe() {
|
func subscribe() {
|
||||||
var text_filter = NostrFilter(kinds: [.text, .longform])
|
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
|
||||||
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
|
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
|
||||||
|
|
||||||
profile_filter.authors = [pubkey]
|
profile_filter.authors = [pubkey]
|
||||||
|
|
||||||
text_filter.authors = [pubkey]
|
text_filter.authors = [pubkey]
|
||||||
|
|||||||
@@ -11,35 +11,34 @@ struct PushNotificationClient {
|
|||||||
let keypair: Keypair
|
let keypair: Keypair
|
||||||
let settings: UserSettingsStore
|
let settings: UserSettingsStore
|
||||||
private(set) var device_token: Data? = nil
|
private(set) var device_token: Data? = nil
|
||||||
|
var device_token_hex: String? {
|
||||||
|
guard let device_token else { return nil }
|
||||||
|
return device_token.map { String(format: "%02.2hhx", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
mutating func set_device_token(new_device_token: Data) async throws {
|
mutating func set_device_token(new_device_token: Data) async throws {
|
||||||
self.device_token = new_device_token
|
self.device_token = new_device_token
|
||||||
if settings.enable_experimental_push_notifications && settings.notifications_mode == .push {
|
if settings.enable_push_notifications && settings.notification_mode == .push {
|
||||||
try await self.send_token()
|
try await self.send_token()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func send_token() async throws {
|
func send_token() async throws {
|
||||||
guard let device_token else { return }
|
|
||||||
// Send the device token and pubkey to the server
|
// Send the device token and pubkey to the server
|
||||||
let token = device_token.map { String(format: "%02.2hhx", $0) }.joined()
|
guard let token = device_token_hex else { return }
|
||||||
|
|
||||||
Log.info("Sending device token to server: %s", for: .push_notifications, token)
|
Log.info("Sending device token to server: %s", for: .push_notifications, token)
|
||||||
|
|
||||||
let pubkey = self.keypair.pubkey
|
|
||||||
|
|
||||||
// Send those as JSON to the server
|
|
||||||
let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()]
|
|
||||||
|
|
||||||
// create post request
|
// create post request
|
||||||
let url = self.settings.send_device_token_to_localhost ? Constants.DEVICE_TOKEN_RECEIVER_TEST_URL : Constants.DEVICE_TOKEN_RECEIVER_PRODUCTION_URL
|
let url = self.current_push_notification_environment().api_base_url()
|
||||||
let json_data = try JSONSerialization.data(withJSONObject: json)
|
.appendingPathComponent("user-info")
|
||||||
|
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||||
|
.appendingPathComponent(token)
|
||||||
|
|
||||||
let (data, response) = try await make_nip98_authenticated_request(
|
let (data, response) = try await make_nip98_authenticated_request(
|
||||||
method: .post,
|
method: .put,
|
||||||
url: url,
|
url: url,
|
||||||
payload: json_data,
|
payload: nil,
|
||||||
payload_type: .json,
|
payload_type: .json,
|
||||||
auth_keypair: self.keypair
|
auth_keypair: self.keypair
|
||||||
)
|
)
|
||||||
@@ -58,26 +57,23 @@ struct PushNotificationClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func revoke_token() async throws {
|
func revoke_token() async throws {
|
||||||
guard let device_token else { return }
|
guard let token = device_token_hex else { return }
|
||||||
// Send the device token and pubkey to the server
|
|
||||||
let token = device_token.map { String(format: "%02.2hhx", $0) }.joined()
|
|
||||||
|
|
||||||
Log.info("Revoking device token from server: %s", for: .push_notifications, token)
|
Log.info("Revoking device token from server: %s", for: .push_notifications, token)
|
||||||
|
|
||||||
let pubkey = self.keypair.pubkey
|
let pubkey = self.keypair.pubkey
|
||||||
|
|
||||||
// Send those as JSON to the server
|
|
||||||
let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()]
|
|
||||||
|
|
||||||
// create post request
|
// create post request
|
||||||
let url = self.settings.send_device_token_to_localhost ? Constants.DEVICE_TOKEN_REVOKER_TEST_URL : Constants.DEVICE_TOKEN_REVOKER_PRODUCTION_URL
|
let url = self.current_push_notification_environment().api_base_url()
|
||||||
let json_data = try JSONSerialization.data(withJSONObject: json)
|
.appendingPathComponent("user-info")
|
||||||
|
.appendingPathComponent(pubkey.hex())
|
||||||
|
.appendingPathComponent(token)
|
||||||
|
|
||||||
|
|
||||||
let (data, response) = try await make_nip98_authenticated_request(
|
let (data, response) = try await make_nip98_authenticated_request(
|
||||||
method: .post,
|
method: .delete,
|
||||||
url: url,
|
url: url,
|
||||||
payload: json_data,
|
payload: nil,
|
||||||
payload_type: .json,
|
payload_type: .json,
|
||||||
auth_keypair: self.keypair
|
auth_keypair: self.keypair
|
||||||
)
|
)
|
||||||
@@ -94,6 +90,78 @@ struct PushNotificationClient {
|
|||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func set_settings(_ new_settings: NotificationSettings? = nil) async throws {
|
||||||
|
// Send the device token and pubkey to the server
|
||||||
|
guard let token = device_token_hex else { return }
|
||||||
|
|
||||||
|
Log.info("Sending notification preferences to the server", for: .push_notifications)
|
||||||
|
|
||||||
|
let url = self.current_push_notification_environment().api_base_url()
|
||||||
|
.appendingPathComponent("user-info")
|
||||||
|
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||||
|
.appendingPathComponent(token)
|
||||||
|
.appendingPathComponent("preferences")
|
||||||
|
|
||||||
|
let json_payload = try JSONEncoder().encode(new_settings ?? NotificationSettings.from(settings: settings))
|
||||||
|
|
||||||
|
let (data, response) = try await make_nip98_authenticated_request(
|
||||||
|
method: .put,
|
||||||
|
url: url,
|
||||||
|
payload: json_payload,
|
||||||
|
payload_type: .json,
|
||||||
|
auth_keypair: self.keypair
|
||||||
|
)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200:
|
||||||
|
Log.info("Sent notification settings to Damus push notification server successfully", for: .push_notifications)
|
||||||
|
default:
|
||||||
|
Log.error("Error in sending notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||||
|
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func get_settings() async throws -> NotificationSettings {
|
||||||
|
// Send the device token and pubkey to the server
|
||||||
|
guard let token = device_token_hex else {
|
||||||
|
throw ClientError.no_device_token
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = self.current_push_notification_environment().api_base_url()
|
||||||
|
.appendingPathComponent("user-info")
|
||||||
|
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||||
|
.appendingPathComponent(token)
|
||||||
|
.appendingPathComponent("preferences")
|
||||||
|
|
||||||
|
let (data, response) = try await make_nip98_authenticated_request(
|
||||||
|
method: .get,
|
||||||
|
url: url,
|
||||||
|
payload: nil,
|
||||||
|
payload_type: .json,
|
||||||
|
auth_keypair: self.keypair
|
||||||
|
)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200:
|
||||||
|
guard let notification_settings = NotificationSettings.from(json_data: data) else { throw ClientError.json_decoding_error }
|
||||||
|
return notification_settings
|
||||||
|
default:
|
||||||
|
Log.error("Error in getting notification settings to Damus push notification server. HTTP status code: %d; Response: %s", for: .push_notifications, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||||
|
throw ClientError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ClientError.could_not_process_response
|
||||||
|
}
|
||||||
|
|
||||||
|
func current_push_notification_environment() -> Environment {
|
||||||
|
return self.settings.push_notification_environment
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Helper structures
|
// MARK: Helper structures
|
||||||
@@ -101,5 +169,121 @@ struct PushNotificationClient {
|
|||||||
extension PushNotificationClient {
|
extension PushNotificationClient {
|
||||||
enum ClientError: Error {
|
enum ClientError: Error {
|
||||||
case http_response_error(status_code: Int, response: Data)
|
case http_response_error(status_code: Int, response: Data)
|
||||||
|
case could_not_process_response
|
||||||
|
case no_device_token
|
||||||
|
case json_decoding_error
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NotificationSettings: Codable, Equatable {
|
||||||
|
let zap_notifications_enabled: Bool
|
||||||
|
let mention_notifications_enabled: Bool
|
||||||
|
let repost_notifications_enabled: Bool
|
||||||
|
let reaction_notifications_enabled: Bool
|
||||||
|
let dm_notifications_enabled: Bool
|
||||||
|
let only_notifications_from_following_enabled: Bool
|
||||||
|
|
||||||
|
static func from(json_data: Data) -> Self? {
|
||||||
|
guard let decoded = try? JSONDecoder().decode(Self.self, from: json_data) else { return nil }
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(settings: UserSettingsStore) -> Self {
|
||||||
|
return NotificationSettings(
|
||||||
|
zap_notifications_enabled: settings.zap_notification,
|
||||||
|
mention_notifications_enabled: settings.mention_notification,
|
||||||
|
repost_notifications_enabled: settings.repost_notification,
|
||||||
|
reaction_notifications_enabled: settings.like_notification,
|
||||||
|
dm_notifications_enabled: settings.dm_notification,
|
||||||
|
only_notifications_from_following_enabled: settings.notification_only_from_following
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Environment: CaseIterable, Codable, Identifiable, StringCodable, Equatable, Hashable {
|
||||||
|
static var allCases: [Environment] = [.local_test(host: nil), .staging, .production]
|
||||||
|
|
||||||
|
case local_test(host: String?)
|
||||||
|
case staging
|
||||||
|
case production
|
||||||
|
|
||||||
|
func text_description() -> String {
|
||||||
|
switch self {
|
||||||
|
case .local_test:
|
||||||
|
return NSLocalizedString("Test (local)", comment: "Label indicating a local test environment for Push notification functionality (Developer feature)")
|
||||||
|
case .production:
|
||||||
|
return NSLocalizedString("Production", comment: "Label indicating the production environment for Push notification functionality")
|
||||||
|
case .staging:
|
||||||
|
return NSLocalizedString("Staging (for dev builds)", comment: "Label indicating the staging environment for Push notification functionality")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func api_base_url() -> URL {
|
||||||
|
switch self {
|
||||||
|
case .local_test(let host):
|
||||||
|
URL(string: "http://\(host ?? "localhost:8000")") ?? Constants.PUSH_NOTIFICATION_SERVER_TEST_BASE_URL
|
||||||
|
case .production:
|
||||||
|
Constants.PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL
|
||||||
|
case .staging:
|
||||||
|
Constants.PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func custom_host() -> String? {
|
||||||
|
switch self {
|
||||||
|
case .local_test(let host):
|
||||||
|
return host
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(from string: String) {
|
||||||
|
switch string {
|
||||||
|
case "local_test":
|
||||||
|
self = .local_test(host: nil)
|
||||||
|
case "production":
|
||||||
|
self = .production
|
||||||
|
case "staging":
|
||||||
|
self = .staging
|
||||||
|
default:
|
||||||
|
let components = string.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||||
|
if components.count == 2 && components[0] == "local_test" {
|
||||||
|
self = .local_test(host: String(components[1]))
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func to_string() -> String {
|
||||||
|
switch self {
|
||||||
|
case .local_test(let host):
|
||||||
|
if let host {
|
||||||
|
return "local_test:\(host)"
|
||||||
|
}
|
||||||
|
return "local_test"
|
||||||
|
case .staging:
|
||||||
|
return "staging"
|
||||||
|
case .production:
|
||||||
|
return "production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .local_test(let host):
|
||||||
|
if let host {
|
||||||
|
return "local_test:\(host)"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "local_test"
|
||||||
|
}
|
||||||
|
case .production:
|
||||||
|
return "production"
|
||||||
|
case .staging:
|
||||||
|
return "staging"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class SearchModel: ObservableObject {
|
|||||||
func subscribe() {
|
func subscribe() {
|
||||||
// since 1 month
|
// since 1 month
|
||||||
search.limit = self.limit
|
search.limit = self.limit
|
||||||
search.kinds = [.text, .like, .longform]
|
search.kinds = [.text, .like, .longform, .highlight]
|
||||||
|
|
||||||
//likes_filter.ids = ref_events.referenced_ids!
|
//likes_filter.ids = ref_events.referenced_ids!
|
||||||
|
|
||||||
|
|||||||
@@ -11,16 +11,24 @@ import Foundation
|
|||||||
class ThreadModel: ObservableObject {
|
class ThreadModel: ObservableObject {
|
||||||
@Published var event: NostrEvent
|
@Published var event: NostrEvent
|
||||||
let original_event: NostrEvent
|
let original_event: NostrEvent
|
||||||
|
let highlight: String?
|
||||||
var event_map: Set<NostrEvent>
|
var event_map: Set<NostrEvent>
|
||||||
|
|
||||||
init(event: NostrEvent, damus_state: DamusState) {
|
init(event: NostrEvent, damus_state: DamusState, highlight: String? = nil) {
|
||||||
self.damus_state = damus_state
|
self.damus_state = damus_state
|
||||||
self.event_map = Set()
|
self.event_map = Set()
|
||||||
self.event = event
|
self.event = event
|
||||||
self.original_event = event
|
self.original_event = event
|
||||||
|
self.highlight = highlight
|
||||||
add_event(event, keypair: damus_state.keypair)
|
add_event(event, keypair: damus_state.keypair)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func events() -> [NostrEvent] {
|
||||||
|
return Array(event_map).sorted(by: { a, b in
|
||||||
|
return a.created_at < b.created_at
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var is_original: Bool {
|
var is_original: Bool {
|
||||||
return original_event.id == event.id
|
return original_event.id == event.id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
|
|||||||
var model: Model {
|
var model: Model {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("none_translation_service", value: "None", comment: "Dropdown option for selecting no translation service."))
|
let displayName: String
|
||||||
|
if TranslationService.isAppleTranslationSupported {
|
||||||
|
displayName = NSLocalizedString("apple_translation_service", value: "Apple", comment: "Dropdown option for selecting Apple as a translation service.")
|
||||||
|
} else {
|
||||||
|
displayName = NSLocalizedString("none_translation_service", value: "None", comment: "Dropdown option for selecting no translation service.")
|
||||||
|
}
|
||||||
|
return .init(tag: self.rawValue, displayName: displayName)
|
||||||
case .purple:
|
case .purple:
|
||||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("Damus Purple", comment: "Dropdown option for selecting Damus Purple as a translation service."))
|
return .init(tag: self.rawValue, displayName: NSLocalizedString("Damus Purple", comment: "Dropdown option for selecting Damus Purple as a translation service."))
|
||||||
case .libretranslate:
|
case .libretranslate:
|
||||||
@@ -51,4 +57,16 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
|
|||||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("translate.nostr.wine (DeepL, Pay with BTC)", comment: "Dropdown option for selecting translate.nostr.wine as the translation service."))
|
return .init(tag: self.rawValue, displayName: NSLocalizedString("translate.nostr.wine (DeepL, Pay with BTC)", comment: "Dropdown option for selecting translate.nostr.wine as the translation service."))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var isAppleTranslationSupported: Bool {
|
||||||
|
#if targetEnvironment(macCatalyst)
|
||||||
|
return false
|
||||||
|
#else
|
||||||
|
if #available(iOS 17.4, macOS 14.4, *) {
|
||||||
|
return true
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ class UserSettingsStore: ObservableObject {
|
|||||||
@Setting(key: "like_notification", default_value: true)
|
@Setting(key: "like_notification", default_value: true)
|
||||||
var like_notification: Bool
|
var like_notification: Bool
|
||||||
|
|
||||||
@StringSetting(key: "notifications_mode", default_value: .local)
|
@StringSetting(key: "notification_mode", default_value: .push)
|
||||||
var notifications_mode: NotificationsMode
|
var notification_mode: NotificationsMode
|
||||||
|
|
||||||
@Setting(key: "notification_only_from_following", default_value: false)
|
@Setting(key: "notification_only_from_following", default_value: false)
|
||||||
var notification_only_from_following: Bool
|
var notification_only_from_following: Bool
|
||||||
@@ -180,6 +180,9 @@ class UserSettingsStore: ObservableObject {
|
|||||||
@Setting(key: "auto_translate", default_value: true)
|
@Setting(key: "auto_translate", default_value: true)
|
||||||
var auto_translate: Bool
|
var auto_translate: Bool
|
||||||
|
|
||||||
|
@Setting(key: "translate_offline", default_value: true)
|
||||||
|
var translate_offline: Bool
|
||||||
|
|
||||||
@Setting(key: "show_general_statuses", default_value: true)
|
@Setting(key: "show_general_statuses", default_value: true)
|
||||||
var show_general_statuses: Bool
|
var show_general_statuses: Bool
|
||||||
|
|
||||||
@@ -207,11 +210,12 @@ class UserSettingsStore: ObservableObject {
|
|||||||
@Setting(key: "always_show_onboarding_suggestions", default_value: false)
|
@Setting(key: "always_show_onboarding_suggestions", default_value: false)
|
||||||
var always_show_onboarding_suggestions: Bool
|
var always_show_onboarding_suggestions: Bool
|
||||||
|
|
||||||
@Setting(key: "enable_experimental_push_notifications", default_value: false)
|
// @Setting(key: "enable_experimental_push_notifications", default_value: false)
|
||||||
var enable_experimental_push_notifications: Bool
|
// This was a feature flag setting during early development, but now this is enabled for everyone.
|
||||||
|
var enable_push_notifications: Bool = true
|
||||||
|
|
||||||
@Setting(key: "send_device_token_to_localhost", default_value: false)
|
@StringSetting(key: "push_notification_environment", default_value: .production)
|
||||||
var send_device_token_to_localhost: Bool
|
var push_notification_environment: PushNotificationClient.Environment
|
||||||
|
|
||||||
@Setting(key: "enable_experimental_purple_api", default_value: false)
|
@Setting(key: "enable_experimental_purple_api", default_value: false)
|
||||||
var enable_experimental_purple_api: Bool
|
var enable_experimental_purple_api: Bool
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ enum NostrKind: UInt32, Codable {
|
|||||||
case longform = 30023
|
case longform = 30023
|
||||||
case zap = 9735
|
case zap = 9735
|
||||||
case zap_request = 9734
|
case zap_request = 9734
|
||||||
|
case highlight = 9802
|
||||||
case nwc_request = 23194
|
case nwc_request = 23194
|
||||||
case nwc_response = 23195
|
case nwc_response = 23195
|
||||||
case http_auth = 27235
|
case http_auth = 27235
|
||||||
|
|||||||
@@ -122,20 +122,22 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
|||||||
case hashtag(Hashtag)
|
case hashtag(Hashtag)
|
||||||
case param(TagElem)
|
case param(TagElem)
|
||||||
case naddr(NAddr)
|
case naddr(NAddr)
|
||||||
|
case reference(String)
|
||||||
|
|
||||||
var key: RefKey {
|
var key: RefKey {
|
||||||
switch self {
|
switch self {
|
||||||
case .event: return .e
|
case .event: return .e
|
||||||
case .pubkey: return .p
|
case .pubkey: return .p
|
||||||
case .quote: return .q
|
case .quote: return .q
|
||||||
case .hashtag: return .t
|
case .hashtag: return .t
|
||||||
case .param: return .d
|
case .param: return .d
|
||||||
case .naddr: return .a
|
case .naddr: return .a
|
||||||
|
case .reference: return .r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
|
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
|
||||||
case e, p, t, d, q, a
|
case e, p, t, d, q, a, r
|
||||||
|
|
||||||
var keychar: AsciiCharacter {
|
var keychar: AsciiCharacter {
|
||||||
self.rawValue
|
self.rawValue
|
||||||
@@ -159,6 +161,8 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
|||||||
case .param(let string): return string.string()
|
case .param(let string): return string.string()
|
||||||
case .naddr(let naddr):
|
case .naddr(let naddr):
|
||||||
return naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier
|
return naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier
|
||||||
|
case .reference(let string):
|
||||||
|
return string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +183,7 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
|||||||
case .t: return .hashtag(Hashtag(hashtag: t1.string()))
|
case .t: return .hashtag(Hashtag(hashtag: t1.string()))
|
||||||
case .d: return .param(t1)
|
case .d: return .param(t1)
|
||||||
case .a: return .naddr(NAddr(identifier: "", author: Pubkey(Data()), relays: [], kind: 0))
|
case .a: return .naddr(NAddr(identifier: "", author: Pubkey(Data()), relays: [], kind: 0))
|
||||||
|
case .r: return .reference(t1.string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ final class RelayConnection: ObservableObject {
|
|||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
self.last_pong = .now
|
self.last_pong = .now
|
||||||
|
Log.info("Got pong from '%s'", for: .networking, self.relay_url.absoluteString)
|
||||||
self.log?.add("Successful ping")
|
self.log?.add("Successful ping")
|
||||||
} else {
|
} else {
|
||||||
print("pong failed, reconnecting \(self.relay_url.id)")
|
Log.info("Ping failed, reconnecting to '%s'", for: .networking, self.relay_url.absoluteString)
|
||||||
self.isConnected = false
|
self.isConnected = false
|
||||||
self.isConnecting = false
|
self.isConnecting = false
|
||||||
self.reconnect_with_backoff()
|
self.reconnect_with_backoff()
|
||||||
@@ -126,7 +127,7 @@ final class RelayConnection: ObservableObject {
|
|||||||
self.receive(message: message)
|
self.receive(message: message)
|
||||||
case .disconnected(let closeCode, let reason):
|
case .disconnected(let closeCode, let reason):
|
||||||
if closeCode != .normalClosure {
|
if closeCode != .normalClosure {
|
||||||
print("⚠️ Warning: RelayConnection (\(self.relay_url)) closed with code \(closeCode), reason: \(String(describing: reason))")
|
Log.error("⚠️ Warning: RelayConnection (%d) closed with code: %s", for: .networking, String(describing: closeCode), String(describing: reason))
|
||||||
}
|
}
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isConnected = false
|
self.isConnected = false
|
||||||
@@ -134,12 +135,16 @@ final class RelayConnection: ObservableObject {
|
|||||||
self.reconnect()
|
self.reconnect()
|
||||||
}
|
}
|
||||||
case .error(let error):
|
case .error(let error):
|
||||||
print("⚠️ Warning: RelayConnection (\(self.relay_url)) error: \(error)")
|
Log.error("⚠️ Warning: RelayConnection (%s) error: %s", for: .networking, self.relay_url.absoluteString, error.localizedDescription)
|
||||||
let nserr = error as NSError
|
let nserr = error as NSError
|
||||||
if nserr.domain == NSPOSIXErrorDomain && nserr.code == 57 {
|
if nserr.domain == NSPOSIXErrorDomain && nserr.code == 57 {
|
||||||
// ignore socket not connected?
|
// ignore socket not connected?
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if nserr.domain == NSURLErrorDomain && nserr.code == -999 {
|
||||||
|
// these aren't real error, it just means task was cancelled
|
||||||
|
return
|
||||||
|
}
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isConnected = false
|
self.isConnected = false
|
||||||
self.isConnecting = false
|
self.isConnecting = false
|
||||||
@@ -156,14 +161,21 @@ final class RelayConnection: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func reconnect_with_backoff() {
|
func reconnect_with_backoff() {
|
||||||
self.backoff *= 1.5
|
self.backoff *= 2.0
|
||||||
self.reconnect_in(after: self.backoff)
|
self.reconnect_in(after: self.backoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
func reconnect() {
|
func reconnect() {
|
||||||
guard !isConnecting && !isDisabled else {
|
guard !isConnecting && !isDisabled else {
|
||||||
|
self.log?.add("Cancelling reconnect, already connecting")
|
||||||
return // we're already trying to connect or we're disabled
|
return // we're already trying to connect or we're disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard !self.isConnected else {
|
||||||
|
self.log?.add("Cancelling reconnect, already connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
disconnect()
|
disconnect()
|
||||||
connect()
|
connect()
|
||||||
log?.add("Reconnecting...")
|
log?.add("Reconnecting...")
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ class RelayPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ping() {
|
func ping() {
|
||||||
|
Log.info("Pinging %d relays", for: .networking, relays.count)
|
||||||
for relay in relays {
|
for relay in relays {
|
||||||
relay.connection.ping()
|
relay.connection.ping()
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-1
File diff suppressed because one or more lines are too long
@@ -10,13 +10,14 @@ import Foundation
|
|||||||
class Constants {
|
class Constants {
|
||||||
//static let EXAMPLE_DEMOS: DamusState = .empty
|
//static let EXAMPLE_DEMOS: DamusState = .empty
|
||||||
static let DAMUS_APP_GROUP_IDENTIFIER: String = "group.com.damus"
|
static let DAMUS_APP_GROUP_IDENTIFIER: String = "group.com.damus"
|
||||||
static let DEVICE_TOKEN_RECEIVER_PRODUCTION_URL: URL = URL(string: "https://notify.damus.io:8000/user-info")!
|
|
||||||
static let DEVICE_TOKEN_RECEIVER_TEST_URL: URL = URL(string: "http://localhost:8000/user-info")!
|
|
||||||
static let DEVICE_TOKEN_REVOKER_PRODUCTION_URL: URL = URL(string: "https://notify.damus.io:8000/user-info/remove")!
|
|
||||||
static let DEVICE_TOKEN_REVOKER_TEST_URL: URL = URL(string: "http://localhost:8000/user-info/remove")!
|
|
||||||
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
|
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
|
||||||
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
|
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
|
||||||
|
|
||||||
|
// MARK: Push notification server
|
||||||
|
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
|
||||||
|
static let PUSH_NOTIFICATION_SERVER_STAGING_BASE_URL: URL = URL(string: "https://notify-staging.damus.io")!
|
||||||
|
static let PUSH_NOTIFICATION_SERVER_TEST_BASE_URL: URL = URL(string: "http://localhost:8000")!
|
||||||
|
|
||||||
// MARK: Purple
|
// MARK: Purple
|
||||||
// API
|
// API
|
||||||
static let PURPLE_API_LOCAL_TEST_BASE_URL: URL = URL(string: "http://localhost:8989")!
|
static let PURPLE_API_LOCAL_TEST_BASE_URL: URL = URL(string: "http://localhost:8989")!
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//
|
||||||
|
// DamusAliases.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2024-08-12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
let this_app: UIApplication = UIApplication.shared
|
||||||
+18
-14
@@ -97,13 +97,13 @@ class EventCache {
|
|||||||
// TODO: remove me and change code to use ndb directly
|
// TODO: remove me and change code to use ndb directly
|
||||||
private let ndb: Ndb
|
private let ndb: Ndb
|
||||||
private var events: [NoteId: NostrEvent] = [:]
|
private var events: [NoteId: NostrEvent] = [:]
|
||||||
private var replies = ReplyMap()
|
|
||||||
private var cancellable: AnyCancellable?
|
private var cancellable: AnyCancellable?
|
||||||
private var image_metadata: [String: ImageMetadataState] = [:] // lowercased URL key
|
private var image_metadata: [String: ImageMetadataState] = [:] // lowercased URL key
|
||||||
private var event_data: [NoteId: EventData] = [:]
|
private var event_data: [NoteId: EventData] = [:]
|
||||||
|
var replies = ReplyMap()
|
||||||
|
|
||||||
//private var thread_latest: [String: Int64]
|
//private var thread_latest: [String: Int64]
|
||||||
|
|
||||||
init(ndb: Ndb) {
|
init(ndb: Ndb) {
|
||||||
self.ndb = ndb
|
self.ndb = ndb
|
||||||
cancellable = NotificationCenter.default.publisher(
|
cancellable = NotificationCenter.default.publisher(
|
||||||
@@ -187,7 +187,7 @@ class EventCache {
|
|||||||
replies.add(id: reply, reply_id: ev.id)
|
replies.add(id: reply, reply_id: ev.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func child_events(event: NostrEvent) -> [NostrEvent] {
|
func child_events(event: NostrEvent) -> [NostrEvent] {
|
||||||
guard let xs = replies.lookup(event.id) else {
|
guard let xs = replies.lookup(event.id) else {
|
||||||
return []
|
return []
|
||||||
@@ -244,16 +244,12 @@ class EventCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
|
func should_translate(event: NostrEvent, our_keypair: Keypair, note_lang: String?) -> Bool {
|
||||||
guard settings.can_translate else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't translate reposts, longform, etc
|
// don't translate reposts, longform, etc
|
||||||
if event.kind != 1 {
|
if event.kind != 1 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not translate self-authored notes if logged in with a private key
|
// Do not translate self-authored notes if logged in with a private key
|
||||||
// as we can assume the user can understand their own notes.
|
// as we can assume the user can understand their own notes.
|
||||||
// The detected language prediction could be incorrect and not in the list of preferred languages.
|
// The detected language prediction could be incorrect and not in the list of preferred languages.
|
||||||
@@ -261,25 +257,33 @@ func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSet
|
|||||||
if our_keypair.privkey != nil && our_keypair.pubkey == event.pubkey {
|
if our_keypair.privkey != nil && our_keypair.pubkey == event.pubkey {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if let note_lang {
|
if let note_lang {
|
||||||
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
|
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
|
||||||
|
|
||||||
// Don't translate if its in our preferred languages
|
// Don't translate if its in our preferred languages
|
||||||
guard !preferredLanguages.contains(note_lang) else {
|
guard !preferredLanguages.contains(note_lang) else {
|
||||||
// if its the same, give up and don't retry
|
// if its the same, give up and don't retry
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we should start translating if we have auto_translate on
|
// we should start translating if we have auto_translate on
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func can_and_should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
|
||||||
|
guard settings.can_translate else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return should_translate(event: event, our_keypair: our_keypair, note_lang: note_lang)
|
||||||
|
}
|
||||||
|
|
||||||
func should_preload_translation(event: NostrEvent, our_keypair: Keypair, current_status: TranslateStatus, settings: UserSettingsStore, note_lang: String?) -> Bool {
|
func should_preload_translation(event: NostrEvent, our_keypair: Keypair, current_status: TranslateStatus, settings: UserSettingsStore, note_lang: String?) -> Bool {
|
||||||
switch current_status {
|
switch current_status {
|
||||||
case .havent_tried:
|
case .havent_tried:
|
||||||
return should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate
|
return can_and_should_translate(event: event, our_keypair: our_keypair, settings: settings, note_lang: note_lang) && settings.auto_translate
|
||||||
case .translating: return false
|
case .translating: return false
|
||||||
case .translated: return false
|
case .translated: return false
|
||||||
case .not_needed: return false
|
case .not_needed: return false
|
||||||
@@ -413,7 +417,7 @@ func preload_event(plan: PreloadPlan, state: DamusState) async {
|
|||||||
|
|
||||||
var translations: TranslateStatus? = nil
|
var translations: TranslateStatus? = nil
|
||||||
// We have to recheck should_translate here now that we have note_language
|
// We have to recheck should_translate here now that we have note_language
|
||||||
if plan.load_translations && should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate
|
if plan.load_translations && can_and_should_translate(event: plan.event, our_keypair: our_keypair, settings: settings, note_lang: note_language) && settings.auto_translate
|
||||||
{
|
{
|
||||||
translations = await translate_note(profiles: profiles, keypair: our_keypair, event: plan.event, settings: settings, note_lang: note_language, purple: state.purple)
|
translations = await translate_note(profiles: profiles, keypair: our_keypair, event: plan.event, settings: settings, note_lang: note_language, purple: state.purple)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// VectorMath.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2024-06-17.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension CGPoint {
|
||||||
|
/// Summing a vector to a point
|
||||||
|
static func +(lhs: CGPoint, rhs: CGVector) -> CGPoint {
|
||||||
|
return CGPoint(x: lhs.x + rhs.dx, y: lhs.y + rhs.dy)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subtracting a vector from a point
|
||||||
|
static func -(lhs: CGPoint, rhs: CGVector) -> CGPoint {
|
||||||
|
return CGPoint(x: lhs.x - rhs.dx, y: lhs.y - rhs.dy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CGVector {
|
||||||
|
/// Multiplying a vector by a scalar
|
||||||
|
static func *(lhs: CGVector, rhs: CGFloat) -> CGVector {
|
||||||
|
return CGVector(dx: lhs.dx * rhs, dy: lhs.dy * rhs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,8 @@ struct ImageMetadata: Equatable {
|
|||||||
|
|
||||||
func process_blurhash(blurhash: String, size: CGSize?) async -> UIImage? {
|
func process_blurhash(blurhash: String, size: CGSize?) async -> UIImage? {
|
||||||
let res = Task.detached(priority: .low) {
|
let res = Task.detached(priority: .low) {
|
||||||
let size = get_blurhash_size(img_size: size ?? CGSize(width: 100.0, height: 100.0))
|
let default_size = CGSize(width: 100.0, height: 100.0)
|
||||||
|
let size = get_blurhash_size(img_size: size ?? default_size) ?? default_size
|
||||||
guard let img = UIImage.init(blurHash: blurhash, size: size) else {
|
guard let img = UIImage.init(blurHash: blurhash, size: size) else {
|
||||||
let noimg: UIImage? = nil
|
let noimg: UIImage? = nil
|
||||||
return noimg
|
return noimg
|
||||||
@@ -135,7 +136,8 @@ extension UIImage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func get_blurhash_size(img_size: CGSize) -> CGSize {
|
func get_blurhash_size(img_size: CGSize) -> CGSize? {
|
||||||
|
guard img_size.width > 0 && img_size.height > 0 else { return nil }
|
||||||
return CGSize(width: 100.0, height: (100.0/img_size.width) * img_size.height)
|
return CGSize(width: 100.0, height: (100.0/img_size.width) * img_size.height)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +147,7 @@ func calculate_blurhash(img: UIImage) async -> String? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let res = Task.detached(priority: .low) {
|
let res = Task.detached(priority: .low) {
|
||||||
let bhs = get_blurhash_size(img_size: img.size)
|
let bhs = get_blurhash_size(img_size: img.size) ?? CGSize(width: 100.0, height: 100.0)
|
||||||
let smaller = img.resized(to: bhs)
|
let smaller = img.resized(to: bhs)
|
||||||
|
|
||||||
guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else {
|
guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public struct DismissKeyboardOnTap: ViewModifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func end_editing() {
|
public func end_editing() {
|
||||||
UIApplication.shared.connectedScenes
|
this_app.connectedScenes
|
||||||
.filter {$0.activationState == .foregroundActive}
|
.filter {$0.activationState == .foregroundActive}
|
||||||
.map {$0 as? UIWindowScene}
|
.map {$0 as? UIWindowScene}
|
||||||
.compactMap({$0})
|
.compactMap({$0})
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//
|
||||||
|
// LanguageSortComparator.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 9/22/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct LanguageSortComparator: SortComparator {
|
||||||
|
var order: SortOrder
|
||||||
|
|
||||||
|
func compare(_ lhs: Locale.Language, _ rhs: Locale.Language) -> ComparisonResult {
|
||||||
|
let comparisonResult = compareForward(lhs, rhs)
|
||||||
|
switch order {
|
||||||
|
case .forward:
|
||||||
|
return comparisonResult
|
||||||
|
case .reverse:
|
||||||
|
switch comparisonResult {
|
||||||
|
case .orderedAscending:
|
||||||
|
return .orderedDescending
|
||||||
|
case .orderedDescending:
|
||||||
|
return .orderedAscending
|
||||||
|
case .orderedSame:
|
||||||
|
return .orderedSame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func compareForward(_ lhs: Locale.Language, _ rhs: Locale.Language) -> ComparisonResult {
|
||||||
|
let currentLocale = Locale.current
|
||||||
|
let localizedLhs = currentLocale.localizedString(forLanguage: lhs)
|
||||||
|
let localizedRhs = currentLocale.localizedString(forLanguage: rhs)
|
||||||
|
|
||||||
|
return localizedLhs.localizedCompare(localizedRhs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Locale {
|
||||||
|
func localizedString(forLanguage language: Locale.Language) -> String {
|
||||||
|
guard let languageCode = language.languageCode, let localizedLanguageCode = localizedString(forLanguageCode: languageCode.identifier) else {
|
||||||
|
return language.languageCode?.identifier ?? language.minimalIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
if let region = language.region, let localizedRegion = localizedString(forRegionCode: region.identifier) {
|
||||||
|
return "\(localizedLanguageCode) (\(localizedRegion))"
|
||||||
|
} else {
|
||||||
|
return localizedLanguageCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,10 +48,24 @@ struct LossyLocalNotification {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum NotificationTarget {
|
||||||
|
case note(NostrEvent)
|
||||||
|
case note_id(NoteId)
|
||||||
|
|
||||||
|
var id: NoteId {
|
||||||
|
switch self {
|
||||||
|
case .note(let note):
|
||||||
|
return note.id
|
||||||
|
case .note_id(let id):
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct LocalNotification {
|
struct LocalNotification {
|
||||||
let type: LocalNotificationType
|
let type: LocalNotificationType
|
||||||
let event: NostrEvent
|
let event: NostrEvent
|
||||||
let target: NostrEvent
|
let target: NotificationTarget
|
||||||
let content: String
|
let content: String
|
||||||
|
|
||||||
func to_lossy() -> LossyLocalNotification {
|
func to_lossy() -> LossyLocalNotification {
|
||||||
@@ -63,6 +77,8 @@ enum LocalNotificationType: String {
|
|||||||
case dm
|
case dm
|
||||||
case like
|
case like
|
||||||
case mention
|
case mention
|
||||||
|
case reply
|
||||||
|
case tagged
|
||||||
case repost
|
case repost
|
||||||
case zap
|
case zap
|
||||||
case profile_zap
|
case profile_zap
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ enum LogCategory: String {
|
|||||||
case nav
|
case nav
|
||||||
case render
|
case render
|
||||||
case storage
|
case storage
|
||||||
|
case networking
|
||||||
case push_notifications
|
case push_notifications
|
||||||
case damus_purple
|
case damus_purple
|
||||||
case image_uploading
|
case image_uploading
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ enum Route: Hashable {
|
|||||||
case .TranslationSettings(let settings):
|
case .TranslationSettings(let settings):
|
||||||
TranslationSettingsView(settings: settings, damus_state: damusState)
|
TranslationSettingsView(settings: settings, damus_state: damusState)
|
||||||
case .ReactionsSettings(let settings):
|
case .ReactionsSettings(let settings):
|
||||||
ReactionsSettingsView(settings: settings)
|
ReactionsSettingsView(settings: settings, damus_state: damusState)
|
||||||
case .SearchSettings(let settings):
|
case .SearchSettings(let settings):
|
||||||
SearchSettingsView(settings: settings)
|
SearchSettingsView(settings: settings)
|
||||||
case .DeveloperSettings(let settings):
|
case .DeveloperSettings(let settings):
|
||||||
@@ -93,7 +93,8 @@ enum Route: Hashable {
|
|||||||
case .FirstAidSettings(settings: let settings):
|
case .FirstAidSettings(settings: let settings):
|
||||||
FirstAidSettingsView(damus_state: damusState, settings: settings)
|
FirstAidSettingsView(damus_state: damusState, settings: settings)
|
||||||
case .Thread(let thread):
|
case .Thread(let thread):
|
||||||
ThreadView(state: damusState, thread: thread)
|
ChatroomThreadView(damus: damusState, thread: thread)
|
||||||
|
//ThreadView(state: damusState, thread: thread)
|
||||||
case .Reposts(let reposts):
|
case .Reposts(let reposts):
|
||||||
RepostsView(damus_state: damusState, model: reposts)
|
RepostsView(damus_state: damusState, model: reposts)
|
||||||
case .QuoteReposts(let quote_reposts):
|
case .QuoteReposts(let quote_reposts):
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import UIKit
|
|||||||
class Theme {
|
class Theme {
|
||||||
|
|
||||||
static var safeAreaInsets: UIEdgeInsets? {
|
static var safeAreaInsets: UIEdgeInsets? {
|
||||||
return UIApplication
|
return this_app
|
||||||
.shared
|
|
||||||
.connectedScenes
|
.connectedScenes
|
||||||
.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
|
.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
|
||||||
.first { $0.isKeyWindow }?.safeAreaInsets
|
.first { $0.isKeyWindow }?.safeAreaInsets
|
||||||
|
|||||||
+6
-19
@@ -309,14 +309,10 @@ struct Zap {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
guard let desc = get_zap_description(zap_ev, inv_desc: zap_invoice.description) else {
|
guard let zap_req = get_zap_request(zap_ev) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let zap_req = decode_nostr_event_json(desc) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
guard validate_event(ev: zap_req) == .ok else {
|
guard validate_event(ev: zap_req) == .ok else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -399,21 +395,12 @@ func event_has_tag(ev: NostrEvent, tag: String) -> Bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches the description from either the invoice, or tags, depending on the type of invoice
|
func get_zap_request(_ ev: NostrEvent) -> NostrEvent? {
|
||||||
func get_zap_description(_ ev: NostrEvent, inv_desc: InvoiceDescription) -> String? {
|
guard let desc = event_tag(ev, name: "description") else {
|
||||||
switch inv_desc {
|
return nil
|
||||||
case .description(let string):
|
|
||||||
return string
|
|
||||||
case .description_hash(let deschash):
|
|
||||||
guard let desc = event_tag(ev, name: "description") else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let data = desc.data(using: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return desc
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return decode_nostr_event_json(desc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func invoice_to_zap_invoice(_ invoice: Invoice) -> ZapInvoice? {
|
func invoice_to_zap_invoice(_ invoice: Invoice) -> ZapInvoice? {
|
||||||
|
|||||||
@@ -6,28 +6,34 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MCEmojiPicker
|
import EmojiPicker
|
||||||
|
import EmojiKit
|
||||||
|
import SwipeActions
|
||||||
|
|
||||||
struct EventActionBar: View {
|
struct EventActionBar: View {
|
||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
let event: NostrEvent
|
let event: NostrEvent
|
||||||
let generator = UIImpactFeedbackGenerator(style: .medium)
|
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||||
let userProfile : ProfileModel
|
let userProfile : ProfileModel
|
||||||
|
let swipe_context: SwipeContext?
|
||||||
|
let options: Options
|
||||||
|
|
||||||
// just used for previews
|
// just used for previews
|
||||||
@State var show_share_sheet: Bool = false
|
@State var show_share_sheet: Bool = false
|
||||||
@State var show_share_action: Bool = false
|
@State var show_share_action: Bool = false
|
||||||
@State var show_repost_action: Bool = false
|
@State var show_repost_action: Bool = false
|
||||||
|
|
||||||
@State private var isOnTopHalfOfScreen: Bool = false
|
@State private var selectedEmoji: Emoji? = nil
|
||||||
|
|
||||||
@ObservedObject var bar: ActionBarModel
|
@ObservedObject var bar: ActionBarModel
|
||||||
|
|
||||||
init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil) {
|
init(damus_state: DamusState, event: NostrEvent, bar: ActionBarModel? = nil, options: Options = [], swipe_context: SwipeContext? = nil) {
|
||||||
self.damus_state = damus_state
|
self.damus_state = damus_state
|
||||||
self.event = event
|
self.event = event
|
||||||
_bar = ObservedObject(wrappedValue: bar ?? make_actionbar_model(ev: event.id, damus: damus_state))
|
_bar = ObservedObject(wrappedValue: bar ?? make_actionbar_model(ev: event.id, damus: damus_state))
|
||||||
self.userProfile = ProfileModel(pubkey: event.pubkey, damus: damus_state)
|
self.userProfile = ProfileModel(pubkey: event.pubkey, damus: damus_state)
|
||||||
|
self.options = options
|
||||||
|
self.swipe_context = swipe_context
|
||||||
}
|
}
|
||||||
|
|
||||||
var lnurl: String? {
|
var lnurl: String? {
|
||||||
@@ -44,60 +50,176 @@ struct EventActionBar: View {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var space_if_spread: AnyView {
|
||||||
HStack {
|
if options.contains(.no_spread) {
|
||||||
if damus_state.keypair.privkey != nil {
|
return AnyView(EmptyView())
|
||||||
HStack(spacing: 4) {
|
|
||||||
EventActionButton(img: "bubble2", col: bar.replied ? DamusColors.purple : Color.gray) {
|
|
||||||
notify(.compose(.replying_to(event)))
|
|
||||||
}
|
|
||||||
.accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
|
|
||||||
Text(verbatim: "\(bar.replies > 0 ? "\(bar.replies)" : "")")
|
|
||||||
.font(.footnote.weight(.medium))
|
|
||||||
.foregroundColor(bar.replied ? DamusColors.purple : Color.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
|
|
||||||
EventActionButton(img: "repost", col: bar.boosted ? Color.green : nil) {
|
|
||||||
self.show_repost_action = true
|
|
||||||
}
|
|
||||||
.accessibilityLabel(NSLocalizedString("Reposts", comment: "Accessibility label for boosts button"))
|
|
||||||
Text(verbatim: "\(bar.boosts > 0 ? "\(bar.boosts)" : "")")
|
|
||||||
.font(.footnote.weight(.medium))
|
|
||||||
.foregroundColor(bar.boosted ? Color.green : Color.gray)
|
|
||||||
}
|
|
||||||
|
|
||||||
if show_like {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
LikeButton(damus_state: damus_state, liked: bar.liked, liked_emoji: bar.our_like != nil ? to_reaction_emoji(ev: bar.our_like!) : nil, isOnTopHalfOfScreen: $isOnTopHalfOfScreen) { emoji in
|
|
||||||
if bar.liked {
|
|
||||||
//notify(.delete, bar.our_like)
|
|
||||||
} else {
|
|
||||||
send_like(emoji: emoji)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(verbatim: "\(bar.likes > 0 ? "\(bar.likes)" : "")")
|
|
||||||
.font(.footnote.weight(.medium))
|
|
||||||
.nip05_colorized(gradient: bar.liked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let lnurl = self.lnurl {
|
|
||||||
Spacer()
|
|
||||||
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
EventActionButton(img: "upload", col: Color.gray) {
|
|
||||||
show_share_action = true
|
|
||||||
}
|
|
||||||
.accessibilityLabel(NSLocalizedString("Share", comment: "Button to share a note"))
|
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
return AnyView(Spacer())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Swipe action menu buttons
|
||||||
|
|
||||||
|
var reply_swipe_button: some View {
|
||||||
|
SwipeAction(systemImage: "arrowshape.turn.up.left.fill", backgroundColor: DamusColors.adaptableGrey) {
|
||||||
|
notify(.compose(.replying_to(event)))
|
||||||
|
self.swipe_context?.state.wrappedValue = .closed
|
||||||
|
}
|
||||||
|
.allowSwipeToTrigger()
|
||||||
|
.swipeButtonStyle()
|
||||||
|
.accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var repost_swipe_button: some View {
|
||||||
|
SwipeAction(image: "repost", backgroundColor: DamusColors.adaptableGrey) {
|
||||||
|
self.show_repost_action = true
|
||||||
|
self.swipe_context?.state.wrappedValue = .closed
|
||||||
|
}
|
||||||
|
.swipeButtonStyle()
|
||||||
|
.accessibilityLabel(NSLocalizedString("Repost or quote this note", comment: "Accessibility label for repost/quote button"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var like_swipe_button: some View {
|
||||||
|
SwipeAction(image: "shaka", backgroundColor: DamusColors.adaptableGrey) {
|
||||||
|
send_like(emoji: damus_state.settings.default_emoji_reaction)
|
||||||
|
self.swipe_context?.state.wrappedValue = .closed
|
||||||
|
}
|
||||||
|
.swipeButtonStyle()
|
||||||
|
.accessibilityLabel(NSLocalizedString("React with default reaction emoji", comment: "Accessibility label for react button"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var share_swipe_button: some View {
|
||||||
|
SwipeAction(image: "upload", backgroundColor: DamusColors.adaptableGrey) {
|
||||||
|
show_share_action = true
|
||||||
|
self.swipe_context?.state.wrappedValue = .closed
|
||||||
|
}
|
||||||
|
.swipeButtonStyle()
|
||||||
|
.accessibilityLabel(NSLocalizedString("Share externally", comment: "Accessibility label for external share button"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Bar buttons
|
||||||
|
|
||||||
|
var reply_button: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
EventActionButton(img: "bubble2", col: bar.replied ? DamusColors.purple : Color.gray) {
|
||||||
|
notify(.compose(.replying_to(event)))
|
||||||
|
}
|
||||||
|
.accessibilityLabel(NSLocalizedString("Reply", comment: "Accessibility label for reply button"))
|
||||||
|
Text(verbatim: "\(bar.replies > 0 ? "\(bar.replies)" : "")")
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
.foregroundColor(bar.replied ? DamusColors.purple : Color.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var repost_button: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
|
||||||
|
EventActionButton(img: "repost", col: bar.boosted ? Color.green : nil) {
|
||||||
|
self.show_repost_action = true
|
||||||
|
}
|
||||||
|
.accessibilityLabel(NSLocalizedString("Reposts", comment: "Accessibility label for boosts button"))
|
||||||
|
Text(verbatim: "\(bar.boosts > 0 ? "\(bar.boosts)" : "")")
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
.foregroundColor(bar.boosted ? Color.green : Color.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var like_button: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
LikeButton(damus_state: damus_state, liked: bar.liked, liked_emoji: bar.our_like != nil ? to_reaction_emoji(ev: bar.our_like!) : nil) { emoji in
|
||||||
|
if bar.liked {
|
||||||
|
//notify(.delete, bar.our_like)
|
||||||
|
} else {
|
||||||
|
send_like(emoji: emoji)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(verbatim: "\(bar.likes > 0 ? "\(bar.likes)" : "")")
|
||||||
|
.font(.footnote.weight(.medium))
|
||||||
|
.nip05_colorized(gradient: bar.liked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var share_button: some View {
|
||||||
|
EventActionButton(img: "upload", col: Color.gray) {
|
||||||
|
show_share_action = true
|
||||||
|
}
|
||||||
|
.accessibilityLabel(NSLocalizedString("Share", comment: "Button to share a note"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Main views
|
||||||
|
|
||||||
|
var swipe_action_menu_content: some View {
|
||||||
|
Group {
|
||||||
|
self.reply_swipe_button
|
||||||
|
self.repost_swipe_button
|
||||||
|
if show_like {
|
||||||
|
self.like_swipe_button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var swipe_action_menu_reverse_content: some View {
|
||||||
|
Group {
|
||||||
|
if show_like {
|
||||||
|
self.like_swipe_button
|
||||||
|
}
|
||||||
|
self.repost_swipe_button
|
||||||
|
self.reply_swipe_button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var action_bar_content: some View {
|
||||||
|
let hide_items_without_activity = options.contains(.hide_items_without_activity)
|
||||||
|
let should_hide_chat_bubble = hide_items_without_activity && bar.replies == 0
|
||||||
|
let should_hide_repost = hide_items_without_activity && bar.boosts == 0
|
||||||
|
let should_hide_reactions = hide_items_without_activity && bar.likes == 0
|
||||||
|
let zap_model = self.damus_state.events.get_cache_data(self.event.id).zaps_model
|
||||||
|
let should_hide_zap = hide_items_without_activity && zap_model.zap_total > 0
|
||||||
|
let should_hide_share_button = hide_items_without_activity
|
||||||
|
|
||||||
|
return HStack(spacing: options.contains(.no_spread) ? 10 : 0) {
|
||||||
|
if damus_state.keypair.privkey != nil && !should_hide_chat_bubble {
|
||||||
|
self.reply_button
|
||||||
|
}
|
||||||
|
|
||||||
|
if !should_hide_repost {
|
||||||
|
self.space_if_spread
|
||||||
|
self.repost_button
|
||||||
|
}
|
||||||
|
|
||||||
|
if show_like && !should_hide_reactions {
|
||||||
|
self.space_if_spread
|
||||||
|
self.like_button
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lnurl = self.lnurl, !should_hide_zap {
|
||||||
|
self.space_if_spread
|
||||||
|
NoteZapButton(damus_state: damus_state, target: ZapTarget.note(id: event.id, author: event.pubkey), lnurl: lnurl, zaps: zap_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !should_hide_share_button {
|
||||||
|
self.space_if_spread
|
||||||
|
self.share_button
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var content: some View {
|
||||||
|
if options.contains(.swipe_action_menu) {
|
||||||
|
AnyView(self.swipe_action_menu_content)
|
||||||
|
}
|
||||||
|
else if options.contains(.swipe_action_menu_reverse) {
|
||||||
|
AnyView(self.swipe_action_menu_reverse_content)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
AnyView(self.action_bar_content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
self.content
|
||||||
.onAppear {
|
.onAppear {
|
||||||
self.bar.update(damus: damus_state, evid: self.event.id)
|
self.bar.update(damus: damus_state, evid: self.event.id)
|
||||||
}
|
}
|
||||||
@@ -136,20 +258,6 @@ struct EventActionBar: View {
|
|||||||
self.bar.our_like = liked.event
|
self.bar.our_like = liked.event
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(
|
|
||||||
GeometryReader { geometry in
|
|
||||||
EmptyView()
|
|
||||||
.onAppear {
|
|
||||||
let eventActionBarY = geometry.frame(in: .global).midY
|
|
||||||
let screenMidY = UIScreen.main.bounds.midY
|
|
||||||
self.isOnTopHalfOfScreen = eventActionBarY > screenMidY
|
|
||||||
}
|
|
||||||
.onChange(of: geometry.frame(in: .global).midY) { newY in
|
|
||||||
let screenMidY = UIScreen.main.bounds.midY
|
|
||||||
self.isOnTopHalfOfScreen = newY > screenMidY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func send_like(emoji: String) {
|
func send_like(emoji: String) {
|
||||||
@@ -164,6 +272,17 @@ struct EventActionBar: View {
|
|||||||
|
|
||||||
damus_state.postbox.send(like_ev)
|
damus_state.postbox.send(like_ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Helper structures
|
||||||
|
|
||||||
|
struct Options: OptionSet {
|
||||||
|
let rawValue: UInt32
|
||||||
|
|
||||||
|
static let no_spread = Options(rawValue: 1 << 0)
|
||||||
|
static let hide_items_without_activity = Options(rawValue: 1 << 1)
|
||||||
|
static let swipe_action_menu = Options(rawValue: 1 << 2)
|
||||||
|
static let swipe_action_menu_reverse = Options(rawValue: 1 << 3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -183,7 +302,6 @@ struct LikeButton: View {
|
|||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
let liked: Bool
|
let liked: Bool
|
||||||
let liked_emoji: String?
|
let liked_emoji: String?
|
||||||
@Binding var isOnTopHalfOfScreen: Bool
|
|
||||||
let action: (_ emoji: String) -> Void
|
let action: (_ emoji: String) -> Void
|
||||||
|
|
||||||
// For reactions background
|
// For reactions background
|
||||||
@@ -192,7 +310,7 @@ struct LikeButton: View {
|
|||||||
|
|
||||||
@State private var isReactionsVisible = false
|
@State private var isReactionsVisible = false
|
||||||
|
|
||||||
@State private var selectedEmoji: String = ""
|
@State private var selectedEmoji: Emoji?
|
||||||
|
|
||||||
// Following four are Shaka animation properties
|
// Following four are Shaka animation properties
|
||||||
let timer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
|
let timer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
|
||||||
@@ -231,6 +349,11 @@ struct LikeButton: View {
|
|||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $isReactionsVisible) {
|
||||||
|
NavigationView {
|
||||||
|
EmojiPickerView(selectedEmoji: $selectedEmoji, emojiProvider: damus_state.emoji_provider)
|
||||||
|
}.presentationDetents([.medium, .large])
|
||||||
|
}
|
||||||
.accessibilityLabel(NSLocalizedString("Like", comment: "Accessibility Label for Like button"))
|
.accessibilityLabel(NSLocalizedString("Like", comment: "Accessibility Label for Like button"))
|
||||||
.rotationEffect(Angle(degrees: shouldAnimate ? rotationAngle : 0))
|
.rotationEffect(Angle(degrees: shouldAnimate ? rotationAngle : 0))
|
||||||
.onReceive(self.timer) { _ in
|
.onReceive(self.timer) { _ in
|
||||||
@@ -245,14 +368,10 @@ struct LikeButton: View {
|
|||||||
amountOfAngleIncrease = 20.0
|
amountOfAngleIncrease = 20.0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.emojiPicker(
|
|
||||||
isPresented: $isReactionsVisible,
|
|
||||||
selectedEmoji: $selectedEmoji,
|
|
||||||
arrowDirection: isOnTopHalfOfScreen ? .down : .up,
|
|
||||||
isDismissAfterChoosing: true
|
|
||||||
)
|
|
||||||
.onChange(of: selectedEmoji) { newSelectedEmoji in
|
.onChange(of: selectedEmoji) { newSelectedEmoji in
|
||||||
self.action(newSelectedEmoji)
|
if let newSelectedEmoji {
|
||||||
|
self.action(newSelectedEmoji.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +418,6 @@ struct LikeButton: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct EventActionBar_Previews: PreviewProvider {
|
struct EventActionBar_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let ds = test_damus_state
|
let ds = test_damus_state
|
||||||
@@ -324,7 +442,44 @@ struct EventActionBar_Previews: PreviewProvider {
|
|||||||
EventActionBar(damus_state: ds, event: ev, bar: extra_max_bar)
|
EventActionBar(damus_state: ds, event: ev, bar: extra_max_bar)
|
||||||
|
|
||||||
EventActionBar(damus_state: ds, event: ev, bar: mega_max_bar)
|
EventActionBar(damus_state: ds, event: ev, bar: mega_max_bar)
|
||||||
|
|
||||||
|
EventActionBar(damus_state: ds, event: ev, bar: bar, options: [.no_spread])
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Helpers
|
||||||
|
|
||||||
|
fileprivate struct SwipeButtonStyle: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(Circle().stroke(Color.damusAdaptableGrey2, lineWidth: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate extension View {
|
||||||
|
func swipeButtonStyle() -> some View {
|
||||||
|
modifier(SwipeButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Needed extensions for SwipeAction
|
||||||
|
|
||||||
|
public extension SwipeAction where Label == Image, Background == Color {
|
||||||
|
init(
|
||||||
|
image: String,
|
||||||
|
backgroundColor: Color = Color.primary.opacity(0.1),
|
||||||
|
highlightOpacity: Double = 0.5,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.init(action: action) { highlight in
|
||||||
|
Image(image)
|
||||||
|
} background: { highlight in
|
||||||
|
backgroundColor
|
||||||
|
.opacity(highlight ? highlightOpacity : 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ struct AddRelayView: View {
|
|||||||
}
|
}
|
||||||
new_relay = ""
|
new_relay = ""
|
||||||
|
|
||||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: MediaUploa
|
|||||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
|
||||||
// If uploading to a media host that support NIP-98 authorization, add the header
|
// If uploading to a media host that support NIP-98 authorization, add the header
|
||||||
if mediaUploader == .nostrBuild,
|
if mediaUploader == .nostrBuild || mediaUploader == .nostrcheck,
|
||||||
let keypair,
|
let keypair,
|
||||||
let method = request.httpMethod,
|
let method = request.httpMethod,
|
||||||
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
|
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ struct EditBannerImageView: View {
|
|||||||
var damus_state: DamusState
|
var damus_state: DamusState
|
||||||
@ObservedObject var viewModel: ImageUploadingObserver
|
@ObservedObject var viewModel: ImageUploadingObserver
|
||||||
let callback: (URL?) -> Void
|
let callback: (URL?) -> Void
|
||||||
let defaultImage = UIImage(named: "profile-banner") ?? UIImage()
|
let defaultImage = UIImage(named: "damoose") ?? UIImage()
|
||||||
|
|
||||||
@State var banner_image: URL? = nil
|
@State var banner_image: URL? = nil
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ struct EditBannerImageView: View {
|
|||||||
Color(uiColor: .secondarySystemBackground)
|
Color(uiColor: .secondarySystemBackground)
|
||||||
}
|
}
|
||||||
.onFailureImage(defaultImage)
|
.onFailureImage(defaultImage)
|
||||||
|
.kfClickable()
|
||||||
|
|
||||||
EditPictureControl(uploader: damus_state.settings.default_media_uploader, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
|
EditPictureControl(uploader: damus_state.settings.default_media_uploader, pubkey: damus_state.pubkey, image_url: $banner_image, uploadObserver: viewModel, callback: callback)
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,7 @@ struct EditBannerImageView: View {
|
|||||||
struct InnerBannerImageView: View {
|
struct InnerBannerImageView: View {
|
||||||
let disable_animation: Bool
|
let disable_animation: Bool
|
||||||
let url: URL?
|
let url: URL?
|
||||||
let defaultImage = UIImage(named: "profile-banner") ?? UIImage()
|
let defaultImage = UIImage(named: "damoose") ?? UIImage()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -54,6 +55,7 @@ struct InnerBannerImageView: View {
|
|||||||
Color(uiColor: .secondarySystemBackground)
|
Color(uiColor: .secondarySystemBackground)
|
||||||
}
|
}
|
||||||
.onFailureImage(defaultImage)
|
.onFailureImage(defaultImage)
|
||||||
|
.kfClickable()
|
||||||
} else {
|
} else {
|
||||||
Image(uiImage: defaultImage).resizable()
|
Image(uiImage: defaultImage).resizable()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ struct FriendsButton: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
switch self.filter {
|
switch self.filter {
|
||||||
case .all:
|
case .all:
|
||||||
self.filter = .friends
|
self.filter = .friends_of_friends
|
||||||
case .friends:
|
case .friends_of_friends:
|
||||||
self.filter = .all
|
self.filter = .all
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
if filter == .friends {
|
if filter == .friends_of_friends {
|
||||||
LINEAR_GRADIENT
|
LINEAR_GRADIENT
|
||||||
.mask(Image("user-added")
|
.mask(Image("user-added")
|
||||||
.resizable()
|
.resizable()
|
||||||
@@ -28,7 +28,7 @@ struct FriendsButton: View {
|
|||||||
Image("user-added")
|
Image("user-added")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 28, height: 28)
|
.frame(width: 28, height: 28)
|
||||||
.foregroundColor(DamusColors.adaptableGrey)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//
|
||||||
|
// ChatBubbleView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2024-06-17.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Use this view to display content inside of a custom-designed chat bubble shape.
|
||||||
|
struct ChatBubble<T: View, U: ShapeStyle, V: View>: View {
|
||||||
|
/// The direction at which the chat bubble tip will be pointing towards
|
||||||
|
let direction: Direction
|
||||||
|
let stroke_content: U
|
||||||
|
let stroke_style: StrokeStyle
|
||||||
|
let background_style: V
|
||||||
|
@ViewBuilder let content: T
|
||||||
|
|
||||||
|
// Constants, which are loosely tied to `OFFSET_X` and `OFFSET_Y`
|
||||||
|
let OFFSET_X_PADDING: CGFloat = 6
|
||||||
|
let OFFSET_Y_BOTTOM_PADDING: CGFloat = 3
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
self.content
|
||||||
|
.padding(direction == .left ? .leading : .trailing, OFFSET_X_PADDING)
|
||||||
|
.padding(.bottom, OFFSET_Y_BOTTOM_PADDING)
|
||||||
|
.background(self.background_style)
|
||||||
|
.clipShape(
|
||||||
|
BubbleShape(direction: self.direction)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
BubbleShape(direction: self.direction)
|
||||||
|
.stroke(self.stroke_content, style: self.stroke_style)
|
||||||
|
)
|
||||||
|
.padding(direction == .left ? .leading : .trailing, -OFFSET_X_PADDING)
|
||||||
|
.padding(.bottom, -OFFSET_Y_BOTTOM_PADDING)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Direction {
|
||||||
|
case right
|
||||||
|
case left
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BubbleShape: Shape {
|
||||||
|
/// The direction at which the chat bubble tip will be pointing towards
|
||||||
|
let direction: Direction
|
||||||
|
|
||||||
|
// MARK: Constant parameters that defines the shape and look of the chat bubbles
|
||||||
|
|
||||||
|
/// The corner radius of the round edges
|
||||||
|
let CORNER_RADIUS: CGFloat = 10
|
||||||
|
/// The height of the chat bubble tip detail
|
||||||
|
let DETAIL_HEIGHT: CGFloat = 10
|
||||||
|
/// The horizontal distance between the chat bubble tip and the vertical edge of the bubble
|
||||||
|
let OFFSET_X: CGFloat = 7
|
||||||
|
/// The vertical distance between the chat bubble tip and the bottom edge of the bubble
|
||||||
|
let OFFSET_Y: CGFloat = 5
|
||||||
|
/// Value between 0 and 1 that determines curvature of the upper chat bubble curve detail
|
||||||
|
let DETAIL_CURVE_FACTOR: CGFloat = 0.75
|
||||||
|
/// Value between 0 and 1 that determines curvature of the lower chat bubble curve detail
|
||||||
|
let LOWER_DETAIL_CURVE_FACTOR: CGFloat = 0.4
|
||||||
|
/// The horizontal distance between the chat bubble tip and the point at which the lower chat bubble curve detail attaches to the bottom of the chat bubble
|
||||||
|
let LOWER_DETAIL_ATTACHMENT_OFFSET_X: CGFloat = 20
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
return self.direction == .left ? self.draw_left_bubble(in: rect) : self.draw_right_bubble(in: rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func draw_left_bubble(in rect: CGRect) -> Path {
|
||||||
|
return Path { p in
|
||||||
|
// Start at the top left, just below the end of the corner radius
|
||||||
|
let start = CGPoint(x: OFFSET_X, y: CORNER_RADIUS)
|
||||||
|
// Left edge
|
||||||
|
p.move(to: start)
|
||||||
|
p.addLine(to: CGPoint(x: OFFSET_X, y: rect.height - DETAIL_HEIGHT))
|
||||||
|
// Draw the chat bubble tip
|
||||||
|
p.addLine(to: CGPoint(x: OFFSET_X, y: rect.height - DETAIL_HEIGHT))
|
||||||
|
let tip_of_bubble = CGPoint(x: 0, y: rect.height)
|
||||||
|
p.addQuadCurve(
|
||||||
|
to: tip_of_bubble,
|
||||||
|
control: CGPoint(x: 0, y: rect.height - DETAIL_HEIGHT) + CGVector(dx: OFFSET_X, dy: DETAIL_HEIGHT) * DETAIL_CURVE_FACTOR
|
||||||
|
)
|
||||||
|
let lower_detail_attachment = CGPoint(x: LOWER_DETAIL_ATTACHMENT_OFFSET_X, y: rect.height - OFFSET_Y)
|
||||||
|
p.addCurve(
|
||||||
|
to: lower_detail_attachment,
|
||||||
|
control1: tip_of_bubble + CGVector(dx: LOWER_DETAIL_ATTACHMENT_OFFSET_X, dy: 0) * LOWER_DETAIL_CURVE_FACTOR,
|
||||||
|
control2: lower_detail_attachment - CGVector(dx: LOWER_DETAIL_ATTACHMENT_OFFSET_X, dy: 0) * LOWER_DETAIL_CURVE_FACTOR
|
||||||
|
)
|
||||||
|
// Draw the bottom edge
|
||||||
|
p.addLine(to: CGPoint(x: rect.width - CORNER_RADIUS, y: rect.height - OFFSET_Y))
|
||||||
|
// Draw the bottom right round corner
|
||||||
|
p.addQuadCurve(
|
||||||
|
to: CGPoint(x: rect.width, y: rect.height - OFFSET_Y - CORNER_RADIUS),
|
||||||
|
control: CGPoint(x: rect.width, y: rect.height - OFFSET_Y)
|
||||||
|
)
|
||||||
|
// Draw right edge
|
||||||
|
p.addLine(to: CGPoint(x: rect.width, y: CORNER_RADIUS))
|
||||||
|
// Draw top right round corner
|
||||||
|
p.addQuadCurve(
|
||||||
|
to: CGPoint(x: rect.width - CORNER_RADIUS, y: 0),
|
||||||
|
control: CGPoint(x: rect.width, y: 0)
|
||||||
|
)
|
||||||
|
// Draw top edge
|
||||||
|
p.addLine(to: CGPoint(x: CORNER_RADIUS + OFFSET_X, y: 0))
|
||||||
|
// Draw top left round corner
|
||||||
|
p.addQuadCurve(
|
||||||
|
to: start,
|
||||||
|
control: CGPoint(x: OFFSET_X, y: 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func draw_right_bubble(in rect: CGRect) -> Path {
|
||||||
|
return Path { p in
|
||||||
|
// Start at the top right, just below the end of the corner radius
|
||||||
|
let right_edge = rect.width - OFFSET_X
|
||||||
|
let start = CGPoint(x: right_edge, y: CORNER_RADIUS)
|
||||||
|
p.move(to: start)
|
||||||
|
// Right edge
|
||||||
|
p.addLine(to: CGPoint(x: right_edge, y: rect.height - DETAIL_HEIGHT))
|
||||||
|
// Draw the chat bubble tip
|
||||||
|
let tip_of_bubble = CGPoint(x: rect.width, y: rect.height)
|
||||||
|
p.addQuadCurve(
|
||||||
|
to: tip_of_bubble,
|
||||||
|
control: CGPoint(x: rect.width, y: rect.height - DETAIL_HEIGHT) + CGVector(dx: -OFFSET_X, dy: DETAIL_HEIGHT) * DETAIL_CURVE_FACTOR
|
||||||
|
)
|
||||||
|
let lower_detail_attachment = CGPoint(x: rect.width - LOWER_DETAIL_ATTACHMENT_OFFSET_X, y: rect.height - OFFSET_Y)
|
||||||
|
p.addCurve(
|
||||||
|
to: lower_detail_attachment,
|
||||||
|
control1: tip_of_bubble - CGVector(dx: LOWER_DETAIL_ATTACHMENT_OFFSET_X, dy: 0) * LOWER_DETAIL_CURVE_FACTOR,
|
||||||
|
control2: lower_detail_attachment + CGVector(dx: LOWER_DETAIL_ATTACHMENT_OFFSET_X, dy: 0) * LOWER_DETAIL_CURVE_FACTOR
|
||||||
|
)
|
||||||
|
// Draw the bottom edge
|
||||||
|
p.addLine(to: CGPoint(x: CORNER_RADIUS, y: rect.height - OFFSET_Y))
|
||||||
|
// Draw the bottom left round corner
|
||||||
|
p.addQuadCurve(
|
||||||
|
to: CGPoint(x: 0, y: rect.height - OFFSET_Y - CORNER_RADIUS),
|
||||||
|
control: CGPoint(x: 0, y: rect.height - OFFSET_Y)
|
||||||
|
)
|
||||||
|
// Draw left edge
|
||||||
|
p.addLine(to: CGPoint(x: 0, y: CORNER_RADIUS))
|
||||||
|
// Draw top right round corner
|
||||||
|
p.addQuadCurve(
|
||||||
|
to: CGPoint(x: CORNER_RADIUS, y: 0),
|
||||||
|
control: CGPoint(x: 0, y: 0)
|
||||||
|
)
|
||||||
|
// Draw top edge
|
||||||
|
p.addLine(to: CGPoint(x: rect.width - CORNER_RADIUS - OFFSET_X, y: 0))
|
||||||
|
// Draw top left round corner
|
||||||
|
p.addQuadCurve(
|
||||||
|
to: start,
|
||||||
|
control: CGPoint(x: rect.width - OFFSET_X, y: 0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
VStack {
|
||||||
|
ChatBubble(
|
||||||
|
direction: .left,
|
||||||
|
stroke_content: Color.accentColor.opacity(0),
|
||||||
|
stroke_style: .init(lineWidth: 4),
|
||||||
|
background_style: Color.accentColor
|
||||||
|
) {
|
||||||
|
Text(verbatim: "Hello there")
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
|
||||||
|
ChatBubble(
|
||||||
|
direction: .right,
|
||||||
|
stroke_content: Color.accentColor.opacity(0),
|
||||||
|
stroke_style: .init(lineWidth: 4),
|
||||||
|
background_style: Color.accentColor
|
||||||
|
) {
|
||||||
|
Text(verbatim: "Hello there")
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
//
|
||||||
|
// ChatView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by William Casarin on 2022-04-19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import EmojiKit
|
||||||
|
import EmojiPicker
|
||||||
|
import SwipeActions
|
||||||
|
|
||||||
|
fileprivate let CORNER_RADIUS: CGFloat = 10
|
||||||
|
|
||||||
|
struct ChatEventView: View {
|
||||||
|
// MARK: Parameters
|
||||||
|
let event: NostrEvent
|
||||||
|
let selected_event: NostrEvent
|
||||||
|
let prev_ev: NostrEvent?
|
||||||
|
let next_ev: NostrEvent?
|
||||||
|
let damus_state: DamusState
|
||||||
|
var thread: ThreadModel
|
||||||
|
let scroll_to_event: ((_ id: NoteId) -> Void)?
|
||||||
|
let focus_event: (() -> Void)?
|
||||||
|
let highlight_bubble: Bool
|
||||||
|
|
||||||
|
// MARK: long-press reaction control objects
|
||||||
|
/// Whether the user is actively pressing the view
|
||||||
|
@State var is_pressing = false
|
||||||
|
/// The dispatched work item scheduled by a timer to bounce the event bubble and show the emoji selector
|
||||||
|
@State var long_press_bounce_work_item: DispatchWorkItem?
|
||||||
|
@State var popover_state: PopoverState = .closed {
|
||||||
|
didSet {
|
||||||
|
let generator = UIImpactFeedbackGenerator(style: popover_state.some_sheet_open() ? .heavy : .light)
|
||||||
|
generator.impactOccurred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@State var selected_emoji: Emoji?
|
||||||
|
|
||||||
|
@State private var isOnTopHalfOfScreen: Bool = false
|
||||||
|
@ObservedObject var bar: ActionBarModel
|
||||||
|
|
||||||
|
enum PopoverState: String {
|
||||||
|
case closed
|
||||||
|
case open_emoji_selector
|
||||||
|
case open_zap_sheet
|
||||||
|
|
||||||
|
func some_sheet_open() -> Bool {
|
||||||
|
return self == .open_zap_sheet || self == .open_emoji_selector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var just_started: Bool {
|
||||||
|
return prev_ev == nil || prev_ev!.pubkey != event.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
func next_replies_to_this() -> Bool {
|
||||||
|
guard let next = next_ev else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return damus_state.events.replies.lookup(next.id) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func is_reply_to_prev(ref_id: NoteId) -> Bool {
|
||||||
|
guard let prev = prev_ev else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let rep = damus_state.events.replies.lookup(event.id) {
|
||||||
|
return rep.contains(prev.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var disable_animation: Bool {
|
||||||
|
self.damus_state.settings.disable_animation
|
||||||
|
}
|
||||||
|
|
||||||
|
var reply_quote_options: EventViewOptions {
|
||||||
|
return [.no_previews, .no_action_bar, .truncate_content_very_short, .no_show_more, .no_translate, .no_media]
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile_picture_view: some View {
|
||||||
|
VStack {
|
||||||
|
ProfilePicView(pubkey: event.pubkey, size: 32, highlight: .none, profiles: damus_state.profiles, disable_animation: disable_animation)
|
||||||
|
.onTapGesture {
|
||||||
|
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: event.pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
var by_other_user: Bool {
|
||||||
|
return event.pubkey != damus_state.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
var is_ours: Bool { return !by_other_user }
|
||||||
|
|
||||||
|
// MARK: Zapping properties
|
||||||
|
|
||||||
|
var lnurl: String? {
|
||||||
|
damus_state.profiles.lookup_with_timestamp(event.pubkey)?.map({ pr in
|
||||||
|
pr?.lnurl
|
||||||
|
}).value
|
||||||
|
}
|
||||||
|
var zap_target: ZapTarget {
|
||||||
|
ZapTarget.note(id: event.id, author: event.pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Views
|
||||||
|
|
||||||
|
var event_bubble: some View {
|
||||||
|
ChatBubble(
|
||||||
|
direction: is_ours ? .right : .left,
|
||||||
|
stroke_content: Color.accentColor.opacity(highlight_bubble ? 1 : 0),
|
||||||
|
stroke_style: .init(lineWidth: 4),
|
||||||
|
background_style: by_other_user ? DamusColors.adaptableGrey : DamusColors.adaptablePurpleBackground
|
||||||
|
) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
if by_other_user {
|
||||||
|
HStack {
|
||||||
|
ProfileName(pubkey: event.pubkey, damus: damus_state)
|
||||||
|
.onTapGesture {
|
||||||
|
show_profile_action_sheet_if_enabled(damus_state: damus_state, pubkey: event.pubkey)
|
||||||
|
}
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(verbatim: "\(format_relative_time(event.created_at))")
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let replying_to = event.direct_replies(),
|
||||||
|
replying_to != selected_event.id {
|
||||||
|
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: replying_to, state: damus_state, thread: thread, options: reply_quote_options)
|
||||||
|
.background(is_ours ? DamusColors.adaptablePurpleBackground2 : DamusColors.adaptableGrey2)
|
||||||
|
.foregroundColor(is_ours ? Color.damusAdaptablePurpleForeground : Color.damusAdaptableBlack)
|
||||||
|
.cornerRadius(5)
|
||||||
|
.onTapGesture {
|
||||||
|
self.scroll_to_event?(replying_to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let blur_images = should_blur_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey)
|
||||||
|
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [.truncate_content])
|
||||||
|
.padding(2)
|
||||||
|
if let mention = first_eref_mention(ev: event, keypair: damus_state.keypair) {
|
||||||
|
MentionView(damus_state: damus_state, mention: mention)
|
||||||
|
.background(DamusColors.adaptableWhite)
|
||||||
|
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 5, alignment: is_ours ? .trailing : .leading)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.tint(Color.accentColor)
|
||||||
|
.overlay(
|
||||||
|
ZStack(alignment: is_ours ? .bottomLeading : .bottomTrailing) {
|
||||||
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
self.action_bar
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
if popover_state == .closed {
|
||||||
|
focus_event?()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
popover_state = .closed
|
||||||
|
let generator = UIImpactFeedbackGenerator(style: .light)
|
||||||
|
generator.impactOccurred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var event_bubble_with_long_press_interaction: some View {
|
||||||
|
ZStack(alignment: is_ours ? .bottomLeading : .bottomTrailing) {
|
||||||
|
self.event_bubble
|
||||||
|
.sheet(isPresented: Binding(get: { popover_state == .open_emoji_selector }, set: { new_state in
|
||||||
|
withAnimation(new_state == true ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
|
||||||
|
popover_state = new_state == true ? .open_emoji_selector : .closed
|
||||||
|
}
|
||||||
|
})) {
|
||||||
|
NavigationView {
|
||||||
|
EmojiPickerView(selectedEmoji: $selected_emoji, emojiProvider: damus_state.emoji_provider)
|
||||||
|
}.presentationDetents([.medium, .large])
|
||||||
|
}
|
||||||
|
.sheet(isPresented: Binding(get: { popover_state == .open_zap_sheet }, set: { new_state in
|
||||||
|
withAnimation(new_state == true ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
|
||||||
|
popover_state = new_state == true ? .open_zap_sheet : .closed
|
||||||
|
}
|
||||||
|
})) {
|
||||||
|
ZapSheetViewIfPossible(damus_state: damus_state, target: zap_target, lnurl: lnurl)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
}
|
||||||
|
.onChange(of: selected_emoji) { newSelectedEmoji in
|
||||||
|
if let newSelectedEmoji {
|
||||||
|
send_like(emoji: newSelectedEmoji.value)
|
||||||
|
popover_state = .closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scaleEffect(self.popover_state.some_sheet_open() ? 1.08 : is_pressing ? 1.02 : 1)
|
||||||
|
.shadow(color: (is_pressing || self.popover_state.some_sheet_open()) ? .black.opacity(0.1) : .black.opacity(0.3), radius: (is_pressing || self.popover_state.some_sheet_open()) ? 8 : 0, y: (is_pressing || self.popover_state.some_sheet_open()) ? 15 : 0)
|
||||||
|
.onLongPressGesture(minimumDuration: 0.5, maximumDistance: 10, perform: {
|
||||||
|
long_press_bounce_work_item?.cancel()
|
||||||
|
}, onPressingChanged: { is_pressing in
|
||||||
|
withAnimation(is_pressing ? .easeIn(duration: 0.5) : .easeOut(duration: 0.1)) {
|
||||||
|
self.is_pressing = is_pressing
|
||||||
|
if popover_state != .closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.is_pressing {
|
||||||
|
let item = DispatchWorkItem {
|
||||||
|
// Ensure the action is performed only if the condition is still valid
|
||||||
|
if self.is_pressing {
|
||||||
|
withAnimation(.bouncy(duration: 0.2, extraBounce: 0.35)) {
|
||||||
|
let should_show_zap_sheet = !damus_state.settings.nozaps && damus_state.settings.onlyzaps_mode
|
||||||
|
popover_state = should_show_zap_sheet ? .open_zap_sheet : .open_emoji_selector
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
long_press_bounce_work_item = item
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.background(
|
||||||
|
GeometryReader { geometry in
|
||||||
|
EmptyView()
|
||||||
|
.onAppear {
|
||||||
|
let eventActionBarY = geometry.frame(in: .global).midY
|
||||||
|
let screenMidY = UIScreen.main.bounds.midY
|
||||||
|
self.isOnTopHalfOfScreen = eventActionBarY > screenMidY
|
||||||
|
}
|
||||||
|
.onChange(of: geometry.frame(in: .global).midY) { newY in
|
||||||
|
let screenMidY = UIScreen.main.bounds.midY
|
||||||
|
self.isOnTopHalfOfScreen = newY > screenMidY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func send_like(emoji: String) {
|
||||||
|
guard let keypair = damus_state.keypair.to_full(),
|
||||||
|
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.bar.our_like = like_ev
|
||||||
|
|
||||||
|
let generator = UIImpactFeedbackGenerator(style: .medium)
|
||||||
|
generator.impactOccurred()
|
||||||
|
|
||||||
|
damus_state.postbox.send(like_ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
var action_bar: some View {
|
||||||
|
return Group {
|
||||||
|
if !bar.is_empty {
|
||||||
|
HStack {
|
||||||
|
if by_other_user {
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
EventActionBar(damus_state: damus_state, event: event, bar: bar, options: [.no_spread, .hide_items_without_activity])
|
||||||
|
.padding(10)
|
||||||
|
.background(DamusColors.adaptableLighterGrey)
|
||||||
|
.disabled(true)
|
||||||
|
.cornerRadius(100)
|
||||||
|
.overlay(RoundedRectangle(cornerSize: CGSize(width: 100, height: 100)).stroke(DamusColors.adaptableWhite, lineWidth: 1))
|
||||||
|
.shadow(color: Color.black.opacity(0.05),radius: 3, y: 3)
|
||||||
|
.scaleEffect(0.7, anchor: is_ours ? .leading : .trailing)
|
||||||
|
if !by_other_user {
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, -20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var event_bubble_with_long_press_and_swipe_interactions: some View {
|
||||||
|
Group {
|
||||||
|
SwipeView {
|
||||||
|
self.event_bubble_with_long_press_interaction
|
||||||
|
} leadingActions: { context in
|
||||||
|
if !is_ours {
|
||||||
|
EventActionBar(
|
||||||
|
damus_state: damus_state,
|
||||||
|
event: event,
|
||||||
|
bar: bar,
|
||||||
|
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
|
||||||
|
swipe_context: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} trailingActions: { context in
|
||||||
|
if is_ours {
|
||||||
|
EventActionBar(
|
||||||
|
damus_state: damus_state,
|
||||||
|
event: event,
|
||||||
|
bar: bar,
|
||||||
|
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
|
||||||
|
swipe_context: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeSpacing(-20)
|
||||||
|
.swipeActionsStyle(.mask)
|
||||||
|
.swipeMinimumDistance(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var content: some View {
|
||||||
|
return VStack {
|
||||||
|
HStack(alignment: .bottom, spacing: 4) {
|
||||||
|
if by_other_user {
|
||||||
|
self.profile_picture_view
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.event_bubble_with_long_press_and_swipe_interactions
|
||||||
|
|
||||||
|
if !by_other_user {
|
||||||
|
self.profile_picture_view
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.id(event.id)
|
||||||
|
.padding([.bottom], bar.is_empty ? 6 : 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if [.boost, .zap, .longform].contains(where: { event.known_kind == $0 }) {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
self.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static var toggle_thread_view: Notification.Name {
|
||||||
|
return Notification.Name("convert_to_thread")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||||
|
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||||
|
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||||
|
return ChatEventView(event: test_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: true, bar: bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||||
|
return ChatEventView(event: test_super_short_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//
|
||||||
|
// ChatroomView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by William Casarin on 2022-04-19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwipeActions
|
||||||
|
|
||||||
|
struct ChatroomThreadView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State var once: Bool = false
|
||||||
|
let damus: DamusState
|
||||||
|
@ObservedObject var thread: ThreadModel
|
||||||
|
@State var selected_note_id: NoteId? = nil
|
||||||
|
@State var user_just_posted_flag: Bool = false
|
||||||
|
@Namespace private var animation
|
||||||
|
|
||||||
|
@State var parent_events: [NostrEvent] = []
|
||||||
|
@State var sorted_child_events: [NostrEvent] = []
|
||||||
|
|
||||||
|
func compute_events(selected_event: NostrEvent? = nil) {
|
||||||
|
let selected_event = selected_event ?? thread.event
|
||||||
|
self.parent_events = damus.events.parent_events(event: selected_event, keypair: damus.keypair)
|
||||||
|
let all_recursive_child_events = self.recursive_child_events(event: selected_event)
|
||||||
|
self.sorted_child_events = all_recursive_child_events.filter({
|
||||||
|
should_show_event(event: $0, damus_state: damus) // Hide muted events from chatroom conversation
|
||||||
|
}).sorted(by: { a, b in
|
||||||
|
return a.created_at < b.created_at
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func recursive_child_events(event: NdbNote) -> [NdbNote] {
|
||||||
|
let immediate_children = damus.events.child_events(event: event)
|
||||||
|
var indirect_children: [NdbNote] = []
|
||||||
|
for immediate_child in immediate_children {
|
||||||
|
indirect_children.append(contentsOf: self.recursive_child_events(event: immediate_child))
|
||||||
|
}
|
||||||
|
return immediate_children + indirect_children
|
||||||
|
}
|
||||||
|
|
||||||
|
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
|
||||||
|
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top)
|
||||||
|
selected_note_id = note_id
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
|
||||||
|
withAnimation {
|
||||||
|
selected_note_id = nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) {
|
||||||
|
withAnimation {
|
||||||
|
self.compute_events(selected_event: ev)
|
||||||
|
thread.set_active_event(ev, keypair: self.damus.keypair)
|
||||||
|
self.go_to_event(scroller: scroller, note_id: ev.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollViewReader { scroller in
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
// MARK: - Parents events view
|
||||||
|
ForEach(parent_events, id: \.id) { parent_event in
|
||||||
|
EventMutingContainerView(damus_state: damus, event: parent_event) {
|
||||||
|
EventView(damus: damus, event: parent_event)
|
||||||
|
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.onTapGesture {
|
||||||
|
self.set_active_event(scroller: scroller, ev: parent_event)
|
||||||
|
}
|
||||||
|
.id(parent_event.id)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.top, 4)
|
||||||
|
.padding(.leading, 25 * 2)
|
||||||
|
|
||||||
|
}.background(GeometryReader { geometry in
|
||||||
|
// get the height and width of the EventView view
|
||||||
|
let eventHeight = geometry.frame(in: .global).height
|
||||||
|
// let eventWidth = geometry.frame(in: .global).width
|
||||||
|
|
||||||
|
// vertical gray line in the background
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray.opacity(0.25))
|
||||||
|
.frame(width: 2, height: eventHeight)
|
||||||
|
.offset(x: 40, y: 40)
|
||||||
|
})
|
||||||
|
|
||||||
|
// MARK: - Actual event view
|
||||||
|
EventMutingContainerView(
|
||||||
|
damus_state: damus,
|
||||||
|
event: self.thread.event,
|
||||||
|
muteBox: { event_shown, muted_reason in
|
||||||
|
AnyView(
|
||||||
|
EventMutedBoxView(shown: event_shown, reason: muted_reason)
|
||||||
|
.padding(5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
SelectedEventView(damus: damus, event: self.thread.event, size: .selected)
|
||||||
|
.matchedGeometryEffect(id: self.thread.event.id.hex(), in: animation, anchor: .center)
|
||||||
|
}
|
||||||
|
.id(self.thread.event.id)
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Children view
|
||||||
|
let events = sorted_child_events
|
||||||
|
let count = events.count
|
||||||
|
SwipeViewGroup {
|
||||||
|
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
|
||||||
|
ChatEventView(event: events[ind],
|
||||||
|
selected_event: self.thread.event,
|
||||||
|
prev_ev: ind > 0 ? events[ind-1] : nil,
|
||||||
|
next_ev: ind == count-1 ? nil : events[ind+1],
|
||||||
|
damus_state: damus,
|
||||||
|
thread: thread,
|
||||||
|
scroll_to_event: { note_id in
|
||||||
|
self.go_to_event(scroller: scroller, note_id: note_id)
|
||||||
|
},
|
||||||
|
focus_event: {
|
||||||
|
self.set_active_event(scroller: scroller, ev: ev)
|
||||||
|
},
|
||||||
|
highlight_bubble: selected_note_id == ev.id,
|
||||||
|
bar: make_actionbar_model(ev: ev.id, damus: damus)
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.id(ev.id)
|
||||||
|
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top)
|
||||||
|
EndBlock()
|
||||||
|
}
|
||||||
|
.onReceive(handle_notify(.post), perform: { notify in
|
||||||
|
switch notify {
|
||||||
|
case .post(_):
|
||||||
|
user_just_posted_flag = true
|
||||||
|
case .cancel:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onReceive(thread.objectWillChange) {
|
||||||
|
self.compute_events()
|
||||||
|
if let last_event = thread.events().last, last_event.pubkey == damus.pubkey, user_just_posted_flag {
|
||||||
|
self.go_to_event(scroller: scroller, note_id: last_event.id)
|
||||||
|
user_just_posted_flag = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear() {
|
||||||
|
thread.subscribe()
|
||||||
|
self.compute_events()
|
||||||
|
scroll_to_event(scroller: scroller, id: thread.event.id, delay: 0.1, animate: false)
|
||||||
|
}
|
||||||
|
.onDisappear() {
|
||||||
|
thread.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggle_thread_view() {
|
||||||
|
NotificationCenter.default.post(name: .toggle_thread_view, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
struct ChatroomView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
Group {
|
||||||
|
ChatroomThreadView(damus: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state))
|
||||||
|
.previewDisplayName("Test note")
|
||||||
|
|
||||||
|
let test_thread = ThreadModel(event: test_thread_note_1, damus_state: test_damus_state)
|
||||||
|
ChatroomThreadView(damus: test_damus_state, thread: test_thread)
|
||||||
|
.onAppear {
|
||||||
|
test_thread.add_event(test_thread_note_2, keypair: test_keypair)
|
||||||
|
test_thread.add_event(test_thread_note_3, keypair: test_keypair)
|
||||||
|
test_thread.add_event(test_thread_note_4, keypair: test_keypair)
|
||||||
|
test_thread.add_event(test_thread_note_5, keypair: test_keypair)
|
||||||
|
test_thread.add_event(test_thread_note_6, keypair: test_keypair)
|
||||||
|
test_thread.add_event(test_thread_note_7, keypair: test_keypair)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) {
|
||||||
|
scroll_to_event(scroller: proxy, id: thread.event.id, delay: 0.1, animate: false)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
//
|
||||||
|
// ReplyQuoteView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by William Casarin on 2022-04-19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ReplyQuoteView: View {
|
||||||
|
let keypair: Keypair
|
||||||
|
let quoter: NostrEvent
|
||||||
|
let event_id: NoteId
|
||||||
|
let state: DamusState
|
||||||
|
@ObservedObject var thread: ThreadModel
|
||||||
|
let options: EventViewOptions
|
||||||
|
|
||||||
|
func content(event: NdbNote) -> some View {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
if should_show_event(event: event, damus_state: state) {
|
||||||
|
ProfilePicView(pubkey: event.pubkey, size: 14, highlight: .reply, profiles: state.profiles, disable_animation: false)
|
||||||
|
let blur_images = should_blur_images(settings: state.settings, contacts: state.contacts, ev: event, our_pubkey: state.pubkey)
|
||||||
|
NoteContentView(damus_state: state, event: event, blur_images: blur_images, size: .small, options: options)
|
||||||
|
.font(.callout)
|
||||||
|
.lineLimit(1)
|
||||||
|
.padding(.bottom, -7)
|
||||||
|
.padding(.top, -5)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.frame(height: 20)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Text("Note you've muted", comment: "Label indicating note has been muted")
|
||||||
|
.italic()
|
||||||
|
.font(.caption)
|
||||||
|
.opacity(0.5)
|
||||||
|
.padding(.bottom, -7)
|
||||||
|
.padding(.top, -5)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.frame(height: 20)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(5)
|
||||||
|
.padding(.leading, 5+3)
|
||||||
|
Rectangle()
|
||||||
|
.foregroundStyle(.accent)
|
||||||
|
.frame(width: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let event = state.events.lookup(event_id) {
|
||||||
|
self.content(event: event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ReplyQuoteView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let s = test_damus_state
|
||||||
|
let quoter = test_note
|
||||||
|
ReplyQuoteView(keypair: s.keypair, quoter: quoter, event_id: test_note.id, state: s, thread: ThreadModel(event: quoter, damus_state: s), options: [.no_previews, .no_action_bar, .truncate_content_very_short, .no_show_more, .no_translate])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,25 +182,6 @@ extension CodeScannerView {
|
|||||||
delegate?.didFail(reason: .badOutput)
|
delegate?.didFail(reason: .badOutput)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override public func viewWillLayoutSubviews() {
|
|
||||||
previewLayer?.frame = view.layer.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func updateOrientation() {
|
|
||||||
guard let orientation = view.window?.windowScene?.interfaceOrientation else { return }
|
|
||||||
guard let connection = captureSession.connections.last, connection.isVideoOrientationSupported else { return }
|
|
||||||
connection.videoOrientation = AVCaptureVideoOrientation(rawValue: orientation.rawValue) ?? .portrait
|
|
||||||
}
|
|
||||||
|
|
||||||
override public func viewDidAppear(_ animated: Bool) {
|
|
||||||
super.viewDidAppear(animated)
|
|
||||||
updateOrientation()
|
|
||||||
}
|
|
||||||
|
|
||||||
override public func viewWillAppear(_ animated: Bool) {
|
|
||||||
super.viewWillAppear(animated)
|
|
||||||
|
|
||||||
if previewLayer == nil {
|
if previewLayer == nil {
|
||||||
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
|
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
|
||||||
@@ -220,6 +201,21 @@ extension CodeScannerView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override public func viewWillLayoutSubviews() {
|
||||||
|
previewLayer?.frame = view.layer.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func updateOrientation() {
|
||||||
|
guard let orientation = view.window?.windowScene?.interfaceOrientation else { return }
|
||||||
|
guard let connection = captureSession.connections.last, connection.isVideoOrientationSupported else { return }
|
||||||
|
connection.videoOrientation = AVCaptureVideoOrientation(rawValue: orientation.rawValue) ?? .portrait
|
||||||
|
}
|
||||||
|
|
||||||
|
override public func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
updateOrientation()
|
||||||
|
}
|
||||||
|
|
||||||
private func addviewfinder() {
|
private func addviewfinder() {
|
||||||
guard showViewfinder, let imageView = viewFinder else { return }
|
guard showViewfinder, let imageView = viewFinder else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -25,68 +25,44 @@ struct CreateAccountView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
VStack {
|
VStack {
|
||||||
|
Spacer()
|
||||||
VStack(alignment: .center) {
|
VStack(alignment: .center) {
|
||||||
EditPictureControl(uploader: .nostrBuild, pubkey: account.pubkey, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
|
|
||||||
|
EditPictureControl(uploader: .nostrBuild, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
|
||||||
Text("Public Key", comment: "Label to indicate the public key of the account.")
|
.shadow(radius: 2)
|
||||||
|
.padding(.top, 100)
|
||||||
|
|
||||||
|
Text("Add Photo", comment: "Label to indicate user can add a photo.")
|
||||||
.bold()
|
.bold()
|
||||||
.padding()
|
.foregroundColor(DamusColors.neutral6)
|
||||||
.onTapGesture {
|
|
||||||
regen_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyText($account.pubkey)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.onTapGesture {
|
|
||||||
regen_key()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(minWidth: 300, maxWidth: .infinity, minHeight: 250, alignment: .center)
|
|
||||||
.background {
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(DamusColors.adaptableGrey, strokeBorder: .gray.opacity(0.5), lineWidth: 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SignupForm {
|
SignupForm {
|
||||||
FormLabel(NSLocalizedString("Display name", comment: "Label to prompt display name entry."), optional: true)
|
FormLabel(NSLocalizedString("Name", comment: "Label to prompt name entry."), optional: false)
|
||||||
FormTextInput(NSLocalizedString("Satoshi Nakamoto", comment: "Name of Bitcoin creator(s)."), text: $account.real_name)
|
.foregroundColor(DamusColors.neutral6)
|
||||||
|
FormTextInput(NSLocalizedString("Satoshi Nakamoto", comment: "Name of Bitcoin creator(s)."), text: $account.name)
|
||||||
.textInputAutocapitalization(.words)
|
.textInputAutocapitalization(.words)
|
||||||
|
|
||||||
FormLabel(NSLocalizedString("About", comment: "Label to prompt for about text entry for user to describe about themself."), optional: true)
|
FormLabel(NSLocalizedString("Bio", comment: "Label to prompt bio entry for user to describe themself."), optional: true)
|
||||||
FormTextInput(NSLocalizedString("Creator(s) of Bitcoin. Absolute legend.", comment: "Example description about Bitcoin creator(s), Satoshi Nakamoto."), text: $account.about)
|
.foregroundColor(DamusColors.neutral6)
|
||||||
|
FormTextInput(NSLocalizedString("Absolute legend.", comment: "Example Bio"), text: $account.about)
|
||||||
}
|
}
|
||||||
.padding(.top, 10)
|
.padding(.top, 25)
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
nav.push(route: Route.SaveKeys(account: account))
|
nav.push(route: Route.SaveKeys(account: account))
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Create account now", comment: "Button to create account.")
|
Text("Next", comment: "Button to continue with account creation.")
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
||||||
}
|
}
|
||||||
.buttonStyle(GradientButtonStyle())
|
.buttonStyle(GradientButtonStyle())
|
||||||
.disabled(profileUploadObserver.isLoading)
|
.disabled(profileUploadObserver.isLoading || account.name.isEmpty)
|
||||||
.opacity(profileUploadObserver.isLoading ? 0.5 : 1)
|
.opacity(profileUploadObserver.isLoading || account.name.isEmpty ? 0.5 : 1)
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
|
||||||
Text("By signing up, you agree to our ", comment: "Ask the user if they already have an account on Nostr")
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(Color("DamusMediumGrey"))
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
nav.push(route: Route.EULA)
|
|
||||||
}, label: {
|
|
||||||
Text("EULA")
|
|
||||||
.font(.subheadline)
|
|
||||||
})
|
|
||||||
.padding(.vertical, 5)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
LoginPrompt()
|
LoginPrompt()
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|
||||||
@@ -94,8 +70,8 @@ struct CreateAccountView: View {
|
|||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
.background(DamusBackground(maxHeight: UIScreen.main.bounds.size.height/2), alignment: .top)
|
||||||
.dismissKeyboardOnTap()
|
.dismissKeyboardOnTap()
|
||||||
.navigationTitle("Create account")
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationBarBackButtonHidden(true)
|
.navigationBarBackButtonHidden(true)
|
||||||
.navigationBarItems(leading: BackNav())
|
.navigationBarItems(leading: BackNav())
|
||||||
@@ -111,7 +87,7 @@ struct LoginPrompt: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Already on Nostr?", comment: "Ask the user if they already have an account on Nostr")
|
Text("Already on Nostr?", comment: "Ask the user if they already have an account on Nostr")
|
||||||
.foregroundColor(Color("DamusMediumGrey"))
|
.foregroundColor(DamusColors.neutral6)
|
||||||
|
|
||||||
Button(NSLocalizedString("Login", comment: "Button to navigate to login view.")) {
|
Button(NSLocalizedString("Login", comment: "Button to navigate to login view.")) {
|
||||||
self.dismiss()
|
self.dismiss()
|
||||||
@@ -127,8 +103,8 @@ struct BackNav: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Image("chevron-left")
|
Image("chevron-left")
|
||||||
.foregroundColor(DamusColors.adaptableBlack)
|
.foregroundColor(DamusColors.adaptableBlack)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
self.dismiss()
|
self.dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,20 +124,11 @@ extension View {
|
|||||||
|
|
||||||
struct CreateAccountView_Previews: PreviewProvider {
|
struct CreateAccountView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let model = CreateAccountModel(real: "", nick: "jb55", about: "")
|
let model = CreateAccountModel(display_name: "", name: "jb55", about: "")
|
||||||
return CreateAccountView(account: model, nav: .init())
|
return CreateAccountView(account: model, nav: .init())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func KeyText(_ pubkey: Binding<Pubkey>) -> some View {
|
|
||||||
let bechkey = bech32_encode(hrp: PUBKEY_HRP, pubkey.wrappedValue.bytes)
|
|
||||||
return Text(bechkey)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.font(.callout.monospaced())
|
|
||||||
.foregroundStyle(DamusLogoGradient.gradient)
|
|
||||||
}
|
|
||||||
|
|
||||||
func FormTextInput(_ title: String, text: Binding<String>) -> some View {
|
func FormTextInput(_ title: String, text: Binding<String>) -> some View {
|
||||||
return TextField("", text: text)
|
return TextField("", text: text)
|
||||||
.placeholder(when: text.wrappedValue.isEmpty) {
|
.placeholder(when: text.wrappedValue.isEmpty) {
|
||||||
@@ -171,6 +138,10 @@ func FormTextInput(_ title: String, text: Binding<String>) -> some View {
|
|||||||
.background {
|
.background {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke(.gray.opacity(0.5), lineWidth: 1)
|
.stroke(.gray.opacity(0.5), lineWidth: 1)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.foregroundColor(.damusAdaptableWhite)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.font(.body.bold())
|
.font(.body.bold())
|
||||||
}
|
}
|
||||||
@@ -183,6 +154,10 @@ func FormLabel(_ title: String, optional: Bool = false) -> some View {
|
|||||||
Text("optional", comment: "Label indicating that a form input is optional.")
|
Text("optional", comment: "Label indicating that a form input is optional.")
|
||||||
.font(.callout)
|
.font(.callout)
|
||||||
.foregroundColor(DamusColors.mediumGrey)
|
.foregroundColor(DamusColors.mediumGrey)
|
||||||
|
} else {
|
||||||
|
Text("required", comment: "Label indicating that a form input is required.")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(DamusColors.mediumGrey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,13 +72,11 @@ struct DirectMessagesView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
CustomPicker(selection: $dm_type, content: {
|
CustomPicker(tabs: [
|
||||||
Text("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message.")
|
(NSLocalizedString("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message."), DMType.friend),
|
||||||
.tag(DMType.friend)
|
(NSLocalizedString("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet"), DMType.rando),
|
||||||
Text("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet). DM is the English abbreviation for Direct Message.")
|
], selection: $dm_type)
|
||||||
.tag(DMType.rando)
|
|
||||||
})
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
|
|
||||||
@@ -105,7 +103,7 @@ struct DirectMessagesView: View {
|
|||||||
|
|
||||||
func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageModel]) -> Bool {
|
func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageModel]) -> Bool {
|
||||||
for dm in dms {
|
for dm in dms {
|
||||||
if !FriendFilter.friends.filter(contacts: contacts, pubkey: dm.pubkey) {
|
if !FriendFilter.friends_of_friends.filter(contacts: contacts, pubkey: dm.pubkey) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ struct EventView: View {
|
|||||||
}
|
}
|
||||||
} else if event.known_kind == .longform {
|
} else if event.known_kind == .longform {
|
||||||
LongformPreview(state: damus, ev: event, options: options)
|
LongformPreview(state: damus, ev: event, options: options)
|
||||||
|
} else if event.known_kind == .highlight {
|
||||||
|
HighlightView(state: damus, event: event, options: options)
|
||||||
} else {
|
} else {
|
||||||
TextEvent(damus: damus, event: event, pubkey: pubkey, options: options)
|
TextEvent(damus: damus, event: event, pubkey: pubkey, options: options)
|
||||||
//.padding([.top], 6)
|
//.padding([.top], 6)
|
||||||
@@ -71,6 +73,16 @@ func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: Nos
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// blame the porn bots for this code too
|
||||||
|
func should_blur_images(damus_state: DamusState, ev: NostrEvent) -> Bool {
|
||||||
|
return should_blur_images(
|
||||||
|
settings: damus_state.settings,
|
||||||
|
contacts: damus_state.contacts,
|
||||||
|
ev: ev,
|
||||||
|
our_pubkey: damus_state.pubkey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func format_relative_time(_ created_at: UInt32) -> String
|
func format_relative_time(_ created_at: UInt32) -> String
|
||||||
{
|
{
|
||||||
return time_ago_since(Date(timeIntervalSince1970: Double(created_at)))
|
return time_ago_since(Date(timeIntervalSince1970: Double(created_at)))
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ struct ReplyPart: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let reply_ref = event.thread_reply()?.reply {
|
if event.known_kind == .highlight {
|
||||||
ReplyDescription(event: event, replying_to: events.lookup(reply_ref.note_id), ndb: ndb)
|
let highlighted_note = event.highlighted_note_id().flatMap { events.lookup($0) }
|
||||||
} else {
|
let highlight_note = HighlightEvent.parse(from: event)
|
||||||
EmptyView()
|
HighlightDescription(highlight_event: highlight_note, highlighted_event: highlighted_note, ndb: ndb)
|
||||||
|
} else if let reply_ref = event.thread_reply()?.reply {
|
||||||
|
let replying_to = events.lookup(reply_ref.note_id)
|
||||||
|
ReplyDescription(event: event, replying_to: replying_to, ndb: ndb)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ struct EventBody: View {
|
|||||||
if !options.contains(.truncate_content) {
|
if !options.contains(.truncate_content) {
|
||||||
note_content
|
note_content
|
||||||
}
|
}
|
||||||
|
} else if event.known_kind == .highlight {
|
||||||
|
HighlightBodyView(state: damus_state, ev: event, options: options)
|
||||||
|
.onTapGesture {
|
||||||
|
if let highlighted_note = event.highlighted_note_id().flatMap({ damus_state.events.lookup($0) }) {
|
||||||
|
let thread = ThreadModel(event: highlighted_note, damus_state: damus_state, highlight: event.content)
|
||||||
|
damus_state.nav.push(route: Route.Thread(thread: thread))
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
note_content
|
note_content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// HighlightDescription.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/28/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Modified from Reply Description
|
||||||
|
struct HighlightDescription: View {
|
||||||
|
let highlight_event: HighlightEvent
|
||||||
|
let highlighted_event: NostrEvent?
|
||||||
|
let ndb: Ndb
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
(Text(Image(systemName: "highlighter")) + Text(verbatim: " \(highlight_event.source_description_text(ndb: ndb, highlighted_event: highlighted_event))"))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HighlightDescription_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
HighlightDescription(highlight_event: HighlightEvent.parse(from: test_note), highlighted_event: nil, ndb: test_damus_state.ndb)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
//
|
||||||
|
// HighlightDraftContentView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 5/26/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct HighlightDraftContentView: View {
|
||||||
|
let draft: HighlightContentDraft
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
var attributedString: AttributedString {
|
||||||
|
var attributedString = AttributedString(draft.selected_text)
|
||||||
|
|
||||||
|
if let range = attributedString.range(of: draft.selected_text) {
|
||||||
|
attributedString[range].backgroundColor = DamusColors.highlight
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributedString
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(attributedString)
|
||||||
|
.lineSpacing(5)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
|
||||||
|
alignment: .leading
|
||||||
|
)
|
||||||
|
|
||||||
|
if case .external_url(let url) = draft.source {
|
||||||
|
LinkViewRepresentable(meta: .url(url))
|
||||||
|
.frame(height: 50)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//
|
||||||
|
// HighlightEventRef.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/29/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct HighlightEventRef: View {
|
||||||
|
let damus_state: DamusState
|
||||||
|
let event_ref: NoteId
|
||||||
|
|
||||||
|
init(damus_state: DamusState, event_ref: NoteId) {
|
||||||
|
self.damus_state = damus_state
|
||||||
|
self.event_ref = event_ref
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FailedImage: View {
|
||||||
|
var body: some View {
|
||||||
|
Image("markdown")
|
||||||
|
.resizable()
|
||||||
|
.foregroundColor(DamusColors.neutral6)
|
||||||
|
.background(DamusColors.neutral3)
|
||||||
|
.frame(width: 35, height: 35)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.gray.opacity(0.5), lineWidth: 0.5))
|
||||||
|
.scaledToFit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
EventLoaderView(damus_state: damus_state, event_id: event_ref) { event in
|
||||||
|
EventMutingContainerView(damus_state: damus_state, event: event) {
|
||||||
|
if event.known_kind == .longform {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
let longform_event = LongformEvent.parse(from: event)
|
||||||
|
if let url = longform_event.image {
|
||||||
|
KFAnimatedImage(url)
|
||||||
|
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||||
|
.backgroundDecode(true)
|
||||||
|
.imageContext(.note, disable_animation: true)
|
||||||
|
.image_fade(duration: 0.25)
|
||||||
|
.cancelOnDisappear(true)
|
||||||
|
.configure { view in
|
||||||
|
view.framePreloadCount = 3
|
||||||
|
}
|
||||||
|
.background {
|
||||||
|
FailedImage()
|
||||||
|
}
|
||||||
|
.frame(width: 35, height: 35)
|
||||||
|
.kfClickable()
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.gray.opacity(0.5), lineWidth: 0.5))
|
||||||
|
.scaledToFit()
|
||||||
|
} else {
|
||||||
|
FailedImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(longform_event.title ?? "Untitled")
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
let profile_txn = damus_state.profiles.lookup(id: longform_event.event.pubkey, txn_name: "highlight-profile")
|
||||||
|
let profile = profile_txn?.unsafeUnownedValue
|
||||||
|
|
||||||
|
if let display_name = profile?.display_name {
|
||||||
|
Text(display_name)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
} else if let name = profile?.name {
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding([.leading, .vertical], 7)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(DamusColors.neutral3, lineWidth: 2)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
//
|
||||||
|
// HighlightLink.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/28/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct HighlightLink: View {
|
||||||
|
let state: DamusState
|
||||||
|
let url: URL
|
||||||
|
let content: String
|
||||||
|
@Environment(\.openURL) var openURL
|
||||||
|
|
||||||
|
func text_fragment_url() -> URL? {
|
||||||
|
let fragmentDirective = "#:~:"
|
||||||
|
let textDirective = "text="
|
||||||
|
let separator = ","
|
||||||
|
var text = ""
|
||||||
|
|
||||||
|
let components = content.components(separatedBy: " ")
|
||||||
|
if components.count <= 10 {
|
||||||
|
text = content
|
||||||
|
} else {
|
||||||
|
let textStart = Array(components.prefix(5)).joined(separator: " ")
|
||||||
|
let textEnd = Array(components.suffix(2)).joined(separator: " ")
|
||||||
|
text = textStart + separator + textEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
let url_with_fragments = url.absoluteString + fragmentDirective + textDirective + text
|
||||||
|
return URL(string: url_with_fragments)
|
||||||
|
}
|
||||||
|
|
||||||
|
func get_url_icon() -> URL? {
|
||||||
|
var icon = URL(string: url.absoluteString + "/favicon.ico")
|
||||||
|
if let url_host = url.host() {
|
||||||
|
icon = URL(string: "https://" + url_host + "/favicon.ico")
|
||||||
|
}
|
||||||
|
return icon
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: {
|
||||||
|
openURL(text_fragment_url() ?? url)
|
||||||
|
}, label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if let url = get_url_icon() {
|
||||||
|
KFAnimatedImage(url)
|
||||||
|
.imageContext(.pfp, disable_animation: true)
|
||||||
|
.cancelOnDisappear(true)
|
||||||
|
.configure { view in
|
||||||
|
view.framePreloadCount = 3
|
||||||
|
}
|
||||||
|
.placeholder { _ in
|
||||||
|
Image("link")
|
||||||
|
.resizable()
|
||||||
|
.padding(5)
|
||||||
|
.foregroundColor(DamusColors.neutral6)
|
||||||
|
.background(DamusColors.adaptableWhite)
|
||||||
|
}
|
||||||
|
.frame(width: 35, height: 35)
|
||||||
|
.kfClickable()
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.scaledToFit()
|
||||||
|
} else {
|
||||||
|
Image("link")
|
||||||
|
.resizable()
|
||||||
|
.padding(5)
|
||||||
|
.foregroundColor(DamusColors.neutral6)
|
||||||
|
.background(DamusColors.adaptableWhite)
|
||||||
|
.frame(width: 35, height: 35)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(url.absoluteString)
|
||||||
|
.font(eventviewsize_to_font(.normal, font_size: state.settings.font_size))
|
||||||
|
.foregroundColor(DamusColors.adaptableBlack)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.padding([.leading, .vertical], 7)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(DamusColors.neutral3)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(DamusColors.neutral3, lineWidth: 2)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HighlightLink_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
let url = URL(string: "https://damus.io")!
|
||||||
|
VStack {
|
||||||
|
HighlightLink(state: test_damus_state, url: url, content: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//
|
||||||
|
// HighlightView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/22/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct HighlightTruncatedText: View {
|
||||||
|
let attributedString: AttributedString
|
||||||
|
let maxChars: Int
|
||||||
|
|
||||||
|
init(attributedString: AttributedString, maxChars: Int = 360) {
|
||||||
|
self.attributedString = attributedString
|
||||||
|
self.maxChars = maxChars
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
|
||||||
|
let truncatedAttributedString: AttributedString? = attributedString.truncateOrNil(maxLength: maxChars)
|
||||||
|
|
||||||
|
if let truncatedAttributedString {
|
||||||
|
Text(truncatedAttributedString)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else {
|
||||||
|
Text(attributedString)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if truncatedAttributedString != nil {
|
||||||
|
Spacer()
|
||||||
|
Button(NSLocalizedString("Show more", comment: "Button to show entire note.")) { }
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HighlightBodyView: View {
|
||||||
|
let state: DamusState
|
||||||
|
let event: HighlightEvent
|
||||||
|
let options: EventViewOptions
|
||||||
|
|
||||||
|
init(state: DamusState, ev: HighlightEvent, options: EventViewOptions) {
|
||||||
|
self.state = state
|
||||||
|
self.event = ev
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
init(state: DamusState, ev: NostrEvent, options: EventViewOptions) {
|
||||||
|
self.state = state
|
||||||
|
self.event = HighlightEvent.parse(from: ev)
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if options.contains(.wide) {
|
||||||
|
Main
|
||||||
|
} else {
|
||||||
|
Main.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var truncate: Bool {
|
||||||
|
return options.contains(.truncate_content)
|
||||||
|
}
|
||||||
|
|
||||||
|
var truncate_very_short: Bool {
|
||||||
|
return options.contains(.truncate_content_very_short)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncatedText(attributedString: AttributedString) -> some View {
|
||||||
|
Group {
|
||||||
|
if truncate_very_short {
|
||||||
|
HighlightTruncatedText(attributedString: attributedString, maxChars: 140)
|
||||||
|
.font(eventviewsize_to_font(.normal, font_size: state.settings.font_size))
|
||||||
|
}
|
||||||
|
else if truncate {
|
||||||
|
HighlightTruncatedText(attributedString: attributedString)
|
||||||
|
.font(eventviewsize_to_font(.normal, font_size: state.settings.font_size))
|
||||||
|
} else {
|
||||||
|
Text(attributedString)
|
||||||
|
.font(eventviewsize_to_font(.normal, font_size: state.settings.font_size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var Main: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
|
||||||
|
if self.event.event.referenced_comment_items.first?.content != nil {
|
||||||
|
let all_options = options.union(.no_action_bar)
|
||||||
|
NoteContentView(
|
||||||
|
damus_state: self.state,
|
||||||
|
event: self.event.event,
|
||||||
|
blur_images: should_blur_images(damus_state: self.state, ev: self.event.event),
|
||||||
|
size: .normal,
|
||||||
|
options: all_options
|
||||||
|
).padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
var attributedString: AttributedString {
|
||||||
|
var attributedString: AttributedString = ""
|
||||||
|
if let context = event.context {
|
||||||
|
if context.count < event.event.content.count {
|
||||||
|
attributedString = AttributedString(event.event.content)
|
||||||
|
} else {
|
||||||
|
attributedString = AttributedString(context)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
attributedString = AttributedString(event.event.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let range = attributedString.range(of: event.event.content) {
|
||||||
|
attributedString[range].backgroundColor = DamusColors.highlight
|
||||||
|
}
|
||||||
|
return attributedString
|
||||||
|
}
|
||||||
|
|
||||||
|
truncatedText(attributedString: attributedString)
|
||||||
|
.lineSpacing(5)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 25).fill(DamusColors.highlight).frame(width: 4),
|
||||||
|
alignment: .leading
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
if let url = event.url_ref {
|
||||||
|
HighlightLink(state: state, url: url, content: event.event.content)
|
||||||
|
.padding(.horizontal)
|
||||||
|
} else {
|
||||||
|
if let evRef = event.event_ref {
|
||||||
|
if let eventHex = hex_decode_id(evRef) {
|
||||||
|
HighlightEventRef(damus_state: state, event_ref: NoteId(eventHex))
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HighlightView: View {
|
||||||
|
let state: DamusState
|
||||||
|
let event: HighlightEvent
|
||||||
|
let options: EventViewOptions
|
||||||
|
|
||||||
|
init(state: DamusState, event: NostrEvent, options: EventViewOptions) {
|
||||||
|
self.state = state
|
||||||
|
self.event = HighlightEvent.parse(from: event)
|
||||||
|
self.options = options.union(.no_mentions)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
EventShell(state: state, event: event.event, options: options) {
|
||||||
|
HighlightBodyView(state: state, ev: event, options: options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HighlightView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
|
||||||
|
let content = "Nostr, a decentralized and open social network protocol. Without ads, toxic algorithms, or censorship"
|
||||||
|
let context = "Damus is built on Nostr, a decentralized and open social network protocol. Without ads, toxic algorithms, or censorship, Damus gives you access to the social network that a truly free and healthy society needs — and deserves."
|
||||||
|
|
||||||
|
let test_highlight_event = HighlightEvent.parse(from: NostrEvent(
|
||||||
|
content: content,
|
||||||
|
keypair: test_keypair,
|
||||||
|
kind: NostrKind.highlight.rawValue,
|
||||||
|
tags: [
|
||||||
|
["context", context],
|
||||||
|
["r", "https://damus.io"],
|
||||||
|
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
|
||||||
|
])!
|
||||||
|
)
|
||||||
|
|
||||||
|
let test_highlight_event2 = HighlightEvent.parse(from: NostrEvent(
|
||||||
|
content: content,
|
||||||
|
keypair: test_keypair,
|
||||||
|
kind: NostrKind.highlight.rawValue,
|
||||||
|
tags: [
|
||||||
|
["context", context],
|
||||||
|
["e", "36017b098859d62e1dbd802290d59c9de9f18bb0ca00ba4b875c2930dd5891ae"],
|
||||||
|
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
|
||||||
|
])!
|
||||||
|
)
|
||||||
|
VStack {
|
||||||
|
HighlightView(state: test_damus_state, event: test_highlight_event.event, options: [])
|
||||||
|
|
||||||
|
HighlightView(state: test_damus_state, event: test_highlight_event2.event, options: [.wide])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,13 +51,13 @@ struct LongformPreviewBody: View {
|
|||||||
func truncatedText(content: CompatibleText) -> some View {
|
func truncatedText(content: CompatibleText) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if truncate_very_short {
|
if truncate_very_short {
|
||||||
TruncatedText(text: content, maxChars: 140)
|
TruncatedText(text: content, maxChars: 140, show_show_more_button: !options.contains(.no_show_more))
|
||||||
.font(header ? .body : .caption)
|
.font(header ? .body : .caption)
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
}
|
}
|
||||||
else if truncate {
|
else if truncate {
|
||||||
TruncatedText(text: content)
|
TruncatedText(text: content, show_show_more_button: !options.contains(.no_show_more))
|
||||||
.font(header ? .body : .caption)
|
.font(header ? .body : .caption)
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
@@ -98,6 +98,7 @@ struct LongformPreviewBody: View {
|
|||||||
}
|
}
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(maxWidth: .infinity, maxHeight: header ? .infinity : 150)
|
.frame(maxWidth: .infinity, maxHeight: header ? .infinity : 150)
|
||||||
|
.kfClickable()
|
||||||
.cornerRadius(1)
|
.cornerRadius(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ struct LongformView: View {
|
|||||||
var options: EventViewOptions {
|
var options: EventViewOptions {
|
||||||
return [.wide, .no_mentions, .no_replying_to]
|
return [.wide, .no_mentions, .no_replying_to]
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
EventShell(state: state, event: event.event, options: options) {
|
EventShell(state: state, event: event.event, options: options) {
|
||||||
SelectableText(attributedString: AttributedString(stringLiteral: event.title ?? "Untitled"), size: .title)
|
SelectableText(damus_state: state, event: event.event, attributedString: AttributedString(stringLiteral: event.title ?? "Untitled"), size: .title)
|
||||||
|
|
||||||
NoteContentView(damus_state: state, event: event.event, blur_images: false, size: .selected, options: options)
|
NoteContentView(damus_state: state, event: event.event, blur_images: false, size: .selected, options: options)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,12 +39,10 @@ struct SelectedEventView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.minimumScaleFactor(0.75)
|
.minimumScaleFactor(0.75)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
if let reply_ref = event.thread_reply()?.reply {
|
ReplyPart(events: damus.events, event: event, keypair: damus.keypair, ndb: damus.ndb)
|
||||||
ReplyDescription(event: event, replying_to: damus.events.lookup(reply_ref.note_id), ndb: damus.ndb)
|
.padding(.horizontal)
|
||||||
.padding(.horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
ProxyView(event: event)
|
ProxyView(event: event)
|
||||||
.padding(.top, 5)
|
.padding(.top, 5)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ struct EventViewOptions: OptionSet {
|
|||||||
static let no_mentions = EventViewOptions(rawValue: 1 << 9)
|
static let no_mentions = EventViewOptions(rawValue: 1 << 9)
|
||||||
static let no_media = EventViewOptions(rawValue: 1 << 10)
|
static let no_media = EventViewOptions(rawValue: 1 << 10)
|
||||||
static let truncate_content_very_short = EventViewOptions(rawValue: 1 << 11)
|
static let truncate_content_very_short = EventViewOptions(rawValue: 1 << 11)
|
||||||
|
static let no_previews = EventViewOptions(rawValue: 1 << 12)
|
||||||
|
static let no_show_more = EventViewOptions(rawValue: 1 << 13)
|
||||||
|
|
||||||
static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested]
|
static let embedded: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested]
|
||||||
static let embedded_text_only: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested, .no_media, .truncate_content_very_short]
|
static let embedded_text_only: EventViewOptions = [.no_action_bar, .small_pfp, .wide, .truncate_content, .nested, .no_media, .truncate_content_very_short, .no_previews]
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TextEvent: View {
|
struct TextEvent: View {
|
||||||
|
|||||||
@@ -160,10 +160,10 @@ struct FollowingView: View {
|
|||||||
.navigationBarTitle(NSLocalizedString("Following", comment: "Navigation bar title for view that shows who a user is following."))
|
.navigationBarTitle(NSLocalizedString("Following", comment: "Navigation bar title for view that shows who a user is following."))
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
CustomPicker(selection: $tab_selection, content: {
|
CustomPicker(tabs: [
|
||||||
Text("People", comment: "Label for filter for seeing only people follows.").tag(FollowingViewTabSelection.people)
|
(NSLocalizedString("People", comment: "Label for filter for seeing only people follows."), FollowingViewTabSelection.people),
|
||||||
Text("Hashtags", comment: "Label for filter for seeing only hashtag follows.").tag(FollowingViewTabSelection.hashtags)
|
(NSLocalizedString("Hashtags", comment: "Label for filter for seeing only hashtag follows."), FollowingViewTabSelection.hashtags)
|
||||||
})
|
], selection: $tab_selection)
|
||||||
Divider()
|
Divider()
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ struct ImageContainerView: View {
|
|||||||
view.framePreloadCount = 3
|
view.framePreloadCount = 3
|
||||||
}
|
}
|
||||||
.imageModifier(ImageHandler(handler: $image))
|
.imageModifier(ImageHandler(handler: $image))
|
||||||
|
.kfClickable()
|
||||||
.clipped()
|
.clipped()
|
||||||
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
|
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
|
||||||
.sheet(isPresented: $showShareSheet) {
|
.sheet(isPresented: $showShareSheet) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ struct ProfileImageContainerView: View {
|
|||||||
.imageModifier(ImageHandler(handler: $image))
|
.imageModifier(ImageHandler(handler: $image))
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
|
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
|
||||||
|
.kfClickable()
|
||||||
.sheet(isPresented: $showShareSheet) {
|
.sheet(isPresented: $showShareSheet) {
|
||||||
ShareSheet(activityItems: [url])
|
ShareSheet(activityItems: [url])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ struct LoginView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
VStack {
|
VStack {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
SignInHeader()
|
SignInHeader()
|
||||||
.padding(.top, 100)
|
|
||||||
|
|
||||||
SignInEntry(key: $key, shouldSaveKey: $shouldSaveKey)
|
SignInEntry(key: $key, shouldSaveKey: $shouldSaveKey)
|
||||||
|
|
||||||
@@ -112,8 +113,9 @@ struct LoginView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
.padding(.bottom, 50)
|
||||||
}
|
}
|
||||||
.background(DamusBackground(maxHeight: 350), alignment: .top)
|
.background(DamusBackground(maxHeight: UIScreen.main.bounds.size.height/2), alignment: .top)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
credential_handler.check_credentials()
|
credential_handler.check_credentials()
|
||||||
}
|
}
|
||||||
@@ -320,9 +322,13 @@ struct KeyInput: View {
|
|||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.overlay {
|
.background {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
.stroke(.gray, lineWidth: 1)
|
.stroke(.gray, lineWidth: 1)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.foregroundColor(.damusAdaptableWhite)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,11 +343,12 @@ struct SignInHeader: View {
|
|||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
|
|
||||||
Text("Sign in", comment: "Title of view to log into an account.")
|
Text("Sign in", comment: "Title of view to log into an account.")
|
||||||
|
.foregroundColor(DamusColors.neutral6)
|
||||||
.font(.system(size: 32, weight: .bold))
|
.font(.system(size: 32, weight: .bold))
|
||||||
.padding(.bottom, 5)
|
.padding(.bottom, 5)
|
||||||
|
|
||||||
Text("Welcome to the social network you control", comment: "Welcome text")
|
Text("Welcome to the social network you control", comment: "Welcome text")
|
||||||
.foregroundColor(Color("DamusMediumGrey"))
|
.foregroundColor(DamusColors.neutral6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,6 +360,7 @@ struct SignInEntry: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text("Enter your account key", comment: "Prompt for user to enter an account key to login.")
|
Text("Enter your account key", comment: "Prompt for user to enter an account key to login.")
|
||||||
|
.foregroundColor(DamusColors.neutral6)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.padding(.top, 30)
|
.padding(.top, 30)
|
||||||
|
|
||||||
@@ -444,7 +452,9 @@ struct LoginView_Previews: PreviewProvider {
|
|||||||
let bech32_pubkey = "KeyInput"
|
let bech32_pubkey = "KeyInput"
|
||||||
Group {
|
Group {
|
||||||
LoginView(key: pubkey, nav: .init())
|
LoginView(key: pubkey, nav: .init())
|
||||||
|
.previewDevice(PreviewDevice(rawValue: "iPhone SE (3rd generation)"))
|
||||||
LoginView(key: bech32_pubkey, nav: .init())
|
LoginView(key: bech32_pubkey, nav: .init())
|
||||||
|
.previewDevice(PreviewDevice(rawValue: "iPhone 15 Pro Max"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct AddMuteItemView: View {
|
struct AddMuteItemView: View {
|
||||||
let state: DamusState
|
let state: DamusState
|
||||||
@State var new_text: String = ""
|
@Binding var new_text: String
|
||||||
@State var expiration: DamusDuration = .indefinite
|
@State var expiration: DamusDuration = .indefinite
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -87,7 +87,7 @@ struct AddMuteItemView: View {
|
|||||||
|
|
||||||
new_text = ""
|
new_text = ""
|
||||||
|
|
||||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
@@ -108,6 +108,6 @@ struct AddMuteItemView: View {
|
|||||||
|
|
||||||
struct AddMuteItemView_Previews: PreviewProvider {
|
struct AddMuteItemView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
AddMuteItemView(state: test_damus_state)
|
AddMuteItemView(state: test_damus_state, new_text: .constant(""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ struct MutelistView: View {
|
|||||||
@State var hashtags: [MuteItem] = []
|
@State var hashtags: [MuteItem] = []
|
||||||
@State var threads: [MuteItem] = []
|
@State var threads: [MuteItem] = []
|
||||||
@State var words: [MuteItem] = []
|
@State var words: [MuteItem] = []
|
||||||
|
|
||||||
|
@State var new_text: String = ""
|
||||||
|
|
||||||
func RemoveAction(item: MuteItem) -> some View {
|
func RemoveAction(item: MuteItem) -> some View {
|
||||||
Button {
|
Button {
|
||||||
@@ -120,13 +122,9 @@ struct MutelistView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $show_add_muteitem, onDismiss: { self.show_add_muteitem = false }) {
|
.sheet(isPresented: $show_add_muteitem, onDismiss: { self.show_add_muteitem = false }) {
|
||||||
if #available(iOS 16.0, *) {
|
AddMuteItemView(state: damus_state, new_text: $new_text)
|
||||||
AddMuteItemView(state: damus_state)
|
.presentationDetents([.height(300)])
|
||||||
.presentationDetents([.height(300)])
|
.presentationDragIndicator(.visible)
|
||||||
.presentationDragIndicator(.visible)
|
|
||||||
} else {
|
|
||||||
AddMuteItemView(state: damus_state)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
import LinkPresentation
|
import LinkPresentation
|
||||||
import NaturalLanguage
|
import NaturalLanguage
|
||||||
import MarkdownUI
|
import MarkdownUI
|
||||||
|
import Translation
|
||||||
|
|
||||||
struct Blur: UIViewRepresentable {
|
struct Blur: UIViewRepresentable {
|
||||||
var style: UIBlurEffect.Style = .systemUltraThinMaterial
|
var style: UIBlurEffect.Style = .systemUltraThinMaterial
|
||||||
@@ -32,6 +33,8 @@ struct NoteContentView: View {
|
|||||||
let preview_height: CGFloat?
|
let preview_height: CGFloat?
|
||||||
let options: EventViewOptions
|
let options: EventViewOptions
|
||||||
|
|
||||||
|
@State var isAppleTranslationPopoverPresented: Bool = false
|
||||||
|
|
||||||
@ObservedObject var artifacts_model: NoteArtifactsModel
|
@ObservedObject var artifacts_model: NoteArtifactsModel
|
||||||
@ObservedObject var preview_model: PreviewModel
|
@ObservedObject var preview_model: PreviewModel
|
||||||
@ObservedObject var settings: UserSettingsStore
|
@ObservedObject var settings: UserSettingsStore
|
||||||
@@ -78,11 +81,11 @@ struct NoteContentView: View {
|
|||||||
func truncatedText(content: CompatibleText) -> some View {
|
func truncatedText(content: CompatibleText) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if truncate_very_short {
|
if truncate_very_short {
|
||||||
TruncatedText(text: content, maxChars: 140)
|
TruncatedText(text: content, maxChars: 140, show_show_more_button: !options.contains(.no_show_more))
|
||||||
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
|
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
|
||||||
}
|
}
|
||||||
else if truncate {
|
else if truncate {
|
||||||
TruncatedText(text: content)
|
TruncatedText(text: content, show_show_more_button: !options.contains(.no_show_more))
|
||||||
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
|
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size))
|
||||||
} else {
|
} else {
|
||||||
content.text
|
content.text
|
||||||
@@ -96,7 +99,17 @@ struct NoteContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var translateView: some View {
|
var translateView: some View {
|
||||||
TranslateView(damus_state: damus_state, event: event, size: self.size)
|
#if targetEnvironment(macCatalyst)
|
||||||
|
TranslateView(damus_state: damus_state, event: event, size: self.size, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
|
||||||
|
#else
|
||||||
|
if #available(iOS 18.0, macOS 15.0, *), damus_state.settings.translate_offline {
|
||||||
|
AnyView(OfflineTranslateView(damus_state: damus_state, event: event, size: self.size))
|
||||||
|
} else {
|
||||||
|
AnyView(
|
||||||
|
TranslateView(damus_state: damus_state, event: event, size: self.size, isAppleTranslationPopoverPresented: $isAppleTranslationPopoverPresented)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewView(links: [URL]) -> some View {
|
func previewView(links: [URL]) -> some View {
|
||||||
@@ -120,8 +133,7 @@ struct NoteContentView: View {
|
|||||||
EventView(damus: damus_state, event: self.event, options: .embedded_text_only)
|
EventView(damus: damus_state, event: self.event, options: .embedded_text_only)
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
}
|
}
|
||||||
.background(.thinMaterial)
|
.background(.thickMaterial)
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.onTapGesture(perform: {
|
.onTapGesture(perform: {
|
||||||
damus_state.nav.push(route: Route.Thread(thread: .init(event: self.event, damus_state: damus_state)))
|
damus_state.nav.push(route: Route.Thread(thread: .init(event: self.event, damus_state: damus_state)))
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -132,10 +144,10 @@ struct NoteContentView: View {
|
|||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if size == .selected {
|
if size == .selected {
|
||||||
if with_padding {
|
if with_padding {
|
||||||
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
|
SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
} else {
|
} else {
|
||||||
SelectableText(attributedString: artifacts.content.attributed, size: self.size)
|
SelectableText(damus_state: damus_state, event: self.event, attributedString: artifacts.content.attributed, size: self.size)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if with_padding {
|
if with_padding {
|
||||||
@@ -146,7 +158,7 @@ struct NoteContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !options.contains(.no_translate) && (size == .selected || damus_state.settings.auto_translate) {
|
if !options.contains(.no_translate) && (size == .selected || TranslationService.isAppleTranslationSupported || damus_state.settings.auto_translate) {
|
||||||
if with_padding {
|
if with_padding {
|
||||||
translateView
|
translateView
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
@@ -185,18 +197,22 @@ struct NoteContentView: View {
|
|||||||
invoicesView(invoices: artifacts.invoices)
|
invoicesView(invoices: artifacts.invoices)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if damus_state.settings.media_previews {
|
if damus_state.settings.media_previews, has_previews {
|
||||||
if with_padding {
|
if with_padding {
|
||||||
previewView(links: artifacts.links).padding(.horizontal)
|
previewView(links: artifacts.links).padding(.horizontal)
|
||||||
} else {
|
} else {
|
||||||
previewView(links: artifacts.links)
|
previewView(links: artifacts.links)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var has_previews: Bool {
|
||||||
|
!options.contains(.no_previews)
|
||||||
|
}
|
||||||
|
|
||||||
func loadMediaButton(artifacts: NoteArtifactsSeparated) -> some View {
|
func loadMediaButton(artifacts: NoteArtifactsSeparated) -> some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
load_media = true
|
load_media = true
|
||||||
@@ -295,7 +311,16 @@ struct NoteContentView: View {
|
|||||||
Markdown(md.markdown)
|
Markdown(md.markdown)
|
||||||
.padding([.leading, .trailing, .top])
|
.padding([.leading, .trailing, .top])
|
||||||
case .separated(let separated):
|
case .separated(let separated):
|
||||||
MainContent(artifacts: separated)
|
if #available(iOS 18.0, macOS 15.0, *), damus_state.settings.auto_translate {
|
||||||
|
MainContent(artifacts: separated)
|
||||||
|
} else if #available(iOS 17.4, macOS 14.4, *) {
|
||||||
|
MainContent(artifacts: separated)
|
||||||
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
.translationPresentation(isPresented: $isAppleTranslationPopoverPresented, text: event.get_content(damus_state.keypair))
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
MainContent(artifacts: separated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -386,7 +411,12 @@ struct NoteContentView_Previews: PreviewProvider {
|
|||||||
NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .normal, options: [])
|
NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .normal, options: [])
|
||||||
}
|
}
|
||||||
.previewDisplayName("Short note")
|
.previewDisplayName("Short note")
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
NoteContentView(damus_state: state, event: test_super_short_note, blur_images: true, size: .normal, options: [])
|
||||||
|
}
|
||||||
|
.previewDisplayName("Super short note")
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: [])
|
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: [])
|
||||||
}
|
}
|
||||||
@@ -397,6 +427,14 @@ struct NoteContentView_Previews: PreviewProvider {
|
|||||||
.border(Color.red)
|
.border(Color.red)
|
||||||
}
|
}
|
||||||
.previewDisplayName("Long-form note")
|
.previewDisplayName("Long-form note")
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .small, options: [.no_previews, .no_action_bar, .truncate_content_very_short, .no_show_more])
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
.previewDisplayName("Small single-line note")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ struct DamusAppNotificationView: View {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func open_url(url: URL) {
|
func open_url(url: URL) {
|
||||||
UIApplication.shared.open(url)
|
this_app.open(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ struct NotificationsView: View {
|
|||||||
@ObservedObject var notifications: NotificationsModel
|
@ObservedObject var notifications: NotificationsModel
|
||||||
@StateObject var filter = NotificationFilter()
|
@StateObject var filter = NotificationFilter()
|
||||||
@SceneStorage("NotificationsView.filter_state") var filter_state: NotificationFilterState = .all
|
@SceneStorage("NotificationsView.filter_state") var filter_state: NotificationFilterState = .all
|
||||||
|
@Binding var subtitle: String?
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@@ -99,6 +100,15 @@ struct NotificationsView: View {
|
|||||||
.tag(NotificationFilterState.replies)
|
.tag(NotificationFilterState.replies)
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button(
|
||||||
|
action: { state.nav.push(route: Route.NotificationSettings(settings: state.settings)) },
|
||||||
|
label: {
|
||||||
|
Image("settings")
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
|
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
|
||||||
FriendsButton(filter: $filter.fine_filter)
|
FriendsButton(filter: $filter.fine_filter)
|
||||||
@@ -107,27 +117,23 @@ struct NotificationsView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: filter.fine_filter) { val in
|
.onChange(of: filter.fine_filter) { val in
|
||||||
state.settings.friend_filter = val
|
state.settings.friend_filter = val
|
||||||
|
self.subtitle = filter.fine_filter.description()
|
||||||
}
|
}
|
||||||
.onChange(of: filter_state) { val in
|
.onChange(of: filter_state) { val in
|
||||||
filter.state = val
|
filter.state = val
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
self.filter.fine_filter = state.settings.friend_filter
|
self.filter.fine_filter = state.settings.friend_filter
|
||||||
|
self.subtitle = filter.fine_filter.description()
|
||||||
filter.state = filter_state
|
filter.state = filter_state
|
||||||
}
|
}
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
CustomPicker(selection: $filter_state, content: {
|
CustomPicker(tabs: [
|
||||||
Text("All", comment: "Label for filter for all notifications.")
|
(NSLocalizedString("All", comment: "Label for filter for all notifications."), NotificationFilterState.all),
|
||||||
.tag(NotificationFilterState.all)
|
(NSLocalizedString("Zaps", comment: "Label for filter for zap notifications."), NotificationFilterState.zaps),
|
||||||
|
(NSLocalizedString("Mentions", comment: "Label for filter for seeing mention notifications (replies, etc)."), NotificationFilterState.replies),
|
||||||
Text("Zaps", comment: "Label for filter for zap notifications.")
|
], selection: $filter_state)
|
||||||
.tag(NotificationFilterState.zaps)
|
|
||||||
|
|
||||||
Text("Mentions", comment: "Label for filter for seeing mention notifications (replies, etc).")
|
|
||||||
.tag(NotificationFilterState.replies)
|
|
||||||
|
|
||||||
})
|
|
||||||
Divider()
|
Divider()
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
}
|
}
|
||||||
@@ -169,7 +175,7 @@ struct NotificationsView: View {
|
|||||||
|
|
||||||
struct NotificationsView_Previews: PreviewProvider {
|
struct NotificationsView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
NotificationsView(state: test_damus_state, notifications: NotificationsModel(), filter: NotificationFilter())
|
NotificationsView(state: test_damus_state, notifications: NotificationsModel(), filter: NotificationFilter(), subtitle: .constant(nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +186,7 @@ func would_filter_non_friends_from_notifications(contacts: Contacts, state: Noti
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if item.would_filter({ ev in FriendFilter.friends.filter(contacts: contacts, pubkey: ev.pubkey) }) {
|
if item.would_filter({ ev in FriendFilter.friends_of_friends.filter(contacts: contacts, pubkey: ev.pubkey) }) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-30
@@ -30,15 +30,18 @@ enum PostAction {
|
|||||||
case replying_to(NostrEvent)
|
case replying_to(NostrEvent)
|
||||||
case quoting(NostrEvent)
|
case quoting(NostrEvent)
|
||||||
case posting(PostTarget)
|
case posting(PostTarget)
|
||||||
|
case highlighting(HighlightContentDraft)
|
||||||
|
|
||||||
var ev: NostrEvent? {
|
var ev: NostrEvent? {
|
||||||
switch self {
|
switch self {
|
||||||
case .replying_to(let ev):
|
case .replying_to(let ev):
|
||||||
return ev
|
return ev
|
||||||
case .quoting(let ev):
|
case .quoting(let ev):
|
||||||
return ev
|
return ev
|
||||||
case .posting:
|
case .posting:
|
||||||
return nil
|
return nil
|
||||||
|
case .highlighting:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +131,12 @@ struct PostView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var posting_disabled: Bool {
|
var posting_disabled: Bool {
|
||||||
return is_post_empty || uploading_disabled
|
switch action {
|
||||||
|
case .highlighting(_):
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return is_post_empty || uploading_disabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns a valid height for the text box, even when textHeight is not a number
|
// Returns a valid height for the text box, even when textHeight is not a number
|
||||||
@@ -204,6 +212,8 @@ struct PostView: View {
|
|||||||
damus_state.drafts.quotes.removeValue(forKey: quoting)
|
damus_state.drafts.quotes.removeValue(forKey: quoting)
|
||||||
case .posting:
|
case .posting:
|
||||||
damus_state.drafts.post = nil
|
damus_state.drafts.post = nil
|
||||||
|
case .highlighting(let draft):
|
||||||
|
damus_state.drafts.highlights.removeValue(forKey: draft.source)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -371,6 +381,9 @@ struct PostView: View {
|
|||||||
if case .quoting(let ev) = action {
|
if case .quoting(let ev) = action {
|
||||||
BuilderEventView(damus: damus_state, event: ev)
|
BuilderEventView(damus: damus_state, event: ev)
|
||||||
}
|
}
|
||||||
|
else if case .highlighting(let draft) = action {
|
||||||
|
HighlightDraftContentView(draft: draft)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
@@ -454,14 +467,15 @@ struct PostView: View {
|
|||||||
let loaded_draft = load_draft()
|
let loaded_draft = load_draft()
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case .replying_to(let replying_to):
|
case .replying_to(let replying_to):
|
||||||
references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to)
|
references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to)
|
||||||
case .quoting(let quoting):
|
case .quoting(let quoting):
|
||||||
references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting)
|
references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting)
|
||||||
case .posting(let target):
|
case .posting(let target):
|
||||||
guard !loaded_draft else { break }
|
guard !loaded_draft else { break }
|
||||||
|
fill_target_content(target: target)
|
||||||
fill_target_content(target: target)
|
case .highlighting(let draft):
|
||||||
|
references = [draft.source.ref()]
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
@@ -597,6 +611,8 @@ func set_draft_for_post(drafts: Drafts, action: PostAction, artifacts: DraftArti
|
|||||||
drafts.quotes[ev] = artifacts
|
drafts.quotes[ev] = artifacts
|
||||||
case .posting:
|
case .posting:
|
||||||
drafts.post = artifacts
|
drafts.post = artifacts
|
||||||
|
case .highlighting(let draft):
|
||||||
|
drafts.highlights[draft.source] = artifacts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,6 +624,8 @@ func load_draft_for_post(drafts: Drafts, action: PostAction) -> DraftArtifacts?
|
|||||||
return drafts.quotes[ev]
|
return drafts.quotes[ev]
|
||||||
case .posting:
|
case .posting:
|
||||||
return drafts.post
|
return drafts.post
|
||||||
|
case .highlighting(let draft):
|
||||||
|
return drafts.highlights[draft.source]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,27 +687,40 @@ func build_post(state: DamusState, post: NSMutableAttributedString, action: Post
|
|||||||
var tags: [[String]] = []
|
var tags: [[String]] = []
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case .replying_to(let replying_to):
|
case .replying_to(let replying_to):
|
||||||
// start off with the reply tags
|
// start off with the reply tags
|
||||||
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
|
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
|
||||||
|
|
||||||
case .quoting(let ev):
|
case .quoting(let ev):
|
||||||
content.append(" nostr:" + bech32_note_id(ev.id))
|
content.append(" nostr:" + bech32_note_id(ev.id))
|
||||||
|
|
||||||
if let quoted_ev = state.events.lookup(ev.id) {
|
if let quoted_ev = state.events.lookup(ev.id) {
|
||||||
tags.append(["p", quoted_ev.pubkey.hex()])
|
tags.append(["p", quoted_ev.pubkey.hex()])
|
||||||
}
|
}
|
||||||
case .posting(let postTarget):
|
case .posting(let postTarget):
|
||||||
break
|
break
|
||||||
}
|
case .highlighting(let draft):
|
||||||
|
break
|
||||||
// include pubkeys
|
|
||||||
tags += pubkeys.map { pk in
|
|
||||||
["p", pk.hex()]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// append additional tags
|
// append additional tags
|
||||||
tags += uploadedMedias.compactMap { $0.metadata?.to_tag() }
|
tags += uploadedMedias.compactMap { $0.metadata?.to_tag() }
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case .highlighting(let draft):
|
||||||
|
tags.append(contentsOf: draft.source.tags())
|
||||||
|
if !(content.isEmpty || content.allSatisfy { $0.isWhitespace }) {
|
||||||
|
tags.append(["comment", content])
|
||||||
|
}
|
||||||
|
tags += pubkeys.map { pk in
|
||||||
|
["p", pk.hex(), "mention"]
|
||||||
|
}
|
||||||
|
return NostrPost(content: draft.selected_text, kind: .highlight, tags: tags)
|
||||||
|
default:
|
||||||
|
tags += pubkeys.map { pk in
|
||||||
|
["p", pk.hex()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return NostrPost(content: content, kind: .text, tags: tags)
|
return NostrPost(content: content, kind: .text, tags: tags)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user