Compare commits
136 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 |
@@ -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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.kernel.extended-virtual-addressing</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
@@ -10,5 +12,9 @@
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.jb55.damus2</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -55,6 +55,9 @@ struct NotificationFormatter {
|
||||
var identifier = ""
|
||||
|
||||
switch notify.type {
|
||||
case .tagged:
|
||||
title = String(format: NSLocalizedString("Tagged by %@", comment: "Tagged by heading in local notification"), displayName)
|
||||
identifier = "myMentionNotification"
|
||||
case .mention:
|
||||
title = String(format: NSLocalizedString("Mentioned by %@", comment: "Mentioned by heading in local notification"), displayName)
|
||||
identifier = "myMentionNotification"
|
||||
@@ -70,6 +73,9 @@ struct NotificationFormatter {
|
||||
case .zap, .profile_zap:
|
||||
// not handled here. Try `format_message(displayName: String, notify: LocalNotification, state: HeadlessDamusState) async -> (content: UNMutableNotificationContent, identifier: String)?`
|
||||
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.body = notify.content
|
||||
@@ -87,10 +93,11 @@ struct NotificationFormatter {
|
||||
|
||||
// If it does not work, try async formatting methods
|
||||
let content = UNMutableNotificationContent()
|
||||
|
||||
|
||||
switch notify.type {
|
||||
case .zap, .profile_zap:
|
||||
guard let zap = await get_zap(from: notify.event, state: state) else {
|
||||
Log.debug("format_message: async get_zap failed", for: .push_notifications)
|
||||
return nil
|
||||
}
|
||||
content.title = Self.zap_notification_title(zap)
|
||||
|
||||
@@ -27,9 +27,9 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
// Log that we got a push notification
|
||||
Log.debug("Got nostr event push notification from pubkey %s", for: .push_notifications, nostr_event.pubkey.hex())
|
||||
|
||||
guard let state = NotificationExtensionState(),
|
||||
let display_name = state.ndb.lookup_profile(nostr_event.pubkey)?.unsafeUnownedValue?.profile?.display_name // We are not holding the txn here.
|
||||
else {
|
||||
guard let state = NotificationExtensionState() else {
|
||||
Log.debug("Failed to open nostrdb", for: .push_notifications)
|
||||
|
||||
// Something failed to initialize so let's go for the next best thing
|
||||
guard let improved_content = NotificationFormatter.shared.format_message(event: nostr_event) else {
|
||||
// We cannot format this nostr event. Suppress notification.
|
||||
@@ -39,7 +39,11 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
contentHandler(improved_content)
|
||||
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.
|
||||
// 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) {
|
||||
@@ -54,6 +58,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
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.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// 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 {
|
||||
Log.debug("generate_local_notification_object failed", for: .push_notifications)
|
||||
// We could not process this notification. Probably an unsupported nostr event kind. Suppress.
|
||||
// contentHandler(UNNotificationContent())
|
||||
// TODO: We cannot really suppress until we have the notification supression entitlement. Show the raw notification
|
||||
@@ -70,9 +76,13 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
Task {
|
||||
if let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: display_name, notify: notification_object, state: state) {
|
||||
contentHandler(improvedContent)
|
||||
guard let (improvedContent, _) = await NotificationFormatter.shared.format_message(displayName: name, notify: notification_object, state: state) else {
|
||||
|
||||
Log.debug("NotificationFormatter.format_message failed", for: .push_notifications)
|
||||
return
|
||||
}
|
||||
|
||||
contentHandler(improvedContent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1260
-14
File diff suppressed because it is too large
Load Diff
@@ -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" : "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
|
||||
]), startPoint: .leading, endPoint: .trailing)
|
||||
|
||||
struct CustomPicker<SelectionValue: Hashable, Content: View>: View {
|
||||
struct CustomPicker<SelectionValue: Hashable>: View {
|
||||
|
||||
let tabs: [(String, SelectionValue)]
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
|
||||
@Namespace var picker
|
||||
@Binding var selection: SelectionValue
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
|
||||
public var body: some View {
|
||||
let contentMirror = Mirror(reflecting: content)
|
||||
let blocksCount = Mirror(reflecting: contentMirror.descendant("value")!).children.count
|
||||
HStack {
|
||||
ForEach(0..<blocksCount, id: \.self) { index 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
|
||||
|
||||
ForEach(tabs, id: \.1) { (text, tag) in
|
||||
Button {
|
||||
withAnimation(.spring()) {
|
||||
selection = tag
|
||||
}
|
||||
} label: {
|
||||
text
|
||||
.padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
|
||||
Text(text).padding(EdgeInsets(top: 15, leading: 0, bottom: 10, trailing: 0))
|
||||
.font(.system(size: 14, weight: .heavy))
|
||||
.tag(tag)
|
||||
}
|
||||
.background(
|
||||
Group {
|
||||
|
||||
@@ -28,6 +28,7 @@ class DamusColors {
|
||||
static let green = Color("DamusGreen")
|
||||
static let purple = Color("DamusPurple")
|
||||
static let deepPurple = Color("DamusDeepPurple")
|
||||
static let highlight = Color("DamusHighlight")
|
||||
static let blue = Color("DamusBlue")
|
||||
static let bitcoin = Color("Bitcoin")
|
||||
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)
|
||||
}
|
||||
.aspectRatio(contentMode: filling ? .fill : .fit)
|
||||
.kfClickable()
|
||||
.position(x: geo.size.width / 2, y: geo.size.height / 2)
|
||||
.tabItem {
|
||||
Text(url.absoluteString)
|
||||
@@ -274,8 +275,14 @@ struct ImageCarousel<Content: View>: View {
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Medias
|
||||
.onTapGesture { }
|
||||
if #available(iOS 18.0, *) {
|
||||
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 {
|
||||
PageControlView(currentPage: $model.selectedIndex, numberOfPages: urls.count)
|
||||
|
||||
@@ -94,8 +94,8 @@ enum OpenWalletError: Error {
|
||||
}
|
||||
|
||||
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
||||
if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url)
|
||||
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
|
||||
this_app.open(url)
|
||||
} else {
|
||||
guard let store_link = wallet.appStoreLink else {
|
||||
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
|
||||
}
|
||||
|
||||
guard UIApplication.shared.canOpenURL(url) else {
|
||||
guard this_app.canOpenURL(url) else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
struct SelectableText: View {
|
||||
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent?
|
||||
let attributedString: AttributedString
|
||||
let textAlignment: NSTextAlignment
|
||||
|
||||
@State private var selectedTextActionState: SelectedTextActionState = .hide
|
||||
@State private var selectedTextHeight: CGFloat = .zero
|
||||
@State private var selectedTextWidth: CGFloat = .zero
|
||||
|
||||
|
||||
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.textAlignment = textAlignment ?? NSTextAlignment.natural
|
||||
self.size = size
|
||||
@@ -32,6 +35,13 @@ struct SelectableText: View {
|
||||
font: eventviewsize_to_uifont(size),
|
||||
fixedWidth: selectedTextWidth,
|
||||
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
|
||||
)
|
||||
.padding([.leading, .trailing], -1.0)
|
||||
@@ -46,22 +56,123 @@ struct SelectableText: View {
|
||||
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)
|
||||
}
|
||||
|
||||
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 textColor: UIColor
|
||||
let font: UIFont
|
||||
let fixedWidth: CGFloat
|
||||
let textAlignment: NSTextAlignment
|
||||
|
||||
let enableHighlighting: Bool
|
||||
let postHighlight: (String) -> Void
|
||||
let muteWord: (String) -> Void
|
||||
@Binding var height: CGFloat
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<Self>) -> UITextView {
|
||||
let view = UITextView()
|
||||
func makeUIView(context: UIViewRepresentableContext<Self>) -> TextView {
|
||||
let view = TextView(frame: .zero, textContainer: nil, postHighlight: postHighlight, muteWord: muteWord)
|
||||
view.isEditable = false
|
||||
view.dataDetectorTypes = .all
|
||||
view.isSelectable = true
|
||||
@@ -71,10 +182,16 @@ struct SelectableText: View {
|
||||
view.textContainerInset.left = 1.0
|
||||
view.textContainerInset.right = 1.0
|
||||
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
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<Self>) {
|
||||
func updateUIView(_ uiView: TextView, context: UIViewRepresentableContext<Self>) {
|
||||
let mutableAttributedString = createNSAttributedString()
|
||||
uiView.attributedText = mutableAttributedString
|
||||
uiView.textAlignment = self.textAlignment
|
||||
|
||||
@@ -11,11 +11,13 @@ struct SupporterBadge: View {
|
||||
let percent: Int?
|
||||
let purple_account: DamusPurple.Account?
|
||||
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.purple_account = purple_account
|
||||
self.style = style
|
||||
self.text_color = text_color
|
||||
}
|
||||
|
||||
let size: CGFloat = 17
|
||||
@@ -31,7 +33,7 @@ struct SupporterBadge: View {
|
||||
if self.style == .full {
|
||||
let date = format_date(date: purple_account.created_at, time_style: .none)
|
||||
Text(date)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(text_color)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,19 +27,26 @@ struct TranslateView: View {
|
||||
let damus_state: DamusState
|
||||
let event: NostrEvent
|
||||
let size: EventViewKind
|
||||
|
||||
|
||||
@Binding var isAppleTranslationPopoverPresented: Bool
|
||||
|
||||
@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.event = event
|
||||
self.size = size
|
||||
self._isAppleTranslationPopoverPresented = isAppleTranslationPopoverPresented
|
||||
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()
|
||||
if damus_state.settings.translation_service == .none {
|
||||
isAppleTranslationPopoverPresented = true
|
||||
} else {
|
||||
translate()
|
||||
}
|
||||
}
|
||||
.translate_button_style()
|
||||
}
|
||||
@@ -51,9 +58,9 @@ struct TranslateView: View {
|
||||
.foregroundColor(.gray)
|
||||
.font(.footnote)
|
||||
.padding([.top, .bottom], 10)
|
||||
|
||||
|
||||
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 {
|
||||
artifacts.content.text
|
||||
.font(eventviewsize_to_font(self.size, font_size: font_size))
|
||||
@@ -74,17 +81,25 @@ struct TranslateView: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
Group {
|
||||
switch self.translations_model.state {
|
||||
case .havent_tried:
|
||||
if damus_state.settings.auto_translate {
|
||||
if damus_state.settings.auto_translate && damus_state.settings.translation_service != .none {
|
||||
Text("")
|
||||
} else if let note_lang = translations_model.note_language, should_transl(note_lang) {
|
||||
TranslateButton
|
||||
TranslateButton
|
||||
} else {
|
||||
Text("")
|
||||
}
|
||||
@@ -114,9 +129,11 @@ extension View {
|
||||
}
|
||||
|
||||
struct TranslateView_Previews: PreviewProvider {
|
||||
@State static var isAppleTranslationPopoverPresented: Bool = false
|
||||
|
||||
static var previews: some View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ enum NoteContent {
|
||||
case content(String, TagsSequence?)
|
||||
|
||||
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)
|
||||
} else {
|
||||
self = .note(note)
|
||||
|
||||
+26
-66
@@ -57,6 +57,10 @@ enum Sheets: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
func present_sheet(_ sheet: Sheets) {
|
||||
notify(.present_sheet(sheet))
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
let keypair: Keypair
|
||||
let appDelegate: AppDelegate?
|
||||
@@ -73,77 +77,26 @@ struct ContentView: View {
|
||||
|
||||
@State var active_sheet: Sheets? = nil
|
||||
@State var damus_state: DamusState!
|
||||
@SceneStorage("ContentView.selected_timeline") var selected_timeline: Timeline = .home
|
||||
@State var 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 confirm_mute: Bool = false
|
||||
@State var hide_bar: Bool = false
|
||||
@State var user_muted_confirm: Bool = false
|
||||
@State var confirm_overwrite_mutelist: Bool = false
|
||||
@SceneStorage("ContentView.filter_state") var filter_state : FilterState = .posts_and_replies
|
||||
@State private var isSideBarOpened = false
|
||||
var home: HomeModel = HomeModel()
|
||||
@StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator()
|
||||
@AppStorage("has_seen_suggested_users") private var hasSeenOnboardingSuggestions = false
|
||||
let sub_id = UUID().description
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
// connect retry timer
|
||||
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 {
|
||||
return navigationCoordinator.isAtRoot()
|
||||
}
|
||||
@@ -153,9 +106,16 @@ struct ContentView: View {
|
||||
isSideBarOpened = false
|
||||
}
|
||||
|
||||
var timelineNavItem: Text {
|
||||
return Text(timeline_name(selected_timeline))
|
||||
.bold()
|
||||
var timelineNavItem: some View {
|
||||
VStack {
|
||||
Text(timeline_name(selected_timeline))
|
||||
.bold()
|
||||
if let menu_subtitle {
|
||||
Text(menu_subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func MainContent(damus: DamusState) -> some View {
|
||||
@@ -171,10 +131,10 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
case .home:
|
||||
PostingTimelineView
|
||||
PostingTimelineView(damus_state: damus_state!, home: home, active_sheet: $active_sheet)
|
||||
|
||||
case .notifications:
|
||||
NotificationsView(state: damus, notifications: home.notifications)
|
||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||
|
||||
case .dms:
|
||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
||||
@@ -774,7 +734,7 @@ struct ContentView: View {
|
||||
selected_timeline = .dms
|
||||
damus_state.dms.set_active_dm(target.pubkey)
|
||||
navigationCoordinator.push(route: Route.DMChat(dms: damus_state.dms.active_model))
|
||||
case .like, .zap, .mention, .repost:
|
||||
case .like, .zap, .mention, .repost, .reply, .tagged:
|
||||
open_event(ev: target)
|
||||
case .profile_zap:
|
||||
break
|
||||
@@ -875,7 +835,7 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos
|
||||
|
||||
|
||||
func setup_notifications() {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
this_app.registerForRemoteNotifications()
|
||||
let center = UNUserNotificationCenter.current()
|
||||
|
||||
center.getNotificationSettings { settings in
|
||||
@@ -1107,7 +1067,7 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
|
||||
//let post = tup.0
|
||||
//let to_relays = tup.1
|
||||
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
|
||||
}
|
||||
postbox.send(new_ev)
|
||||
@@ -1168,7 +1128,7 @@ func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) ->
|
||||
}
|
||||
case .hashtag(let ht):
|
||||
result(.filter(.filter_hashtag([ht.hashtag])))
|
||||
case .param, .quote:
|
||||
case .param, .quote, .reference:
|
||||
// doesn't really make sense here
|
||||
break
|
||||
case .naddr(let naddr):
|
||||
|
||||
@@ -151,7 +151,7 @@ public class CameraService: NSObject, Identifiable {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.alertError = AlertError(title: "Camera Access", message: "Damus needs camera and microphone access. Enable in settings.", primaryButtonTitle: "Go to settings", secondaryButtonTitle: nil, primaryAction: {
|
||||
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!,
|
||||
this_app.open(URL(string: UIApplication.openSettingsURLString)!,
|
||||
options: [:], completionHandler: 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)):
|
||||
return pk == follow_pk
|
||||
case (.hashtag, .pubkey), (.pubkey, .hashtag),
|
||||
(.event, _), (.quote, _), (.param, _), (.naddr, _):
|
||||
(.event, _), (.quote, _), (.param, _), (.naddr, _), (.reference(_), _):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ enum FilterState : Int {
|
||||
func filter(ev: NostrEvent) -> Bool {
|
||||
switch self {
|
||||
case .posts:
|
||||
return ev.known_kind == .boost || !ev.is_reply()
|
||||
return ev.known_kind == .boost || ev.known_kind == .highlight || !ev.is_reply()
|
||||
case .posts_and_replies:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -9,31 +9,31 @@ import Foundation
|
||||
|
||||
|
||||
class CreateAccountModel: ObservableObject {
|
||||
@Published var real_name: String = ""
|
||||
@Published var nick_name: String = ""
|
||||
@Published var display_name: String = ""
|
||||
@Published var name: String = ""
|
||||
@Published var about: String = ""
|
||||
@Published var pubkey: Pubkey = .empty
|
||||
@Published var privkey: Privkey = .empty
|
||||
@Published var profile_image: URL? = nil
|
||||
|
||||
var rendered_name: String {
|
||||
if real_name.isEmpty {
|
||||
return nick_name
|
||||
if display_name.isEmpty {
|
||||
return name
|
||||
}
|
||||
return real_name
|
||||
return display_name
|
||||
}
|
||||
|
||||
var keypair: Keypair {
|
||||
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()
|
||||
self.pubkey = keypair.pubkey
|
||||
self.privkey = keypair.privkey
|
||||
|
||||
self.real_name = real
|
||||
self.nick_name = nick
|
||||
self.display_name = display_name
|
||||
self.name = name
|
||||
self.about = about
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,79 @@ class DamusState: HeadlessDamusState {
|
||||
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
|
||||
func add_zap(zap: Zapping) -> Bool {
|
||||
|
||||
@@ -28,4 +28,5 @@ class Drafts: ObservableObject {
|
||||
@Published var post: DraftArtifacts? = nil
|
||||
@Published var replies: [NostrEvent: DraftArtifacts] = [:]
|
||||
@Published var quotes: [NostrEvent: DraftArtifacts] = [:]
|
||||
@Published var highlights: [HighlightSource: DraftArtifacts] = [:]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Foundation
|
||||
|
||||
enum FriendFilter: String, StringCodable {
|
||||
case all
|
||||
case friends
|
||||
case friends_of_friends
|
||||
|
||||
init?(from string: String) {
|
||||
guard let ff = FriendFilter(rawValue: string) else {
|
||||
@@ -27,8 +27,17 @@ enum FriendFilter: String, StringCodable {
|
||||
switch self {
|
||||
case .all:
|
||||
return true
|
||||
case .friends:
|
||||
case .friends_of_friends:
|
||||
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
|
||||
/// 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() {
|
||||
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 = NoteId(hex: latest_contact_event_id_hex) else { return }
|
||||
guard let latest_contact_event: NdbNote = damus_state.ndb.lookup_note( latest_contact_event_id)?.unsafeUnownedValue?.to_owned() else { return }
|
||||
process_contact_event(state: damus_state, ev: latest_contact_event)
|
||||
damus_state.contacts.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - ContactsDelegate functions
|
||||
@@ -184,7 +184,7 @@ class HomeModel: ContactsDelegate {
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case .chat, .longform, .text:
|
||||
case .chat, .longform, .text, .highlight:
|
||||
handle_text_event(sub_id: sub_id, ev)
|
||||
case .contacts:
|
||||
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) {
|
||||
// TODO: separate likes?
|
||||
var home_filter_kinds: [NostrKind] = [
|
||||
.text, .longform, .boost
|
||||
.text, .longform, .boost, .highlight
|
||||
]
|
||||
if !damus_state.settings.onlyzaps_mode {
|
||||
home_filter_kinds.append(.like)
|
||||
|
||||
@@ -10,7 +10,7 @@ import Foundation
|
||||
enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
|
||||
var id: String { self.rawValue }
|
||||
case nostrBuild
|
||||
case nostrImg
|
||||
case nostrcheck
|
||||
|
||||
init?(from string: String) {
|
||||
guard let mu = MediaUploader(rawValue: string) else {
|
||||
@@ -23,95 +23,73 @@ enum MediaUploader: String, CaseIterable, Identifiable, StringCodable {
|
||||
func to_string() -> String {
|
||||
return rawValue
|
||||
}
|
||||
|
||||
|
||||
var nameParam: String {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return "\"fileToUpload\""
|
||||
case .nostrImg:
|
||||
return "\"image\""
|
||||
default:
|
||||
return "\"file\""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var supportsVideo: Bool {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return true
|
||||
case .nostrImg:
|
||||
return false
|
||||
case .nostrcheck:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct Model: Identifiable, Hashable {
|
||||
var id: String { self.tag }
|
||||
var index: Int
|
||||
var tag: String
|
||||
var displayName : String
|
||||
}
|
||||
|
||||
|
||||
var model: Model {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return .init(index: -1, tag: "nostrBuild", displayName: "nostr.build")
|
||||
case .nostrImg:
|
||||
return .init(index: 0, tag: "nostrImg", displayName: "nostrimg.com")
|
||||
case .nostrcheck:
|
||||
return .init(index: 0, tag: "nostrcheck", displayName: "nostrcheck.me")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
var postAPI: String {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
return "https://nostr.build/api/v2/upload/files"
|
||||
case .nostrImg:
|
||||
return "https://nostrimg.com/api/upload"
|
||||
return "https://nostr.build/api/v2/nip96/upload"
|
||||
case .nostrcheck:
|
||||
return "https://nostrcheck.me/api/v2/media"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func getMediaURL(from data: Data) -> String? {
|
||||
switch self {
|
||||
case .nostrBuild:
|
||||
do {
|
||||
if let jsonObject = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
|
||||
let status = jsonObject["status"] as? String {
|
||||
|
||||
if status == "success", let dataArray = jsonObject["data"] as? [[String: Any]] {
|
||||
|
||||
var urls: [String] = []
|
||||
|
||||
for dataDict in dataArray {
|
||||
if let mainUrl = dataDict["url"] as? String {
|
||||
urls.append(mainUrl)
|
||||
do {
|
||||
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 let tags = nip94Event["tags"] as? [[String]] {
|
||||
for tagArray in tags {
|
||||
if tagArray.count > 1, tagArray[0] == "url" {
|
||||
return tagArray[1]
|
||||
}
|
||||
}
|
||||
|
||||
return urls.joined(separator: "\n")
|
||||
} else if status == "error", let message = jsonObject["message"] as? String {
|
||||
print("Upload Error: \(message)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Failed JSONSerialization")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case .nostrImg:
|
||||
guard let responseString = String(data: data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue)) else {
|
||||
print("Upload failed getting response string")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let startIndex = responseString.range(of: "https://i.nostrimg.com/")?.lowerBound else {
|
||||
} else if status == "error", let message = jsonObject["message"] as? String {
|
||||
print("Upload Error: \(message)")
|
||||
return nil
|
||||
}
|
||||
let stringContainingName = responseString[startIndex..<responseString.endIndex]
|
||||
guard let endIndex = stringContainingName.range(of: "\"")?.lowerBound else {
|
||||
return nil
|
||||
}
|
||||
let nostrBuildImageName = responseString[startIndex..<endIndex]
|
||||
let nostrBuildURL = "\(nostrBuildImageName)"
|
||||
return nostrBuildURL
|
||||
} catch {
|
||||
print("Failed JSONSerialization")
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,46 +256,3 @@ func find_tag_ref(type: String, id: String, tags: [[String]]) -> Int? {
|
||||
|
||||
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) {
|
||||
switch item {
|
||||
case .user(_, _):
|
||||
guard !users.contains(item) else { return }
|
||||
users.insert(item)
|
||||
case .hashtag(_, _):
|
||||
guard !hashtags.contains(item) else { return }
|
||||
hashtags.insert(item)
|
||||
case .word(_, _):
|
||||
guard !words.contains(item) else { return }
|
||||
words.insert(item)
|
||||
case .thread(_, _):
|
||||
guard !threads.contains(item) else { return }
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -61,36 +61,55 @@ func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamu
|
||||
|
||||
if type == .text, state.settings.mention_notification {
|
||||
let blocks = ev.blocks(state.keypair).blocks
|
||||
|
||||
for case .mention(let mention) in blocks {
|
||||
guard case .pubkey(let pk) = mention.ref, pk == state.keypair.pubkey else {
|
||||
continue
|
||||
}
|
||||
let content_preview = render_notification_content_preview(ev: ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .mention, event: ev, target: ev, content: content_preview)
|
||||
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,
|
||||
state.settings.repost_notification,
|
||||
let inner_ev = ev.get_inner_event()
|
||||
{
|
||||
let content_preview = render_notification_content_preview(ev: inner_ev, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .repost, event: ev, target: inner_ev, content: content_preview)
|
||||
} else if type == .like,
|
||||
state.settings.like_notification,
|
||||
let evid = ev.referenced_ids.last,
|
||||
let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
|
||||
let liked_event = txn.unsafeUnownedValue?.to_owned()
|
||||
{
|
||||
let content_preview = render_notification_content_preview(ev: liked_event, profiles: state.profiles, keypair: state.keypair)
|
||||
return LocalNotification(type: .like, event: ev, target: liked_event, content: content_preview)
|
||||
return LocalNotification(type: .repost, event: ev, target: .note(inner_ev), content: content_preview)
|
||||
} else if type == .like, state.settings.like_notification, let evid = ev.referenced_ids.last {
|
||||
if let txn = state.ndb.lookup_note(evid, txn_name: "local_notification_like"),
|
||||
let liked_event = txn.unsafeUnownedValue
|
||||
{
|
||||
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)
|
||||
} else {
|
||||
return LocalNotification(type: .like, event: ev, target: .note_id(evid), content: "")
|
||||
}
|
||||
}
|
||||
else if type == .dm,
|
||||
state.settings.dm_notification {
|
||||
let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
|
||||
return LocalNotification(type: .dm, event: ev, target: ev, content: convo)
|
||||
return LocalNotification(type: .dm, event: ev, target: .note(ev), content: convo)
|
||||
}
|
||||
else if type == .zap,
|
||||
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
|
||||
|
||||
+77
-1
@@ -17,10 +17,86 @@ struct NostrPost {
|
||||
self.kind = kind
|
||||
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] {
|
||||
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: prof_subid)
|
||||
}
|
||||
|
||||
|
||||
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])
|
||||
|
||||
|
||||
profile_filter.authors = [pubkey]
|
||||
|
||||
text_filter.authors = [pubkey]
|
||||
|
||||
@@ -11,35 +11,34 @@ struct PushNotificationClient {
|
||||
let keypair: Keypair
|
||||
let settings: UserSettingsStore
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
func send_token() async throws {
|
||||
guard let device_token else { return }
|
||||
// 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)
|
||||
|
||||
let pubkey = self.keypair.pubkey
|
||||
|
||||
// Send those as JSON to the server
|
||||
let json: [String: Any] = ["deviceToken": token, "pubkey": pubkey.hex()]
|
||||
|
||||
// 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 json_data = try JSONSerialization.data(withJSONObject: json)
|
||||
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(self.keypair.pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .post,
|
||||
method: .put,
|
||||
url: url,
|
||||
payload: json_data,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
@@ -58,26 +57,23 @@ struct PushNotificationClient {
|
||||
}
|
||||
|
||||
func revoke_token() async throws {
|
||||
guard let device_token else { return }
|
||||
// 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("Revoking device token from 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
|
||||
let url = self.settings.send_device_token_to_localhost ? Constants.DEVICE_TOKEN_REVOKER_TEST_URL : Constants.DEVICE_TOKEN_REVOKER_PRODUCTION_URL
|
||||
let json_data = try JSONSerialization.data(withJSONObject: json)
|
||||
let url = self.current_push_notification_environment().api_base_url()
|
||||
.appendingPathComponent("user-info")
|
||||
.appendingPathComponent(pubkey.hex())
|
||||
.appendingPathComponent(token)
|
||||
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .post,
|
||||
method: .delete,
|
||||
url: url,
|
||||
payload: json_data,
|
||||
payload: nil,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
@@ -94,6 +90,78 @@ struct PushNotificationClient {
|
||||
|
||||
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
|
||||
@@ -101,5 +169,121 @@ struct PushNotificationClient {
|
||||
extension PushNotificationClient {
|
||||
enum ClientError: Error {
|
||||
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() {
|
||||
// since 1 month
|
||||
search.limit = self.limit
|
||||
search.kinds = [.text, .like, .longform]
|
||||
search.kinds = [.text, .like, .longform, .highlight]
|
||||
|
||||
//likes_filter.ids = ref_events.referenced_ids!
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@ import Foundation
|
||||
class ThreadModel: ObservableObject {
|
||||
@Published var event: NostrEvent
|
||||
let original_event: NostrEvent
|
||||
let highlight: String?
|
||||
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.event_map = Set()
|
||||
self.event = event
|
||||
self.original_event = event
|
||||
self.highlight = highlight
|
||||
add_event(event, keypair: damus_state.keypair)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,13 @@ enum TranslationService: String, CaseIterable, Identifiable, StringCodable {
|
||||
var model: Model {
|
||||
switch self {
|
||||
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:
|
||||
return .init(tag: self.rawValue, displayName: NSLocalizedString("Damus Purple", comment: "Dropdown option for selecting Damus Purple as a translation service."))
|
||||
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."))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
var like_notification: Bool
|
||||
|
||||
@StringSetting(key: "notifications_mode", default_value: .local)
|
||||
var notifications_mode: NotificationsMode
|
||||
@StringSetting(key: "notification_mode", default_value: .push)
|
||||
var notification_mode: NotificationsMode
|
||||
|
||||
@Setting(key: "notification_only_from_following", default_value: false)
|
||||
var notification_only_from_following: Bool
|
||||
@@ -180,6 +180,9 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "auto_translate", default_value: true)
|
||||
var auto_translate: Bool
|
||||
|
||||
@Setting(key: "translate_offline", default_value: true)
|
||||
var translate_offline: Bool
|
||||
|
||||
@Setting(key: "show_general_statuses", default_value: true)
|
||||
var show_general_statuses: Bool
|
||||
|
||||
@@ -207,11 +210,12 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "always_show_onboarding_suggestions", default_value: false)
|
||||
var always_show_onboarding_suggestions: Bool
|
||||
|
||||
@Setting(key: "enable_experimental_push_notifications", default_value: false)
|
||||
var enable_experimental_push_notifications: Bool
|
||||
// @Setting(key: "enable_experimental_push_notifications", default_value: false)
|
||||
// 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)
|
||||
var send_device_token_to_localhost: Bool
|
||||
@StringSetting(key: "push_notification_environment", default_value: .production)
|
||||
var push_notification_environment: PushNotificationClient.Environment
|
||||
|
||||
@Setting(key: "enable_experimental_purple_api", default_value: false)
|
||||
var enable_experimental_purple_api: Bool
|
||||
|
||||
@@ -22,6 +22,7 @@ enum NostrKind: UInt32, Codable {
|
||||
case longform = 30023
|
||||
case zap = 9735
|
||||
case zap_request = 9734
|
||||
case highlight = 9802
|
||||
case nwc_request = 23194
|
||||
case nwc_response = 23195
|
||||
case http_auth = 27235
|
||||
|
||||
@@ -122,20 +122,22 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case hashtag(Hashtag)
|
||||
case param(TagElem)
|
||||
case naddr(NAddr)
|
||||
case reference(String)
|
||||
|
||||
var key: RefKey {
|
||||
switch self {
|
||||
case .event: return .e
|
||||
case .pubkey: return .p
|
||||
case .quote: return .q
|
||||
case .hashtag: return .t
|
||||
case .param: return .d
|
||||
case .naddr: return .a
|
||||
case .event: return .e
|
||||
case .pubkey: return .p
|
||||
case .quote: return .q
|
||||
case .hashtag: return .t
|
||||
case .param: return .d
|
||||
case .naddr: return .a
|
||||
case .reference: return .r
|
||||
}
|
||||
}
|
||||
|
||||
enum RefKey: AsciiCharacter, TagKey, CustomStringConvertible {
|
||||
case e, p, t, d, q, a
|
||||
case e, p, t, d, q, a, r
|
||||
|
||||
var keychar: AsciiCharacter {
|
||||
self.rawValue
|
||||
@@ -159,6 +161,8 @@ enum RefId: TagConvertible, TagKeys, Equatable, Hashable {
|
||||
case .param(let string): return string.string()
|
||||
case .naddr(let naddr):
|
||||
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 .d: return .param(t1)
|
||||
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 {
|
||||
self.last_pong = .now
|
||||
Log.info("Got pong from '%s'", for: .networking, self.relay_url.absoluteString)
|
||||
self.log?.add("Successful ping")
|
||||
} 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.isConnecting = false
|
||||
self.reconnect_with_backoff()
|
||||
@@ -126,7 +127,7 @@ final class RelayConnection: ObservableObject {
|
||||
self.receive(message: message)
|
||||
case .disconnected(let closeCode, let reason):
|
||||
if closeCode != .normalClosure {
|
||||
print("⚠️ Warning: RelayConnection (\(self.relay_url)) closed with code \(closeCode), reason: \(String(describing: reason))")
|
||||
Log.error("⚠️ Warning: RelayConnection (%d) closed with code: %s", for: .networking, String(describing: closeCode), String(describing: reason))
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.isConnected = false
|
||||
@@ -134,12 +135,16 @@ final class RelayConnection: ObservableObject {
|
||||
self.reconnect()
|
||||
}
|
||||
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
|
||||
if nserr.domain == NSPOSIXErrorDomain && nserr.code == 57 {
|
||||
// ignore socket not connected?
|
||||
return
|
||||
}
|
||||
if nserr.domain == NSURLErrorDomain && nserr.code == -999 {
|
||||
// these aren't real error, it just means task was cancelled
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.isConnected = false
|
||||
self.isConnecting = false
|
||||
@@ -156,14 +161,21 @@ final class RelayConnection: ObservableObject {
|
||||
}
|
||||
|
||||
func reconnect_with_backoff() {
|
||||
self.backoff *= 1.5
|
||||
self.backoff *= 2.0
|
||||
self.reconnect_in(after: self.backoff)
|
||||
}
|
||||
|
||||
func reconnect() {
|
||||
guard !isConnecting && !isDisabled else {
|
||||
self.log?.add("Cancelling reconnect, already connecting")
|
||||
return // we're already trying to connect or we're disabled
|
||||
}
|
||||
|
||||
guard !self.isConnected else {
|
||||
self.log?.add("Cancelling reconnect, already connected")
|
||||
return
|
||||
}
|
||||
|
||||
disconnect()
|
||||
connect()
|
||||
log?.add("Reconnecting...")
|
||||
|
||||
@@ -89,6 +89,7 @@ class RelayPool {
|
||||
}
|
||||
|
||||
func ping() {
|
||||
Log.info("Pinging %d relays", for: .networking, relays.count)
|
||||
for relay in relays {
|
||||
relay.connection.ping()
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@ let test_short_note =
|
||||
createdAt: UInt32(Date().timeIntervalSince1970 - 100)
|
||||
)!
|
||||
|
||||
let test_super_short_note =
|
||||
NostrEvent(
|
||||
content: "A",
|
||||
keypair: jack_keypair,
|
||||
createdAt: UInt32(Date().timeIntervalSince1970 - 100)
|
||||
)!
|
||||
|
||||
let test_note_json_with_escaped_slash = "{\"tags\":[],\"pubkey\":\"f8e6c64342f1e052480630e27e1016dce35fc3a614e60434fef4aa2503328ca9\",\"content\":\"https:\\/\\/cdn.nostr.build\\/i\\/5c1d3296f66c2630131bf123106486aeaf051ed8466031c0e0532d70b33cddb2.jpg\",\"created_at\":1691864981,\"kind\":1,\"sig\":\"fc0033aa3d4df50b692a5b346fa816fdded698de2045e36e0642a021391468c44ca69c2471adc7e92088131872d4aaa1e90ea6e1ad97f3cc748f4aed96dfae18\",\"id\":\"e8f6eca3b161abba034dac9a02bb6930ecde9fd2fb5d6c5f22a05526e11382cb\"}"
|
||||
let test_encoded_note_with_image = NostrEvent.owned_from_json(json: test_note_json_with_escaped_slash)
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import Foundation
|
||||
class Constants {
|
||||
//static let EXAMPLE_DEMOS: DamusState = .empty
|
||||
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 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
|
||||
// API
|
||||
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
|
||||
+15
-11
@@ -244,16 +244,12 @@ class EventCache {
|
||||
}
|
||||
}
|
||||
|
||||
func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSettingsStore, note_lang: String?) -> Bool {
|
||||
guard settings.can_translate else {
|
||||
return false
|
||||
}
|
||||
|
||||
func should_translate(event: NostrEvent, our_keypair: Keypair, note_lang: String?) -> Bool {
|
||||
// don't translate reposts, longform, etc
|
||||
if event.kind != 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Do not translate self-authored notes if logged in with a private key
|
||||
// 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.
|
||||
@@ -261,25 +257,33 @@ func should_translate(event: NostrEvent, our_keypair: Keypair, settings: UserSet
|
||||
if our_keypair.privkey != nil && our_keypair.pubkey == event.pubkey {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
if let note_lang {
|
||||
let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) })
|
||||
|
||||
|
||||
// Don't translate if its in our preferred languages
|
||||
guard !preferredLanguages.contains(note_lang) else {
|
||||
// if its the same, give up and don't retry
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// we should start translating if we have auto_translate on
|
||||
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 {
|
||||
switch current_status {
|
||||
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 .translated: return false
|
||||
case .not_needed: return false
|
||||
@@ -413,7 +417,7 @@ func preload_event(plan: PreloadPlan, state: DamusState) async {
|
||||
|
||||
var translations: TranslateStatus? = nil
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ struct ImageMetadata: Equatable {
|
||||
|
||||
func process_blurhash(blurhash: String, size: CGSize?) async -> UIImage? {
|
||||
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 {
|
||||
let noimg: UIImage? = nil
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -145,7 +147,7 @@ func calculate_blurhash(img: UIImage) async -> String? {
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
guard let blurhash = smaller.blurHash(numberOfComponents: (5,5)) else {
|
||||
|
||||
@@ -30,7 +30,7 @@ public struct DismissKeyboardOnTap: ViewModifier {
|
||||
}
|
||||
|
||||
public func end_editing() {
|
||||
UIApplication.shared.connectedScenes
|
||||
this_app.connectedScenes
|
||||
.filter {$0.activationState == .foregroundActive}
|
||||
.map {$0 as? UIWindowScene}
|
||||
.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 {
|
||||
let type: LocalNotificationType
|
||||
let event: NostrEvent
|
||||
let target: NostrEvent
|
||||
let target: NotificationTarget
|
||||
let content: String
|
||||
|
||||
func to_lossy() -> LossyLocalNotification {
|
||||
@@ -63,6 +77,8 @@ enum LocalNotificationType: String {
|
||||
case dm
|
||||
case like
|
||||
case mention
|
||||
case reply
|
||||
case tagged
|
||||
case repost
|
||||
case zap
|
||||
case profile_zap
|
||||
|
||||
@@ -13,6 +13,7 @@ enum LogCategory: String {
|
||||
case nav
|
||||
case render
|
||||
case storage
|
||||
case networking
|
||||
case push_notifications
|
||||
case damus_purple
|
||||
case image_uploading
|
||||
|
||||
@@ -11,8 +11,7 @@ import UIKit
|
||||
class Theme {
|
||||
|
||||
static var safeAreaInsets: UIEdgeInsets? {
|
||||
return UIApplication
|
||||
.shared
|
||||
return this_app
|
||||
.connectedScenes
|
||||
.flatMap { ($0 as? UIWindowScene)?.windows ?? [] }
|
||||
.first { $0.isKeyWindow }?.safeAreaInsets
|
||||
|
||||
+6
-19
@@ -309,14 +309,10 @@ struct Zap {
|
||||
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
|
||||
}
|
||||
|
||||
guard let zap_req = decode_nostr_event_json(desc) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard validate_event(ev: zap_req) == .ok else {
|
||||
return nil
|
||||
}
|
||||
@@ -399,21 +395,12 @@ func event_has_tag(ev: NostrEvent, tag: String) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Fetches the description from either the invoice, or tags, depending on the type of invoice
|
||||
func get_zap_description(_ ev: NostrEvent, inv_desc: InvoiceDescription) -> String? {
|
||||
switch inv_desc {
|
||||
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
|
||||
func get_zap_request(_ ev: NostrEvent) -> NostrEvent? {
|
||||
guard let desc = event_tag(ev, name: "description") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return decode_nostr_event_json(desc)
|
||||
}
|
||||
|
||||
func invoice_to_zap_invoice(_ invoice: Invoice) -> ZapInvoice? {
|
||||
|
||||
@@ -116,7 +116,7 @@ struct AddRelayView: View {
|
||||
}
|
||||
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()
|
||||
}) {
|
||||
|
||||
@@ -40,7 +40,7 @@ func create_upload_request(mediaToUpload: MediaUpload, mediaUploader: MediaUploa
|
||||
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 mediaUploader == .nostrBuild,
|
||||
if mediaUploader == .nostrBuild || mediaUploader == .nostrcheck,
|
||||
let keypair,
|
||||
let method = request.httpMethod,
|
||||
let signature = create_nip98_signature(keypair: keypair, method: method, url: url) {
|
||||
|
||||
@@ -13,7 +13,7 @@ struct EditBannerImageView: View {
|
||||
var damus_state: DamusState
|
||||
@ObservedObject var viewModel: ImageUploadingObserver
|
||||
let callback: (URL?) -> Void
|
||||
let defaultImage = UIImage(named: "profile-banner") ?? UIImage()
|
||||
let defaultImage = UIImage(named: "damoose") ?? UIImage()
|
||||
|
||||
@State var banner_image: URL? = nil
|
||||
|
||||
@@ -29,6 +29,7 @@ struct EditBannerImageView: View {
|
||||
Color(uiColor: .secondarySystemBackground)
|
||||
}
|
||||
.onFailureImage(defaultImage)
|
||||
.kfClickable()
|
||||
|
||||
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 {
|
||||
let disable_animation: Bool
|
||||
let url: URL?
|
||||
let defaultImage = UIImage(named: "profile-banner") ?? UIImage()
|
||||
let defaultImage = UIImage(named: "damoose") ?? UIImage()
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -54,6 +55,7 @@ struct InnerBannerImageView: View {
|
||||
Color(uiColor: .secondarySystemBackground)
|
||||
}
|
||||
.onFailureImage(defaultImage)
|
||||
.kfClickable()
|
||||
} else {
|
||||
Image(uiImage: defaultImage).resizable()
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ struct FriendsButton: View {
|
||||
Button(action: {
|
||||
switch self.filter {
|
||||
case .all:
|
||||
self.filter = .friends
|
||||
case .friends:
|
||||
self.filter = .friends_of_friends
|
||||
case .friends_of_friends:
|
||||
self.filter = .all
|
||||
}
|
||||
}) {
|
||||
if filter == .friends {
|
||||
if filter == .friends_of_friends {
|
||||
LINEAR_GRADIENT
|
||||
.mask(Image("user-added")
|
||||
.resizable()
|
||||
@@ -28,7 +28,7 @@ struct FriendsButton: View {
|
||||
Image("user-added")
|
||||
.resizable()
|
||||
.frame(width: 28, height: 28)
|
||||
.foregroundColor(DamusColors.adaptableGrey)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -165,7 +165,7 @@ struct ChatBubble<T: View, U: ShapeStyle, V: View>: View {
|
||||
stroke_style: .init(lineWidth: 4),
|
||||
background_style: Color.accentColor
|
||||
) {
|
||||
Text("Hello there")
|
||||
Text(verbatim: "Hello there")
|
||||
.padding()
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
@@ -176,7 +176,7 @@ struct ChatBubble<T: View, U: ShapeStyle, V: View>: View {
|
||||
stroke_style: .init(lineWidth: 4),
|
||||
background_style: Color.accentColor
|
||||
) {
|
||||
Text("Hello there")
|
||||
Text(verbatim: "Hello there")
|
||||
.padding()
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
|
||||
@@ -31,7 +31,7 @@ struct ChatEventView: View {
|
||||
@State var long_press_bounce_work_item: DispatchWorkItem?
|
||||
@State var popover_state: PopoverState = .closed {
|
||||
didSet {
|
||||
let generator = UIImpactFeedbackGenerator(style: popover_state == .open_emoji_selector ? .heavy : .light)
|
||||
let generator = UIImpactFeedbackGenerator(style: popover_state.some_sheet_open() ? .heavy : .light)
|
||||
generator.impactOccurred()
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,11 @@ struct ChatEventView: View {
|
||||
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 {
|
||||
@@ -90,9 +95,22 @@ struct ChatEventView: View {
|
||||
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,
|
||||
@@ -107,6 +125,7 @@ struct ChatEventView: View {
|
||||
.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)
|
||||
}
|
||||
@@ -124,13 +143,18 @@ struct ChatEventView: View {
|
||||
}
|
||||
|
||||
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: [])
|
||||
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: 150, alignment: is_ours ? .trailing : .leading)
|
||||
.frame(minWidth: 5, alignment: is_ours ? .trailing : .leading)
|
||||
.padding(10)
|
||||
}
|
||||
.tint(is_ours ? Color.white : Color.accentColor)
|
||||
.tint(Color.accentColor)
|
||||
.overlay(
|
||||
ZStack(alignment: is_ours ? .bottomLeading : .bottomTrailing) {
|
||||
VStack {
|
||||
@@ -164,6 +188,14 @@ struct ChatEventView: View {
|
||||
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)
|
||||
@@ -171,8 +203,8 @@ struct ChatEventView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(self.popover_state == .open_emoji_selector ? 1.08 : is_pressing ? 1.02 : 1)
|
||||
.shadow(color: (is_pressing || self.popover_state == .open_emoji_selector) ? .black.opacity(0.1) : .black.opacity(0.3), radius: (is_pressing || self.popover_state == .open_emoji_selector) ? 8 : 0, y: (is_pressing || self.popover_state == .open_emoji_selector) ? 15 : 0)
|
||||
.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
|
||||
@@ -186,7 +218,8 @@ struct ChatEventView: View {
|
||||
// Ensure the action is performed only if the condition is still valid
|
||||
if self.is_pressing {
|
||||
withAnimation(.bouncy(duration: 0.2, extraBounce: 0.35)) {
|
||||
popover_state = .open_emoji_selector
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -254,13 +287,25 @@ struct ChatEventView: View {
|
||||
SwipeView {
|
||||
self.event_bubble_with_long_press_interaction
|
||||
} leadingActions: { context in
|
||||
EventActionBar(
|
||||
damus_state: damus_state,
|
||||
event: event,
|
||||
bar: bar,
|
||||
options: is_ours ? [.swipe_action_menu_reverse] : [.swipe_action_menu],
|
||||
swipe_context: context
|
||||
)
|
||||
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)
|
||||
@@ -322,3 +367,8 @@ extension Notification.Name {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -182,25 +182,6 @@ extension CodeScannerView {
|
||||
delegate?.didFail(reason: .badOutput)
|
||||
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 {
|
||||
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() {
|
||||
guard showViewfinder, let imageView = viewFinder else { return }
|
||||
|
||||
|
||||
@@ -25,68 +25,44 @@ struct CreateAccountView: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
VStack {
|
||||
Spacer()
|
||||
VStack(alignment: .center) {
|
||||
EditPictureControl(uploader: .nostrBuild, pubkey: account.pubkey, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
|
||||
|
||||
Text("Public Key", comment: "Label to indicate the public key of the account.")
|
||||
|
||||
EditPictureControl(uploader: .nostrBuild, pubkey: account.pubkey, size: 75, setup: true, image_url: $account.profile_image , uploadObserver: profileUploadObserver, callback: uploadedProfilePicture)
|
||||
.shadow(radius: 2)
|
||||
.padding(.top, 100)
|
||||
|
||||
Text("Add Photo", comment: "Label to indicate user can add a photo.")
|
||||
.bold()
|
||||
.padding()
|
||||
.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)
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
}
|
||||
|
||||
SignupForm {
|
||||
FormLabel(NSLocalizedString("Display name", comment: "Label to prompt display name entry."), optional: true)
|
||||
FormTextInput(NSLocalizedString("Satoshi Nakamoto", comment: "Name of Bitcoin creator(s)."), text: $account.real_name)
|
||||
FormLabel(NSLocalizedString("Name", comment: "Label to prompt name entry."), optional: false)
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
FormTextInput(NSLocalizedString("Satoshi Nakamoto", comment: "Name of Bitcoin creator(s)."), text: $account.name)
|
||||
.textInputAutocapitalization(.words)
|
||||
|
||||
FormLabel(NSLocalizedString("About", comment: "Label to prompt for about text entry for user to describe about themself."), optional: true)
|
||||
FormTextInput(NSLocalizedString("Creator(s) of Bitcoin. Absolute legend.", comment: "Example description about Bitcoin creator(s), Satoshi Nakamoto."), text: $account.about)
|
||||
FormLabel(NSLocalizedString("Bio", comment: "Label to prompt bio entry for user to describe themself."), optional: true)
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
FormTextInput(NSLocalizedString("Absolute legend.", comment: "Example Bio"), text: $account.about)
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.top, 25)
|
||||
|
||||
Button(action: {
|
||||
nav.push(route: Route.SaveKeys(account: account))
|
||||
}) {
|
||||
HStack {
|
||||
Text("Create account now", comment: "Button to create account.")
|
||||
Text("Next", comment: "Button to continue with account creation.")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
.disabled(profileUploadObserver.isLoading)
|
||||
.opacity(profileUploadObserver.isLoading ? 0.5 : 1)
|
||||
.disabled(profileUploadObserver.isLoading || account.name.isEmpty)
|
||||
.opacity(profileUploadObserver.isLoading || account.name.isEmpty ? 0.5 : 1)
|
||||
.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()
|
||||
.padding(.top)
|
||||
|
||||
@@ -94,8 +70,8 @@ struct CreateAccountView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(DamusBackground(maxHeight: UIScreen.main.bounds.size.height/2), alignment: .top)
|
||||
.dismissKeyboardOnTap()
|
||||
.navigationTitle("Create account")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.navigationBarItems(leading: BackNav())
|
||||
@@ -111,7 +87,7 @@ struct LoginPrompt: View {
|
||||
var body: some View {
|
||||
HStack {
|
||||
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.")) {
|
||||
self.dismiss()
|
||||
@@ -127,8 +103,8 @@ struct BackNav: View {
|
||||
var body: some View {
|
||||
Image("chevron-left")
|
||||
.foregroundColor(DamusColors.adaptableBlack)
|
||||
.onTapGesture {
|
||||
self.dismiss()
|
||||
.onTapGesture {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,20 +124,11 @@ extension View {
|
||||
|
||||
struct CreateAccountView_Previews: PreviewProvider {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
return TextField("", text: text)
|
||||
.placeholder(when: text.wrappedValue.isEmpty) {
|
||||
@@ -171,6 +138,10 @@ func FormTextInput(_ title: String, text: Binding<String>) -> some View {
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(.gray.opacity(0.5), lineWidth: 1)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.foregroundColor(.damusAdaptableWhite)
|
||||
}
|
||||
}
|
||||
.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.")
|
||||
.font(.callout)
|
||||
.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 {
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(selection: $dm_type, content: {
|
||||
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.")
|
||||
.tag(DMType.friend)
|
||||
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.")
|
||||
.tag(DMType.rando)
|
||||
})
|
||||
|
||||
CustomPicker(tabs: [
|
||||
(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),
|
||||
(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),
|
||||
], selection: $dm_type)
|
||||
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
|
||||
@@ -105,7 +103,7 @@ struct DirectMessagesView: View {
|
||||
|
||||
func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageModel]) -> Bool {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ struct EventView: View {
|
||||
}
|
||||
} else if event.known_kind == .longform {
|
||||
LongformPreview(state: damus, ev: event, options: options)
|
||||
} else if event.known_kind == .highlight {
|
||||
HighlightView(state: damus, event: event, options: options)
|
||||
} else {
|
||||
TextEvent(damus: damus, event: event, pubkey: pubkey, options: options)
|
||||
//.padding([.top], 6)
|
||||
@@ -71,6 +73,16 @@ func should_blur_images(settings: UserSettingsStore, contacts: Contacts, ev: Nos
|
||||
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
|
||||
{
|
||||
return time_ago_since(Date(timeIntervalSince1970: Double(created_at)))
|
||||
|
||||
@@ -15,10 +15,13 @@ struct ReplyPart: View {
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let reply_ref = event.thread_reply()?.reply {
|
||||
ReplyDescription(event: event, replying_to: events.lookup(reply_ref.note_id), ndb: ndb)
|
||||
} else {
|
||||
EmptyView()
|
||||
if event.known_kind == .highlight {
|
||||
let highlighted_note = event.highlighted_note_id().flatMap { events.lookup($0) }
|
||||
let highlight_note = HighlightEvent.parse(from: event)
|
||||
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) {
|
||||
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 {
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ struct LongformPreviewBody: View {
|
||||
}
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(maxWidth: .infinity, maxHeight: header ? .infinity : 150)
|
||||
.kfClickable()
|
||||
.cornerRadius(1)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,10 @@ struct LongformView: View {
|
||||
var options: EventViewOptions {
|
||||
return [.wide, .no_mentions, .no_replying_to]
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -39,12 +39,10 @@ struct SelectedEventView: View {
|
||||
.padding(.horizontal)
|
||||
.minimumScaleFactor(0.75)
|
||||
.lineLimit(1)
|
||||
|
||||
if let reply_ref = event.thread_reply()?.reply {
|
||||
ReplyDescription(event: event, replying_to: damus.events.lookup(reply_ref.note_id), ndb: damus.ndb)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
|
||||
ReplyPart(events: damus.events, event: event, keypair: damus.keypair, ndb: damus.ndb)
|
||||
.padding(.horizontal)
|
||||
|
||||
ProxyView(event: event)
|
||||
.padding(.top, 5)
|
||||
.padding(.horizontal)
|
||||
|
||||
@@ -160,10 +160,10 @@ struct FollowingView: View {
|
||||
.navigationBarTitle(NSLocalizedString("Following", comment: "Navigation bar title for view that shows who a user is following."))
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(selection: $tab_selection, content: {
|
||||
Text("People", comment: "Label for filter for seeing only people follows.").tag(FollowingViewTabSelection.people)
|
||||
Text("Hashtags", comment: "Label for filter for seeing only hashtag follows.").tag(FollowingViewTabSelection.hashtags)
|
||||
})
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("People", comment: "Label for filter for seeing only people follows."), FollowingViewTabSelection.people),
|
||||
(NSLocalizedString("Hashtags", comment: "Label for filter for seeing only hashtag follows."), FollowingViewTabSelection.hashtags)
|
||||
], selection: $tab_selection)
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ struct ImageContainerView: View {
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.imageModifier(ImageHandler(handler: $image))
|
||||
.kfClickable()
|
||||
.clipped()
|
||||
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
|
||||
@@ -33,6 +33,7 @@ struct ProfileImageContainerView: View {
|
||||
.imageModifier(ImageHandler(handler: $image))
|
||||
.clipShape(Circle())
|
||||
.modifier(ImageContextMenuModifier(url: url, image: image, settings: settings, showShareSheet: $showShareSheet))
|
||||
.kfClickable()
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
ShareSheet(activityItems: [url])
|
||||
}
|
||||
|
||||
@@ -62,8 +62,9 @@ struct LoginView: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
SignInHeader()
|
||||
.padding(.top, 100)
|
||||
|
||||
SignInEntry(key: $key, shouldSaveKey: $shouldSaveKey)
|
||||
|
||||
@@ -112,8 +113,9 @@ struct LoginView: View {
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.padding(.bottom, 50)
|
||||
}
|
||||
.background(DamusBackground(maxHeight: 350), alignment: .top)
|
||||
.background(DamusBackground(maxHeight: UIScreen.main.bounds.size.height/2), alignment: .top)
|
||||
.onAppear {
|
||||
credential_handler.check_credentials()
|
||||
}
|
||||
@@ -320,9 +322,13 @@ struct KeyInput: View {
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal, 10)
|
||||
.overlay {
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(.gray, lineWidth: 1)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.foregroundColor(.damusAdaptableWhite)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,11 +343,12 @@ struct SignInHeader: View {
|
||||
.padding(.bottom)
|
||||
|
||||
Text("Sign in", comment: "Title of view to log into an account.")
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.padding(.bottom, 5)
|
||||
|
||||
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 {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Enter your account key", comment: "Prompt for user to enter an account key to login.")
|
||||
.foregroundColor(DamusColors.neutral6)
|
||||
.fontWeight(.medium)
|
||||
.padding(.top, 30)
|
||||
|
||||
@@ -444,7 +452,9 @@ struct LoginView_Previews: PreviewProvider {
|
||||
let bech32_pubkey = "KeyInput"
|
||||
Group {
|
||||
LoginView(key: pubkey, nav: .init())
|
||||
.previewDevice(PreviewDevice(rawValue: "iPhone SE (3rd generation)"))
|
||||
LoginView(key: bech32_pubkey, nav: .init())
|
||||
.previewDevice(PreviewDevice(rawValue: "iPhone 15 Pro Max"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import SwiftUI
|
||||
|
||||
struct AddMuteItemView: View {
|
||||
let state: DamusState
|
||||
@State var new_text: String = ""
|
||||
@Binding var new_text: String
|
||||
@State var expiration: DamusDuration = .indefinite
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -87,7 +87,7 @@ struct AddMuteItemView: View {
|
||||
|
||||
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()
|
||||
}) {
|
||||
@@ -108,6 +108,6 @@ struct AddMuteItemView: View {
|
||||
|
||||
struct AddMuteItemView_Previews: PreviewProvider {
|
||||
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 threads: [MuteItem] = []
|
||||
@State var words: [MuteItem] = []
|
||||
|
||||
@State var new_text: String = ""
|
||||
|
||||
func RemoveAction(item: MuteItem) -> some View {
|
||||
Button {
|
||||
@@ -120,13 +122,9 @@ struct MutelistView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_add_muteitem, onDismiss: { self.show_add_muteitem = false }) {
|
||||
if #available(iOS 16.0, *) {
|
||||
AddMuteItemView(state: damus_state)
|
||||
.presentationDetents([.height(300)])
|
||||
.presentationDragIndicator(.visible)
|
||||
} else {
|
||||
AddMuteItemView(state: damus_state)
|
||||
}
|
||||
AddMuteItemView(state: damus_state, new_text: $new_text)
|
||||
.presentationDetents([.height(300)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import LinkPresentation
|
||||
import NaturalLanguage
|
||||
import MarkdownUI
|
||||
import Translation
|
||||
|
||||
struct Blur: UIViewRepresentable {
|
||||
var style: UIBlurEffect.Style = .systemUltraThinMaterial
|
||||
@@ -32,6 +33,8 @@ struct NoteContentView: View {
|
||||
let preview_height: CGFloat?
|
||||
let options: EventViewOptions
|
||||
|
||||
@State var isAppleTranslationPopoverPresented: Bool = false
|
||||
|
||||
@ObservedObject var artifacts_model: NoteArtifactsModel
|
||||
@ObservedObject var preview_model: PreviewModel
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@@ -96,7 +99,17 @@ struct NoteContentView: 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 {
|
||||
@@ -120,8 +133,7 @@ struct NoteContentView: View {
|
||||
EventView(damus: damus_state, event: self.event, options: .embedded_text_only)
|
||||
.padding(.top)
|
||||
}
|
||||
.background(.thinMaterial)
|
||||
.preferredColorScheme(.dark)
|
||||
.background(.thickMaterial)
|
||||
.onTapGesture(perform: {
|
||||
damus_state.nav.push(route: Route.Thread(thread: .init(event: self.event, damus_state: damus_state)))
|
||||
dismiss()
|
||||
@@ -132,10 +144,10 @@ struct NoteContentView: View {
|
||||
VStack(alignment: .leading) {
|
||||
if size == .selected {
|
||||
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)
|
||||
} 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 {
|
||||
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 {
|
||||
translateView
|
||||
.padding(.horizontal)
|
||||
@@ -299,7 +311,16 @@ struct NoteContentView: View {
|
||||
Markdown(md.markdown)
|
||||
.padding([.leading, .trailing, .top])
|
||||
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)
|
||||
@@ -390,7 +411,12 @@ struct NoteContentView_Previews: PreviewProvider {
|
||||
NoteContentView(damus_state: state, event: test_note, blur_images: false, size: .normal, options: [])
|
||||
}
|
||||
.previewDisplayName("Short note")
|
||||
|
||||
|
||||
VStack {
|
||||
NoteContentView(damus_state: state, event: test_super_short_note, blur_images: true, size: .normal, options: [])
|
||||
}
|
||||
.previewDisplayName("Super short note")
|
||||
|
||||
VStack {
|
||||
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: [])
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ struct DamusAppNotificationView: View {
|
||||
|
||||
@MainActor
|
||||
func open_url(url: URL) {
|
||||
UIApplication.shared.open(url)
|
||||
this_app.open(url)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -56,6 +56,7 @@ struct NotificationsView: View {
|
||||
@ObservedObject var notifications: NotificationsModel
|
||||
@StateObject var filter = NotificationFilter()
|
||||
@SceneStorage("NotificationsView.filter_state") var filter_state: NotificationFilterState = .all
|
||||
@Binding var subtitle: String?
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@@ -99,6 +100,15 @@ struct NotificationsView: View {
|
||||
.tag(NotificationFilterState.replies)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(
|
||||
action: { state.nav.push(route: Route.NotificationSettings(settings: state.settings)) },
|
||||
label: {
|
||||
Image("settings")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
|
||||
FriendsButton(filter: $filter.fine_filter)
|
||||
@@ -107,27 +117,23 @@ struct NotificationsView: View {
|
||||
}
|
||||
.onChange(of: filter.fine_filter) { val in
|
||||
state.settings.friend_filter = val
|
||||
self.subtitle = filter.fine_filter.description()
|
||||
}
|
||||
.onChange(of: filter_state) { val in
|
||||
filter.state = val
|
||||
}
|
||||
.onAppear {
|
||||
self.filter.fine_filter = state.settings.friend_filter
|
||||
self.subtitle = filter.fine_filter.description()
|
||||
filter.state = filter_state
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(selection: $filter_state, content: {
|
||||
Text("All", comment: "Label for filter for all notifications.")
|
||||
.tag(NotificationFilterState.all)
|
||||
|
||||
Text("Zaps", comment: "Label for filter for zap notifications.")
|
||||
.tag(NotificationFilterState.zaps)
|
||||
|
||||
Text("Mentions", comment: "Label for filter for seeing mention notifications (replies, etc).")
|
||||
.tag(NotificationFilterState.replies)
|
||||
|
||||
})
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("All", comment: "Label for filter for all notifications."), 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),
|
||||
], selection: $filter_state)
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
@@ -169,7 +175,7 @@ struct NotificationsView: View {
|
||||
|
||||
struct NotificationsView_Previews: PreviewProvider {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+61
-30
@@ -30,15 +30,18 @@ enum PostAction {
|
||||
case replying_to(NostrEvent)
|
||||
case quoting(NostrEvent)
|
||||
case posting(PostTarget)
|
||||
case highlighting(HighlightContentDraft)
|
||||
|
||||
var ev: NostrEvent? {
|
||||
switch self {
|
||||
case .replying_to(let ev):
|
||||
return ev
|
||||
case .quoting(let ev):
|
||||
return ev
|
||||
case .posting:
|
||||
return nil
|
||||
case .replying_to(let ev):
|
||||
return ev
|
||||
case .quoting(let ev):
|
||||
return ev
|
||||
case .posting:
|
||||
return nil
|
||||
case .highlighting:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +131,12 @@ struct PostView: View {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -204,6 +212,8 @@ struct PostView: View {
|
||||
damus_state.drafts.quotes.removeValue(forKey: quoting)
|
||||
case .posting:
|
||||
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 {
|
||||
BuilderEventView(damus: damus_state, event: ev)
|
||||
}
|
||||
else if case .highlighting(let draft) = action {
|
||||
HighlightDraftContentView(draft: draft)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@@ -454,14 +467,15 @@ struct PostView: View {
|
||||
let loaded_draft = load_draft()
|
||||
|
||||
switch action {
|
||||
case .replying_to(let replying_to):
|
||||
references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to)
|
||||
case .quoting(let quoting):
|
||||
references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting)
|
||||
case .posting(let target):
|
||||
guard !loaded_draft else { break }
|
||||
|
||||
fill_target_content(target: target)
|
||||
case .replying_to(let replying_to):
|
||||
references = gather_reply_ids(our_pubkey: damus_state.pubkey, from: replying_to)
|
||||
case .quoting(let quoting):
|
||||
references = gather_quote_ids(our_pubkey: damus_state.pubkey, from: quoting)
|
||||
case .posting(let target):
|
||||
guard !loaded_draft else { break }
|
||||
fill_target_content(target: target)
|
||||
case .highlighting(let draft):
|
||||
references = [draft.source.ref()]
|
||||
}
|
||||
|
||||
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
|
||||
case .posting:
|
||||
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]
|
||||
case .posting:
|
||||
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]] = []
|
||||
|
||||
switch action {
|
||||
case .replying_to(let replying_to):
|
||||
// start off with the reply tags
|
||||
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
|
||||
case .replying_to(let replying_to):
|
||||
// start off with the reply tags
|
||||
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
|
||||
|
||||
case .quoting(let ev):
|
||||
content.append(" nostr:" + bech32_note_id(ev.id))
|
||||
case .quoting(let ev):
|
||||
content.append(" nostr:" + bech32_note_id(ev.id))
|
||||
|
||||
if let quoted_ev = state.events.lookup(ev.id) {
|
||||
tags.append(["p", quoted_ev.pubkey.hex()])
|
||||
}
|
||||
case .posting(let postTarget):
|
||||
break
|
||||
}
|
||||
|
||||
// include pubkeys
|
||||
tags += pubkeys.map { pk in
|
||||
["p", pk.hex()]
|
||||
if let quoted_ev = state.events.lookup(ev.id) {
|
||||
tags.append(["p", quoted_ev.pubkey.hex()])
|
||||
}
|
||||
case .posting(let postTarget):
|
||||
break
|
||||
case .highlighting(let draft):
|
||||
break
|
||||
}
|
||||
|
||||
// append additional tags
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ struct AboutView: View {
|
||||
Group {
|
||||
if let about_string {
|
||||
let truncated_about = show_full_about ? about_string : about_string.truncateOrNil(maxLength: max_about_length)
|
||||
SelectableText(attributedString: truncated_about ?? about_string, textAlignment: self.text_alignment, size: .subheadline)
|
||||
SelectableText(damus_state: state, event: nil, attributedString: truncated_about ?? about_string, textAlignment: self.text_alignment, size: .subheadline)
|
||||
|
||||
if truncated_about != nil {
|
||||
if show_full_about {
|
||||
|
||||
@@ -21,13 +21,15 @@ struct EditMetadataView: View {
|
||||
@State var ln: String
|
||||
@State var website: String
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State var confirm_ln_address: Bool = false
|
||||
@State var confirm_save_alert: Bool = false
|
||||
|
||||
@StateObject var profileUploadObserver = ImageUploadingObserver()
|
||||
@StateObject var bannerUploadObserver = ImageUploadingObserver()
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
|
||||
init(damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
|
||||
@@ -77,7 +79,7 @@ struct EditMetadataView: View {
|
||||
var TopSection: some View {
|
||||
ZStack(alignment: .top) {
|
||||
GeometryReader { geo in
|
||||
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:))
|
||||
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:), banner_image: URL(string: banner))
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: geo.size.width, height: BANNER_HEIGHT)
|
||||
.clipped()
|
||||
@@ -86,7 +88,7 @@ struct EditMetadataView: View {
|
||||
let pfp_size: CGFloat = 90.0
|
||||
|
||||
HStack(alignment: .center) {
|
||||
EditProfilePictureView(pubkey: damus_state.pubkey, damus_state: damus_state, size: pfp_size, uploadObserver: profileUploadObserver, callback: uploadedProfilePicture(image_url:))
|
||||
EditProfilePictureView(profile_url: URL(string: picture), pubkey: damus_state.pubkey, damus_state: damus_state, size: pfp_size, uploadObserver: profileUploadObserver, callback: uploadedProfilePicture(image_url:))
|
||||
.offset(y: -(pfp_size/2.0)) // Increase if set a frame
|
||||
|
||||
Spacer()
|
||||
@@ -97,6 +99,28 @@ struct EditMetadataView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func navImage(img: String) -> some View {
|
||||
Image(img)
|
||||
.frame(width: 33, height: 33)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
var navBackButton: some View {
|
||||
HStack {
|
||||
Button {
|
||||
if didChange() {
|
||||
confirm_save_alert.toggle()
|
||||
} else {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
} label: {
|
||||
navImage(img: "chevron-left")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
TopSection
|
||||
@@ -116,18 +140,6 @@ struct EditMetadataView: View {
|
||||
|
||||
}
|
||||
|
||||
Section (NSLocalizedString("Profile Picture", comment: "Label for Profile Picture section of user profile form.")) {
|
||||
TextField(NSLocalizedString("https://example.com/pic.jpg", comment: "Placeholder example text for profile picture URL."), text: $picture)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
Section (NSLocalizedString("Banner Image", comment: "Label for Banner Image section of user profile form.")) {
|
||||
TextField(NSLocalizedString("https://example.com/pic.jpg", comment: "Placeholder example text for profile picture URL."), text: $banner)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
Section(NSLocalizedString("Website", comment: "Label for Website section of user profile form.")) {
|
||||
TextField(NSLocalizedString("https://jb55.com", comment: "Placeholder example text for website URL for user profile."), text: $website)
|
||||
.autocorrectionDisabled(true)
|
||||
@@ -139,10 +151,10 @@ struct EditMetadataView: View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
TextEditor(text: $about)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.frame(minHeight: 20, alignment: .leading)
|
||||
.frame(minHeight: 45, alignment: .leading)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text(about.isEmpty ? placeholder : about)
|
||||
.padding(.leading, 4)
|
||||
.padding(4)
|
||||
.opacity(about.isEmpty ? 1 : 0)
|
||||
.foregroundColor(Color(uiColor: .placeholderText))
|
||||
}
|
||||
@@ -175,25 +187,48 @@ struct EditMetadataView: View {
|
||||
}
|
||||
})
|
||||
|
||||
Button(NSLocalizedString("Save", comment: "Button for saving profile.")) {
|
||||
if !ln.isEmpty && !is_ln_valid(ln: ln) {
|
||||
confirm_ln_address = true
|
||||
} else {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
if !ln.isEmpty && !is_ln_valid(ln: ln) {
|
||||
confirm_ln_address = true
|
||||
} else {
|
||||
save()
|
||||
dismiss()
|
||||
}
|
||||
.disabled(profileUploadObserver.isLoading || bannerUploadObserver.isLoading)
|
||||
.alert(NSLocalizedString("Invalid Tip Address", comment: "Title of alerting as invalid tip address."), isPresented: $confirm_ln_address) {
|
||||
Button(NSLocalizedString("Ok", comment: "Button to dismiss the alert.")) {
|
||||
}
|
||||
} message: {
|
||||
Text("The address should either begin with LNURL or should look like an email address.", comment: "Giving the description of the alert message.")
|
||||
}, label: {
|
||||
Text(NSLocalizedString("Save", comment: "Button for saving profile."))
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||
})
|
||||
.buttonStyle(GradientButtonStyle(padding: 15))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
.disabled(!didChange())
|
||||
.opacity(!didChange() ? 0.5 : 1)
|
||||
.disabled(profileUploadObserver.isLoading || bannerUploadObserver.isLoading)
|
||||
.alert(NSLocalizedString("Invalid Tip Address", comment: "Title of alerting as invalid tip address."), isPresented: $confirm_ln_address) {
|
||||
Button(NSLocalizedString("Ok", comment: "Button to dismiss the alert.")) {
|
||||
}
|
||||
} message: {
|
||||
Text("The address should either begin with LNURL or should look like an email address.", comment: "Giving the description of the alert message.")
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
navBackButton
|
||||
}
|
||||
}
|
||||
.alert(NSLocalizedString("Discard changes?", comment: "Alert user that changes have been made."), isPresented: $confirm_save_alert) {
|
||||
Button(NSLocalizedString("No", comment: "Do not discard changes."), role: .cancel) {
|
||||
}
|
||||
Button(NSLocalizedString("Yes", comment: "Agree to discard changes made to profile.")) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadedProfilePicture(image_url: URL?) {
|
||||
@@ -203,6 +238,45 @@ struct EditMetadataView: View {
|
||||
func uploadedBanner(image_url: URL?) {
|
||||
banner = image_url?.absoluteString ?? ""
|
||||
}
|
||||
|
||||
func didChange() -> Bool {
|
||||
let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
|
||||
let data = profile_txn?.unsafeUnownedValue
|
||||
|
||||
if data?.name ?? "" != name {
|
||||
return true
|
||||
}
|
||||
|
||||
if data?.display_name ?? "" != display_name {
|
||||
return true
|
||||
}
|
||||
|
||||
if data?.about ?? "" != about {
|
||||
return true
|
||||
}
|
||||
|
||||
if data?.website ?? "" != website {
|
||||
return true
|
||||
}
|
||||
|
||||
if data?.picture ?? "" != picture {
|
||||
return true
|
||||
}
|
||||
|
||||
if data?.banner ?? "" != banner {
|
||||
return true
|
||||
}
|
||||
|
||||
if data?.nip05 ?? "" != nip05 {
|
||||
return true
|
||||
}
|
||||
|
||||
if data?.lud16 ?? data?.lud06 ?? "" != ln {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct EditMetadataView_Previews: PreviewProvider {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
class ImageUploadingObserver: ObservableObject {
|
||||
@Published var isLoading: Bool = false
|
||||
@@ -14,7 +15,10 @@ class ImageUploadingObserver: ObservableObject {
|
||||
struct EditPictureControl: View {
|
||||
let uploader: MediaUploader
|
||||
let pubkey: Pubkey
|
||||
var size: CGFloat? = 25
|
||||
var setup: Bool? = false
|
||||
@Binding var image_url: URL?
|
||||
@State var image_url_temp: URL?
|
||||
@ObservedObject var uploadObserver: ImageUploadingObserver
|
||||
let callback: (URL?) -> Void
|
||||
|
||||
@@ -22,12 +26,21 @@ struct EditPictureControl: View {
|
||||
|
||||
@State private var show_camera = false
|
||||
@State private var show_library = false
|
||||
@State private var show_url_sheet = false
|
||||
@State var image_upload_confirm: Bool = false
|
||||
|
||||
@State var preUploadedMedia: PreUploadedMedia? = nil
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Button(action: {
|
||||
self.show_url_sheet = true
|
||||
}) {
|
||||
Text("Image URL", comment: "Option to enter a url")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
self.show_library = true
|
||||
}) {
|
||||
@@ -43,20 +56,54 @@ struct EditPictureControl: View {
|
||||
if uploadObserver.isLoading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: DamusColors.purple))
|
||||
.frame(width: size, height: size)
|
||||
.padding(10)
|
||||
.background(DamusColors.white.opacity(0.7))
|
||||
.clipShape(Circle())
|
||||
.shadow(color: DamusColors.purple, radius: 15, x: 0, y: 0)
|
||||
} else if let url = image_url, setup ?? false {
|
||||
KFAnimatedImage(url)
|
||||
.imageContext(.pfp, disable_animation: false)
|
||||
.onFailure(fallbackUrl: URL(string: robohash(pubkey)), cacheKey: url.absoluteString)
|
||||
.cancelOnDisappear(true)
|
||||
.configure { view in
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: (size ?? 25) + 10, height: (size ?? 25) + 10)
|
||||
.kfClickable()
|
||||
.foregroundColor(DamusColors.white)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(.white, lineWidth: 4))
|
||||
} else {
|
||||
Image("camera")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 25, height: 25)
|
||||
.foregroundColor(DamusColors.purple)
|
||||
.padding(10)
|
||||
.background(DamusColors.white.opacity(0.7))
|
||||
.clipShape(Circle())
|
||||
.shadow(color: DamusColors.purple, radius: 15, x: 0, y: 0)
|
||||
if setup ?? false {
|
||||
Image(systemName: "person")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(DamusColors.white)
|
||||
.padding(20)
|
||||
.clipShape(Circle())
|
||||
.background {
|
||||
Circle()
|
||||
.fill(PinkGradient, strokeBorder: .white, lineWidth: 4)
|
||||
}
|
||||
|
||||
} else {
|
||||
Image("camera")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: size, height: size)
|
||||
.foregroundColor(DamusColors.purple)
|
||||
.padding(10)
|
||||
.background(DamusColors.white.opacity(0.7))
|
||||
.clipShape(Circle())
|
||||
.background {
|
||||
Circle()
|
||||
.fill(DamusColors.purple, strokeBorder: .white, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_camera) {
|
||||
@@ -79,6 +126,70 @@ struct EditPictureControl: View {
|
||||
Button(NSLocalizedString("Cancel", comment: "Button to cancel the upload."), role: .cancel) {}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $show_url_sheet) {
|
||||
ZStack {
|
||||
DamusColors.adaptableWhite.edgesIgnoringSafeArea(.all)
|
||||
VStack {
|
||||
Text("Image URL")
|
||||
.bold()
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal)
|
||||
|
||||
HStack {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.gray)
|
||||
.onTapGesture {
|
||||
if let pastedURL = UIPasteboard.general.string {
|
||||
image_url_temp = URL(string: pastedURL)
|
||||
}
|
||||
}
|
||||
TextField(image_url_temp?.absoluteString ?? "", text: Binding(
|
||||
get: { image_url_temp?.absoluteString ?? "" },
|
||||
set: { image_url_temp = URL(string: $0) }
|
||||
))
|
||||
}
|
||||
.padding(12)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(.gray.opacity(0.5), lineWidth: 1)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.foregroundColor(.damusAdaptableWhite)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
|
||||
Button(action: {
|
||||
show_url_sheet.toggle()
|
||||
}, label: {
|
||||
Text("Cancel", comment: "Cancel button text for dismissing updating image url.")
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||
.padding(10)
|
||||
})
|
||||
.buttonStyle(NeutralButtonStyle())
|
||||
.padding(10)
|
||||
|
||||
Button(action: {
|
||||
image_url = image_url_temp
|
||||
callback(image_url)
|
||||
show_url_sheet.toggle()
|
||||
}, label: {
|
||||
Text("Update", comment: "Update button text for updating image url.")
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||
})
|
||||
.buttonStyle(GradientButtonStyle(padding: 10))
|
||||
.padding(.horizontal, 10)
|
||||
.disabled(image_url_temp == image_url)
|
||||
.opacity(image_url_temp == image_url ? 0.5 : 1)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
image_url_temp = image_url
|
||||
}
|
||||
.presentationDetents([.height(300)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
|
||||
private func handle_upload(media: MediaUpload) {
|
||||
@@ -110,7 +221,7 @@ struct EditPictureControl_Previews: PreviewProvider {
|
||||
let observer = ImageUploadingObserver()
|
||||
ZStack {
|
||||
Color.gray
|
||||
EditPictureControl(uploader: .nostrBuild, pubkey: test_pubkey, image_url: url, uploadObserver: observer) { _ in
|
||||
EditPictureControl(uploader: .nostrBuild, pubkey: test_pubkey, size: 100, setup: false, image_url: url, uploadObserver: observer) { _ in
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,31 +125,34 @@ struct ProfileName: View {
|
||||
return
|
||||
}
|
||||
|
||||
var profile: Profile!
|
||||
var profile_txn: NdbTxn<Profile?>!
|
||||
|
||||
switch update {
|
||||
case .remote(let pubkey):
|
||||
profile_txn = damus_state.profiles.lookup(id: pubkey)
|
||||
guard let prof = profile_txn.unsafeUnownedValue else { return }
|
||||
profile = prof
|
||||
guard let profile_txn = damus_state.profiles.lookup(id: pubkey),
|
||||
let prof = profile_txn.unsafeUnownedValue else {
|
||||
return
|
||||
}
|
||||
handle_profile_update(profile: prof)
|
||||
case .manual(_, let prof):
|
||||
profile = prof
|
||||
handle_profile_update(profile: prof)
|
||||
}
|
||||
|
||||
let display_name = Profile.displayName(profile: profile, pubkey: pubkey)
|
||||
if self.display_name != display_name {
|
||||
self.display_name = display_name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nip05 = damus_state.profiles.is_validated(pubkey)
|
||||
if nip05 != self.nip05 {
|
||||
self.nip05 = nip05
|
||||
}
|
||||
@MainActor
|
||||
func handle_profile_update(profile: Profile) {
|
||||
let display_name = Profile.displayName(profile: profile, pubkey: pubkey)
|
||||
if self.display_name != display_name {
|
||||
self.display_name = display_name
|
||||
}
|
||||
|
||||
if donation != profile.damus_donation {
|
||||
donation = profile.damus_donation
|
||||
}
|
||||
let nip05 = damus_state.profiles.is_validated(pubkey)
|
||||
if nip05 != self.nip05 {
|
||||
self.nip05 = nip05
|
||||
}
|
||||
|
||||
if donation != profile.damus_donation {
|
||||
donation = profile.damus_donation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ struct InnerProfilePicView: View {
|
||||
}
|
||||
.scaledToFill()
|
||||
.frame(width: size, height: size)
|
||||
.kfClickable()
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(highlight_color(highlight), lineWidth: pfp_line_width(highlight)))
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ struct EditProfilePictureView: View {
|
||||
view.framePreloadCount = 3
|
||||
}
|
||||
.scaledToFill()
|
||||
.kfClickable()
|
||||
|
||||
EditPictureControl(uploader: damus_state?.settings.default_media_uploader ?? .nostrBuild, pubkey: pubkey, image_url: $profile_url, uploadObserver: uploadObserver, callback: callback)
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ struct ProfileView: View {
|
||||
@State var show_share_sheet: Bool = false
|
||||
@State var show_qr_code: Bool = false
|
||||
@State var action_sheet_presented: Bool = false
|
||||
@State var mute_dialog_presented: Bool = false
|
||||
@State var filter_state : FilterState = .posts
|
||||
@State var yOffset: CGFloat = 0
|
||||
|
||||
@@ -162,7 +163,10 @@ struct ProfileView: View {
|
||||
Button(action: {
|
||||
action_sheet_presented = true
|
||||
}) {
|
||||
navImage(img: "share3")
|
||||
Image(systemName: "ellipsis")
|
||||
.frame(width: 33, height: 33)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.confirmationDialog(NSLocalizedString("Actions", comment: "Title for confirmation dialog to either share, report, or mute a profile."), isPresented: $action_sheet_presented) {
|
||||
Button(NSLocalizedString("Share", comment: "Button to share the link to a profile.")) {
|
||||
@@ -196,25 +200,21 @@ struct ProfileView: View {
|
||||
damus_state.postbox.send(new_ev)
|
||||
}
|
||||
} else {
|
||||
MuteDurationMenu { duration in
|
||||
notify(.mute(.user(profile.pubkey, duration?.date_from_now)))
|
||||
} label: {
|
||||
Text("Mute", comment: "Button to mute a profile.")
|
||||
.foregroundStyle(.red)
|
||||
Button(NSLocalizedString("Mute", comment: "Button to mute a profile"), role: .destructive) {
|
||||
mute_dialog_presented = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var customNavbar: some View {
|
||||
HStack {
|
||||
navBackButton
|
||||
Spacer()
|
||||
navActionSheetButton
|
||||
.confirmationDialog(NSLocalizedString("Mute", comment: "Title for confirmation dialog to mute a profile."), isPresented: $mute_dialog_presented) {
|
||||
ForEach(DamusDuration.allCases, id: \.self) { duration in
|
||||
Button {
|
||||
notify(.mute(.user(profile.pubkey, duration.date_from_now)))
|
||||
} label: {
|
||||
Text(duration.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 5)
|
||||
.accentColor(DamusColors.white)
|
||||
}
|
||||
|
||||
func lnButton(unownedProfile: Profile?, record: ProfileRecord?) -> some View {
|
||||
@@ -424,10 +424,10 @@ struct ProfileView: View {
|
||||
aboutSection
|
||||
|
||||
VStack(spacing: 0) {
|
||||
CustomPicker(selection: $filter_state, content: {
|
||||
Text("Notes", comment: "Label for filter for seeing only your notes (instead of notes and replies).").tag(FilterState.posts)
|
||||
Text("Notes & Replies", comment: "Label for filter for seeing your notes and replies (instead of only your notes).").tag(FilterState.posts_and_replies)
|
||||
})
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("Notes", comment: "Label for filter for seeing only notes (instead of notes and replies)."), FilterState.posts),
|
||||
(NSLocalizedString("Notes & Replies", comment: "Label for filter for seeing notes and replies (instead of only notes)."), FilterState.posts_and_replies)
|
||||
], selection: $filter_state)
|
||||
Divider()
|
||||
.frame(height: 1)
|
||||
}
|
||||
@@ -448,8 +448,15 @@ struct ProfileView: View {
|
||||
.navigationTitle("")
|
||||
.navigationBarBackButtonHidden()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
customNavbar
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
navBackButton
|
||||
.padding(.top, 5)
|
||||
.accentColor(DamusColors.white)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
navActionSheetButton
|
||||
.padding(.top, 5)
|
||||
.accentColor(DamusColors.white)
|
||||
}
|
||||
}
|
||||
.toolbarBackground(.hidden)
|
||||
|
||||
@@ -45,6 +45,21 @@ struct ProfileActionSheetView: View {
|
||||
)
|
||||
}
|
||||
|
||||
var muteButton: some View {
|
||||
let target_pubkey = self.profile.pubkey
|
||||
return VStack(alignment: .center, spacing: 10) {
|
||||
MuteDurationMenu { duration in
|
||||
notify(.mute(.user(target_pubkey, duration?.date_from_now)))
|
||||
} label: {
|
||||
Image("mute")
|
||||
}
|
||||
.buttonStyle(NeutralButtonShape.circle.style)
|
||||
Text("Mute", comment: "Button label that allows the user to mute the user shown on-screen")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
var dmButton: some View {
|
||||
let dm_model = damus_state.dms.lookup_or_create(profile.pubkey)
|
||||
return VStack(alignment: .center, spacing: 10) {
|
||||
@@ -103,6 +118,9 @@ struct ProfileActionSheetView: View {
|
||||
self.followButton
|
||||
self.zapButton
|
||||
self.dmButton
|
||||
if damus_state.keypair.pubkey != profile.pubkey && damus_state.keypair.privkey != nil {
|
||||
self.muteButton
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ struct DamusPurpleAccountView: View {
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.8))
|
||||
.preferredColorScheme(.dark)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.padding()
|
||||
}
|
||||
@@ -81,7 +80,8 @@ struct DamusPurpleAccountView: View {
|
||||
SupporterBadge(
|
||||
percent: nil,
|
||||
purple_account: account,
|
||||
style: .full
|
||||
style: .full,
|
||||
text_color: .white
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,11 +126,11 @@ struct QRCodeView: View {
|
||||
|
||||
if our_profile?.picture != nil {
|
||||
ProfilePicView(pubkey: pubkey, size: 90.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
||||
.padding(.top, 50)
|
||||
.padding(.top, 20)
|
||||
} else {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 60))
|
||||
.padding(.top, 50)
|
||||
.padding(.top, 20)
|
||||
}
|
||||
|
||||
if let display_name = profile?.display_name {
|
||||
@@ -150,17 +150,18 @@ struct QRCodeView: View {
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 300, height: 300)
|
||||
.frame(minWidth: 100, maxWidth: 300, minHeight: 100, maxHeight: 300)
|
||||
.cornerRadius(10)
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(DamusColors.white, lineWidth: 5.0))
|
||||
.stroke(DamusColors.white, lineWidth: 5.0)
|
||||
.scaledToFit())
|
||||
.shadow(radius: 10)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("Follow me on Nostr", comment: "Text on QR code view to prompt viewer looking at screen to follow the user.")
|
||||
.font(.system(size: 24, weight: .heavy))
|
||||
.padding(.top)
|
||||
.padding(.top, 10)
|
||||
.foregroundColor(.white)
|
||||
|
||||
Text("Scan the code", comment: "Text on QR code view to prompt viewer to scan the QR code on screen with their device camera.")
|
||||
@@ -179,7 +180,7 @@ struct QRCodeView: View {
|
||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center)
|
||||
}
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
.padding(50)
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +202,11 @@ struct QRCodeView: View {
|
||||
}
|
||||
}
|
||||
.scaledToFit()
|
||||
.frame(width: 300, height: 300)
|
||||
.frame(maxWidth: 300, maxHeight: 300)
|
||||
.cornerRadius(10)
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0).scaledToFit())
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).trim(from: 0.0, to: outerTrimEnd).stroke(DamusColors.black, lineWidth: 5.5)
|
||||
.rotationEffect(.degrees(-90)))
|
||||
.rotationEffect(.degrees(-90)).scaledToFit())
|
||||
.shadow(radius: 10)
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -16,7 +16,7 @@ struct ReactionsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(model.events.events, id: \.id) { ev in
|
||||
ForEach(model.events.events.filter { $0.last_refid() == model.target }, id: \.id) { ev in
|
||||
ReactionView(damus_state: damus_state, reaction: ev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ struct InnerRelayPicView: View {
|
||||
Placeholder(url: url)
|
||||
}
|
||||
.scaledToFit()
|
||||
.kfClickable()
|
||||
} else {
|
||||
FailedRelayImage(url: nil)
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@ struct RelayView: View {
|
||||
.padding(.bottom, 2)
|
||||
.lineLimit(1)
|
||||
RelayType(is_paid: state.relay_model_cache.model(with_relay_id: relay)?.metadata.is_paid ?? false)
|
||||
|
||||
if relay.absoluteString.hasSuffix(".onion") {
|
||||
Image("tor")
|
||||
.resizable()
|
||||
.interpolation(.none)
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
Text(relay.absoluteString)
|
||||
.font(.subheadline)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user