Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e5c82ec64b
|
|||
| fdbf271432 | |||
| b26eedc633 | |||
| 793970beaf | |||
| 049d9170be | |||
| fd10c5672a | |||
| 37bd9447f0 | |||
| e8457d7486 | |||
| 280297ad35 | |||
| 7da3ead01e | |||
| 3ddb2625e9 | |||
| f53ffae767 | |||
| b9168f9914 | |||
| 63ff2b6f9e | |||
| 7d9468388b | |||
|
66b555e0ff
|
|||
|
8df332472c
|
|||
|
6072668438
|
|||
|
6f26ddf7ac
|
|||
|
df156df6d9
|
|||
|
11c367b541
|
|||
|
4e1b23d1cb
|
|||
|
2de3083dad
|
|||
|
93149642db
|
|||
|
0b0d422b7a
|
|||
|
036ea50a3a
|
|||
| 073feccbbf | |||
| eeea9d3266 | |||
| b8bf5df7bc | |||
| e9e68422d4 | |||
| 6f9a00d728 | |||
| 51e07df1b5 | |||
| 2a42723b81 | |||
| 839ef6a80d | |||
| c073dd8fea | |||
| 8d9f728cf0 | |||
| 2c62741e25 | |||
| 1f612f7fde | |||
| 0e9e102d0f | |||
| b94e8765a1 | |||
| 53964f5c1a | |||
| bd574d93c3 | |||
| 47514ace79 | |||
| 298b43733f | |||
| 02116c0af5 | |||
| 92121e3b2d | |||
| c92094823e | |||
| f4b1a504a5 | |||
| 99ae7de5eb | |||
| b3d9ee3fc0 | |||
| e65219ee3e | |||
| 414c67a919 | |||
| f436291209 | |||
| a9196a39df | |||
| 6a8ee9c360 | |||
|
947e24864e
|
|||
|
b9198d6bd7
|
|||
| 14bf187a6e | |||
| c996e5f8b3 | |||
|
b6dad349c9
|
|||
|
56dde30cf6
|
|||
|
95bfbae131
|
|||
|
3da0ff7ecc
|
|||
|
b8f846ded8
|
|||
|
e74c45ad39
|
|||
|
e6a03522c6
|
|||
|
dbc7d79ecd
|
|||
|
d2b5a65eca
|
|||
|
16b19d3a96
|
|||
|
70edb8d7c5
|
|||
|
ea04ebe95c
|
|||
|
44cf47faa4
|
|||
|
612abfd862
|
|||
|
20af086273
|
@@ -6,3 +6,4 @@ damus.xcodeproj/xcshareddata/xcbaselines
|
|||||||
TODO.bak
|
TODO.bak
|
||||||
tags
|
tags
|
||||||
build-git-hash.txt
|
build-git-hash.txt
|
||||||
|
.build
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
[](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
|
<div align="center">
|
||||||
|
|
||||||
# damus
|
<img src="./damus/Assets.xcassets/damus-home.imageset/damus-home@2x.png" alt="Damus Logo" title="Damus logo" width=""/>
|
||||||
|
|
||||||
|
# Damus
|
||||||
|
|
||||||
|
The social network you control
|
||||||
|
|
||||||
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
|
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
|
||||||
|
|
||||||
<img src="./ss.png" width="50%" height="50%" />
|
[](/LICENSE)
|
||||||
|
|
||||||
|
## Download and Install
|
||||||
|
|
||||||
|
[](https://apps.apple.com/us/app/damus/id1628663131)
|
||||||
|
|
||||||
|
## Supported Platforms
|
||||||
|
|
||||||
|
iOS 16.0+ • macOS 13.0+
|
||||||
|
|
||||||
|
<img src="./demo1.png" width="70%" height="50%" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
[nostr]: https://github.com/fiatjaf/nostr
|
[nostr]: https://github.com/fiatjaf/nostr
|
||||||
|
|
||||||
|
|||||||
+265
-16
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "06318d35ee2e6bd681b95591e67da33a9461b48a3c652e58bd9d1a6f0d82bdac",
|
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "codescanner",
|
"identity" : "codescanner",
|
||||||
@@ -35,6 +35,15 @@
|
|||||||
"version" : "0.2.0"
|
"version" : "0.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "faviconfinder",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/will-lumley/FaviconFinder.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "9279f4371f4877ca302ba3bf1015f3f58ae4a56c",
|
||||||
|
"version" : "5.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "gsplayer",
|
"identity" : "gsplayer",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -105,6 +114,15 @@
|
|||||||
"version" : "0.1.2"
|
"version" : "0.1.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftsoup",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
|
||||||
|
"version" : "2.8.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "swiftycrop",
|
"identity" : "swiftycrop",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "bbw.jpg",
|
"filename" : "blink.png",
|
||||||
"idiom" : "universal"
|
"idiom" : "universal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
@@ -162,6 +162,7 @@ class CarouselModel: ObservableObject {
|
|||||||
// Upon updating information, update the carousel fill size if the size for the current url has changed
|
// Upon updating information, update the carousel fill size if the size for the current url has changed
|
||||||
if oldValue[current_url] != media_size_information[current_url] {
|
if oldValue[current_url] != media_size_information[current_url] {
|
||||||
self.refresh_current_item_fill()
|
self.refresh_current_item_fill()
|
||||||
|
self.refresh_first_item_height()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,6 +187,13 @@ class CarouselModel: ObservableObject {
|
|||||||
/// and is automatically updated upon changes to these properties.
|
/// and is automatically updated upon changes to these properties.
|
||||||
@Published private(set) var current_item_fill: ImageFill?
|
@Published private(set) var current_item_fill: ImageFill?
|
||||||
|
|
||||||
|
/// Holds the ideal fill dimensions for the first item in the carousel.
|
||||||
|
/// This is used to maintain a consistent height for the carousel when swiping between images.
|
||||||
|
/// **Usage note:** This property is automatically updated when other properties are set, and should not be set directly.
|
||||||
|
/// **Implementation note:** This property ensures the carousel maintains a consistent height based on the first image,
|
||||||
|
/// preventing the UI from "jumping" when swiping between images of different aspect ratios.
|
||||||
|
@Published private(set) var first_image_fill: ImageFill?
|
||||||
|
|
||||||
|
|
||||||
// MARK: Initialization and de-initialization
|
// MARK: Initialization and de-initialization
|
||||||
|
|
||||||
@@ -207,6 +215,7 @@ class CarouselModel: ObservableObject {
|
|||||||
self.observe_video_sizes()
|
self.observe_video_sizes()
|
||||||
Task {
|
Task {
|
||||||
self.refresh_current_item_fill()
|
self.refresh_current_item_fill()
|
||||||
|
self.refresh_first_item_height()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,10 +250,17 @@ class CarouselModel: ObservableObject {
|
|||||||
/// **Usage note:** This is private, do not call this directly from outside the class.
|
/// **Usage note:** This is private, do not call this directly from outside the class.
|
||||||
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
|
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
|
||||||
private func refresh_current_item_fill() {
|
private func refresh_current_item_fill() {
|
||||||
if let current_url,
|
self.current_item_fill = self.compute_item_fill(url: current_url)
|
||||||
let item_size = self.media_size_information[current_url],
|
}
|
||||||
|
|
||||||
|
/// Computes the image fill properties for a given URL without side effects.
|
||||||
|
/// This is a pure function that calculates the appropriate fill dimensions based on image size and container constraints.
|
||||||
|
/// **Usage note:** This is a helper method used by both `refresh_current_item_fill` and `refresh_first_item_height`.
|
||||||
|
private func compute_item_fill(url: URL?) -> ImageFill? {
|
||||||
|
if let url,
|
||||||
|
let item_size = self.media_size_information[url],
|
||||||
let geo_size {
|
let geo_size {
|
||||||
self.current_item_fill = ImageFill.calculate_image_fill(
|
return ImageFill.calculate_image_fill(
|
||||||
geo_size: geo_size,
|
geo_size: geo_size,
|
||||||
img_size: item_size,
|
img_size: item_size,
|
||||||
maxHeight: self.max_height,
|
maxHeight: self.max_height,
|
||||||
@@ -252,9 +268,26 @@ class CarouselModel: ObservableObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
self.current_item_fill = nil // Not enough information to compute the proper fill. Default to nil
|
return nil // Not enough information to compute the proper fill. Default to nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This function refreshes the first item height based on the current state of the model
|
||||||
|
/// **Usage note:** This is private, do not call this directly from outside the class.
|
||||||
|
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the height.
|
||||||
|
/// When the first image dimensions change, this ensures the carousel maintains consistent dimensions.
|
||||||
|
private func refresh_first_item_height() {
|
||||||
|
self.first_image_fill = self.compute_first_item_fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the first item fill with no side-effects.
|
||||||
|
/// **Usage note:** Not to be used outside the class. Use the `first_image_fill` property instead.
|
||||||
|
/// **Implementation note:** This retrieves the first URL from the carousel and computes its fill properties
|
||||||
|
/// to establish a consistent height for the entire carousel.
|
||||||
|
private func compute_first_item_fill() -> ImageFill? {
|
||||||
|
guard let first_url = urls[safe: 0] else { return nil }
|
||||||
|
return self.compute_item_fill(url: first_url.url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Image Carousel
|
// MARK: - Image Carousel
|
||||||
@@ -286,13 +319,15 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
self.content = content
|
self.content = content
|
||||||
}
|
}
|
||||||
|
|
||||||
var filling: Bool {
|
/// Determines if the image should fill its container.
|
||||||
model.current_item_fill?.filling == true
|
/// Always returns true to ensure images consistently fill the width of the container.
|
||||||
}
|
/// This simplifies the layout behavior and prevents inconsistent sizing between carousel items.
|
||||||
|
var filling: Bool { true }
|
||||||
|
|
||||||
var height: CGFloat {
|
var height: CGFloat {
|
||||||
// Use the calculated fill height if available, otherwise use the default fill height
|
// Use the first image height (to prevent height from jumping when swiping), then default to the default fill height
|
||||||
model.current_item_fill?.height ?? model.default_fill_height
|
// This prioritization ensures consistent carousel height regardless of which image is currently displayed
|
||||||
|
model.first_image_fill?.height ?? model.default_fill_height
|
||||||
}
|
}
|
||||||
|
|
||||||
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
|
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
|
||||||
@@ -376,6 +411,7 @@ struct ImageCarousel<Content: View>: View {
|
|||||||
}
|
}
|
||||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||||
.frame(height: height)
|
.frame(height: height)
|
||||||
|
.clipped() // Prevents content from overflowing the frame, ensuring clean edges in the carousel
|
||||||
.onChange(of: model.selectedIndex) { value in
|
.onChange(of: model.selectedIndex) { value in
|
||||||
model.selectedIndex = value
|
model.selectedIndex = value
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,27 @@
|
|||||||
// Created by William Casarin on 2023-01-11.
|
// Created by William Casarin on 2023-01-11.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
|
import Kingfisher
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct NIP05Badge: View {
|
struct NIP05Badge: View {
|
||||||
let nip05: NIP05
|
let nip05: NIP05
|
||||||
let pubkey: Pubkey
|
let pubkey: Pubkey
|
||||||
let contacts: Contacts
|
let damus_state: DamusState
|
||||||
let show_domain: Bool
|
let show_domain: Bool
|
||||||
let profiles: Profiles
|
let nip05_domain_favicon: FaviconURL?
|
||||||
|
|
||||||
@Environment(\.openURL) var openURL
|
init(nip05: NIP05, pubkey: Pubkey, damus_state: DamusState, show_domain: Bool, nip05_domain_favicon: FaviconURL?) {
|
||||||
|
|
||||||
init(nip05: NIP05, pubkey: Pubkey, contacts: Contacts, show_domain: Bool, profiles: Profiles) {
|
|
||||||
self.nip05 = nip05
|
self.nip05 = nip05
|
||||||
self.pubkey = pubkey
|
self.pubkey = pubkey
|
||||||
self.contacts = contacts
|
self.damus_state = damus_state
|
||||||
self.show_domain = show_domain
|
self.show_domain = show_domain
|
||||||
self.profiles = profiles
|
self.nip05_domain_favicon = nip05_domain_favicon
|
||||||
}
|
}
|
||||||
|
|
||||||
var nip05_color: Bool {
|
var nip05_color: Bool {
|
||||||
return use_nip05_color(pubkey: pubkey, contacts: contacts)
|
return use_nip05_color(pubkey: pubkey, contacts: damus_state.contacts)
|
||||||
}
|
}
|
||||||
|
|
||||||
var Seal: some View {
|
var Seal: some View {
|
||||||
@@ -44,8 +44,23 @@ struct NIP05Badge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var domainBadge: some View {
|
||||||
|
Group {
|
||||||
|
if let nip05_domain_favicon {
|
||||||
|
KFImage(nip05_domain_favicon.source)
|
||||||
|
.imageContext(.favicon, disable_animation: true)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var username_matches_nip05: Bool {
|
var username_matches_nip05: Bool {
|
||||||
guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
|
guard let name = damus_state.profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
|
||||||
else {
|
else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -65,14 +80,18 @@ struct NIP05Badge: View {
|
|||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Seal
|
Seal
|
||||||
|
|
||||||
|
Group {
|
||||||
if show_domain {
|
if show_domain {
|
||||||
Text(nip05_string)
|
Text(nip05_string)
|
||||||
.nip05_colorized(gradient: nip05_color)
|
.nip05_colorized(gradient: nip05_color)
|
||||||
|
}
|
||||||
|
|
||||||
|
if nip05_domain_favicon != nil {
|
||||||
|
domainBadge
|
||||||
|
}
|
||||||
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
if let nip5url = nip05.siteUrl {
|
damus_state.nav.push(route: Route.NIP05DomainEvents(events: NIP05DomainEventsModel(state: damus_state, domain: nip05.host), nip05_domain_favicon: nip05_domain_favicon))
|
||||||
openURL(nip5url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +117,9 @@ struct NIP05Badge_Previews: PreviewProvider {
|
|||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let test_state = test_damus_state
|
let test_state = test_damus_state
|
||||||
VStack {
|
VStack {
|
||||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
|
||||||
|
|
||||||
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
|
||||||
|
|
||||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
|
|
||||||
|
|
||||||
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: Contacts(our_pubkey: test_pubkey), show_domain: true, profiles: test_state.profiles)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-3
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
import AVKit
|
import AVKit
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import EmojiPicker
|
import EmojiPicker
|
||||||
|
import TipKit
|
||||||
|
|
||||||
struct ZapSheet {
|
struct ZapSheet {
|
||||||
let target: ZapTarget
|
let target: ZapTarget
|
||||||
@@ -178,7 +179,7 @@ struct ContentView: View {
|
|||||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||||
|
|
||||||
case .dms:
|
case .dms:
|
||||||
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
|
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings, subtitle: $menu_subtitle)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(DamusColors.adaptableWhite)
|
.background(DamusColors.adaptableWhite)
|
||||||
@@ -333,7 +334,20 @@ struct ContentView: View {
|
|||||||
.presentationDetents([.height(550)])
|
.presentationDetents([.height(550)])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
case .onboardingSuggestions:
|
case .onboardingSuggestions:
|
||||||
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
|
if let model = try? SuggestedUsersViewModel(damus_state: damus_state!) {
|
||||||
|
OnboardingSuggestionsView(model: model)
|
||||||
|
.interactiveDismissDisabled(true)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ErrorView(
|
||||||
|
damus_state: damus_state,
|
||||||
|
error: .init(
|
||||||
|
user_visible_description: NSLocalizedString("Unexpected error loading user suggestions", comment: "Human readable error label"),
|
||||||
|
tip: NSLocalizedString("Please contact support", comment: "Human readable error tip"),
|
||||||
|
technical_info: "Error inializing SuggestedUsersViewModel"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
case .purple(let purple_url):
|
case .purple(let purple_url):
|
||||||
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
|
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
|
||||||
case .purple_onboarding:
|
case .purple_onboarding:
|
||||||
@@ -686,7 +700,8 @@ struct ContentView: View {
|
|||||||
video: DamusVideoCoordinator(),
|
video: DamusVideoCoordinator(),
|
||||||
ndb: ndb,
|
ndb: ndb,
|
||||||
quote_reposts: .init(our_pubkey: pubkey),
|
quote_reposts: .init(our_pubkey: pubkey),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||||
|
favicon_cache: FaviconCache()
|
||||||
)
|
)
|
||||||
|
|
||||||
home.damus_state = self.damus_state!
|
home.damus_state = self.damus_state!
|
||||||
@@ -704,6 +719,21 @@ struct ContentView: View {
|
|||||||
|
|
||||||
damus_state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
damus_state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||||
damus_state.nostrNetwork.connect()
|
damus_state.nostrNetwork.connect()
|
||||||
|
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
if damus_state.settings.developer_mode && damus_state.settings.reset_tips_on_launch {
|
||||||
|
do {
|
||||||
|
try Tips.resetDatastore()
|
||||||
|
} catch {
|
||||||
|
Log.error("Failed to reset tips datastore: %s", for: .tips, error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try Tips.configure()
|
||||||
|
} catch {
|
||||||
|
Log.error("Failed to configure tips: %s", for: .tips, error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func music_changed(_ state: MusicState) {
|
func music_changed(_ state: MusicState) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// Interests.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-06-25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct DIP06 {
|
||||||
|
/// Standard general interest topics.
|
||||||
|
/// See https://github.com/damus-io/dips/pull/3
|
||||||
|
enum Interest: String, CaseIterable {
|
||||||
|
/// Bitcoin-related topics (e.g. Bitcoin, Lightning, e-cash etc)
|
||||||
|
case bitcoin = "bitcoin"
|
||||||
|
/// Any non-Bitcoin technology-related topic (e.g. Linux, new releases, software development, supersonic flight, etc)
|
||||||
|
case technology = "technology"
|
||||||
|
/// Any science-related topic (e.g. astronomy, biology, physics, etc)
|
||||||
|
case science = "science"
|
||||||
|
/// Lifestyle topics (e.g. Worldschooling, Digital nomading, vagabonding, homesteading, digital minimalism, life hacks, etc)
|
||||||
|
case lifestyle = "lifestyle"
|
||||||
|
/// Travel-related topics (e.g. Information about locations to visit, travel logs, etc)
|
||||||
|
case travel = "travel"
|
||||||
|
/// Any art-related topic (e.g. poetry, painting, sculpting, photography, etc)
|
||||||
|
case art = "art"
|
||||||
|
/// Topics focused on improving human health (e.g. advances in medicine, exercising, nutrition, meditation, sleep, etc)
|
||||||
|
case health = "health"
|
||||||
|
/// Any music-related topic (e.g. Bands, fan pages, instruments, classical music theory, etc)
|
||||||
|
case music = "music"
|
||||||
|
/// Any topic related to food (e.g. Cooking, recipes, meal planning, nutrition)
|
||||||
|
case food = "food"
|
||||||
|
/// Any topic related to sports (e.g. Athlete fan pages, general sports information, sports news, sports equipment, etc)
|
||||||
|
case sports = "sports"
|
||||||
|
/// Any topic related to religion, spirituality, or faith (e.g. Christianity, Judaism, Buddhism, Islamism, Hinduism, Taoism, general meditation practice, etc)
|
||||||
|
case religionSpirituality = "religion-spirituality"
|
||||||
|
/// General humanities topics (e.g. philosophy, sociology, culture, etc)
|
||||||
|
case humanities = "humanities"
|
||||||
|
/// General topics about politics
|
||||||
|
case politics = "politics"
|
||||||
|
/// Other miscellaneous topics that do not fit in any of the previous items of the list
|
||||||
|
case other = "other"
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .bitcoin:
|
||||||
|
return NSLocalizedString("₿ Bitcoin", comment: "Interest topic label")
|
||||||
|
case .technology:
|
||||||
|
return NSLocalizedString("💻 Tech", comment: "Interest topic label")
|
||||||
|
case .science:
|
||||||
|
return NSLocalizedString("🔭 Science", comment: "Interest topic label")
|
||||||
|
case .lifestyle:
|
||||||
|
return NSLocalizedString("🏝️ Lifestyle", comment: "Interest topic label")
|
||||||
|
case .travel:
|
||||||
|
return NSLocalizedString("✈️ Travel", comment: "Interest topic label")
|
||||||
|
case .art:
|
||||||
|
return NSLocalizedString("🎨 Art", comment: "Interest topic label")
|
||||||
|
case .health:
|
||||||
|
return NSLocalizedString("🏃 Health", comment: "Interest topic label")
|
||||||
|
case .music:
|
||||||
|
return NSLocalizedString("🎶 Music", comment: "Interest topic label")
|
||||||
|
case .food:
|
||||||
|
return NSLocalizedString("🍱 Food", comment: "Interest topic label")
|
||||||
|
case .sports:
|
||||||
|
return NSLocalizedString("⚾️ Sports", comment: "Interest topic label")
|
||||||
|
case .religionSpirituality:
|
||||||
|
return NSLocalizedString("🛐 Religion", comment: "Interest topic label")
|
||||||
|
case .humanities:
|
||||||
|
return NSLocalizedString("📚 Humanities", comment: "Interest topic label")
|
||||||
|
case .politics:
|
||||||
|
return NSLocalizedString("🏛️ Politics", comment: "Interest topic label")
|
||||||
|
case .other:
|
||||||
|
return NSLocalizedString("♾️ Other", comment: "Interest topic label")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -25,12 +25,13 @@ class ActionBarModel: ObservableObject {
|
|||||||
@Published private(set) var zaps: Int
|
@Published private(set) var zaps: Int
|
||||||
@Published var zap_total: Int64
|
@Published var zap_total: Int64
|
||||||
@Published var replies: Int
|
@Published var replies: Int
|
||||||
|
@Published var relays: Int
|
||||||
|
|
||||||
static func empty() -> ActionBarModel {
|
static func empty() -> ActionBarModel {
|
||||||
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
|
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0) {
|
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0, relays: Int = 0) {
|
||||||
self.likes = likes
|
self.likes = likes
|
||||||
self.boosts = boosts
|
self.boosts = boosts
|
||||||
self.zaps = zaps
|
self.zaps = zaps
|
||||||
@@ -42,6 +43,7 @@ class ActionBarModel: ObservableObject {
|
|||||||
self.our_reply = our_reply
|
self.our_reply = our_reply
|
||||||
self.our_quote_repost = our_quote_repost
|
self.our_quote_repost = our_quote_repost
|
||||||
self.quote_reposts = quote_reposts
|
self.quote_reposts = quote_reposts
|
||||||
|
self.relays = relays
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(damus: DamusState, evid: NoteId) {
|
func update(damus: DamusState, evid: NoteId) {
|
||||||
@@ -56,11 +58,12 @@ class ActionBarModel: ObservableObject {
|
|||||||
self.our_zap = damus.zaps.our_zaps[evid]?.first
|
self.our_zap = damus.zaps.our_zaps[evid]?.first
|
||||||
self.our_reply = damus.replies.our_reply(evid)
|
self.our_reply = damus.replies.our_reply(evid)
|
||||||
self.our_quote_repost = damus.quote_reposts.our_events[evid]
|
self.our_quote_repost = damus.quote_reposts.our_events[evid]
|
||||||
|
self.relays = (damus.nostrNetwork.pool.seen[evid] ?? []).count
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
|
||||||
var is_empty: Bool {
|
var is_empty: Bool {
|
||||||
return likes == 0 && boosts == 0 && zaps == 0
|
return likes == 0 && boosts == 0 && zaps == 0 && quote_reposts == 0 && relays == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var liked: Bool {
|
var liked: Bool {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class Contacts {
|
|||||||
return friends
|
return friends
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func get_friend_of_friends_list() -> Set<Pubkey> {
|
||||||
|
return friend_of_friends
|
||||||
|
}
|
||||||
|
|
||||||
func get_followed_hashtags() -> Set<String> {
|
func get_followed_hashtags() -> Set<String> {
|
||||||
guard let ev = self.event else { return Set() }
|
guard let ev = self.event else { return Set() }
|
||||||
return Set(ev.referenced_hashtags.map({ $0.hashtag }))
|
return Set(ev.referenced_hashtags.map({ $0.hashtag }))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ enum FilterState : Int {
|
|||||||
case posts = 0
|
case posts = 0
|
||||||
case posts_and_replies = 1
|
case posts_and_replies = 1
|
||||||
case conversations = 2
|
case conversations = 2
|
||||||
|
case follow_list = 3
|
||||||
|
|
||||||
func filter(ev: NostrEvent) -> Bool {
|
func filter(ev: NostrEvent) -> Bool {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -22,13 +23,15 @@ enum FilterState : Int {
|
|||||||
return true
|
return true
|
||||||
case .conversations:
|
case .conversations:
|
||||||
return true
|
return true
|
||||||
|
case .follow_list:
|
||||||
|
return ev.known_kind == .follow_list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Simple filter to determine whether to show posts with #nsfw tags
|
/// Simple filter to determine whether to show posts with #nsfw tags
|
||||||
func nsfw_tag_filter(ev: NostrEvent) -> Bool {
|
func nsfw_tag_filter(ev: NostrEvent) -> Bool {
|
||||||
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
|
return ev.referenced_hashtags.first(where: { t in t.hashtag.caseInsensitiveCompare("nsfw") == .orderedSame }) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
|
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ class DamusState: HeadlessDamusState {
|
|||||||
var purple: DamusPurple
|
var purple: DamusPurple
|
||||||
var push_notification_client: PushNotificationClient
|
var push_notification_client: PushNotificationClient
|
||||||
let emoji_provider: EmojiProvider
|
let emoji_provider: EmojiProvider
|
||||||
|
let favicon_cache: FaviconCache
|
||||||
private(set) var nostrNetwork: NostrNetworkManager
|
private(set) var nostrNetwork: NostrNetworkManager
|
||||||
|
|
||||||
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) {
|
init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider, favicon_cache: FaviconCache) {
|
||||||
self.keypair = keypair
|
self.keypair = keypair
|
||||||
self.likes = likes
|
self.likes = likes
|
||||||
self.boosts = boosts
|
self.boosts = boosts
|
||||||
@@ -68,6 +69,7 @@ class DamusState: HeadlessDamusState {
|
|||||||
self.quote_reposts = quote_reposts
|
self.quote_reposts = quote_reposts
|
||||||
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
||||||
self.emoji_provider = emoji_provider
|
self.emoji_provider = emoji_provider
|
||||||
|
self.favicon_cache = FaviconCache()
|
||||||
|
|
||||||
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
|
let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters)
|
||||||
self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate)
|
self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate)
|
||||||
@@ -126,7 +128,8 @@ class DamusState: HeadlessDamusState {
|
|||||||
video: DamusVideoCoordinator(),
|
video: DamusVideoCoordinator(),
|
||||||
ndb: ndb,
|
ndb: ndb,
|
||||||
quote_reposts: .init(our_pubkey: pubkey),
|
quote_reposts: .init(our_pubkey: pubkey),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||||
|
favicon_cache: FaviconCache()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +197,8 @@ class DamusState: HeadlessDamusState {
|
|||||||
video: DamusVideoCoordinator(),
|
video: DamusVideoCoordinator(),
|
||||||
ndb: .empty,
|
ndb: .empty,
|
||||||
quote_reposts: .init(our_pubkey: empty_pub),
|
quote_reposts: .init(our_pubkey: empty_pub),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||||
|
favicon_cache: FaviconCache()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// FollowPackEvent.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/30/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FollowPackEvent: Hashable {
|
||||||
|
let event: NostrEvent
|
||||||
|
var title: String? = nil
|
||||||
|
var uuid: String? = nil
|
||||||
|
var image: URL? = nil
|
||||||
|
var description: String? = nil
|
||||||
|
var publicKeys: [Pubkey] = []
|
||||||
|
var interests: Set<DIP06.Interest> = []
|
||||||
|
|
||||||
|
|
||||||
|
static func parse(from ev: NostrEvent) -> FollowPackEvent {
|
||||||
|
var followlist = FollowPackEvent(event: ev)
|
||||||
|
|
||||||
|
for tag in ev.tags {
|
||||||
|
guard tag.count >= 2 else { continue }
|
||||||
|
switch tag[0].string() {
|
||||||
|
case "title": followlist.title = tag[1].string()
|
||||||
|
case "d": followlist.uuid = tag[1].string()
|
||||||
|
case "image": followlist.image = URL(string: tag[1].string())
|
||||||
|
case "description": followlist.description = tag[1].string()
|
||||||
|
case "p":
|
||||||
|
followlist.publicKeys.append(Pubkey(Data(hex: tag[1].string())))
|
||||||
|
case "t":
|
||||||
|
if let interest = DIP06.Interest(rawValue: tag[1].string()) {
|
||||||
|
followlist.interests.insert(interest)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return followlist
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//
|
||||||
|
// FollowPackModel.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 6/5/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
|
||||||
|
class FollowPackModel: ObservableObject {
|
||||||
|
var events: EventHolder
|
||||||
|
@Published var loading: Bool = false
|
||||||
|
|
||||||
|
let damus_state: DamusState
|
||||||
|
let subid = UUID().description
|
||||||
|
let limit: UInt32 = 500
|
||||||
|
|
||||||
|
init(damus_state: DamusState) {
|
||||||
|
self.damus_state = damus_state
|
||||||
|
self.events = EventHolder(on_queue: { ev in
|
||||||
|
preload_events(state: damus_state, events: [ev])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscribe(follow_pack_users: [Pubkey]) {
|
||||||
|
loading = true
|
||||||
|
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||||
|
var filter = NostrFilter(kinds: [.text, .chat])
|
||||||
|
filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||||
|
filter.authors = follow_pack_users
|
||||||
|
filter.limit = 500
|
||||||
|
|
||||||
|
damus_state.nostrNetwork.pool.subscribe(sub_id: subid, filters: [filter], handler: handle_event, to: to_relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsubscribe(to: RelayURL? = nil) {
|
||||||
|
loading = false
|
||||||
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: to.map { [$0] })
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||||
|
guard case .nostr_event(let event) = conn_ev else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event {
|
||||||
|
case .event(let sub_id, let ev):
|
||||||
|
guard sub_id == self.subid else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||||
|
{
|
||||||
|
if self.events.insert(ev) {
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .notice(let msg):
|
||||||
|
print("follow pack notice: \(msg)")
|
||||||
|
case .ok:
|
||||||
|
break
|
||||||
|
case .eose(let sub_id):
|
||||||
|
loading = false
|
||||||
|
|
||||||
|
if sub_id == self.subid {
|
||||||
|
unsubscribe(to: relay_id)
|
||||||
|
|
||||||
|
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
case .auth:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -35,9 +35,9 @@ enum FriendFilter: String, StringCodable {
|
|||||||
func description() -> String {
|
func description() -> String {
|
||||||
switch self {
|
switch self {
|
||||||
case .all:
|
case .all:
|
||||||
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
|
return NSLocalizedString("All", comment: "Human-readable short description of the 'trusted network filter' when it is disabled, and therefore is showing all content.")
|
||||||
case .friends_of_friends:
|
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'")
|
return NSLocalizedString("Trusted Network", comment: "Human-readable short description of the 'trusted network filter' when it is enabled, and therefore showing content from only the trusted network.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ class HomeModel: ContactsDelegate {
|
|||||||
break
|
break
|
||||||
case .relay_list:
|
case .relay_list:
|
||||||
break // This will be handled by `UserRelayListManager`
|
break // This will be handled by `UserRelayListManager`
|
||||||
|
case .follow_list:
|
||||||
|
break
|
||||||
|
case .interest_list:
|
||||||
|
break // Don't care for now
|
||||||
|
case .pinned_notes:
|
||||||
|
break // FIXME(tyiu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,24 +296,12 @@ class HomeModel: ContactsDelegate {
|
|||||||
Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
|
Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
damus_state.wallet.handle_nwc_response(response: resp) // This can handle success or error cases
|
||||||
|
|
||||||
guard resp.response.error == nil else {
|
guard resp.response.error == nil else {
|
||||||
Log.error("HomeModel: NWC wallet raised an error: %s", for: .nwc, String(describing: resp.response))
|
Log.error("HomeModel: NWC wallet raised an error: %s", for: .nwc, String(describing: resp.response))
|
||||||
WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
|
WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
|
||||||
if let humanReadableError = resp.response.error?.humanReadableError {
|
|
||||||
present_sheet(.error(humanReadableError))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.response.result_type == .list_transactions {
|
|
||||||
Log.info("Received NWC transaction list from %s", for: .nwc, relay.absoluteString)
|
|
||||||
damus_state.wallet.handle_nwc_response(response: resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.response.result_type == .get_balance {
|
|
||||||
Log.info("Received NWC balance information from %s", for: .nwc, relay.absoluteString)
|
|
||||||
damus_state.wallet.handle_nwc_response(response: resp)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,10 +64,35 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .pubkey(let pubkey): return ["p", pubkey.hex()]
|
case .pubkey(let pubkey): return ["p", pubkey.hex()]
|
||||||
case .note(let noteId): return ["e", noteId.hex()]
|
case .note(let noteId): return ["e", noteId.hex()]
|
||||||
case .nevent(let nevent): return ["e", nevent.noteid.hex()]
|
case .nevent(let nevent):
|
||||||
case .nprofile(let nprofile): return ["p", nprofile.author.hex()]
|
var tagBuilder = ["e", nevent.noteid.hex()]
|
||||||
|
|
||||||
|
let relay = nevent.relays.first
|
||||||
|
if let author = nevent.author?.hex() {
|
||||||
|
tagBuilder.append(relay ?? "")
|
||||||
|
tagBuilder.append(author)
|
||||||
|
} else if let relay {
|
||||||
|
tagBuilder.append(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagBuilder
|
||||||
|
case .nprofile(let nprofile):
|
||||||
|
var tagBuilder = ["p", nprofile.author.hex()]
|
||||||
|
|
||||||
|
if let relay = nprofile.relays.first {
|
||||||
|
tagBuilder.append(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagBuilder
|
||||||
case .nrelay(let url): return ["r", url]
|
case .nrelay(let url): return ["r", url]
|
||||||
case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()]
|
case .naddr(let naddr):
|
||||||
|
var tagBuilder = ["a", "\(naddr.kind.description):\(naddr.author.hex()):\(naddr.identifier.string())"]
|
||||||
|
|
||||||
|
if let relay = naddr.relays.first {
|
||||||
|
tagBuilder.append(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tagBuilder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +188,10 @@ struct LightningInvoice<T> {
|
|||||||
let payment_hash: Data
|
let payment_hash: Data
|
||||||
let created_at: UInt64
|
let created_at: UInt64
|
||||||
|
|
||||||
|
var abbreviated: String {
|
||||||
|
return self.string.prefix(8) + "…" + self.string.suffix(8)
|
||||||
|
}
|
||||||
|
|
||||||
var description_string: String {
|
var description_string: String {
|
||||||
switch description {
|
switch description {
|
||||||
case .description(let string):
|
case .description(let string):
|
||||||
@@ -171,6 +200,17 @@ struct LightningInvoice<T> {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func from(string: String) -> Invoice? {
|
||||||
|
// This feels a bit hacky at first, but it is actually clean
|
||||||
|
// because it reuses the same well-tested parsing logic as the rest of the app,
|
||||||
|
// avoiding code duplication and utilizing the guarantees acquired from age and testing.
|
||||||
|
// We could also use the C function `parse_invoice`, but it requires extra C bridging logic.
|
||||||
|
// NDBTODO: This may need updating on the nostrdb upgrade.
|
||||||
|
let parsedBlocks = parse_note_content(content: .content(string,nil)).blocks
|
||||||
|
guard parsedBlocks.count == 1 else { return nil }
|
||||||
|
return parsedBlocks[0].asInvoice
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
|
func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? {
|
||||||
@@ -192,6 +232,13 @@ enum Amount: Equatable {
|
|||||||
return format_msats(amt)
|
return format_msats(amt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func amount_sats() -> Int64? {
|
||||||
|
switch self {
|
||||||
|
case .any: nil
|
||||||
|
case .specific(let amount): amount / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func format_msats_abbrev(_ msats: Int64) -> String {
|
func format_msats_abbrev(_ msats: Int64) -> String {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// NIP05DomainEventsModel.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 4/11/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class NIP05DomainEventsModel: ObservableObject {
|
||||||
|
let state: DamusState
|
||||||
|
var events: EventHolder
|
||||||
|
@Published var loading: Bool = false
|
||||||
|
|
||||||
|
let domain: String
|
||||||
|
var filter: NostrFilter
|
||||||
|
let sub_id = UUID().description
|
||||||
|
let profiles_subid = UUID().description
|
||||||
|
let limit: UInt32 = 500
|
||||||
|
|
||||||
|
init(state: DamusState, domain: String) {
|
||||||
|
self.state = state
|
||||||
|
self.domain = domain
|
||||||
|
self.events = EventHolder(on_queue: { ev in
|
||||||
|
preload_events(state: state, events: [ev])
|
||||||
|
})
|
||||||
|
self.filter = NostrFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor func subscribe() {
|
||||||
|
filter.limit = self.limit
|
||||||
|
filter.kinds = [.text, .longform, .highlight]
|
||||||
|
|
||||||
|
var authors = Set<Pubkey>()
|
||||||
|
for pubkey in state.contacts.get_friend_of_friends_list() {
|
||||||
|
let profile_txn = state.profiles.lookup(id: pubkey)
|
||||||
|
|
||||||
|
guard let profile = profile_txn?.unsafeUnownedValue,
|
||||||
|
let nip05_str = profile.nip05,
|
||||||
|
let nip05 = NIP05.parse(nip05_str),
|
||||||
|
nip05.host.caseInsensitiveCompare(domain) == .orderedSame else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
authors.insert(pubkey)
|
||||||
|
}
|
||||||
|
if authors.isEmpty {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filter.authors = Array(authors)
|
||||||
|
|
||||||
|
print("subscribing to notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
|
||||||
|
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
|
||||||
|
loading = true
|
||||||
|
state.nostrNetwork.pool.send(.subscribe(.init(filters: [filter], sub_id: sub_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func unsubscribe() {
|
||||||
|
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
|
||||||
|
loading = false
|
||||||
|
print("unsubscribing from notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func add_event(_ ev: NostrEvent) {
|
||||||
|
if !event_matches_filter(ev, filter: filter) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard should_show_event(state: state, ev: ev) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.events.insert(ev) {
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
||||||
|
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
|
||||||
|
if sub_id == self.sub_id && ev.is_textlike && ev.should_show_event {
|
||||||
|
self.add_event(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard done else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.loading = false
|
||||||
|
|
||||||
|
if sub_id == self.sub_id {
|
||||||
|
guard let txn = NdbTxn(ndb: state.ndb) else { return }
|
||||||
|
load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,16 @@ class NostrNetworkManager {
|
|||||||
func connect() {
|
func connect() {
|
||||||
self.userRelayList.connect()
|
self.userRelayList.connect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func relaysForEvent(event: NostrEvent) -> [RelayURL] {
|
||||||
|
// TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences
|
||||||
|
// and reliability of relays to maximize chances of others finding this event.
|
||||||
|
if let relays = pool.seen[event.id] {
|
||||||
|
return Array(relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -
|
|||||||
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
|
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME(tyiu): There are a lot of hacks to get this function to render the blocks correctly.
|
||||||
|
// However, the entire note content rendering logic just needs to be rewritten.
|
||||||
|
// Block previews should actually be rendered in the position of the note content where it was found.
|
||||||
|
// Currently, we put some previews at the bottom of the note, which is incorrect as they take things out of
|
||||||
|
// the author's intended context.
|
||||||
func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
|
func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
|
||||||
var invoices: [Invoice] = []
|
var invoices: [Invoice] = []
|
||||||
var urls: [UrlType] = []
|
var urls: [UrlType] = []
|
||||||
@@ -117,6 +122,11 @@ func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewa
|
|||||||
}
|
}
|
||||||
hide_text_index = i
|
hide_text_index = i
|
||||||
} else if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} else if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
// We should hide whitespace at the end sequence.
|
||||||
|
hide_text_index = i
|
||||||
|
} else if case .hashtag = block {
|
||||||
|
// SPECIAL CASE:
|
||||||
|
// We should keep hashtags at the end sequence but hide all the other previewables around it.
|
||||||
hide_text_index = i
|
hide_text_index = i
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
@@ -149,7 +159,16 @@ func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewa
|
|||||||
// No need to show the text representation of the block if the only previewables are the sequence of them
|
// No need to show the text representation of the block if the only previewables are the sequence of them
|
||||||
// found at the end of the content.
|
// found at the end of the content.
|
||||||
// This is to save unnecessary use of screen space.
|
// This is to save unnecessary use of screen space.
|
||||||
|
// The only exception is that if there are hashtags embedded in the end sequence, which is not uncommon,
|
||||||
|
// then we still want to show those hashtags but hide everything else that is previewable in the end sequence.
|
||||||
if ind >= hide_text_index {
|
if ind >= hide_text_index {
|
||||||
|
if case .text(let txt) = block, txt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
if case .hashtag = blocks[safe: ind+1] {
|
||||||
|
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt))
|
||||||
|
}
|
||||||
|
} else if case .hashtag(let htag) = block {
|
||||||
|
return str + hashtag_str(htag)
|
||||||
|
}
|
||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +177,14 @@ func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewa
|
|||||||
case .mention(let m):
|
case .mention(let m):
|
||||||
return str + mention_str(m, profiles: profiles)
|
return str + mention_str(m, profiles: profiles)
|
||||||
case .text(let txt):
|
case .text(let txt):
|
||||||
|
if case .hashtag = blocks[safe: ind+1] {
|
||||||
|
// SPECIAL CASE:
|
||||||
|
// Do not trim whitespaces from suffix if the following block is a hashtag.
|
||||||
|
// This is because of the code further up (see "SPECIAL CASE").
|
||||||
|
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt))
|
||||||
|
} else {
|
||||||
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
|
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
|
||||||
|
}
|
||||||
case .relay(let relay):
|
case .relay(let relay):
|
||||||
return str + CompatibleText(stringLiteral: relay)
|
return str + CompatibleText(stringLiteral: relay)
|
||||||
case .hashtag(let htag):
|
case .hashtag(let htag):
|
||||||
@@ -257,12 +283,20 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
|
|||||||
|
|
||||||
// trim suffix whitespace and newlines
|
// trim suffix whitespace and newlines
|
||||||
func trim_suffix(_ str: String) -> String {
|
func trim_suffix(_ str: String) -> String {
|
||||||
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
|
var result = str
|
||||||
|
while result.last?.isWhitespace == true {
|
||||||
|
result.removeLast()
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// trim prefix whitespace and newlines
|
// trim prefix whitespace and newlines
|
||||||
func trim_prefix(_ str: String) -> String {
|
func trim_prefix(_ str: String) -> String {
|
||||||
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
|
var result = str
|
||||||
|
while result.first?.isWhitespace == true {
|
||||||
|
result.removeFirst()
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LongformContent {
|
struct LongformContent {
|
||||||
|
|||||||
@@ -23,9 +23,16 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private let MAX_SHARE_RELAYS = 4
|
@Published var pinned_notes_list: NostrEvent? = nil
|
||||||
|
var pinned_note_ids: Set<NoteId> {
|
||||||
|
if let pinned_notes_list {
|
||||||
|
return Set(pinned_notes_list.referenced_noterefs.map { $0.note_id })
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
var events: EventHolder
|
var events: EventHolder
|
||||||
|
var pinned_events: EventHolder
|
||||||
let pubkey: Pubkey
|
let pubkey: Pubkey
|
||||||
let damus: DamusState
|
let damus: DamusState
|
||||||
|
|
||||||
@@ -34,6 +41,7 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
var prof_subid = UUID().description
|
var prof_subid = UUID().description
|
||||||
var conversations_subid = UUID().description
|
var conversations_subid = UUID().description
|
||||||
var findRelay_subid = UUID().description
|
var findRelay_subid = UUID().description
|
||||||
|
var pinned_subid = UUID().description
|
||||||
var conversation_events: Set<NoteId> = Set()
|
var conversation_events: Set<NoteId> = Set()
|
||||||
|
|
||||||
init(pubkey: Pubkey, damus: DamusState) {
|
init(pubkey: Pubkey, damus: DamusState) {
|
||||||
@@ -42,6 +50,9 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
self.events = EventHolder(on_queue: { ev in
|
self.events = EventHolder(on_queue: { ev in
|
||||||
preload_events(state: damus, events: [ev])
|
preload_events(state: damus, events: [ev])
|
||||||
})
|
})
|
||||||
|
self.pinned_events = EventHolder(on_queue: { ev in
|
||||||
|
preload_events(state: damus, events: [ev])
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func follows(pubkey: Pubkey) -> Bool {
|
func follows(pubkey: Pubkey) -> Bool {
|
||||||
@@ -76,20 +87,18 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let textKinds: [NostrKind] = [.text, .longform, .highlight]
|
||||||
|
|
||||||
func subscribe() {
|
func subscribe() {
|
||||||
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
|
let text_filter = NostrFilter(kinds: textKinds, limit: 500, authors: [pubkey])
|
||||||
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
|
let profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost], authors: [pubkey])
|
||||||
var relay_list_filter = NostrFilter(kinds: [.relay_list], authors: [pubkey])
|
let relay_list_filter = NostrFilter(kinds: [.relay_list], authors: [pubkey])
|
||||||
|
let pinned_notes_filter = NostrFilter(kinds: [.pinned_notes], authors: [pubkey])
|
||||||
profile_filter.authors = [pubkey]
|
|
||||||
|
|
||||||
text_filter.authors = [pubkey]
|
|
||||||
text_filter.limit = 500
|
|
||||||
|
|
||||||
print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)")
|
print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)")
|
||||||
//print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]])
|
//print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]])
|
||||||
damus.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event)
|
damus.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event)
|
||||||
damus.nostrNetwork.pool.subscribe(sub_id: prof_subid, filters: [profile_filter, relay_list_filter], handler: handle_event)
|
damus.nostrNetwork.pool.subscribe(sub_id: prof_subid, filters: [profile_filter, relay_list_filter, pinned_notes_filter], handler: handle_event)
|
||||||
|
|
||||||
subscribe_to_conversations()
|
subscribe_to_conversations()
|
||||||
}
|
}
|
||||||
@@ -100,7 +109,7 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let conversation_kinds: [NostrKind] = [.text, .longform, .highlight]
|
let conversation_kinds: [NostrKind] = textKinds
|
||||||
let limit: UInt32 = 500
|
let limit: UInt32 = 500
|
||||||
let conversations_filter_them = NostrFilter(kinds: conversation_kinds, pubkeys: [damus.pubkey], limit: limit, authors: [pubkey])
|
let conversations_filter_them = NostrFilter(kinds: conversation_kinds, pubkeys: [damus.pubkey], limit: limit, authors: [pubkey])
|
||||||
let conversations_filter_us = NostrFilter(kinds: conversation_kinds, pubkeys: [pubkey], limit: limit, authors: [damus.pubkey])
|
let conversations_filter_us = NostrFilter(kinds: conversation_kinds, pubkeys: [pubkey], limit: limit, authors: [damus.pubkey])
|
||||||
@@ -108,6 +117,15 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
damus.nostrNetwork.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event)
|
damus.nostrNetwork.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func subscribe_to_pinned_notes() {
|
||||||
|
guard let pinned_notes_list, pinned_notes_list.referenced_noterefs.first != nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let pinned_filter = NostrFilter(ids: Array(pinned_note_ids), kinds: [.text], authors: [pubkey])
|
||||||
|
damus.nostrNetwork.pool.subscribe(sub_id: pinned_subid, filters: [pinned_filter], handler: handle_event)
|
||||||
|
}
|
||||||
|
|
||||||
func handle_profile_contact_event(_ ev: NostrEvent) {
|
func handle_profile_contact_event(_ ev: NostrEvent) {
|
||||||
process_contact_event(state: damus, ev: ev)
|
process_contact_event(state: damus, ev: ev)
|
||||||
|
|
||||||
@@ -128,11 +146,24 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
if self.events.insert(ev) {
|
if self.events.insert(ev) {
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
}
|
}
|
||||||
|
if pinned_note_ids.contains(ev.id) && self.pinned_events.insert(ev) {
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
} else if ev.known_kind == .contacts {
|
} else if ev.known_kind == .contacts {
|
||||||
handle_profile_contact_event(ev)
|
handle_profile_contact_event(ev)
|
||||||
}
|
} else if ev.known_kind == .relay_list {
|
||||||
else if ev.known_kind == .relay_list {
|
|
||||||
self.relay_list = try? NIP65.RelayList(event: ev) // Whether another user's list is malformatted is something beyond our control. Probably best to suppress errors
|
self.relay_list = try? NIP65.RelayList(event: ev) // Whether another user's list is malformatted is something beyond our control. Probably best to suppress errors
|
||||||
|
} else if ev.known_kind == .pinned_notes {
|
||||||
|
if let current_ev = self.pinned_notes_list {
|
||||||
|
guard ev.created_at > current_ev.created_at else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pinned_events.incoming.removeAll()
|
||||||
|
pinned_events.events.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pinned_notes_list = ev
|
||||||
|
subscribe_to_pinned_notes()
|
||||||
}
|
}
|
||||||
seen_event.insert(ev.id)
|
seen_event.insert(ev.id)
|
||||||
}
|
}
|
||||||
@@ -150,6 +181,8 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
} else if sub_id == self.pinned_subid {
|
||||||
|
return self.pubkey == ev.pubkey && pinned_note_ids.contains(ev.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.pubkey == ev.pubkey
|
return self.pubkey == ev.pubkey
|
||||||
@@ -160,7 +193,7 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
case .ws_event:
|
case .ws_event:
|
||||||
return
|
return
|
||||||
case .nostr_event(let resp):
|
case .nostr_event(let resp):
|
||||||
guard resp.subid == self.sub_id || resp.subid == self.prof_subid || resp.subid == self.conversations_subid else {
|
guard [self.sub_id, self.prof_subid, self.conversations_subid, self.pinned_subid].contains(resp.subid) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch resp {
|
switch resp {
|
||||||
@@ -181,12 +214,24 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
if resp.subid == self.conversations_subid {
|
if resp.subid == self.conversations_subid {
|
||||||
conversation_events.insert(ev.id)
|
conversation_events.insert(ev.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if resp.subid == self.pinned_subid, self.pinned_events.insert(ev) {
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
} else if resp.subid == self.conversations_subid && !conversation_events.contains(ev.id) {
|
} else if resp.subid == self.conversations_subid && !conversation_events.contains(ev.id) {
|
||||||
guard relay_filtered_correctly(ev, subid: resp.subid) else {
|
guard relay_filtered_correctly(ev, subid: resp.subid) else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
conversation_events.insert(ev.id)
|
conversation_events.insert(ev.id)
|
||||||
|
} else if resp.subid == self.pinned_subid {
|
||||||
|
guard relay_filtered_correctly(ev, subid: resp.subid) else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.subid == self.pinned_subid, self.pinned_events.insert(ev) {
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .notice:
|
case .notice:
|
||||||
break
|
break
|
||||||
@@ -222,7 +267,7 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getCappedRelayStrings() -> [String] {
|
func getCappedRelayStrings() -> [String] {
|
||||||
return self.relay_urls?.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
return self.relay_urls?.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
//
|
|
||||||
// SearchHomeModel.swift
|
// SearchHomeModel.swift
|
||||||
// damus
|
// damus
|
||||||
//
|
//
|
||||||
@@ -16,6 +15,7 @@ class SearchHomeModel: ObservableObject {
|
|||||||
var seen_pubkey: Set<Pubkey> = Set()
|
var seen_pubkey: Set<Pubkey> = Set()
|
||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
let base_subid = UUID().description
|
let base_subid = UUID().description
|
||||||
|
let follow_pack_subid = UUID().description
|
||||||
let profiles_subid = UUID().description
|
let profiles_subid = UUID().description
|
||||||
let limit: UInt32 = 500
|
let limit: UInt32 = 500
|
||||||
//let multiple_events_per_pubkey: Bool = false
|
//let multiple_events_per_pubkey: Bool = false
|
||||||
@@ -42,12 +42,18 @@ class SearchHomeModel: ObservableObject {
|
|||||||
func subscribe() {
|
func subscribe() {
|
||||||
loading = true
|
loading = true
|
||||||
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters)
|
||||||
|
|
||||||
|
var follow_list_filter = NostrFilter(kinds: [.follow_list])
|
||||||
|
follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970)
|
||||||
|
|
||||||
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
|
damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays)
|
||||||
|
damus_state.nostrNetwork.pool.subscribe(sub_id: follow_pack_subid, filters: [follow_list_filter], handler: handle_event, to: to_relays)
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribe(to: RelayURL? = nil) {
|
func unsubscribe(to: RelayURL? = nil) {
|
||||||
loading = false
|
loading = false
|
||||||
damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] })
|
||||||
|
damus_state.nostrNetwork.pool.unsubscribe(sub_id: follow_pack_subid, to: to.map { [$0] })
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) {
|
||||||
@@ -57,7 +63,7 @@ class SearchHomeModel: ObservableObject {
|
|||||||
|
|
||||||
switch event {
|
switch event {
|
||||||
case .event(let sub_id, let ev):
|
case .event(let sub_id, let ev):
|
||||||
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
|
guard sub_id == self.base_subid || sub_id == self.profiles_subid || sub_id == self.follow_pack_subid else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class SearchModel: ObservableObject {
|
|||||||
func subscribe() {
|
func subscribe() {
|
||||||
// since 1 month
|
// since 1 month
|
||||||
search.limit = self.limit
|
search.limit = self.limit
|
||||||
search.kinds = [.text, .like, .longform, .highlight]
|
search.kinds = [.text, .like, .longform, .highlight, .follow_list]
|
||||||
|
|
||||||
//likes_filter.ids = ref_events.referenced_ids!
|
//likes_filter.ids = ref_events.referenced_ids!
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ class UserSettingsStore: ObservableObject {
|
|||||||
@Setting(key: "dismiss_wallet_high_balance_warning", default_value: false)
|
@Setting(key: "dismiss_wallet_high_balance_warning", default_value: false)
|
||||||
var dismiss_wallet_high_balance_warning: Bool
|
var dismiss_wallet_high_balance_warning: Bool
|
||||||
|
|
||||||
|
@Setting(key: "hide_wallet_balance", default_value: false)
|
||||||
|
var hide_wallet_balance: Bool
|
||||||
|
|
||||||
@Setting(key: "left_handed", default_value: false)
|
@Setting(key: "left_handed", default_value: false)
|
||||||
var left_handed: Bool
|
var left_handed: Bool
|
||||||
|
|
||||||
@@ -125,9 +128,18 @@ class UserSettingsStore: ObservableObject {
|
|||||||
@Setting(key: "media_previews", default_value: true)
|
@Setting(key: "media_previews", default_value: true)
|
||||||
var media_previews: Bool
|
var media_previews: Bool
|
||||||
|
|
||||||
|
@Setting(key: "show_trusted_replies_first", default_value: true)
|
||||||
|
var show_trusted_replies_first: Bool
|
||||||
|
|
||||||
|
@Setting(key: "reset_tips_on_launch", default_value: false)
|
||||||
|
var reset_tips_on_launch: Bool
|
||||||
|
|
||||||
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
|
@Setting(key: "hide_nsfw_tagged_content", default_value: false)
|
||||||
var hide_nsfw_tagged_content: Bool
|
var hide_nsfw_tagged_content: Bool
|
||||||
|
|
||||||
|
@Setting(key: "reduce_bitcoin_content", default_value: false)
|
||||||
|
var reduce_bitcoin_content: Bool
|
||||||
|
|
||||||
@Setting(key: "show_profile_action_sheet_on_pfp_click", default_value: true)
|
@Setting(key: "show_profile_action_sheet_on_pfp_click", default_value: true)
|
||||||
var show_profile_action_sheet_on_pfp_click: Bool
|
var show_profile_action_sheet_on_pfp_click: Bool
|
||||||
|
|
||||||
|
|||||||
@@ -83,8 +83,10 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable {
|
|||||||
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:",
|
return .init(index: 9, tag: "breez", displayName: "Breez", link: "breez:",
|
||||||
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
|
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
|
||||||
case .bitcoinbeach:
|
case .bitcoinbeach:
|
||||||
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://",
|
// Blink used to be called Bitcoin Beach.
|
||||||
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
|
// We have to keep the tag called "bitcoinbeach" for backwards compatibility.
|
||||||
|
return .init(index: 10, tag: "bitcoinbeach", displayName: "Blink", link: "blink://",
|
||||||
|
appStoreLink: "https://apps.apple.com/app/blink-bitcoin-wallet/id1531383905", image: "blink")
|
||||||
case .blixtwallet:
|
case .blixtwallet:
|
||||||
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
|
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
|
||||||
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
|
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class WalletModel: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var connect_state: WalletConnectState
|
@Published private(set) var connect_state: WalletConnectState
|
||||||
|
|
||||||
|
/// A dictionary listing continuations waiting for a response for each request note id.
|
||||||
|
///
|
||||||
|
/// Please see the `waitForResponse` method for context.
|
||||||
|
private var continuations: [NoteId: CheckedContinuation<WalletConnect.Response.Result, any Error>] = [:]
|
||||||
|
|
||||||
init(state: WalletConnectState, settings: UserSettingsStore) {
|
init(state: WalletConnectState, settings: UserSettingsStore) {
|
||||||
self.connect_state = state
|
self.connect_state = state
|
||||||
self.previous_state = .none
|
self.previous_state = .none
|
||||||
@@ -75,12 +80,16 @@ class WalletModel: ObservableObject {
|
|||||||
///
|
///
|
||||||
/// - Parameter response: The NWC response received from the network
|
/// - Parameter response: The NWC response received from the network
|
||||||
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
|
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
|
||||||
switch response.response.result {
|
if let error = response.response.error {
|
||||||
|
self.resume(request: response.req_id, throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let result = response.response.result else { return }
|
||||||
|
self.resume(request: response.req_id, with: result)
|
||||||
|
switch result {
|
||||||
case .get_balance(let balanceResp):
|
case .get_balance(let balanceResp):
|
||||||
self.balance = balanceResp.balance / 1000
|
self.balance = balanceResp.balance / 1000
|
||||||
case .none:
|
case .pay_invoice(_):
|
||||||
return
|
|
||||||
case .some(.pay_invoice(_)):
|
|
||||||
return
|
return
|
||||||
case .list_transactions(let transactionsResp):
|
case .list_transactions(let transactionsResp):
|
||||||
self.transactions = transactionsResp.transactions
|
self.transactions = transactionsResp.transactions
|
||||||
@@ -91,4 +100,41 @@ class WalletModel: ObservableObject {
|
|||||||
self.transactions = nil
|
self.transactions = nil
|
||||||
self.balance = nil
|
self.balance = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Async wallet response waiting mechanism
|
||||||
|
|
||||||
|
func waitForResponse(for requestId: NoteId, timeout: Duration = .seconds(10)) async throws -> WalletConnect.Response.Result {
|
||||||
|
return try await withCheckedThrowingContinuation({ continuation in
|
||||||
|
self.continuations[requestId] = continuation
|
||||||
|
|
||||||
|
let timeoutTask = Task {
|
||||||
|
try? await Task.sleep(for: timeout)
|
||||||
|
self.resume(request: requestId, throwing: WaitError.timeout) // Must resume the continuation exactly once even if there is no response
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resume(request requestId: NoteId, with result: WalletConnect.Response.Result) {
|
||||||
|
continuations[requestId]?.resume(returning: result)
|
||||||
|
continuations[requestId] = nil // Never resume a continuation twice
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resume(request requestId: NoteId, throwing error: any Error) {
|
||||||
|
if let continuation = continuations[requestId] {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
continuations[requestId] = nil // Never resume a continuation twice
|
||||||
|
return // Error will be handled by the listener, no need for the generic error sheet
|
||||||
|
}
|
||||||
|
|
||||||
|
// No listeners to catch the error, show generic error sheet
|
||||||
|
if let error = error as? WalletConnect.WalletResponseErr,
|
||||||
|
let humanReadableError = error.humanReadableError {
|
||||||
|
present_sheet(.error(humanReadableError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WaitError: Error {
|
||||||
|
case timeout
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// InterestList.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D'Aquino on 2025-06-23.
|
||||||
|
//
|
||||||
|
// Some text excerpts taken from the Nostr Protocol itself (which are public domain)
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Includes models and functions for working with NIP-51
|
||||||
|
struct NIP51: Sendable {}
|
||||||
|
|
||||||
|
extension NIP51 {
|
||||||
|
/// An error thrown when decoding an item into a NIP-51 list
|
||||||
|
enum NIP51DecodingError: Error {
|
||||||
|
/// The Nostr event being converted is not a NIP-51 interest list
|
||||||
|
case notInterestList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NIP51 {
|
||||||
|
/// Models a NIP-51 Interest List (kind:10015)
|
||||||
|
struct InterestList: NostrEventConvertible, Sendable {
|
||||||
|
typealias E = NIP51DecodingError
|
||||||
|
|
||||||
|
enum InterestItem: Sendable, Hashable {
|
||||||
|
case hashtag(String)
|
||||||
|
case interestSet(String, String, String) // a-tag: kind, pubkey, identifier
|
||||||
|
|
||||||
|
var tag: [String] {
|
||||||
|
switch self {
|
||||||
|
case .hashtag(let tag):
|
||||||
|
return ["t", tag]
|
||||||
|
case .interestSet(let kind, let pubkey, let identifier):
|
||||||
|
var tag = ["a", "\(kind):\(pubkey):\(identifier)"]
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fromTag(tag: TagSequence) -> InterestItem? {
|
||||||
|
var i = tag.makeIterator()
|
||||||
|
|
||||||
|
guard let t0 = i.next(),
|
||||||
|
let t1 = i.next() else { return nil }
|
||||||
|
|
||||||
|
let tagName = t0.string()
|
||||||
|
|
||||||
|
if tagName == "t" {
|
||||||
|
return .hashtag(t1.string())
|
||||||
|
} else if tagName == "a" {
|
||||||
|
let components = t1.string().split(separator: ":")
|
||||||
|
guard components.count > 2 else { return nil }
|
||||||
|
|
||||||
|
let kind = String(components[0])
|
||||||
|
let pubkey = String(components[1])
|
||||||
|
let identifier = String(components[2])
|
||||||
|
|
||||||
|
return .interestSet(kind, pubkey, identifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let interests: [InterestItem]
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(event: NdbNote) throws(E) {
|
||||||
|
try self.init(event: UnownedNdbNote(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
init(event: borrowing UnownedNdbNote) throws(E) {
|
||||||
|
guard event.known_kind == .interest_list else {
|
||||||
|
throw E.notInterestList
|
||||||
|
}
|
||||||
|
|
||||||
|
var interests: [InterestItem] = []
|
||||||
|
|
||||||
|
for tag in event.tags {
|
||||||
|
if let interest = InterestItem.fromTag(tag: tag) {
|
||||||
|
interests.append(interest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.interests = interests
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(event: NdbNote?) throws(E) {
|
||||||
|
guard let event else { return nil }
|
||||||
|
try self.init(event: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(interests: [InterestItem]) {
|
||||||
|
self.interests = interests
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conversion to a Nostr Event
|
||||||
|
|
||||||
|
func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? {
|
||||||
|
return NdbNote(
|
||||||
|
content: "",
|
||||||
|
keypair: keypair.to_keypair(),
|
||||||
|
kind: NostrKind.interest_list.rawValue,
|
||||||
|
tags: self.interests.map { $0.tag },
|
||||||
|
createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -448,17 +448,26 @@ func random_bytes(count: Int) -> Data {
|
|||||||
return Data(bytes: bytes, count: count)
|
return Data(bytes: bytes, count: count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent) -> NostrEvent? {
|
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent, relayURL: RelayURL?) -> NostrEvent? {
|
||||||
var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
|
var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
|
||||||
|
|
||||||
tags.append(["e", boosted.id.hex(), "", "root"])
|
var eTagBuilder = ["e", boosted.id.hex()]
|
||||||
tags.append(["p", boosted.pubkey.hex()])
|
var pTagBuilder = ["p", boosted.pubkey.hex()]
|
||||||
|
|
||||||
|
let relayURLString = relayURL?.absoluteString
|
||||||
|
if let relayURLString {
|
||||||
|
pTagBuilder.append(relayURLString)
|
||||||
|
}
|
||||||
|
eTagBuilder.append(contentsOf: [relayURLString ?? "", "root", boosted.pubkey.hex()])
|
||||||
|
|
||||||
|
tags.append(eTagBuilder)
|
||||||
|
tags.append(pTagBuilder)
|
||||||
|
|
||||||
let content = event_to_json(ev: boosted)
|
let content = event_to_json(ev: boosted)
|
||||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags)
|
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙") -> NostrEvent? {
|
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙", relayURL: RelayURL?) -> NostrEvent? {
|
||||||
var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in
|
var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in
|
||||||
guard tag.count >= 2,
|
guard tag.count >= 2,
|
||||||
(tag[0].matches_char("e") || tag[0].matches_char("p")) else {
|
(tag[0].matches_char("e") || tag[0].matches_char("p")) else {
|
||||||
@@ -467,8 +476,17 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
|
|||||||
ts.append(tag.strings())
|
ts.append(tag.strings())
|
||||||
}
|
}
|
||||||
|
|
||||||
tags.append(["e", liked.id.hex()])
|
var eTagBuilder = ["e", liked.id.hex()]
|
||||||
tags.append(["p", liked.pubkey.hex()])
|
var pTagBuilder = ["p", liked.pubkey.hex()]
|
||||||
|
|
||||||
|
let relayURLString = relayURL?.absoluteString
|
||||||
|
if let relayURLString {
|
||||||
|
pTagBuilder.append(relayURLString)
|
||||||
|
}
|
||||||
|
eTagBuilder.append(contentsOf: [relayURLString ?? "", liked.pubkey.hex()])
|
||||||
|
|
||||||
|
tags.append(eTagBuilder)
|
||||||
|
tags.append(pTagBuilder)
|
||||||
|
|
||||||
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
|
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ enum NostrKind: UInt32, Codable {
|
|||||||
case like = 7
|
case like = 7
|
||||||
case chat = 42
|
case chat = 42
|
||||||
case mute_list = 10000
|
case mute_list = 10000
|
||||||
|
case pinned_notes = 10001
|
||||||
case relay_list = 10002
|
case relay_list = 10002
|
||||||
|
case interest_list = 10015
|
||||||
case list_deprecated = 30000
|
case list_deprecated = 30000
|
||||||
case draft = 31234
|
case draft = 31234
|
||||||
case longform = 30023
|
case longform = 30023
|
||||||
@@ -30,4 +32,5 @@ enum NostrKind: UInt32, Codable {
|
|||||||
case nwc_response = 23195
|
case nwc_response = 23195
|
||||||
case http_auth = 27235
|
case http_auth = 27235
|
||||||
case status = 30315
|
case status = 30315
|
||||||
|
case follow_list = 39089
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class Profiles {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private var profiles: [Pubkey: ProfileData] = [:]
|
private var profiles: [Pubkey: ProfileData] = [:]
|
||||||
|
|
||||||
|
// Map of validated NIP-05 address to pubkey.
|
||||||
@MainActor
|
@MainActor
|
||||||
var nip05_pubkey: [String: Pubkey] = [:]
|
var nip05_pubkey: [String: Pubkey] = [:]
|
||||||
|
|
||||||
|
|||||||
@@ -19,17 +19,12 @@ struct QueuedRequest {
|
|||||||
let skip_ephemeral: Bool
|
let skip_ephemeral: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SeenEvent: Hashable {
|
|
||||||
let relay_id: RelayURL
|
|
||||||
let evid: NoteId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Establishes and manages connections and subscriptions to a list of relays.
|
/// Establishes and manages connections and subscriptions to a list of relays.
|
||||||
class RelayPool {
|
class RelayPool {
|
||||||
private(set) var relays: [Relay] = []
|
private(set) var relays: [Relay] = []
|
||||||
var handlers: [RelayHandler] = []
|
var handlers: [RelayHandler] = []
|
||||||
var request_queue: [QueuedRequest] = []
|
var request_queue: [QueuedRequest] = []
|
||||||
var seen: Set<SeenEvent> = Set()
|
var seen: [NoteId: Set<RelayURL>] = [:]
|
||||||
var counts: [RelayURL: UInt64] = [:]
|
var counts: [RelayURL: UInt64] = [:]
|
||||||
var ndb: Ndb
|
var ndb: Ndb
|
||||||
/// The keypair used to authenticate with relays
|
/// The keypair used to authenticate with relays
|
||||||
@@ -357,15 +352,12 @@ class RelayPool {
|
|||||||
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
|
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
|
||||||
if case .nostr_event(let ev) = event {
|
if case .nostr_event(let ev) = event {
|
||||||
if case .event(_, let nev) = ev {
|
if case .event(_, let nev) = ev {
|
||||||
let k = SeenEvent(relay_id: relay_id, evid: nev.id)
|
if seen[nev.id]?.contains(relay_id) == true {
|
||||||
if !seen.contains(k) {
|
return
|
||||||
seen.insert(k)
|
|
||||||
if counts[relay_id] == nil {
|
|
||||||
counts[relay_id] = 1
|
|
||||||
} else {
|
|
||||||
counts[relay_id] = (counts[relay_id] ?? 0) + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
seen[nev.id, default: Set()].insert(relay_id)
|
||||||
|
counts[relay_id, default: 0] += 1
|
||||||
|
notify(.update_stats(note_id: nev.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ var test_damus_state: DamusState = ({
|
|||||||
video: .init(),
|
video: .init(),
|
||||||
ndb: ndb,
|
ndb: ndb,
|
||||||
quote_reposts: .init(our_pubkey: our_pubkey),
|
quote_reposts: .init(our_pubkey: our_pubkey),
|
||||||
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
|
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
|
||||||
|
favicon_cache: .init()
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -202,3 +202,13 @@ extension Block {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
extension Block {
|
||||||
|
var asInvoice: Invoice? {
|
||||||
|
switch self {
|
||||||
|
case .invoice(let invoice):
|
||||||
|
return invoice
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,4 +45,3 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ struct NEvent : Equatable, Hashable {
|
|||||||
self.author = author
|
self.author = author
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(event: NostrEvent, relays: [String]) {
|
||||||
|
self.init(noteid: event.id, relays: relays, author: event.pubkey, kind: event.kind)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NProfile : Equatable, Hashable {
|
struct NProfile : Equatable, Hashable {
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ class CoinosDeterministicAccountClient {
|
|||||||
return String(fullText.prefix(16))
|
return String(fullText.prefix(16))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var expectedLud16: String? {
|
||||||
|
guard let username else { return nil }
|
||||||
|
return username + "@coinos.io"
|
||||||
|
}
|
||||||
|
|
||||||
/// A deterministic password for a Coinos account
|
/// A deterministic password for a Coinos account
|
||||||
private var password: String? {
|
private var password: String? {
|
||||||
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
|
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
|
||||||
@@ -163,6 +168,50 @@ class CoinosDeterministicAccountClient {
|
|||||||
throw ClientError.errorProcessingResponse
|
throw ClientError.errorProcessingResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Updates an existing NWC connection with a new maximum budget
|
||||||
|
///
|
||||||
|
/// Note: Account and NWC connection must exist before calling this endpoint
|
||||||
|
func updateNWCConnection(maxAmount: UInt64) async throws -> WalletConnectURL {
|
||||||
|
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||||
|
guard let urlEndpoint = URL(string: "https://coinos.io/api/app") else { throw ClientError.errorFormingRequest }
|
||||||
|
|
||||||
|
try await self.loginIfNeeded()
|
||||||
|
|
||||||
|
// Get existing config first
|
||||||
|
guard let existingConfig = try await self.getNWCAppConnectionConfig() else {
|
||||||
|
throw ClientError.errorProcessingResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create updated config with new max amount
|
||||||
|
let updatedConfig = NewWalletConnectionConfig(
|
||||||
|
name: existingConfig.name ?? self.nwcConnectionName,
|
||||||
|
secret: existingConfig.secret ?? nwcKeypair.privkey.hex(),
|
||||||
|
pubkey: existingConfig.pubkey ?? nwcKeypair.pubkey.hex(),
|
||||||
|
max_amount: maxAmount,
|
||||||
|
budget_renewal: .weekly
|
||||||
|
)
|
||||||
|
|
||||||
|
let configData = try encode_json_data(updatedConfig)
|
||||||
|
|
||||||
|
let (data, response) = try await self.makeAuthenticatedRequest(
|
||||||
|
method: .post,
|
||||||
|
url: urlEndpoint,
|
||||||
|
payload: configData,
|
||||||
|
payload_type: .json
|
||||||
|
)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200:
|
||||||
|
guard let nwc = try await self.getNWCUrl() else { throw ClientError.errorProcessingResponse }
|
||||||
|
return nwc
|
||||||
|
case 401: throw ClientError.unauthorized
|
||||||
|
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ClientError.errorProcessingResponse
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the default wallet connection config
|
/// Returns the default wallet connection config
|
||||||
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
|
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
|
||||||
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ class Constants {
|
|||||||
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
|
static let MAIN_APP_BUNDLE_IDENTIFIER: String = "com.jb55.damus2"
|
||||||
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
|
static let NOTIFICATION_EXTENSION_BUNDLE_IDENTIFIER: String = "com.jb55.damus2.DamusNotificationService"
|
||||||
|
|
||||||
|
// MARK: Curation
|
||||||
|
static let ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY: Pubkey = Pubkey(hex: "895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba")!
|
||||||
|
|
||||||
// MARK: Push notification server
|
// MARK: Push notification server
|
||||||
static let PUSH_NOTIFICATION_SERVER_PRODUCTION_BASE_URL: URL = URL(string: "https://notify.damus.io")!
|
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_STAGING_BASE_URL: URL = URL(string: "https://notify-staging.damus.io")!
|
||||||
@@ -42,4 +45,5 @@ class Constants {
|
|||||||
|
|
||||||
// MARK: General constants
|
// MARK: General constants
|
||||||
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
|
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
|
||||||
|
static let MAX_SHARE_RELAYS = 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ extension KFOptionSetter {
|
|||||||
options.onlyLoadFirstFrame = disable_animation
|
options.onlyLoadFirstFrame = disable_animation
|
||||||
|
|
||||||
switch imageContext {
|
switch imageContext {
|
||||||
case .pfp:
|
case .pfp, .favicon:
|
||||||
options.diskCacheExpiration = .days(60)
|
options.diskCacheExpiration = .days(60)
|
||||||
break
|
break
|
||||||
case .banner:
|
case .banner:
|
||||||
@@ -82,11 +82,14 @@ enum ImageContext {
|
|||||||
case pfp
|
case pfp
|
||||||
case banner
|
case banner
|
||||||
case note
|
case note
|
||||||
|
case favicon
|
||||||
|
|
||||||
func maxMebibyteSize() -> Int {
|
func maxMebibyteSize() -> Int {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .favicon:
|
||||||
|
return 512_000 // 500KiB
|
||||||
case .pfp:
|
case .pfp:
|
||||||
return 5_242_880 // 5Mib
|
return 5_242_880 // 5MiB
|
||||||
case .banner, .note:
|
case .banner, .note:
|
||||||
return 20_971_520 // 20MiB
|
return 20_971_520 // 20MiB
|
||||||
}
|
}
|
||||||
@@ -94,6 +97,8 @@ enum ImageContext {
|
|||||||
|
|
||||||
func downsampleSize() -> CGSize {
|
func downsampleSize() -> CGSize {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .favicon:
|
||||||
|
return CGSize(width: 18, height: 18)
|
||||||
case .pfp:
|
case .pfp:
|
||||||
return CGSize(width: 200, height: 200)
|
return CGSize(width: 200, height: 200)
|
||||||
case .banner:
|
case .banner:
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// FaviconCache.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 5/23/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FaviconFinder
|
||||||
|
|
||||||
|
class FaviconCache {
|
||||||
|
private var nip05DomainFavicons: [String: [FaviconURL]] = [:]
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func lookup(_ domain: String) async -> [FaviconURL] {
|
||||||
|
let lowercasedDomain = domain.lowercased()
|
||||||
|
if let faviconURLs = nip05DomainFavicons[lowercasedDomain] {
|
||||||
|
return faviconURLs
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let siteURL = URL(string: "https://\(lowercasedDomain)"),
|
||||||
|
let faviconURLs = try? await FaviconFinder(
|
||||||
|
url: siteURL,
|
||||||
|
configuration: .init(
|
||||||
|
preferredSource: .ico, // Prefer using common favicon .ico filenames at root level to avoid scraping HTML when possible.
|
||||||
|
preferences: [
|
||||||
|
.html: FaviconFormatType.appleTouchIcon.rawValue,
|
||||||
|
.ico: "favicon.ico",
|
||||||
|
.webApplicationManifestFile: FaviconFormatType.launcherIcon4x.rawValue
|
||||||
|
]
|
||||||
|
)
|
||||||
|
).fetchFaviconURLs()
|
||||||
|
else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
nip05DomainFavicons[lowercasedDomain] = faviconURLs
|
||||||
|
|
||||||
|
return faviconURLs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ enum LogCategory: String {
|
|||||||
case damus_purple
|
case damus_purple
|
||||||
case image_uploading
|
case image_uploading
|
||||||
case video_coordination
|
case video_coordination
|
||||||
|
case tips
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Damus structured logger
|
/// Damus structured logger
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Created by Scott Penrose on 5/7/23.
|
// Created by Scott Penrose on 5/7/23.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum Route: Hashable {
|
enum Route: Hashable {
|
||||||
@@ -46,6 +47,9 @@ enum Route: Hashable {
|
|||||||
case Wallet(wallet: WalletModel)
|
case Wallet(wallet: WalletModel)
|
||||||
case WalletScanner(result: Binding<WalletScanResult>)
|
case WalletScanner(result: Binding<WalletScanResult>)
|
||||||
case FollowersYouKnow(friendedFollowers: [Pubkey], followers: FollowersModel)
|
case FollowersYouKnow(friendedFollowers: [Pubkey], followers: FollowersModel)
|
||||||
|
case NIP05DomainEvents(events: NIP05DomainEventsModel, nip05_domain_favicon: FaviconURL?)
|
||||||
|
case NIP05DomainPubkeys(domain: String, nip05_domain_favicon: FaviconURL?, pubkeys: [Pubkey])
|
||||||
|
case FollowPack(followPack: NostrEvent, model: FollowPackModel, blur_imgs: Bool)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
|
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
|
||||||
@@ -127,6 +131,12 @@ enum Route: Hashable {
|
|||||||
FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers)
|
FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers)
|
||||||
case .Script(let load_model):
|
case .Script(let load_model):
|
||||||
LoadScript(pool: damusState.nostrNetwork.pool, model: load_model)
|
LoadScript(pool: damusState.nostrNetwork.pool, model: load_model)
|
||||||
|
case .NIP05DomainEvents(let events, let nip05_domain_favicon):
|
||||||
|
NIP05DomainTimelineView(damus_state: damusState, model: events, nip05_domain_favicon: nip05_domain_favicon)
|
||||||
|
case .NIP05DomainPubkeys(let domain, let nip05_domain_favicon, let pubkeys):
|
||||||
|
NIP05DomainPubkeysView(damus_state: damusState, domain: domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: pubkeys)
|
||||||
|
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
|
||||||
|
FollowPackView(state: damusState, ev: followPack, model: followPackModel, blur_imgs: blur_imgs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +241,15 @@ enum Route: Hashable {
|
|||||||
case .Script(let model):
|
case .Script(let model):
|
||||||
hasher.combine("script")
|
hasher.combine("script")
|
||||||
hasher.combine(model.data.count)
|
hasher.combine(model.data.count)
|
||||||
|
case .NIP05DomainEvents(let events, _):
|
||||||
|
hasher.combine("nip05DomainEvents")
|
||||||
|
hasher.combine(events.domain)
|
||||||
|
case .NIP05DomainPubkeys(let domain, _, _):
|
||||||
|
hasher.combine("nip05DomainPubkeys")
|
||||||
|
hasher.combine(domain)
|
||||||
|
case .FollowPack(let followPack, let followPackModel, let blur_imgs):
|
||||||
|
hasher.combine("followPack")
|
||||||
|
hasher.combine(followPack.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ extension WalletConnect {
|
|||||||
let req_id: NoteId
|
let req_id: NoteId
|
||||||
let response: Response
|
let response: Response
|
||||||
|
|
||||||
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) async throws(InitializationError) {
|
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) throws(InitializationError) {
|
||||||
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
|
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
|
||||||
|
|
||||||
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
|
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
|
||||||
@@ -85,7 +85,7 @@ extension WalletConnect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WalletResponseErr: Codable {
|
struct WalletResponseErr: Codable, Error {
|
||||||
let code: Code?
|
let code: Code?
|
||||||
let message: String?
|
let message: String?
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,28 @@ extension WalletConnect {
|
|||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func refresh_wallet_information(damus_state: DamusState) async {
|
||||||
|
damus_state.wallet.resetWalletStateInformation()
|
||||||
|
await Self.update_wallet_information(damus_state: damus_state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func update_wallet_information(damus_state: DamusState) async {
|
||||||
|
guard let url = damus_state.settings.nostr_wallet_connect,
|
||||||
|
let nwc = WalletConnectURL(str: url) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let flusher: OnFlush? = nil
|
||||||
|
|
||||||
|
let delay = 0.0 // We don't need a delay when fetching a transaction list or balance
|
||||||
|
|
||||||
|
WalletConnect.request_transaction_list(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
||||||
|
WalletConnect.request_balance_information(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) {
|
static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) {
|
||||||
// find the pending zap and mark it as pending-confirmed
|
// find the pending zap and mark it as pending-confirmed
|
||||||
for kv in state.zaps.our_zaps {
|
for kv in state.zaps.our_zaps {
|
||||||
|
|||||||
@@ -218,6 +218,15 @@ struct EventActionBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var event_relay_url_strings: [String] {
|
||||||
|
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
|
||||||
|
if !relays.isEmpty {
|
||||||
|
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
|
||||||
|
}
|
||||||
|
|
||||||
|
return userProfile.getCappedRelayStrings()
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
self.content
|
self.content
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -233,7 +242,9 @@ struct EventActionBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
|
.sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
|
||||||
ShareSheet(activityItems: [URL(string: "https://damus.io/" + event.id.bech32)!])
|
if let url = URL(string: "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))) {
|
||||||
|
ShareSheet(activityItems: [url])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
|
.sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
|
||||||
|
|
||||||
@@ -262,7 +273,7 @@ struct EventActionBar: View {
|
|||||||
|
|
||||||
func send_like(emoji: String) {
|
func send_like(emoji: String) {
|
||||||
guard let keypair = damus_state.keypair.to_full(),
|
guard let keypair = damus_state.keypair.to_full(),
|
||||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
|
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ struct EventDetailBar: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(PlainButtonStyle())
|
.buttonStyle(PlainButtonStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if bar.relays > 0 {
|
||||||
|
let relays = Array(state.nostrNetwork.pool.seen[target] ?? [])
|
||||||
|
NavigationLink(value: Route.UserRelays(relays: relays)) {
|
||||||
|
let nounString = pluralizedString(key: "relays_count", count: bar.relays)
|
||||||
|
let noun = Text(nounString).foregroundColor(.gray)
|
||||||
|
Text("\(Text(verbatim: bar.relays.formatted()).font(.body.bold())) \(noun)", comment: "Sentence composed of 2 variables to describe how many relays a note was found on. In source English, the first variable is the number of relays, and the second variable is 'Relay' or 'Relays'.")
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct RepostAction: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
|
|
||||||
guard let keypair = self.damus_state.keypair.to_full(),
|
guard let keypair = self.damus_state.keypair.to_full(),
|
||||||
let boost = make_boost_event(keypair: keypair, boosted: self.event) else {
|
let boost = make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ struct ShareAction: View {
|
|||||||
self._show_share = show_share
|
self._show_share = show_share
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var event_relay_url_strings: [String] {
|
||||||
|
let relays = userProfile.damus.nostrNetwork.relaysForEvent(event: event)
|
||||||
|
if !relays.isEmpty {
|
||||||
|
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
|
||||||
|
}
|
||||||
|
|
||||||
|
return userProfile.getCappedRelayStrings()
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
@@ -40,7 +49,7 @@ struct ShareAction: View {
|
|||||||
|
|
||||||
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) {
|
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) {
|
||||||
dismiss()
|
dismiss()
|
||||||
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(noteid: event.id, relays: userProfile.getCappedRelayStrings())))
|
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
|
||||||
}
|
}
|
||||||
|
|
||||||
let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark"
|
let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark"
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ enum AppAccessibilityIdentifiers: String {
|
|||||||
// MARK: Onboarding
|
// MARK: Onboarding
|
||||||
// Prefix: `onboarding`
|
// Prefix: `onboarding`
|
||||||
|
|
||||||
|
/// Any interest option button on the "select your interests" page during onboarding
|
||||||
|
case onboarding_interest_option_button
|
||||||
|
|
||||||
|
/// The "next" button on the onboarding interest page
|
||||||
|
case onboarding_interest_page_next_page
|
||||||
|
|
||||||
|
/// The "next" button on the onboarding content settings page
|
||||||
|
case onboarding_content_settings_page_next_page
|
||||||
|
|
||||||
/// The skip button on the onboarding sheet
|
/// The skip button on the onboarding sheet
|
||||||
case onboarding_sheet_skip_button
|
case onboarding_sheet_skip_button
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct BookmarksView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
InnerTimelineView(events: EventHolder(events: bookmarks, incoming: []), damus: state, filter: noneFilter)
|
InnerTimelineView(events: EventHolder(events: bookmarks, incoming: []), pinned_events: EventHolder(), damus: state, filter: noneFilter)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
.padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
//
|
|
||||||
// FriendsButton.swift
|
|
||||||
// damus
|
|
||||||
//
|
|
||||||
// Created by William Casarin on 2023-04-21.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct FriendsButton: View {
|
|
||||||
@Binding var filter: FriendFilter
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Button(action: {
|
|
||||||
switch self.filter {
|
|
||||||
case .all:
|
|
||||||
self.filter = .friends_of_friends
|
|
||||||
case .friends_of_friends:
|
|
||||||
self.filter = .all
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
if filter == .friends_of_friends {
|
|
||||||
LINEAR_GRADIENT
|
|
||||||
.mask(Image("user-added")
|
|
||||||
.resizable()
|
|
||||||
).frame(width: 28, height: 28)
|
|
||||||
} else {
|
|
||||||
Image("user-added")
|
|
||||||
.resizable()
|
|
||||||
.frame(width: 28, height: 28)
|
|
||||||
.foregroundColor(.gray)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct FriendsButton_Previews: PreviewProvider {
|
|
||||||
@State static var enabled: FriendFilter = .all
|
|
||||||
|
|
||||||
static var previews: some View {
|
|
||||||
FriendsButton(filter: $enabled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//
|
||||||
|
// TrustedNetworkButton.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by William Casarin on 2023-04-21.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TrustedNetworkButton: View {
|
||||||
|
@Binding var filter: FriendFilter
|
||||||
|
var action: (@MainActor () -> Void)? = nil
|
||||||
|
|
||||||
|
var MainButton: some View {
|
||||||
|
Button(action: {
|
||||||
|
switch self.filter {
|
||||||
|
case .all:
|
||||||
|
self.filter = .friends_of_friends
|
||||||
|
case .friends_of_friends:
|
||||||
|
self.filter = .all
|
||||||
|
}
|
||||||
|
|
||||||
|
if let action {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
if filter == .friends_of_friends {
|
||||||
|
LINEAR_GRADIENT
|
||||||
|
.mask(Image(systemName: "network.badge.shield.half.filled")
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
)
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "network.slash")
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MainButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrustedNetworkButton_Previews: PreviewProvider {
|
||||||
|
@State static var enabled: FriendFilter = .all
|
||||||
|
|
||||||
|
static var previews: some View {
|
||||||
|
TrustedNetworkButton(filter: $enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,7 +235,7 @@ struct ChatEventView: View {
|
|||||||
|
|
||||||
func send_like(emoji: String) {
|
func send_like(emoji: String) {
|
||||||
guard let keypair = damus_state.keypair.to_full(),
|
guard let keypair = damus_state.keypair.to_full(),
|
||||||
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
|
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,12 +337,6 @@ struct ChatEventView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Notification.Name {
|
|
||||||
static var toggle_thread_view: Notification.Name {
|
|
||||||
return Notification.Name("convert_to_thread")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||||
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwipeActions
|
import SwipeActions
|
||||||
|
import TipKit
|
||||||
|
|
||||||
struct ChatroomThreadView: View {
|
struct ChatroomThreadView: View {
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -15,11 +16,20 @@ struct ChatroomThreadView: View {
|
|||||||
@ObservedObject var thread: ThreadModel
|
@ObservedObject var thread: ThreadModel
|
||||||
@State var highlighted_note_id: NoteId? = nil
|
@State var highlighted_note_id: NoteId? = nil
|
||||||
@State var user_just_posted_flag: Bool = false
|
@State var user_just_posted_flag: Bool = false
|
||||||
|
@State var untrusted_network_expanded: Bool = true
|
||||||
@Namespace private var animation
|
@Namespace private var animation
|
||||||
|
|
||||||
|
// Add state for sticky header
|
||||||
|
@State var showStickyHeader: Bool = false
|
||||||
|
@State var untrustedSectionOffset: CGFloat = 0
|
||||||
|
|
||||||
|
private static let untrusted_network_section_id = "untrusted-network-section"
|
||||||
|
private static let sticky_header_adjusted_anchor = UnitPoint(x: UnitPoint.top.x, y: 0.2)
|
||||||
|
|
||||||
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
|
func go_to_event(scroller: ScrollViewProxy, note_id: NoteId) {
|
||||||
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top)
|
let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top
|
||||||
|
|
||||||
|
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: adjustedAnchor)
|
||||||
highlighted_note_id = note_id
|
highlighted_note_id = note_id
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
@@ -35,8 +45,69 @@ struct ChatroomThreadView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func trusted_event_filter(_ event: NostrEvent) -> Bool {
|
||||||
|
!damus.settings.show_trusted_replies_first || damus.contacts.is_in_friendosphere(event.pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ThreadedSwipeViewGroup(scroller: ScrollViewProxy, events: [NostrEvent]) -> some View {
|
||||||
|
SwipeViewGroup {
|
||||||
|
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
|
||||||
|
ChatEventView(event: events[ind],
|
||||||
|
selected_event: self.thread.selected_event,
|
||||||
|
prev_ev: ind > 0 ? events[ind-1] : nil,
|
||||||
|
next_ev: ind == events.count-1 ? nil : events[ind+1],
|
||||||
|
damus_state: damus,
|
||||||
|
thread: thread,
|
||||||
|
scroll_to_event: { note_id in
|
||||||
|
self.go_to_event(scroller: scroller, note_id: note_id)
|
||||||
|
},
|
||||||
|
focus_event: {
|
||||||
|
self.set_active_event(scroller: scroller, ev: ev)
|
||||||
|
},
|
||||||
|
highlight_bubble: highlighted_note_id == ev.id,
|
||||||
|
bar: make_actionbar_model(ev: ev.id, damus: damus)
|
||||||
|
)
|
||||||
|
.id(ev.id)
|
||||||
|
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var OutsideTrustedNetworkLabel: some View {
|
||||||
|
HStack {
|
||||||
|
Label(
|
||||||
|
NSLocalizedString(
|
||||||
|
"Replies outside your trusted network",
|
||||||
|
comment: "Section title in thread for replies from outside of the current user's trusted network, which is their follows and follows of follows."),
|
||||||
|
systemImage: "network.slash"
|
||||||
|
)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.rotationEffect(.degrees(untrusted_network_expanded ? 90 : 0))
|
||||||
|
.animation(.easeInOut(duration: 0.1), value: untrusted_network_expanded)
|
||||||
|
}
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
var StickyHeaderView: some View {
|
||||||
|
OutsideTrustedNetworkLabel
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(
|
||||||
|
Color(UIColor.systemBackground)
|
||||||
|
.shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { scroller in
|
ScrollViewReader { scroller in
|
||||||
|
let sorted_child_events = thread.sorted_child_events
|
||||||
|
|
||||||
|
let untrusted_events = sorted_child_events.filter { !trusted_event_filter($0) }
|
||||||
|
let trusted_events = sorted_child_events.filter { trusted_event_filter($0) }
|
||||||
|
|
||||||
|
ZStack(alignment: .top) {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
// MARK: - Parents events view
|
// MARK: - Parents events view
|
||||||
@@ -56,11 +127,8 @@ struct ChatroomThreadView: View {
|
|||||||
.padding(.leading, 25 * 2)
|
.padding(.leading, 25 * 2)
|
||||||
|
|
||||||
}.background(GeometryReader { geometry in
|
}.background(GeometryReader { geometry in
|
||||||
// get the height and width of the EventView view
|
|
||||||
let eventHeight = geometry.frame(in: .global).height
|
let eventHeight = geometry.frame(in: .global).height
|
||||||
// let eventWidth = geometry.frame(in: .global).width
|
|
||||||
|
|
||||||
// vertical gray line in the background
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.gray.opacity(0.25))
|
.fill(Color.gray.opacity(0.25))
|
||||||
.frame(width: 2, height: eventHeight)
|
.frame(width: 2, height: eventHeight)
|
||||||
@@ -83,39 +151,90 @@ struct ChatroomThreadView: View {
|
|||||||
}
|
}
|
||||||
.id(self.thread.selected_event.id)
|
.id(self.thread.selected_event.id)
|
||||||
|
|
||||||
|
// MARK: - Children view - inside trusted network
|
||||||
// MARK: - Children view
|
if !trusted_events.isEmpty {
|
||||||
let events = thread.sorted_child_events
|
ThreadedSwipeViewGroup(scroller: scroller, events: trusted_events)
|
||||||
let count = events.count
|
|
||||||
SwipeViewGroup {
|
|
||||||
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
|
|
||||||
ChatEventView(event: events[ind],
|
|
||||||
selected_event: self.thread.selected_event,
|
|
||||||
prev_ev: ind > 0 ? events[ind-1] : nil,
|
|
||||||
next_ev: ind == count-1 ? nil : events[ind+1],
|
|
||||||
damus_state: damus,
|
|
||||||
thread: thread,
|
|
||||||
scroll_to_event: { note_id in
|
|
||||||
self.go_to_event(scroller: scroller, note_id: note_id)
|
|
||||||
},
|
|
||||||
focus_event: {
|
|
||||||
self.set_active_event(scroller: scroller, ev: ev)
|
|
||||||
},
|
|
||||||
highlight_bubble: highlighted_note_id == ev.id,
|
|
||||||
bar: make_actionbar_model(ev: ev.id, damus: damus)
|
|
||||||
)
|
|
||||||
.padding(.horizontal)
|
|
||||||
.id(ev.id)
|
|
||||||
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top)
|
.padding(.top)
|
||||||
|
|
||||||
|
// MARK: - Children view - outside trusted network
|
||||||
|
if !untrusted_events.isEmpty {
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
TipView(TrustedNetworkRepliesTip.shared, arrowEdge: .bottom)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Track this section's position
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 1)
|
||||||
|
.background(
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Color.clear
|
||||||
|
.onAppear {
|
||||||
|
untrustedSectionOffset = proxy.frame(in: .global).minY
|
||||||
|
}
|
||||||
|
.onChange(of: proxy.frame(in: .global).minY) { newY in
|
||||||
|
let shouldShow = newY <= 100 // Adjust this threshold as needed
|
||||||
|
if shouldShow != showStickyHeader {
|
||||||
|
withAnimation(.easeInOut(duration: 0.3)) {
|
||||||
|
showStickyHeader = shouldShow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
withAnimation {
|
||||||
|
untrusted_network_expanded.toggle()
|
||||||
|
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
TrustedNetworkRepliesTip.shared.invalidate(reason: .actionPerformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll_to_event(scroller: scroller, id: ChatroomThreadView.untrusted_network_section_id, delay: 0.1, animate: true, anchor: ChatroomThreadView.sticky_header_adjusted_anchor)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
OutsideTrustedNetworkLabel
|
||||||
|
}
|
||||||
|
.id(ChatroomThreadView.untrusted_network_section_id)
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if untrusted_network_expanded {
|
||||||
|
withAnimation {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 8) {
|
||||||
|
ThreadedSwipeViewGroup(scroller: scroller, events: untrusted_events)
|
||||||
|
}
|
||||||
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
EndBlock()
|
EndBlock()
|
||||||
|
|
||||||
HStack {}
|
HStack {}
|
||||||
.frame(height: tabHeight + getSafeAreaBottom())
|
.frame(height: tabHeight + getSafeAreaBottom())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if showStickyHeader && !untrusted_events.isEmpty {
|
||||||
|
VStack {
|
||||||
|
StickyHeaderView
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation {
|
||||||
|
untrusted_network_expanded.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
|
.zIndex(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
.onReceive(handle_notify(.post), perform: { notify in
|
.onReceive(handle_notify(.post), perform: { notify in
|
||||||
switch notify {
|
switch notify {
|
||||||
case .post(_):
|
case .post(_):
|
||||||
@@ -139,14 +258,7 @@ struct ChatroomThreadView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggle_thread_view() {
|
|
||||||
NotificationCenter.default.post(name: .toggle_thread_view, object: nil)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct ChatroomView_Previews: PreviewProvider {
|
struct ChatroomView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
@@ -167,8 +279,3 @@ struct ChatroomView_Previews: PreviewProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func scroll_after_load(thread: ThreadModel, proxy: ScrollViewProxy) {
|
|
||||||
scroll_to_event(scroller: proxy, id: thread.selected_event.id, delay: 0.1, animate: false)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import TipKit
|
||||||
|
|
||||||
enum DMType: Hashable {
|
enum DMType: Hashable {
|
||||||
case rando
|
case rando
|
||||||
@@ -18,6 +19,7 @@ struct DirectMessagesView: View {
|
|||||||
@State var dm_type: DMType = .friend
|
@State var dm_type: DMType = .friend
|
||||||
@ObservedObject var model: DirectMessagesModel
|
@ObservedObject var model: DirectMessagesModel
|
||||||
@ObservedObject var settings: UserSettingsStore
|
@ObservedObject var settings: UserSettingsStore
|
||||||
|
@Binding var subtitle: String?
|
||||||
|
|
||||||
func MainContent(requests: Bool) -> some View {
|
func MainContent(requests: Bool) -> some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -72,7 +74,15 @@ struct DirectMessagesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let showTrustedButton = would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms)
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
if #available(iOS 17, *), showTrustedButton {
|
||||||
|
TipView(TrustedNetworkButtonTip.shared)
|
||||||
|
.tipBackground(.clear)
|
||||||
|
.tipViewStyle(TrustedNetworkButtonTipViewStyle())
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
CustomPicker(tabs: [
|
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("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),
|
(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),
|
||||||
@@ -92,11 +102,21 @@ struct DirectMessagesView: View {
|
|||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms) {
|
if showTrustedButton {
|
||||||
|
TrustedNetworkButton(filter: $settings.friend_filter) {
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
TrustedNetworkButtonTip.shared.invalidate(reason: .actionPerformed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
self.subtitle = settings.friend_filter.description()
|
||||||
|
|
||||||
FriendsButton(filter: $settings.friend_filter)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.onChange(of: settings.friend_filter) { val in
|
||||||
|
self.subtitle = val.description()
|
||||||
}
|
}
|
||||||
.navigationTitle(NSLocalizedString("DMs", comment: "Navigation title for view of DMs, where DM is an English abbreviation for Direct Message."))
|
.navigationTitle(NSLocalizedString("DMs", comment: "Navigation title for view of DMs, where DM is an English abbreviation for Direct Message."))
|
||||||
}
|
}
|
||||||
@@ -115,6 +135,6 @@ func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageMo
|
|||||||
struct DirectMessagesView_Previews: PreviewProvider {
|
struct DirectMessagesView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let ds = test_damus_state
|
let ds = test_damus_state
|
||||||
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings)
|
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings, subtitle: .constant(nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,21 @@ struct EventView: View {
|
|||||||
let options: EventViewOptions
|
let options: EventViewOptions
|
||||||
let damus: DamusState
|
let damus: DamusState
|
||||||
let pubkey: Pubkey
|
let pubkey: Pubkey
|
||||||
|
let pinned: Set<NoteId>
|
||||||
|
|
||||||
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, options: EventViewOptions = []) {
|
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, pinned: Set<NoteId> = [], options: EventViewOptions = []) {
|
||||||
self.event = event
|
self.event = event
|
||||||
self.options = options
|
self.options = options
|
||||||
self.damus = damus
|
self.damus = damus
|
||||||
self.pubkey = pubkey ?? event.pubkey
|
self.pubkey = pubkey ?? event.pubkey
|
||||||
|
self.pinned = pinned
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
if event.known_kind == .boost {
|
if pinned.contains(event.id) {
|
||||||
|
PinnedEventView(damus: damus, event: event, options: options)
|
||||||
|
} else if event.known_kind == .boost {
|
||||||
if let inner_ev = event.get_inner_event(cache: damus.events) {
|
if let inner_ev = event.get_inner_event(cache: damus.events) {
|
||||||
RepostedEvent(damus: damus, event: event, inner_ev: inner_ev, options: options)
|
RepostedEvent(damus: damus, event: event, inner_ev: inner_ev, options: options)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ struct MenuItems: View {
|
|||||||
self.profileModel = profileModel
|
self.profileModel = profileModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var event_relay_url_strings: [String] {
|
||||||
|
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
|
||||||
|
if !relays.isEmpty {
|
||||||
|
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
|
||||||
|
}
|
||||||
|
|
||||||
|
return profileModel.getCappedRelayStrings()
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
Button {
|
Button {
|
||||||
@@ -79,7 +88,7 @@ struct MenuItems: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
UIPasteboard.general.string = event.id.bech32
|
UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
|
||||||
} label: {
|
} label: {
|
||||||
Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book")
|
Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
//
|
||||||
|
// FollowPackPreview.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/30/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct FollowPackUsers: View {
|
||||||
|
let state: DamusState
|
||||||
|
var publicKeys: [Pubkey]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
|
||||||
|
if !publicKeys.isEmpty {
|
||||||
|
CondensedProfilePicturesView(state: state, pubkeys: publicKeys, maxPictures: 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
let followPackUserCount = publicKeys.count
|
||||||
|
let nounString = pluralizedString(key: "follow_pack_user_count", count: followPackUserCount)
|
||||||
|
let nounText = Text(verbatim: nounString).font(.subheadline).foregroundColor(.gray)
|
||||||
|
Text("\(Text(verbatim: followPackUserCount.formatted()).font(.subheadline.weight(.medium))) \(nounText)", comment: "Sentence composed of 2 variables to describe how many people are in the follow pack. In source English, the first variable is the number of users, and the second variable is 'user' or 'users'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FollowPackBannerImage: View {
|
||||||
|
let state: DamusState
|
||||||
|
let options: EventViewOptions
|
||||||
|
var image: URL? = nil
|
||||||
|
var preview: Bool
|
||||||
|
@State var blur_imgs: Bool
|
||||||
|
|
||||||
|
func Placeholder(url: URL, preview: Bool) -> some View {
|
||||||
|
Group {
|
||||||
|
if let meta = state.events.lookup_img_metadata(url: url),
|
||||||
|
case .processed(let blurhash) = meta.state {
|
||||||
|
Image(uiImage: blurhash)
|
||||||
|
.resizable()
|
||||||
|
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
|
||||||
|
} else {
|
||||||
|
DamusColors.adaptableWhite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleImage(url: URL, preview: Bool) -> some View {
|
||||||
|
KFAnimatedImage(url)
|
||||||
|
.callbackQueue(.dispatch(.global(qos:.background)))
|
||||||
|
.backgroundDecode(true)
|
||||||
|
.imageContext(.note, disable_animation: state.settings.disable_animation)
|
||||||
|
.image_fade(duration: 0.25)
|
||||||
|
.cancelOnDisappear(true)
|
||||||
|
.configure { view in
|
||||||
|
view.framePreloadCount = 3
|
||||||
|
}
|
||||||
|
.background {
|
||||||
|
Placeholder(url: url, preview: preview)
|
||||||
|
}
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
|
||||||
|
.kfClickable()
|
||||||
|
.cornerRadius(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let url = image {
|
||||||
|
if (self.options.contains(.no_media)) {
|
||||||
|
EmptyView()
|
||||||
|
} else if !blur_imgs {
|
||||||
|
titleImage(url: url, preview: preview)
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
titleImage(url: url, preview: preview)
|
||||||
|
BlurOverlayView(blur_images: $blur_imgs, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView)
|
||||||
|
.frame(maxWidth: preview ? 350 : UIScreen.main.bounds.width, minHeight: preview ? 180 : 200, maxHeight: preview ? 180 : 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(NSLocalizedString("No cover image", comment: "Text letting user know there is no cover image."))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.frame(width: 350, height: 180)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FollowPackPreviewBody: View {
|
||||||
|
let state: DamusState
|
||||||
|
let event: FollowPackEvent
|
||||||
|
let options: EventViewOptions
|
||||||
|
let header: Bool
|
||||||
|
@State var blur_imgs: Bool
|
||||||
|
|
||||||
|
@ObservedObject var artifacts: NoteArtifactsModel
|
||||||
|
|
||||||
|
init(state: DamusState, ev: FollowPackEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = ev
|
||||||
|
self.options = options
|
||||||
|
self.header = header
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
|
||||||
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, header: Bool, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = FollowPackEvent.parse(from: ev)
|
||||||
|
self.options = options
|
||||||
|
self.header = header
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
|
||||||
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if options.contains(.wide) {
|
||||||
|
Main.padding(.horizontal)
|
||||||
|
} else {
|
||||||
|
Main
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var Main: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|
||||||
|
if state.settings.media_previews {
|
||||||
|
FollowPackBannerImage(state: state, options: options, image: event.image, preview: true, blur_imgs: blur_imgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
|
||||||
|
.font(header ? .title : .headline)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.top, 5)
|
||||||
|
|
||||||
|
if let description = event.description {
|
||||||
|
Text(description)
|
||||||
|
.font(header ? .body : .caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
} else {
|
||||||
|
Text("")
|
||||||
|
.font(header ? .body : .caption)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
|
||||||
|
.onTapGesture {
|
||||||
|
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
|
||||||
|
}
|
||||||
|
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
|
||||||
|
let profile = profile_txn?.unsafeUnownedValue
|
||||||
|
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
|
||||||
|
switch displayName {
|
||||||
|
case .one(let one):
|
||||||
|
Text(one)
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
|
||||||
|
case .both(username: let username, displayName: let displayName):
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(verbatim: displayName)
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
|
||||||
|
Text(verbatim: "@\(username)")
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
|
||||||
|
FollowPackUsers(state: state, publicKeys: event.publicKeys)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
.frame(width: 350, height: state.settings.media_previews ? 330 : 150, alignment: .leading)
|
||||||
|
.background(DamusColors.neutral3)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.stroke(DamusColors.neutral1, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.padding(.top, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FollowPackPreview: View {
|
||||||
|
let state: DamusState
|
||||||
|
let event: FollowPackEvent
|
||||||
|
let options: EventViewOptions
|
||||||
|
@State var blur_imgs: Bool
|
||||||
|
|
||||||
|
init(state: DamusState, ev: NostrEvent, options: EventViewOptions, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = FollowPackEvent.parse(from: ev)
|
||||||
|
self.options = options.union(.no_mentions)
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
FollowPackPreviewBody(state: state, ev: event, options: options, header: false, blur_imgs: blur_imgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let test_follow_list_event = FollowPackEvent.parse(from: NostrEvent(
|
||||||
|
content: "",
|
||||||
|
keypair: test_keypair,
|
||||||
|
kind: NostrKind.longform.rawValue,
|
||||||
|
tags: [
|
||||||
|
["title", "DAMUSES"],
|
||||||
|
["description", "Damus Team"],
|
||||||
|
["published_at", "1685638715"],
|
||||||
|
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
|
||||||
|
["p", "8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"],
|
||||||
|
["p", "17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4"],
|
||||||
|
["p", "520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626"],
|
||||||
|
["p", "2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1"],
|
||||||
|
["p", "bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91"],
|
||||||
|
["p", "e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7"],
|
||||||
|
["p", "b88c7f007bbf3bc2fcaeff9e513f186bab33782c0baa6a6cc12add78b9110ba3"],
|
||||||
|
["p", "4a0510f26880d40e432f4865cb5714d9d3c200ca6ebb16b418ae6c555f574967"],
|
||||||
|
["image", "https://damus.io/img/logo.png"],
|
||||||
|
])!
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
struct FollowPackPreview_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
VStack {
|
||||||
|
FollowPackPreview(state: test_damus_state, ev: test_follow_list_event.event, options: [], blur_imgs: false)
|
||||||
|
}
|
||||||
|
.frame(height: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//
|
||||||
|
// FollowPackTimeline.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 5/6/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FollowPackTimelineView<Content: View>: View {
|
||||||
|
@ObservedObject var events: EventHolder
|
||||||
|
@Binding var loading: Bool
|
||||||
|
|
||||||
|
let damus: DamusState
|
||||||
|
let show_friend_icon: Bool
|
||||||
|
let filter: (NostrEvent) -> Bool
|
||||||
|
let content: Content?
|
||||||
|
let apply_mute_rules: Bool
|
||||||
|
|
||||||
|
init(events: EventHolder, loading: Binding<Bool>, headerHeight: Binding<CGFloat>, headerOffset: Binding<CGFloat>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||||
|
self.events = events
|
||||||
|
self._loading = loading
|
||||||
|
self.damus = damus
|
||||||
|
self.show_friend_icon = show_friend_icon
|
||||||
|
self.filter = filter
|
||||||
|
self.apply_mute_rules = apply_mute_rules
|
||||||
|
self.content = content?()
|
||||||
|
}
|
||||||
|
|
||||||
|
init(events: EventHolder, loading: Binding<Bool>, damus: DamusState, show_friend_icon: Bool, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true, content: (() -> Content)? = nil) {
|
||||||
|
self.events = events
|
||||||
|
self._loading = loading
|
||||||
|
self.damus = damus
|
||||||
|
self.show_friend_icon = show_friend_icon
|
||||||
|
self.filter = filter
|
||||||
|
self.apply_mute_rules = apply_mute_rules
|
||||||
|
self.content = content?()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
MainContent
|
||||||
|
}
|
||||||
|
|
||||||
|
var MainContent: some View {
|
||||||
|
ScrollViewReader { scroller in
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
if let content {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.id("startblock")
|
||||||
|
.frame(height: 0)
|
||||||
|
|
||||||
|
FollowPackInnerView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
|
||||||
|
.redacted(reason: loading ? .placeholder : [])
|
||||||
|
.shimmer(loading)
|
||||||
|
.disabled(loading)
|
||||||
|
.background {
|
||||||
|
GeometryReader { proxy -> Color in
|
||||||
|
handle_scroll_queue(proxy, queue: self.events)
|
||||||
|
return Color.clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.coordinateSpace(name: "scroll")
|
||||||
|
.onReceive(handle_notify(.scroll_to_top)) { () in
|
||||||
|
events.flush()
|
||||||
|
self.events.should_queue = false
|
||||||
|
scroll_to_event(scroller: scroller, id: "startblock", delay: 0.0, animate: true, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
events.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FollowPackInnerView: View {
|
||||||
|
@ObservedObject var events: EventHolder
|
||||||
|
let state: DamusState
|
||||||
|
let filter: (NostrEvent) -> Bool
|
||||||
|
|
||||||
|
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
|
||||||
|
self.events = events
|
||||||
|
self.state = damus
|
||||||
|
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
|
||||||
|
}
|
||||||
|
|
||||||
|
var event_options: EventViewOptions {
|
||||||
|
if self.state.settings.truncate_timeline_text {
|
||||||
|
return [.wide, .truncate_content]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.wide]
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyHStack(spacing: 0) {
|
||||||
|
let events = self.events.events
|
||||||
|
if events.isEmpty {
|
||||||
|
EmptyTimelineView()
|
||||||
|
} else {
|
||||||
|
let evs = events.filter(filter)
|
||||||
|
let indexed = Array(zip(evs, 0...))
|
||||||
|
ForEach(indexed, id: \.0.id) { tup in
|
||||||
|
let ev = tup.0
|
||||||
|
let ind = tup.1
|
||||||
|
let blur_imgs = should_blur_images(settings: state.settings, contacts: state.contacts, ev: ev, our_pubkey: state.pubkey)
|
||||||
|
if ev.kind == NostrKind.follow_list.rawValue {
|
||||||
|
FollowPackPreview(state: state, ev: ev, options: event_options, blur_imgs: blur_imgs)
|
||||||
|
.onTapGesture {
|
||||||
|
state.nav.push(route: Route.FollowPack(followPack: ev, model: FollowPackModel(damus_state: state), blur_imgs: blur_imgs))
|
||||||
|
}
|
||||||
|
.padding(.top, 7)
|
||||||
|
.onAppear {
|
||||||
|
let to_preload =
|
||||||
|
Array([indexed[safe: ind+1]?.0,
|
||||||
|
indexed[safe: ind+2]?.0,
|
||||||
|
indexed[safe: ind+3]?.0,
|
||||||
|
indexed[safe: ind+4]?.0,
|
||||||
|
indexed[safe: ind+5]?.0
|
||||||
|
].compactMap({ $0 }))
|
||||||
|
|
||||||
|
preload_events(state: state, events: to_preload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//
|
||||||
|
// FollowPackView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by eric on 4/30/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct FollowPackView: View {
|
||||||
|
let state: DamusState
|
||||||
|
let event: FollowPackEvent
|
||||||
|
@StateObject var model: FollowPackModel
|
||||||
|
@State var blur_imgs: Bool
|
||||||
|
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
@ObservedObject var artifacts: NoteArtifactsModel
|
||||||
|
|
||||||
|
init(state: DamusState, ev: FollowPackEvent, model: FollowPackModel, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = ev
|
||||||
|
self._model = StateObject(wrappedValue: model)
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
|
||||||
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(state: DamusState, ev: NostrEvent, model: FollowPackModel, blur_imgs: Bool) {
|
||||||
|
self.state = state
|
||||||
|
self.event = FollowPackEvent.parse(from: ev)
|
||||||
|
self._model = StateObject(wrappedValue: model)
|
||||||
|
self.blur_imgs = blur_imgs
|
||||||
|
|
||||||
|
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
|
||||||
|
}
|
||||||
|
|
||||||
|
func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) {
|
||||||
|
var filters = ContentFilters.defaults(damus_state: self.state)
|
||||||
|
filters.append({ pubkeys.contains($0.pubkey) })
|
||||||
|
return ContentFilters(filters: filters).filter
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FollowPackTabSelection: Int {
|
||||||
|
case people = 0
|
||||||
|
case posts = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@State var tab_selection: FollowPackTabSelection = .people
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
ScrollView {
|
||||||
|
FollowPackHeader
|
||||||
|
|
||||||
|
FollowPackTabs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if model.events.events.isEmpty {
|
||||||
|
model.subscribe(follow_pack_users: event.publicKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
model.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabs: [(String, FollowPackTabSelection)] {
|
||||||
|
let tabs = [
|
||||||
|
(NSLocalizedString("People", comment: "Label for filter for seeing the people in this follow pack."), FollowPackTabSelection.people),
|
||||||
|
(NSLocalizedString("Posts", comment: "Label for filter for seeing the posts from the people in this follow pack."), FollowPackTabSelection.posts)
|
||||||
|
]
|
||||||
|
return tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
var FollowPackTabs: some View {
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
CustomPicker(tabs: tabs, selection: $tab_selection)
|
||||||
|
Divider()
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||||
|
|
||||||
|
if tab_selection == FollowPackTabSelection.people {
|
||||||
|
LazyVStack(alignment: .leading) {
|
||||||
|
ForEach(event.publicKeys.reversed(), id: \.self) { pk in
|
||||||
|
FollowUserView(target: .pubkey(pk), damus_state: state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.padding(.bottom, 50)
|
||||||
|
.tag(FollowPackTabSelection.people)
|
||||||
|
.id(FollowPackTabSelection.people)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tab_selection == FollowPackTabSelection.posts {
|
||||||
|
InnerTimelineView(events: model.events, pinned_events: EventHolder(), damus: state, filter: content_filter(event.publicKeys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear() {
|
||||||
|
model.subscribe(follow_pack_users: event.publicKeys)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
model.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var FollowPackHeader: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|
||||||
|
if state.settings.media_previews {
|
||||||
|
FollowPackBannerImage(state: state, options: EventViewOptions(), image: event.image, preview: false, blur_imgs: blur_imgs)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
|
||||||
|
.font(.title)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.top, 5)
|
||||||
|
|
||||||
|
if let description = event.description {
|
||||||
|
Text(description)
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
|
||||||
|
.onTapGesture {
|
||||||
|
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
|
||||||
|
}
|
||||||
|
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
|
||||||
|
let profile = profile_txn?.unsafeUnownedValue
|
||||||
|
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
|
||||||
|
switch displayName {
|
||||||
|
case .one(let one):
|
||||||
|
Text("Created by \(one)", comment: "Lets the user know who created this follow pack.")
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
|
||||||
|
case .both(username: let username, displayName: let displayName):
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Created by \(displayName)", comment: "Lets the user know who created this follow pack.")
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
|
||||||
|
Text(verbatim: "@\(username)")
|
||||||
|
.font(.subheadline).foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
FollowPackUsers(state: state, publicKeys: event.publicKeys)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct FollowPackView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
VStack {
|
||||||
|
FollowPackView(state: test_damus_state, ev: test_follow_list_event, model: FollowPackModel(damus_state: test_damus_state), blur_imgs: false)
|
||||||
|
}
|
||||||
|
.frame(height: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// PinnedEventView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 7/21/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PinnedEventView: View {
|
||||||
|
let damus: DamusState
|
||||||
|
let event: NostrEvent
|
||||||
|
let options: EventViewOptions
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
PinnedHeaderView(damus: damus, pubkey: event.pubkey)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
|
||||||
|
TextEvent(damus: damus, event: event, pubkey: event.pubkey, options: options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
PinnedEventView(damus: test_damus_state, event: test_note, options: [])
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// PinnedHeaderView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 7/21/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PinnedHeaderView: View {
|
||||||
|
let damus: DamusState
|
||||||
|
let pubkey: Pubkey
|
||||||
|
|
||||||
|
init(damus: DamusState, pubkey: Pubkey) {
|
||||||
|
self.damus = damus
|
||||||
|
self.pubkey = pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
Image("pin")
|
||||||
|
.foregroundColor(Color.gray)
|
||||||
|
|
||||||
|
Text("Pinned", comment: "FIXME")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
PinnedHeaderView(damus: test_damus_state, pubkey: test_pubkey)
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject {
|
|||||||
case .zap, .zap_request:
|
case .zap, .zap_request:
|
||||||
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
|
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
|
||||||
return .loaded(route: Route.Zaps(target: zap.target))
|
return .loaded(route: Route.Zaps(target: zap.target))
|
||||||
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list:
|
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list, .follow_list, .interest_list, .pinned_notes:
|
||||||
return .unknown_or_unsupported_kind
|
return .unknown_or_unsupported_kind
|
||||||
}
|
}
|
||||||
case .naddr(let naddr):
|
case .naddr(let naddr):
|
||||||
|
|||||||
@@ -47,20 +47,6 @@ struct MutelistView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
Section(NSLocalizedString("Users", comment: "Section header title for a list of muted users.")) {
|
|
||||||
ForEach(users, id: \.self) { user in
|
|
||||||
if case let MuteItem.user(pubkey, _) = user {
|
|
||||||
UserViewRow(damus_state: damus_state, pubkey: pubkey)
|
|
||||||
.id(pubkey)
|
|
||||||
.swipeActions {
|
|
||||||
RemoveAction(item: .user(pubkey, nil))
|
|
||||||
}
|
|
||||||
.onTapGesture {
|
|
||||||
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Section(NSLocalizedString("Hashtags", comment: "Section header title for a list of hashtags that are muted.")) {
|
Section(NSLocalizedString("Hashtags", comment: "Section header title for a list of hashtags that are muted.")) {
|
||||||
ForEach(hashtags, id: \.self) { item in
|
ForEach(hashtags, id: \.self) { item in
|
||||||
if case let MuteItem.hashtag(hashtag, _) = item {
|
if case let MuteItem.hashtag(hashtag, _) = item {
|
||||||
@@ -86,10 +72,7 @@ struct MutelistView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section(
|
Section(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")) {
|
||||||
header: Text(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")),
|
|
||||||
footer: Text("").padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
|
||||||
) {
|
|
||||||
ForEach(threads, id: \.self) { item in
|
ForEach(threads, id: \.self) { item in
|
||||||
if case let MuteItem.thread(note_id, _) = item {
|
if case let MuteItem.thread(note_id, _) = item {
|
||||||
if let event = damus_state.events.lookup(note_id) {
|
if let event = damus_state.events.lookup(note_id) {
|
||||||
@@ -104,6 +87,23 @@ struct MutelistView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Section(
|
||||||
|
header: Text(NSLocalizedString("Users", comment: "Section header title for a list of muted users.")),
|
||||||
|
footer: Text("").padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
||||||
|
) {
|
||||||
|
ForEach(users, id: \.self) { user in
|
||||||
|
if case let MuteItem.user(pubkey, _) = user {
|
||||||
|
UserViewRow(damus_state: damus_state, pubkey: pubkey)
|
||||||
|
.id(pubkey)
|
||||||
|
.swipeActions {
|
||||||
|
RemoveAction(item: .user(pubkey, nil))
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(NSLocalizedString("Muted", comment: "Navigation title of view to see list of muted users & phrases."))
|
.navigationTitle(NSLocalizedString("Muted", comment: "Navigation title of view to see list of muted users & phrases."))
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
//
|
||||||
|
// NIP05DomainPubkeysView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 5/23/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
|
import Kingfisher
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NIP05DomainPubkeysView: View {
|
||||||
|
let damus_state: DamusState
|
||||||
|
let domain: String
|
||||||
|
let nip05_domain_favicon: FaviconURL?
|
||||||
|
let pubkeys: [Pubkey]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(alignment: .leading) {
|
||||||
|
ForEach(pubkeys, id: \.self) { pk in
|
||||||
|
FollowUserView(target: .pubkey(pk), damus_state: damus_state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
HStack {
|
||||||
|
if let nip05_domain_favicon {
|
||||||
|
KFImage(nip05_domain_favicon.source)
|
||||||
|
.imageContext(.favicon, disable_animation: true)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
Text(domain)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let nip05_domain_favicon = FaviconURL(source: URL(string: "https://damus.io/favicon.ico")!, format: .ico, sourceType: .ico)
|
||||||
|
let pubkeys = [test_pubkey, test_pubkey_2]
|
||||||
|
NIP05DomainPubkeysView(damus_state: test_damus_state, domain: "damus.io", nip05_domain_favicon: nip05_domain_favicon, pubkeys: pubkeys)
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//
|
||||||
|
// NIP05DomainTimelineHeaderView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 5/16/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
|
import Kingfisher
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NIP05DomainTimelineHeaderView: View {
|
||||||
|
let damus_state: DamusState
|
||||||
|
@ObservedObject var model: NIP05DomainEventsModel
|
||||||
|
let nip05_domain_favicon: FaviconURL?
|
||||||
|
|
||||||
|
@Environment(\.openURL) var openURL
|
||||||
|
|
||||||
|
var Icon: some View {
|
||||||
|
ZStack {
|
||||||
|
if let nip05_domain_favicon {
|
||||||
|
KFImage(nip05_domain_favicon.source)
|
||||||
|
.imageContext(.favicon, disable_animation: true)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 18, height: 18)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var friendsOfFriends: [Pubkey] {
|
||||||
|
// Order it such that the pubkeys that have events come first in the array so that their profile pictures
|
||||||
|
// show first.
|
||||||
|
let pubkeys = model.events.all_events.map { $0.pubkey } + (model.filter.authors ?? [])
|
||||||
|
|
||||||
|
// Filter out duplicates but retain order, and filter out any that do not have a validated NIP-05.
|
||||||
|
return (NSMutableOrderedSet(array: pubkeys).array as? [Pubkey] ?? [])
|
||||||
|
.filter {
|
||||||
|
damus_state.contacts.is_in_friendosphere($0) && damus_state.profiles.is_validated($0) != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
if nip05_domain_favicon != nil {
|
||||||
|
Icon
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(model.domain)
|
||||||
|
.foregroundStyle(DamusLogoGradient.gradient)
|
||||||
|
.font(.title.bold())
|
||||||
|
.onTapGesture {
|
||||||
|
if let url = URL(string: "https://\(model.domain)") {
|
||||||
|
openURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let friendsOfFriends = friendsOfFriends
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
CondensedProfilePicturesView(state: damus_state, pubkeys: friendsOfFriends, maxPictures: 3)
|
||||||
|
let friendsOfFriendsString = friendsOfFriendsString(friendsOfFriends, ndb: damus_state.ndb)
|
||||||
|
Text(friendsOfFriendsString)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
if !friendsOfFriends.isEmpty {
|
||||||
|
damus_state.nav.push(route: Route.NIP05DomainPubkeys(domain: model.domain, nip05_domain_favicon: nip05_domain_favicon, pubkeys: friendsOfFriends))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func friendsOfFriendsString(_ friendsOfFriends: [Pubkey], ndb: Ndb, locale: Locale = Locale.current) -> String {
|
||||||
|
let bundle = bundleForLocale(locale: locale)
|
||||||
|
let names: [String] = friendsOfFriends.prefix(3).map { pk in
|
||||||
|
let profile = ndb.lookup_profile(pk)?.unsafeUnownedValue?.profile
|
||||||
|
return Profile.displayName(profile: profile, pubkey: pk).username.truncate(maxLength: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch friendsOfFriends.count {
|
||||||
|
case 0:
|
||||||
|
return "No one in your trusted network is associated with this domain."
|
||||||
|
case 1:
|
||||||
|
let format = NSLocalizedString("Notes from %@", bundle: bundle, comment: "Text to indicate that notes from one pubkey in our trusted network are shown below.")
|
||||||
|
return String(format: format, locale: locale, names[0])
|
||||||
|
case 2:
|
||||||
|
let format = NSLocalizedString("Notes from %@ & %@", bundle: bundle, comment: "Text to indicate that notes from two pubkeys in our trusted network are shown below.")
|
||||||
|
return String(format: format, locale: locale, names[0], names[1])
|
||||||
|
case 3:
|
||||||
|
let format = NSLocalizedString("Notes from %@, %@ & %@", bundle: bundle, comment: "Text to indicate that notes from three pubkeys in our trusted network are shown below.")
|
||||||
|
return String(format: format, locale: locale, names[0], names[1], names[2])
|
||||||
|
default:
|
||||||
|
let format = localizedStringFormat(key: "notes_from_three_and_others", locale: locale)
|
||||||
|
return String(format: format, locale: locale, friendsOfFriends.count - 3, names[0], names[1], names[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let model = NIP05DomainEventsModel(state: test_damus_state, domain: "damus.io")
|
||||||
|
let nip05_domain_favicon = FaviconURL(source: URL(string: "https://damus.io/favicon.ico")!, format: .ico, sourceType: .ico)
|
||||||
|
NIP05DomainTimelineHeaderView(damus_state: test_damus_state, model: model, nip05_domain_favicon: nip05_domain_favicon)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//
|
||||||
|
// NIP05DomainTimelineView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 4/11/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
|
import Kingfisher
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct NIP05DomainTimelineView: View {
|
||||||
|
let damus_state: DamusState
|
||||||
|
@ObservedObject var model: NIP05DomainEventsModel
|
||||||
|
let nip05_domain_favicon: FaviconURL?
|
||||||
|
|
||||||
|
func nip05_filter(ev: NostrEvent) -> Bool {
|
||||||
|
damus_state.contacts.is_in_friendosphere(ev.pubkey) && damus_state.profiles.is_validated(ev.pubkey) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var contentFilters: ContentFilters {
|
||||||
|
var filters = Array<(NostrEvent) -> Bool>()
|
||||||
|
filters.append(contentsOf: ContentFilters.defaults(damus_state: damus_state))
|
||||||
|
filters.append(nip05_filter)
|
||||||
|
return ContentFilters(filters: filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let height: CGFloat = 250.0
|
||||||
|
|
||||||
|
TimelineView(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: contentFilters.filter(ev:)) {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
DamusBackground(maxHeight: height)
|
||||||
|
.mask(LinearGradient(gradient: Gradient(colors: [.black, .black, .black, .clear]), startPoint: .top, endPoint: .bottom))
|
||||||
|
NIP05DomainTimelineHeaderView(damus_state: damus_state, model: model, nip05_domain_favicon: nip05_domain_favicon)
|
||||||
|
.padding(.leading, 30)
|
||||||
|
.padding(.top, 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.padding(.bottom, tabHeight)
|
||||||
|
.onAppear {
|
||||||
|
guard model.events.all_events.isEmpty else { return }
|
||||||
|
|
||||||
|
model.subscribe()
|
||||||
|
|
||||||
|
if let pubkeys = model.filter.authors {
|
||||||
|
for pubkey in pubkeys {
|
||||||
|
check_nip05_validity(pubkey: pubkey, profiles: damus_state.profiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
model.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let damus_state = test_damus_state
|
||||||
|
let model = NIP05DomainEventsModel(state: damus_state, domain: "damus.io")
|
||||||
|
let nip05_domain_favicon = FaviconURL(source: URL(string: "https://damus.io/favicon.ico")!, format: .ico, sourceType: .ico)
|
||||||
|
NIP05DomainTimelineView(damus_state: test_damus_state, model: model, nip05_domain_favicon: nip05_domain_favicon)
|
||||||
|
}
|
||||||
@@ -73,15 +73,40 @@ struct NoteContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var preview: LinkViewRepresentable? {
|
var preview: LinkViewRepresentable? {
|
||||||
guard !blur_images,
|
guard case .loaded(let preview) = preview_model.state,
|
||||||
case .loaded(let preview) = preview_model.state,
|
|
||||||
case .value(let cached) = preview else {
|
case .value(let cached) = preview else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If either
|
||||||
|
// (1) the blur images setting is enabled
|
||||||
|
// (2) the media previews setting is disabled
|
||||||
|
// (3) this note content view does not display media
|
||||||
|
// then do not show media in the link preview.
|
||||||
|
if blur_images || !damus_state.settings.media_previews || self.options.contains(.no_media) {
|
||||||
|
return linkPreviewWithNoMedia(cached)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If media is already being shown, do not show media in the link preview
|
||||||
|
// to avoid taking up additional screen space.
|
||||||
|
if case let .separated(separated) = note_artifacts, !separated.media.isEmpty && !self.options.contains(.no_media) {
|
||||||
|
return linkPreviewWithNoMedia(cached)
|
||||||
|
}
|
||||||
|
|
||||||
return LinkViewRepresentable(meta: .linkmeta(cached))
|
return LinkViewRepresentable(meta: .linkmeta(cached))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Creates a LinkViewRepresentable without media previews.
|
||||||
|
func linkPreviewWithNoMedia(_ cached: CachedMetadata) -> LinkViewRepresentable? {
|
||||||
|
let linkMetadata = LPLinkMetadata()
|
||||||
|
|
||||||
|
linkMetadata.originalURL = cached.meta.originalURL
|
||||||
|
linkMetadata.title = cached.meta.title
|
||||||
|
linkMetadata.url = cached.meta.url
|
||||||
|
|
||||||
|
return LinkViewRepresentable(meta: .linkmeta(CachedMetadata(meta: linkMetadata)))
|
||||||
|
}
|
||||||
|
|
||||||
func truncatedText(content: CompatibleText) -> some View {
|
func truncatedText(content: CompatibleText) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if truncate_very_short {
|
if truncate_very_short {
|
||||||
@@ -108,7 +133,7 @@ struct NoteContentView: View {
|
|||||||
|
|
||||||
func previewView(links: [URL]) -> some View {
|
func previewView(links: [URL]) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if let preview = self.preview, !blur_images {
|
if let preview = self.preview {
|
||||||
if let preview_height {
|
if let preview_height {
|
||||||
preview
|
preview
|
||||||
.frame(height: preview_height)
|
.frame(height: preview_height)
|
||||||
@@ -181,7 +206,7 @@ struct NoteContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if damus_state.settings.media_previews, has_previews {
|
if has_previews {
|
||||||
if with_padding {
|
if with_padding {
|
||||||
previewView(links: artifacts.links).padding(.horizontal)
|
previewView(links: artifacts.links).padding(.horizontal)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import TipKit
|
||||||
|
|
||||||
class NotificationFilter: ObservableObject, Equatable {
|
class NotificationFilter: ObservableObject, Equatable {
|
||||||
@Published var state: NotificationFilterState
|
@Published var state: NotificationFilterState
|
||||||
@@ -79,6 +80,7 @@ struct NotificationsView: View {
|
|||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let showTrustedButton = would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications)
|
||||||
TabView(selection: $filter_state) {
|
TabView(selection: $filter_state) {
|
||||||
NotificationTab(
|
NotificationTab(
|
||||||
NotificationFilter(
|
NotificationFilter(
|
||||||
@@ -115,14 +117,19 @@ struct NotificationsView: View {
|
|||||||
Button(
|
Button(
|
||||||
action: { state.nav.push(route: Route.NotificationSettings(settings: state.settings)) },
|
action: { state.nav.push(route: Route.NotificationSettings(settings: state.settings)) },
|
||||||
label: {
|
label: {
|
||||||
Image("settings")
|
Image(systemName: "gearshape")
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
|
if showTrustedButton {
|
||||||
FriendsButton(filter: $filter.friend_filter)
|
TrustedNetworkButton(filter: $filter.friend_filter) {
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
TrustedNetworkButtonTip.shared.invalidate(reason: .actionPerformed)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,6 +147,13 @@ struct NotificationsView: View {
|
|||||||
}
|
}
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
if #available(iOS 17, *), showTrustedButton {
|
||||||
|
TipView(TrustedNetworkButtonTip.shared)
|
||||||
|
.tipBackground(.clear)
|
||||||
|
.tipViewStyle(TrustedNetworkButtonTipViewStyle())
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
CustomPicker(tabs: [
|
CustomPicker(tabs: [
|
||||||
(NSLocalizedString("All", comment: "Label for filter for all notifications."), NotificationFilterState.all),
|
(NSLocalizedString("All", comment: "Label for filter for all notifications."), NotificationFilterState.all),
|
||||||
(NSLocalizedString("Zaps", comment: "Label for filter for zap notifications."), NotificationFilterState.zaps),
|
(NSLocalizedString("Zaps", comment: "Label for filter for zap notifications."), NotificationFilterState.zaps),
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//
|
||||||
|
// InterestSelectionView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-05-16.
|
||||||
|
//
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension OnboardingSuggestionsView {
|
||||||
|
typealias Interest = DIP06.Interest
|
||||||
|
|
||||||
|
struct InterestSelectionView: View {
|
||||||
|
var damus_state: DamusState
|
||||||
|
var next_page: (() -> Void)
|
||||||
|
|
||||||
|
/// Track selected interests using a Set
|
||||||
|
@Binding var selectedInterests: Set<Interest>
|
||||||
|
var isNextEnabled: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Title
|
||||||
|
Text(NSLocalizedString("Select Your Interests", comment: "Screen title for interest selection"))
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top)
|
||||||
|
|
||||||
|
// Instruction subtitle
|
||||||
|
Text(NSLocalizedString("Please pick your interests. This will help us recommend accounts to follow.", comment: "Instruction for interest selection"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
// Interests grid view
|
||||||
|
InterestsGridView(availableInterests: Interest.allCases,
|
||||||
|
selectedInterests: $selectedInterests)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
// Next button wrapped inside a NavigationLink for easy transition.
|
||||||
|
Button(action: {
|
||||||
|
self.next_page()
|
||||||
|
}, label: {
|
||||||
|
Text(NSLocalizedString("Next", comment: "Next button title"))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
})
|
||||||
|
.buttonStyle(GradientButtonStyle())
|
||||||
|
.disabled(!isNextEnabled)
|
||||||
|
.opacity(isNextEnabled ? 1.0 : 0.5)
|
||||||
|
.padding([.leading, .trailing, .bottom])
|
||||||
|
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_interest_page_next_page.rawValue)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A grid view to display interest options
|
||||||
|
struct InterestsGridView: View {
|
||||||
|
let availableInterests: [Interest]
|
||||||
|
@Binding var selectedInterests: Set<Interest>
|
||||||
|
|
||||||
|
// Adaptive grid layout with two columns
|
||||||
|
private let columns = [
|
||||||
|
GridItem(.adaptive(minimum: 120, maximum: 480)),
|
||||||
|
GridItem(.adaptive(minimum: 120, maximum: 480)),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
LazyVGrid(columns: columns, spacing: 16) {
|
||||||
|
ForEach(availableInterests, id: \ .self) { interest in
|
||||||
|
let disabled = false
|
||||||
|
InterestButton(interest: interest,
|
||||||
|
isSelected: selectedInterests.contains(interest)) {
|
||||||
|
// Toggle selection
|
||||||
|
if selectedInterests.contains(interest) {
|
||||||
|
selectedInterests.remove(interest)
|
||||||
|
} else {
|
||||||
|
selectedInterests.insert(interest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_interest_option_button.rawValue)
|
||||||
|
.disabled(disabled)
|
||||||
|
.opacity(disabled ? 0.5 : 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A button view representing a single interest option
|
||||||
|
struct InterestButton: View {
|
||||||
|
let interest: Interest
|
||||||
|
let isSelected: Bool
|
||||||
|
var action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(interest.label)
|
||||||
|
.font(.body)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(isSelected ? Color.accentColor : Color.gray.opacity(0.2))
|
||||||
|
.foregroundColor(isSelected ? Color.white : Color.primary)
|
||||||
|
.cornerRadius(50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InterestSelectionView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
OnboardingSuggestionsView.InterestSelectionView(
|
||||||
|
damus_state: test_damus_state,
|
||||||
|
next_page: { print("next") },
|
||||||
|
selectedInterests: Binding.constant(Set([DIP06.Interest.art, DIP06.Interest.music])), isNextEnabled: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//
|
||||||
|
// OnboardingContentSettings.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-05-19.
|
||||||
|
//
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension OnboardingSuggestionsView {
|
||||||
|
struct OnboardingContentSettings: View {
|
||||||
|
var model: SuggestedUsersViewModel
|
||||||
|
var next_page: (() -> Void)
|
||||||
|
@ObservedObject var settings: UserSettingsStore
|
||||||
|
|
||||||
|
@Binding var selectedInterests: Set<Interest>
|
||||||
|
|
||||||
|
private var isNextEnabled: Bool { true }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
// Title
|
||||||
|
Text(NSLocalizedString("Other preferences", comment: "Screen title for content preferences screen during onboarding"))
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.top)
|
||||||
|
|
||||||
|
// Instruction subtitle
|
||||||
|
Text(NSLocalizedString("Tweak these settings to better match your preferences", comment: "Instructions for content preferences screen during onboarding"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
// Content preferences section with toggles
|
||||||
|
Section() {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with not safe for work tags"), isOn: $settings.hide_nsfw_tagged_content)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
Text(NSLocalizedString("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Explanation of what NSFW means"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.bottom, 10)
|
||||||
|
|
||||||
|
if !selectedInterests.contains(.bitcoin) {
|
||||||
|
Toggle(
|
||||||
|
NSLocalizedString("Show Bitcoin-heavy profile suggestions", comment: "Setting label during onboarding"),
|
||||||
|
isOn: Binding(get: { !model.reduceBitcoinContent }, set: { model.reduceBitcoinContent = !$0 })
|
||||||
|
)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
Text(NSLocalizedString("Some profiles tend to have a lot of Bitcoin-related content alongside their topics of interest. Disable this setting if you prefer to filter out follow suggestions that frequently talk about Bitcoin.", comment: "Explanation label for the 'Show Bitcoin-heavy profile suggestions' onboarding toggle setting"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.secondary.opacity(0.1))
|
||||||
|
.cornerRadius(10)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
self.next_page()
|
||||||
|
}, label: {
|
||||||
|
Text(NSLocalizedString("Next", comment: "Next button title"))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
})
|
||||||
|
.buttonStyle(GradientButtonStyle())
|
||||||
|
.disabled(!isNextEnabled)
|
||||||
|
.opacity(isNextEnabled ? 1.0 : 0.5)
|
||||||
|
.padding([.leading, .trailing, .bottom])
|
||||||
|
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_content_settings_page_next_page.rawValue)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,9 +27,55 @@ struct OnboardingSuggestionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var canLeaveInterestSelectionPage: Bool {
|
||||||
|
let count = model.interests.count
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save the user's selected interests to NDB
|
||||||
|
private func saveInterestsToNdb() {
|
||||||
|
// Convert the selected interests to hashtags for the NIP51 interest list
|
||||||
|
let interestItems = model.interests.map { interest in
|
||||||
|
NIP51.InterestList.InterestItem.hashtag(interest.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the interest list
|
||||||
|
let interestList = NIP51.InterestList(interests: Array(interestItems))
|
||||||
|
|
||||||
|
// Convert to a NostrEvent and send to NDB
|
||||||
|
guard let keypair = model.damus_state.keypair.to_full(),
|
||||||
|
let event = interestList.toNostrEvent(keypair: keypair, timestamp: nil) else {
|
||||||
|
return // Not a big deal, fail silently
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the event to NostrDB to allow us to retrieve later
|
||||||
|
// Did not send this to the network yet because:
|
||||||
|
// 1. I believe we should add an opt-out/opt-in button.
|
||||||
|
// 2. If we do, and the user accepts to share it, it will be an awkward situation considering:
|
||||||
|
// - We don't show that anywhere else yet
|
||||||
|
// - We don't have other mechanisms to allow the user to edit this yet
|
||||||
|
//
|
||||||
|
// Therefore, it is better to just save it locally, and retrieve this once we build out https://github.com/damus-io/damus/issues/3042
|
||||||
|
model.damus_state.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(event)))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
TabView(selection: $current_page) {
|
TabView(selection: $current_page) {
|
||||||
|
InterestSelectionView(damus_state: model.damus_state, next_page: {
|
||||||
|
self.next_page()
|
||||||
|
}, selectedInterests: $model.interests, isNextEnabled: canLeaveInterestSelectionPage)
|
||||||
|
.navigationTitle(NSLocalizedString("Select your interests", comment: "Title for a screen asking the user for interests"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.tag(0)
|
||||||
|
|
||||||
|
if canLeaveInterestSelectionPage {
|
||||||
|
|
||||||
|
OnboardingContentSettings(model: model, next_page: self.next_page, settings: model.damus_state.settings, selectedInterests: $model.interests)
|
||||||
|
.navigationTitle(NSLocalizedString("Content settings", comment: "Title for an onboarding screen showing user some content settings"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.tag(1)
|
||||||
|
|
||||||
SuggestedUsersPageView(model: model, next_page: self.next_page)
|
SuggestedUsersPageView(model: model, next_page: self.next_page)
|
||||||
.navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
|
.navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -41,7 +87,7 @@ struct OnboardingSuggestionsView: View {
|
|||||||
})
|
})
|
||||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
|
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
|
||||||
)
|
)
|
||||||
.tag(0)
|
.tag(2)
|
||||||
|
|
||||||
PostView(
|
PostView(
|
||||||
action: .posting(.user(model.damus_state.pubkey)),
|
action: .posting(.user(model.damus_state.pubkey)),
|
||||||
@@ -66,9 +112,17 @@ struct OnboardingSuggestionsView: View {
|
|||||||
// See https://github.com/damus-io/damus/issues/1726 for more context and information
|
// See https://github.com/damus-io/damus/issues/1726 for more context and information
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.tag(1)
|
.tag(3)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||||
|
.onChange(of: current_page) { newPage in
|
||||||
|
// If the user just swiped from the interests page (0) to the next page (1),
|
||||||
|
// save their interests to NDB
|
||||||
|
if newPage == 1 && current_page == 1 {
|
||||||
|
saveInterestsToNdb()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,20 +133,27 @@ fileprivate struct SuggestedUsersPageView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
VStack {
|
||||||
|
if let suggestions = model.suggestions {
|
||||||
List {
|
List {
|
||||||
ForEach(model.groups) { group in
|
ForEach(suggestions, id: \.self) { followPack in
|
||||||
Section {
|
Section {
|
||||||
ForEach(group.users, id: \.self) { pk in
|
ForEach(followPack.publicKeys, id: \.self) { pk in
|
||||||
if let user = model.suggestedUser(pubkey: pk) {
|
if let usersInterests = model.interestUserMap[pk],
|
||||||
|
!usersInterests.intersection(model.interests).isEmpty && usersInterests.intersection(model.disinterests).isEmpty,
|
||||||
|
let user = model.suggestedUser(pubkey: pk) {
|
||||||
SuggestedUserView(user: user, damus_state: model.damus_state)
|
SuggestedUserView(user: user, damus_state: model.damus_state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
SuggestedUsersSectionHeader(group: group, model: model)
|
SuggestedUsersSectionHeader(followPack: followPack, model: model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
@@ -110,17 +171,14 @@ fileprivate struct SuggestedUsersPageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct SuggestedUsersSectionHeader: View {
|
struct SuggestedUsersSectionHeader: View {
|
||||||
let group: SuggestedUserGroup
|
let followPack: FollowPackEvent
|
||||||
let model: SuggestedUsersViewModel
|
let model: SuggestedUsersViewModel
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
let locale = Locale.current
|
Text(followPack.title ?? NSLocalizedString("Untitled Follow Pack", comment: "Default title for a follow pack if no title is specified"))
|
||||||
let format = localizedStringFormat(key: group.category, locale: locale)
|
|
||||||
let categoryName = String(format: format, locale: locale)
|
|
||||||
Text(categoryName)
|
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) {
|
Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) {
|
||||||
model.follow(pubkeys: group.users)
|
model.follow(pubkeys: followPack.publicKeys)
|
||||||
}
|
}
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
}
|
}
|
||||||
@@ -129,6 +187,6 @@ struct SuggestedUsersSectionHeader: View {
|
|||||||
|
|
||||||
struct SuggestedUsersView_Previews: PreviewProvider {
|
struct SuggestedUsersView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: test_damus_state))
|
OnboardingSuggestionsView(model: try! SuggestedUsersViewModel(damus_state: test_damus_state))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ struct SuggestedUserView: View {
|
|||||||
let target = FollowTarget.pubkey(user.pubkey)
|
let target = FollowTarget.pubkey(user.pubkey)
|
||||||
InnerProfilePicView(url: user.pfp,
|
InnerProfilePicView(url: user.pfp,
|
||||||
fallbackUrl: nil,
|
fallbackUrl: nil,
|
||||||
pubkey: target.pubkey,
|
|
||||||
size: 50,
|
size: 50,
|
||||||
highlight: .none,
|
highlight: .none,
|
||||||
disable_animation: false)
|
disable_animation: false)
|
||||||
|
|||||||
@@ -8,32 +8,76 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
struct SuggestedUserGroup: Identifiable, Codable {
|
/// This model does the following:
|
||||||
let id = UUID()
|
///
|
||||||
let category: String
|
/// - It loads follow packs (From the network, with a local copy fallback), and related profiles
|
||||||
let users: [Pubkey]
|
/// - It tracks the interests and disinterests as selected by the user via an interface
|
||||||
|
/// - It computes publishes suggestions for users based on selected interests
|
||||||
enum CodingKeys: String, CodingKey {
|
@MainActor
|
||||||
case category, users
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SuggestedUsersViewModel: ObservableObject {
|
class SuggestedUsersViewModel: ObservableObject {
|
||||||
|
/// The Damus State
|
||||||
public let damus_state: DamusState
|
public let damus_state: DamusState
|
||||||
|
|
||||||
@Published var groups: [SuggestedUserGroup] = []
|
/// Keeps all the suggested follow packs available. For internal use only.
|
||||||
|
private var allSuggestions: [FollowPackEvent]? = nil {
|
||||||
private let sub_id = UUID().uuidString
|
didSet { self.recomputeSuggestions() }
|
||||||
|
|
||||||
init(damus_state: DamusState) {
|
|
||||||
self.damus_state = damus_state
|
|
||||||
loadSuggestedUserGroups()
|
|
||||||
let pubkeys = getPubkeys(groups: groups)
|
|
||||||
subscribeToSuggestedProfiles(pubkeys: pubkeys)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user-selected topics of interests
|
||||||
|
@Published var interests: Set<Interest> = [] {
|
||||||
|
didSet {
|
||||||
|
self.recomputeSuggestions()
|
||||||
|
if interests.contains(.bitcoin) {
|
||||||
|
// Ensures there are no setting contradictions if user goes back and forth on onboarding
|
||||||
|
reduceBitcoinContent = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A user preference that allows users to reduce bitcoin content
|
||||||
|
@Published var reduceBitcoinContent: Bool {
|
||||||
|
didSet {
|
||||||
|
self.recomputeDisinterests()
|
||||||
|
damus_state.settings.reduce_bitcoin_content = reduceBitcoinContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Published private(set) var disinterests: Set<Interest> = [] {
|
||||||
|
didSet { self.recomputeSuggestions() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps the suggested follow packs to the user.
|
||||||
|
///
|
||||||
|
/// ## Implementation notes
|
||||||
|
///
|
||||||
|
/// This is technically meant to be a computed property (see `recomputeSuggestions`),
|
||||||
|
/// but we also want views that display this to be automatically updated,
|
||||||
|
/// so therefore we use `@Published` instead, and add property write observers on its logical dependencies
|
||||||
|
@Published private(set) var suggestions: [FollowPackEvent]? = nil
|
||||||
|
|
||||||
|
/// A map of suggested pubkeys and the particular interest categories they belong to
|
||||||
|
private(set) var interestUserMap: [Pubkey: Set<Interest>] = [:]
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Helper types
|
||||||
|
|
||||||
|
typealias FollowPackID = String
|
||||||
|
typealias Interest = DIP06.Interest
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init(damus_state: DamusState) throws {
|
||||||
|
self.damus_state = damus_state
|
||||||
|
self.reduceBitcoinContent = damus_state.settings.reduce_bitcoin_content
|
||||||
|
self.recomputeAll()
|
||||||
|
Task.detached {
|
||||||
|
await self.loadSuggestedFollowPacks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - External interface methods
|
||||||
|
|
||||||
|
/// Gets suggested user information from a provided pubkey
|
||||||
func suggestedUser(pubkey: Pubkey) -> SuggestedUser? {
|
func suggestedUser(pubkey: Pubkey) -> SuggestedUser? {
|
||||||
let profile_txn = damus_state.profiles.lookup(id: pubkey)
|
let profile_txn = damus_state.profiles.lookup(id: pubkey)
|
||||||
if let profile = profile_txn?.unsafeUnownedValue,
|
if let profile = profile_txn?.unsafeUnownedValue,
|
||||||
@@ -43,63 +87,154 @@ class SuggestedUsersViewModel: ObservableObject {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Allows the user to follow a list of other users
|
||||||
func follow(pubkeys: [Pubkey]) {
|
func follow(pubkeys: [Pubkey]) {
|
||||||
for pubkey in pubkeys {
|
for pubkey in pubkeys {
|
||||||
notify(.follow(.pubkey(pubkey)))
|
notify(.follow(.pubkey(pubkey)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadSuggestedUserGroups() {
|
|
||||||
guard let url = Bundle.main.url(forResource: "suggested_users", withExtension: "json") else {
|
// MARK: - Internal state management logic
|
||||||
return
|
|
||||||
|
/// State management function that recomputes all "computed" properties
|
||||||
|
///
|
||||||
|
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
|
||||||
|
private func recomputeAll() {
|
||||||
|
self.recomputeDisinterests()
|
||||||
|
self.recomputeSuggestions()
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = try? Data(contentsOf: url) else {
|
/// State management function that recomputes `disinterests` based its logical dependencies
|
||||||
return
|
///
|
||||||
|
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
|
||||||
|
private func recomputeDisinterests() {
|
||||||
|
self.disinterests = reduceBitcoinContent ? Set([.bitcoin]) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoder = JSONDecoder()
|
/// State management function that recomputes `suggestions` based its logical dependencies
|
||||||
do {
|
///
|
||||||
let groups = try decoder.decode([SuggestedUserGroup].self, from: data)
|
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
|
||||||
self.groups = groups
|
private func recomputeSuggestions() {
|
||||||
} catch {
|
self.suggestions = Self.computeSuggestions(basedOn: allSuggestions, interests: interests, disinterests: disinterests)
|
||||||
print(error.localizedDescription.localizedLowercase)
|
}
|
||||||
|
|
||||||
|
/// Purely functional function that computes suggestions based on the ones available, and the user's interest selections
|
||||||
|
private static func computeSuggestions(basedOn allSuggestions: [FollowPackEvent]?, interests: Set<Interest>, disinterests: Set<Interest>) -> [FollowPackEvent]? {
|
||||||
|
guard let allSuggestions else { return nil }
|
||||||
|
return allSuggestions.filter({ suggestion in
|
||||||
|
return !suggestion.interests.intersection(interests).isEmpty && suggestion.interests.intersection(disinterests).isEmpty
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal loading logic
|
||||||
|
|
||||||
|
/// Loads suggestions
|
||||||
|
///
|
||||||
|
/// (This is the main loading function that kicks-off the others)
|
||||||
|
///
|
||||||
|
/// ## Usage notes
|
||||||
|
///
|
||||||
|
/// - Long running task, preferably use this as a detached task
|
||||||
|
private func loadSuggestedFollowPacks() async {
|
||||||
|
// First, try preload events from the local file (To have a fallback in the case of an unstable internet connection)
|
||||||
|
var packsById = await self.loadLocalSuggestedFollowPacks()
|
||||||
|
|
||||||
|
// Then fetch the newest follow packs from the network and overwrite old ones where necessary
|
||||||
|
let subscriptionTask = Task {
|
||||||
|
await self.loadSuggestedFollowPacksFromNetwork(packsById: &packsById)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for 5 seconds before timing out
|
||||||
|
try? await Task.sleep(nanoseconds: 5_000_000_000)
|
||||||
|
// Cancel the subscription task on timeout, to make sure we don't load forever
|
||||||
|
subscriptionTask.cancel()
|
||||||
|
|
||||||
|
// Finish loading and computing suggestions, as well as profile info
|
||||||
|
let allPacks = Array(packsById.values)
|
||||||
|
self.allSuggestions = allPacks
|
||||||
|
await self.loadProfiles(for: allPacks)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the local follow packs, to have a fallback in the case of network instability
|
||||||
|
///
|
||||||
|
/// ## Implementation notes
|
||||||
|
///
|
||||||
|
/// This might seem redundant, but onboarding is a crucial moment for users, so we need to make sure they are onboarded successfully.
|
||||||
|
private func loadLocalSuggestedFollowPacks() async -> [FollowPackID: FollowPackEvent] {
|
||||||
|
var packsById: [String: FollowPackEvent] = [:]
|
||||||
|
|
||||||
|
if let bundleURL = Bundle.main.url(forResource: "follow-packs", withExtension: "jsonl"),
|
||||||
|
let jsonlData = try? Data(contentsOf: bundleURL),
|
||||||
|
let jsonlString = String(data: jsonlData, encoding: .utf8) {
|
||||||
|
|
||||||
|
let lines = jsonlString.components(separatedBy: .newlines)
|
||||||
|
for line in lines where !line.isEmpty {
|
||||||
|
if let note = NdbNote.owned_from_json(json: line) {
|
||||||
|
let followPack = FollowPackEvent.parse(from: note)
|
||||||
|
if let id = followPack.uuid {
|
||||||
|
packsById[id] = followPack
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getPubkeys(groups: [SuggestedUserGroup]) -> [Pubkey] {
|
return packsById
|
||||||
var pubkeys: [Pubkey] = []
|
|
||||||
for group in groups {
|
|
||||||
pubkeys.append(contentsOf: group.users)
|
|
||||||
}
|
|
||||||
return pubkeys
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func subscribeToSuggestedProfiles(pubkeys: [Pubkey]) {
|
/// Loads the newest follow packs from the network, and overwrites the provided follow packs where appropriate
|
||||||
let filter = NostrFilter(kinds: [.metadata], authors: pubkeys)
|
private func loadSuggestedFollowPacksFromNetwork(packsById: inout [FollowPackID: FollowPackEvent]) async {
|
||||||
damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event)
|
let filter = NostrFilter(
|
||||||
|
kinds: [NostrKind.follow_list],
|
||||||
|
authors: [Constants.ONBOARDING_FOLLOW_PACK_CURATOR_PUBKEY]
|
||||||
|
)
|
||||||
|
|
||||||
|
for await item in self.damus_state.nostrNetwork.reader.subscribe(filters: [filter]) {
|
||||||
|
// Check for cancellation on each iteration
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
|
||||||
|
switch item {
|
||||||
|
case .event(let borrow):
|
||||||
|
try? borrow { event in
|
||||||
|
let followPack = FollowPackEvent.parse(from: event.toOwned())
|
||||||
|
|
||||||
|
guard let id = followPack.uuid else { return }
|
||||||
|
|
||||||
|
let latestPackForThisId: FollowPackEvent
|
||||||
|
|
||||||
|
if let existingPack = packsById[id], existingPack.event.created_at > followPack.event.created_at {
|
||||||
|
latestPackForThisId = existingPack
|
||||||
|
} else {
|
||||||
|
latestPackForThisId = followPack
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
|
packsById[id] = latestPackForThisId
|
||||||
guard case .nostr_event(let nev) = ev else {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch nev {
|
|
||||||
case .event:
|
|
||||||
break
|
|
||||||
|
|
||||||
case .notice(let msg):
|
|
||||||
print("suggested user profiles notice: \(msg)")
|
|
||||||
|
|
||||||
case .eose:
|
case .eose:
|
||||||
self.objectWillChange.send()
|
|
||||||
|
|
||||||
case .ok:
|
|
||||||
break
|
|
||||||
|
|
||||||
case .auth:
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finds all profiles mentioned in the follow packs, and loads the profile data from the network
|
||||||
|
private func loadProfiles(for packs: [FollowPackEvent]) async {
|
||||||
|
var allPubkeys: [Pubkey] = []
|
||||||
|
|
||||||
|
for followPack in packs {
|
||||||
|
for pubkey in followPack.publicKeys {
|
||||||
|
self.interestUserMap[pubkey] = Set(Array(self.interestUserMap[pubkey] ?? []) + Array(followPack.interests))
|
||||||
|
allPubkeys.append(pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let profileFilter = NostrFilter(kinds: [.metadata], authors: allPubkeys)
|
||||||
|
for await item in damus_state.nostrNetwork.reader.subscribe(filters: [profileFilter]) {
|
||||||
|
switch item {
|
||||||
|
case .event(_):
|
||||||
|
continue // We just need NostrDB to ingest these for them to be available elsewhere, no need to analyze the data
|
||||||
|
case .eose:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{"kind":39089,"id":"5d0bb7d7cfde8614d91180e534ef9e33c35006d81185d8065a36ab3d3fa079db","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750724913,"tags":[["title","Bitcoin enthusiasts"],["description","Profiles that like to talk about Bitcoin-related topics."],["image","https://images.unsplash.com/photo-1623227413711-25ee4388dae3?q=80&w=2972&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["d","hzgji33wnyku"],["t","bitcoin"],["p","704c4773626bf0f7ebf99d861eb0eae473be2b004f91725f8a1750486c9c848f"],["p","5b3260bc06f8e96b64a8c02e37385125d85479d681bb0302652b7a817c307a5e"],["p","7b3f7803750746f455413a221f80965eecb69ef308f2ead1da89cc2c8912e968"],["p","58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196"],["p","7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84"],["p","692c6feb28a0bd93d67cc19840bb07bfe678f97bf15930b5d812676b4cd512ef"],["p","130dcd3a1963f7fa35b206c44be6bc6f4ea0f5ee531b26126cb989678d5cfff5"],["p","c1831fbe2653f76164421d57db6cee38b8cef8ce6771bc65c12f8543de4b39bf"],["p","4d023ce9dfd75a7f3075b8e8e084008be17a1f750c63b5de721e6ef883adc765"],["p","f783ba3b12b91e375aba6594015b90bd95f7e132b03cc8c4c52ce0a7c36aab52"],["p","e07bfc04ea87c72a9185b0891f055361e6492f259f81247683d6c9ccb2b651ed"],["p","500ccc532c301711d88aa6d30b11dc477e6a32770853f8ab1c2be389b824e3f8"],["p","6a359852238dc902aed19fbbf6a055f9abf21c1ca8915d1c4e27f50df2f290d9"],["p","1586fd57ac81b66177b0087bb0c0fa465f30b9895949c8936836ec5e6cd13132"],["p","31ff694f01417160459e59e2e283b5454b499d3885092c792ae0d6b2de7839f0"],["p","259b1ddc0d3137cad123d4af64f87ff9db3834a843843963e58e2a9ac956cf39"],["p","91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832"],["p","a5e93aef8e820cbc7ab7b6205f854b87aed4b48c5f6b30fbbeba5c99e40dcf3f"],["p","b7b51cc25216d4c10bc85ae27055c9a945fe77cafd463cf23b20917e39ce6816"],["p","25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8"],["p","1c197b12ca9ce0415b70e7405b9770f0ec6bccfb59b32b63aafd42cb242e1642"],["p","9c86d77537a6380ce371e3a4860bc7e1fb2adbb2821bf1a8f1cd4e8ce02240c9"],["p","b203872cefbd5572b5551dde5bdfc0387219d99fb7e8d1c8418145d432ca0390"],["p","97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"],["p","ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","c7617e84337c611c7d5f941b35b1ec51f2ae6e9f41aac9616092d510e1c295e0"],["p","e9986a10caaa96738ceda88aabd3e184307be5143e687457581f9b096c6ef89c"],["p","805b34f708837dfb3e7f05815ac5760564628b58d5a0ce839ccbb6ef3620fac3"],["p","d284b9523c99fe1acc9e759129bd9f17984a5aff413caf87018510a020a656fd"],["p","7644119b18ec56b5b2779e0d035e7712a9d669dbfbe5b2c5f458cb564afb6c95"],["p","5a8e581f16a012e24d2a640152ad562058cb065e1df28e907c1bfa82c150c8ba"],["p","0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33"],["p","738f7873ac2c6cb7701e3150616afc824379b132b467ba5a8429d5964af1b136"],["p","37039b91a6ff36ff48189dfae4a1f6ea017992a61fb9485b47de54fe491f75bf"],["p","d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec"],["p","f4d89779148ccd245c8d50914a284fd62d97cb0fb68b797a70f24a172b522db9"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["p","b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"],["p","40b9c85fffeafc1cadf8c30a4e5c88660ff6e4971a0dc723d5ab674b5e61b451"],["p","963a2dca29e2ed5663a627b5289ed36d445531a3f5ef127716227d8a1aaa5166"],["p","139137969c1c56bf8306cd71272f681ed54206be15eeceb9eb98956d2fd9f7ef"],["p","f1989a96d75aa386b4c871543626cbb362c03248b220dc9ae53d7cefbcaaf2c1"],["p","76e36f2fabfbd8c353ffcda8fe07877a0977ee4aa9d9bcc224f02038d73b3787"],["p","e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"],["p","4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"],["p","5e7e69f863d20ca30103df88887194e091679491fefe5eb8212753013cc3d552"],["p","ce9cfafcdc8425dc90c7045f263f966d077dbde96940b7fab4c9403998c5b49b"],["p","75da94027ad408bc2faffeb1e67d71babe8d78d89c3620da212303b877a65b5c"],["p","eda96cb93aecdd61ade0c1f9d2bfdf95a7e76cf1ca89820c38e6e4cea55c0c05"]],"content":"","sig":"5128ec83bd15ea260f6597b2e1d77125e297c8fef26977fe952b7e9d04e06d8ceb17d5cf94d5e7fb58e475c6afb8ff2880ec63b405bf868f826b425dde12fbe1"}
|
||||||
|
{"kind":39089,"id":"7426682b30d03cf8a40bcd86fe074e2f79c1b81c3b6c335ef56a3ba9d1035d16","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750721921,"tags":[["title","Health"],["d","7i53tdhuhhex"],["image","https://images.unsplash.com/photo-1649134296132-56606326c566?q=80&w=1332&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","6b4ec98f02e647e01440b473bbd92a7fae21e01b6aa6c65e32db94a36092272e"],["description","Profiles that talk about health topics."],["t","health"]],"content":"","sig":"ff9f6f7d3c9a00fe89baef11559ca2ad9b5247e87129c0d6631c75bf3e10a12e9778f1ef5d2ae951aca504c7ac6f08a250868aae978ad5fae235e593af0c98d7"}
|
||||||
|
{"kind":39089,"id":"88d94f81af4eb6b5a8c70dcdb54124cf7d177dddd6408095d30e301f41024f9b","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1750721164,"tags":[["title","Sports"],["d","i6z5fdlgm0mc"],["image","https://images.unsplash.com/photo-1612872087720-bb876e2e67d1?q=80&w=1307&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","7c765d407d3a9d5ea117cb8b8699628560787fc084a0c76afaa449bfbd121d84"],["description","Profiles that talk or post about Sports."],["t","sports"]],"content":"","sig":"6b2d43dea6e50c04cd4bf1e5992e277c1719d1206125bbe045d3396543c3642daa8513d3a44eca5ed6f9a3bd536c9425ab034aaa29a0aa3e096aba93ad83071c"}
|
||||||
|
{"kind":39089,"id":"97d3211d499773c7b67a79f48a0bda207bcc09239af069285b256f247815f774","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749695841,"tags":[["title","Christianity"],["d","fa5b4qnthgtw"],["image","https://images.unsplash.com/photo-1646281579942-ef27ddbf729f?q=80&w=3270&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","ffca54e078fd9884a745d70eb0159c0b8a9e7d383a4906a7484b83ebf6101dd5"],["p","d1b118c61b0fcaac3d9e8beafa8bb9a649cce921756a8fd7c21ca97b4985b38d"],["p","8d34bd2432240c5637174a3db191878baa1c133aec739b64a264259f414be32b"],["p","6e80246f10cc91b261e46d376bd3d64409b79e0c9bb3c2a0ecba612c321f6c4f"],["p","31ff694f01417160459e59e2e283b5454b499d3885092c792ae0d6b2de7839f0"],["description","Profiles that actively post about Christian topics."],["t","religion-spirituality"]],"content":"","sig":"f02e1d37fbb0f18f4b5c5da7d6a5fdfca4506d81232acb1032ef9aab5397641ad645296ed358729c968f0260cafd628fffd7efee60d91694849834b6b5279d86"}
|
||||||
|
{"kind":39089,"id":"7b18fc4ca212dd7d9afb025d08fe9562ff9ac32c6daffdeda3a15196cd1e158e","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749691990,"tags":[["title","Music"],["d","w6d8d4247nr8"],["image","https://images.unsplash.com/photo-1511379938547-c1f69419868d?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55"],["p","23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e"],["p","8806372af51515bf4aef807291b96487ea1826c966a5596bca86697b5d8b23bc"],["p","b9d02cb8fddeb191701ec0648e37ed1f6afba263e0060fc06099a62851d25e04"],["p","eeb11961b25442b16389fe6c7ebea9adf0ac36dd596816ea7119e521b8821b9e"],["p","937ce652c92cb8bb7a8a3715d62c6e9f71dc66fd9164193e6c3cb962fdc07c94"],["description","Profiles of musicians or profiles that talk about music."],["t","music"]],"content":"","sig":"04f03a368481a1c3716fe426ac920a6977f4717f68073c0114ba16b1a76e30e7c3b99a97f8a7dee666317b077d30aaf2a6206223d6a3d65eb6d8377cf38a124d"}
|
||||||
|
{"kind":39089,"id":"9927b76f56750aeb3f1ef454720546a0e22c0f9cfcce346a6928fd7844d728f9","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1749256086,"tags":[["title","Human rights"],["d","vhsaqseo9sbx"],["image","https://images.unsplash.com/photo-1629753908080-e8551ac57b8d?q=80&w=1475&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","84dee6e676e5bb67b4ad4e042cf70cbd8681155db535942fcc6a0533858a7240"],["p","6a359852238dc902aed19fbbf6a055f9abf21c1ca8915d1c4e27f50df2f290d9"],["p","f1989a96d75aa386b4c871543626cbb362c03248b220dc9ae53d7cefbcaaf2c1"],["p","500ccc532c301711d88aa6d30b11dc477e6a32770853f8ab1c2be389b824e3f8"],["p","58c741aa630c2da35a56a77c1d05381908bd10504fdd2d8b43f725efa6d23196"],["p","33bd77e5394520747faae1394a4af5fa47f404389676375b6dc7be865ed81452"],["p","6e1010d8e5b953f9a52314d97bc94c597af26d51bae88b3fdf2c8fbd7e962d01"],["description","Human rights activists, or profiles that talk about human rights."],["t","politics"]],"content":"","sig":"7d2bf4423be8cab7d21138c172c9fc409e011f6c148e86ceed500f51edcf6d10530950385486d28f8b42eee0eb3dbb3b4b502d0e9d4adfe3f24fff99245074b3"}
|
||||||
|
{"kind":39089,"id":"dc17bccfe044d3350768f3c58632aaacee025eaf1e0b17717cbb51461446f377","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748291531,"tags":[["title","Philosophy"],["d","2dnchq9fxd5r"],["image","https://images.unsplash.com/photo-1620662736427-b8a198f52a4d?q=80&w=2971&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","0ffab7d9247132f14bcd38378b0acedc30d35e2b2670d89b7ae8c2d2c306af99"],["p","580f511af0a79d2cca20fb5bf6bb89abe6988593f8568177d2a513e87b2bbef3"],["t","humanities"]],"content":"","sig":"8cc9baf5beac297e575fb6db72785792cf43ac1086460f82deb4e7bc81c232ed0887e061de8bdc05389d6b26ded882036c9a7c120842548a192805c543ef4814"}
|
||||||
|
{"kind":39089,"id":"a77a31ee6fbab6ba3067ce72ba3a5f376b90f8a98f50635179010d98e158095c","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287979,"tags":[["title","Farmers (farmstr)"],["d","cioc58duuftq"],["image","https://images.unsplash.com/photo-1589923188900-85dae523342b?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","d70d50091504b992d1838822af245d5f6b3a16b82d917acb7924cef61ed4acee"],["p","4d023ce9dfd75a7f3075b8e8e084008be17a1f750c63b5de721e6ef883adc765"],["p","8d9d2b77930ee54ec3e46faf774ddd041dbb4e4aa35ad47c025884a286dd65fa"],["p","c0e0c4272134d92da8651650c10ca612b710a670d5e043488f27e073a1f63a16"],["p","5e7e69f863d20ca30103df88887194e091679491fefe5eb8212753013cc3d552"],["p","a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"],["p","f27a4813f31d8faf80377ef15d4a397e3640b5ca1dffba9ce9dca91d3362c091"],["p","715c279295b65454c464b78503d6154ad7697ba82460c56d8a42eb5da11e6c4b"],["p","ab11aa702482080cfead6e35fb6fbad454a3e792dd4d75f183f52f5fea2a2fa9"],["p","6a02b7d5d5c1ceec3d0ad28dd71c4cfeebb6397b95fef5cd5032c9223a13d02a"],["p","e7211c227437dcb5582712b5b1bbfb2a23b27654649ddc7c1fb5a7dde87afeee"],["p","7b8fcd9153d2c47146155df24b7f167924c1fc16fb4e843d98f8b5f3add6bd85"],["p","0f4011159c24a6a53eeea72e1c2e97e0425be8af15e7de397003dd65d9e8d278"],["p","58d2be9f20537ad7074b0283287611507ef7bd313c5edb64def9e1fc5df26d11"],["p","d020fe6e22b3aca9f578172a135e25d7b692e67b52e97fdce55431127d7626af"],["p","7644119b18ec56b5b2779e0d035e7712a9d669dbfbe5b2c5f458cb564afb6c95"],["description","Profiles of farmers or profiles that talk about farming"],["t","food"]],"content":"","sig":"d6bbdbc1d84bf6f85e462f1abbe7dfc5d06218091ab0be00cd0ba440d0835eb26f81e8d0b2aea3e4bd3a5ab3638be0ce27c4072df460e99c5fe9dcc59113a153"}
|
||||||
|
{"kind":39089,"id":"fb51210ce13077cecd890a053f611fa8a258e8898e8b7b3116b641dbf1330959","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287889,"tags":[["title","Lifestyle"],["d","rptxdnrphqsr"],["image","https://images.unsplash.com/photo-1493225255756-d9584f8606e9?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3"],["p","c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865"],["p","e451c4e82e272ef912a04f490373fce9054a044b11a2aa847823b3686c8eba77"],["p","22a193fb86c793d7f73348126a5fe55ab8a4c975805ebc2ff81bf290f7805f7e"],["p","805b34f708837dfb3e7f05815ac5760564628b58d5a0ce839ccbb6ef3620fac3"],["description","Profiles that talk about different lifestyles."],["t","lifestyle"]],"content":"","sig":"11d70b315a7084ad26f31e3821ff295962654c2d41526f7e8491e18dcd7ed5a578acade9c47b0456c76b12e74653147bef069dafd1b05278506d63d7a8cb2316"}
|
||||||
|
{"kind":39089,"id":"d1c8d4b8684b7e1eda6830e6dde96b2ea548ce4f717ff5cf528a00a2206992e7","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287802,"tags":[["title","Art & Photography"],["d","9gnjzbkd59lp"],["image","https://images.unsplash.com/photo-1579541592065-da8a15e49bc7?q=80&w=3201&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e"],["p","f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b"],["p","11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97"],["p","f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0"],["p","af146f51634a5fb0abc592fbc2bed42cf740700990344a766a8999fe55eed1c6"],["p","8816e8938fe60a3a925433c77410f39500fe1320787acb2165f9345e70464592"],["p","8d0638d3e8d9b23e337fb4017bb7d5a97def40bdc258e8121ee7d51e623ca065"],["p","ece8ed2111f73b92251e74a3da22dee1d6c6d9b497040acc68a37ac6bbf5a105"],["p","64bfa9abffe5b18d0731eed57b38173adc2ba89bf87c168da90517f021e722b5"],["p","546bffcb9317edf919828de272dd8eba11f2aadc07969cba6c9bb894c07712ef"],["p","20998a8d433181cc5e778903db528d162f5579918a3437be40fdba0130b3469c"],["p","37c962bfb34d32e667d5075f7d55f3a7ca9bcddbafeff59a4003c276ad147352"],["p","87deef9034c52d87ae327af3a76358e58e7ee2e03d80c738ee1a2be399c23e79"],["p","55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185"],["description","A list of profiles related to art and photography"],["t","art"]],"content":"","sig":"a6409e0172380d30d36446f46ff2ccc97cb01cd3cdff612991b8e759a103a8243928579efdcea9f5e0c44ce2785c240218928fec26cdd6b2b0bafb63a8c0790a"}
|
||||||
|
{"kind":39089,"id":"f650750b46c7f97d9f4f85ee1f1c704015f3ca35b129826ac370ad47faa016a4","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287746,"tags":[["title","Technology companies"],["d","yogtlbnbuw39"],["image","https://images.unsplash.com/photo-1531297484001-80022131f5a1?q=80&w=3220&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","d7df8b3e14166796a8ad8740b06f427aea9dd95b72e0276aa9179210e27f81f7"],["p","6b4a29bbd43d1d0eeead384f512dbb591ce9407d27dba48ad54b00d9d2e1972b"],["p","34104dedf3cc5936802c8308a3d0090f2857d4aba4e8b720accaf6b2ab049969"],["p","ebdee92945ef05283be0ac3de25787c81a6a58a10f568f9c6b61d9dd513adbad"],["p","37cdc3e7f5a7147752cc6cb348bd77e1b999e3e43a4b21b740812193ba81c298"],["p","de7aa63b1c7e809f66a67bdcd2bd4c952a09cd52ee01ed7db358d09bad97f840"],["p","c7617e84337c611c7d5f941b35b1ec51f2ae6e9f41aac9616092d510e1c295e0"],["p","9be21611a341426e9146257c54179e22d178bb7d4106e247ddf3e507b7985a6b"],["description","A list of technology companies on Nostr"],["t","technology"]],"content":"","sig":"d1e9adeb5f9850ecffc3a524e59dbfa6c49913ac77b3653852f46908ae6e6290d7e58fc4c74717c45ee9a2fd4b6421d8c5885b8e198a6aaf14db39e461902d68"}
|
||||||
|
{"kind":39089,"id":"7e734701c3a09c63e9c68a4c6762c7dd122220d2d325802e40d1f8c983733b42","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287584,"tags":[["title","Technology news"],["d","mbj6yxabm94c"],["image","https://images.unsplash.com/photo-1726568313407-c7d9c8a8ce88?q=80&w=2971&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","9c5d0fe246b80bc313082aa3dbb4f82744751ca8d79901dc74f7eb69bba8ad4b"],["p","8451100685869f1fa9b122300167359f9a0459fcf517f2fb1b1dd22319a38815"],["p","de5ba539eb564946ae0e3f5abbf3599e8eeed8efd40875c6320405a0c17d976e"],["t","technology"]],"content":"","sig":"b92854e27172dfb0225308669a1d8c422f82258736e2403a89dc104e0d3a97ac4434d482402f37ecb16e57f65cd7ced7dccf978f1da0065d6f0bcd55782829bc"}
|
||||||
|
{"kind":39089,"id":"9143d35516660826a8c1a4a2440fa124c395b144bd172d39ea647d0222aaaeb3","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287486,"tags":[["title","Bitcoin builders"],["d","2iofns19fsiv"],["image","https://images.unsplash.com/photo-1564241832756-58a1d4055663?q=80&w=2969&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","91c9a5e1a9744114c6fe2d61ae4de82629eaaa0fb52f48288093c7e7e036f832"],["p","25a2192dcf34c3be326988b5c9f942aa96789899d15b59412602854a8723e9e8"],["p","11b9a89404dbf3034e7e1886ba9dc4c6d376f239a118271bd2ec567a889850ce"],["p","b7b51cc25216d4c10bc85ae27055c9a945fe77cafd463cf23b20917e39ce6816"],["p","e88a691e98d9987c964521dff60025f60700378a4879180dcbbb4a5027850411"],["p","5a8e581f16a012e24d2a640152ad562058cb065e1df28e907c1bfa82c150c8ba"],["description","People who builds products and services around Bitcoin.\n\n(Not an exhaustive list, work in progress)"],["t","bitcoin"]],"content":"","sig":"192f65e8392742a01be41b60aef05da4026a1fd22229bde468a6123890f680d2f7765fa8b21c41f75945b676053e91eabfe2529bb289a7dda80789a0e6486d20"}
|
||||||
|
{"kind":39089,"id":"0ed119d7e0828ac23e7a0d735498be9054da91d21e7e268c25633ad747a949c1","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287175,"tags":[["title","Cars"],["d","dgj9fi1mk83t"],["image","https://images.unsplash.com/photo-1526726538690-5cbf956ae2fd?q=80&w=2970&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","98adf137d9da1b1b3654f29ede930f27c95847c9719bf4b38e1cef33c21f7e38"],["description","A list of profiles that talk about cars."],["t","other"]],"content":"","sig":"47e6b974e57b6344e47269cc2ac1c800332a2c1f949c9cd1522b90580ece31d49409a02fa9cdac64a5cfe660c221a1c50b9eeaa5204a44c24d5d6c5bd5ade506"}
|
||||||
|
{"kind":39089,"id":"37d183d3f069167e4df7294c842086d61abee8ca8f7d79a402eeb0d2dca34268","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748287050,"tags":[["title","Science"],["d","ypfr6bg55nrw"],["image","https://images.unsplash.com/photo-1606125784258-570fc63c22c1?q=80&w=3161&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","c582af78dff442700ec59e21786532a7074c00be8b7b1eac989bbf62698069cc"],["p","e85ed75286cb77475776c1007df8c4ff1c9c68eff91c3627347b065c5bf4dc78"],["t","science"]],"content":"","sig":"3649115023c3c62432742d209f3a4791b0d38d784a147a967d81288add8ab127197a6207de82b5cb53d4618cd1c92b0d190b3c7a9f5276535853f0a83fc485d3"}
|
||||||
|
{"kind":39089,"id":"bf1546f39fc0d3dac03ab77b0ce9d62d71707114cfe3f88c75fb13227c0b02ad","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748286976,"tags":[["title","Nostr builders"],["d","68qs4i0xgex8"],["image","https://images.unsplash.com/photo-1588508107117-227d4ab6b751?q=80&w=3228&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],["p","97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"],["p","d61f3bc5b3eb4400efdae6169a5c17cabf3246b514361de939ce4a1a0da6ef4a"],["p","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],["p","a9434ee165ed01b286becfc2771ef1705d3537d051b387288898cc00d5c885be"],["p","17538dc2a62769d09443f18c37cbe358fab5bbf981173542aa7c5ff171ed77c4"],["p","8b2be0a0ad34805d76679272c28a77dbede9adcbfdca48c681ec8b624a1208a6"],["p","4523be58d395b1b196a9b8c82b038b6895cb02b683d0c253a955068dba1facd0"],["p","1739d937dc8c0c7370aa27585938c119e25c41f6c441a5d34c6d38503e3136ef"],["p","8125b911ed0e94dbe3008a0be48cfe5cd0c0b05923cfff917ae7e87da8400883"],["p","1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411"],["p","2779f3d9f42c7dee17f0e6bcdcf89a8f9d592d19e3b1bbd27ef1cffd1a7f98d1"],["p","520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626"],["p","e2ccf7cf20403f3f2a4a55b328f0de3be38558a7d5f33632fdaaefc726c1c8eb"],["p","850605096dbfb50b929e38a6c26c3d56c425325c85e05de29b759bc0e5d6cebc"],["p","0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd"],["p","d36e8083fa7b36daee646cb8b3f99feaa3d89e5a396508741f003e21ac0b6bec"],["p","0d6c8388dcb049b8dd4fc8d3d8c3bb93de3da90ba828e4f09c8ad0f346488a33"],["p","460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"],["p","fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"],["p","bd1e19980e2c91e6dc657e92c25762ca882eb9272d2579e221f037f93788de91"],["p","e7424ad457e512fdf4764a56bf6d428a06a13a1006af1fb8e0fe32f6d03265c7"],["p","68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"],["p","50d94fc2d8580c682b071a542f8b1e31a200b0508bab95a33bef0855df281d63"],["p","76c71aae3a491f1d9eec47cba17e229cda4113a0bbb6e6ae1776d7643e29cafa"],["description","People who build products and services on Nostr. (Not an exhaustive list)"],["t","technology"]],"content":"","sig":"566d88b6ecbfa00f51789bc660c92abb4c9d6d592a3f2022531c0615444ad773aecf6d1781177b19dd2f26fad94e4ab077affcad86b60fda8f5f7658545a2ae6"}
|
||||||
|
{"kind":39089,"id":"700646832f4d70ce896d9ebd833c6c7552a91b2acaaf469c5ac35c59c9d8801b","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1748286876,"tags":[["title","Travel"],["d","2xhos7ml5pcp"],["image","https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=2973&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"],["p","7d33ba57d8a6e8869a1f1d5215254597594ac0dbfeb01b690def8c461b82db35"],["description","Profiles that talk about travel."],["t","travel"]],"content":"","sig":"b62a5df0295cac59d2616a3786a72ba47052a540b7185b2b93f77d26dbb4fad171cf145ea445d15b150e7642260646c5850ea4482330cd5dc5038d0ef26954de"}
|
||||||
|
{"kind":39089,"id":"03012789fd1d247142192bf6f8999ee848ba83edf6b7f4919b4e3690e75889b6","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1747845497,"tags":[["title","Human Architecture, Local Vernacular, and Craftsmanship"],["d","y156932o9xfh"],["image","https://c4.wallpaperflare.com/wallpaper/82/123/783/architecture-building-bulgaria-village-wallpaper-preview.jpg"],["p","5db2be23cde61dd0a69e667a021a943fa38760104fe4160ce13b1a097e9fe447"],["p","7a8e476bd97e1a9c348b2f9e1c8c9d1f371e2fda001dae82e44d336d4ca2f7ec"],["p","16f1a0100d4cfffbcc4230e8e0e4290cc5849c1adc64d6653fda07c031b1074b"],["p","462eb31a6b3de5727407e796d984be2c631cb4bfa854f8a1a2b092dcc6d7bbe1"],["p","e9986a10caaa96738ceda88aabd3e184307be5143e687457581f9b096c6ef89c"],["p","704c4773626bf0f7ebf99d861eb0eae473be2b004f91725f8a1750486c9c848f"],["p","41261aca9c043397d53c1d09d2d62926e8ab230ec8f7516c258c81c6844169c3"],["p","94f57887daad1a4b952bd755539f239922cd614a1b1ba0e623ea8361a4ca2a65"],["description","Aesthetic architecture, craftsmanship, and localism"],["t","art"]],"content":"","sig":"2a987dcbc86d66166599c55221a0bfb39a42fc83cd5db5f79e7aa6ddbf0b3777cfd39e1d97d36572084eb34ae30d58e10fab4e50de29a8798ef21a64e9b90e84"}
|
||||||
|
{"kind":39089,"id":"7e847eafeaf8ec7dec435b7a8f2eda41b645653ea2414297fbf4b9788d77b582","pubkey":"895c2a90a860ac18434aa69e7b0da8465721216fa36e42c022e393579c486cba","created_at":1747767150,"tags":[["title","Linux Enjoyers"],["d","unjue0fdg0ef"],["image","https://image.nostr.build/2e730d4041764c32c40f33dd1e5ae15158f4463ed1bf09f217715b2735f667f1.jpg"],["p","52b4a076bcbbbdc3a1aefa3735816cf74993b1b8db202b01c883c58be7fad8bd"],["p","edb470271297ac5a61f277f3cd14de54c67eb5ccd20ef0d9df29be18685bb004"],["p","fd5989ddfadd9e2af6ceb8b63942a9e31b37367e89917931ede3b2ea76823f10"],["p","139137969c1c56bf8306cd71272f681ed54206be15eeceb9eb98956d2fd9f7ef"],["p","f4d89779148ccd245c8d50914a284fd62d97cb0fb68b797a70f24a172b522db9"],["p","70122128273bdc07af9be7725fa5c4bc0fc146866bec38d44360dc4bc6cc18b9"],["p","0463223adf38df9a22a7fb07999a638fdd42d8437573e0bf19c43e013b14d673"],["p","480ec1a7516406090dc042ddf67780ef30f26f3a864e83b417c053a5a611c838"],["p","93332f1e437be05aa1a4c2cbb694cafc9721be57cca98aef7ec6641895ae4735"],["p","259b1ddc0d3137cad123d4af64f87ff9db3834a843843963e58e2a9ac956cf39"],["p","75656740209960c74fe373e6943f8a21ab896889d8691276a60f86aadbc8f92a"],["p","bd4ae3e67e29964d494172261dc45395c89f6bd2e774642e366127171dfb81f5"],["p","76e36f2fabfbd8c353ffcda8fe07877a0977ee4aa9d9bcc224f02038d73b3787"],["p","75da94027ad408bc2faffeb1e67d71babe8d78d89c3620da212303b877a65b5c"],["p","a793448d75b909e9597252cc5b133b24f64012d02fe041009f70cba16bf72864"],["p","c7eda660a6bc8270530e82b4a7712acdea2e31dc0a56f8dc955ac009efd97c86"],["p","1c197b12ca9ce0415b70e7405b9770f0ec6bccfb59b32b63aafd42cb242e1642"],["p","74fb3ef27cd8985d7fefc6e94d178290275f5492557b4a166ab9cd1458adabc7"],["p","d9a329afabb263a8af216838eb45e1f04cc3000704464947c4b907e1bef580d7"],["p","b203872cefbd5572b5551dde5bdfc0387219d99fb7e8d1c8418145d432ca0390"],["p","566c166f3adab0c8fba5da015b0b3bcc8eb3696b455f2a1d43bfbd97059646a8"],["p","fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1"],["p","97a403640c83ac12bce556ded8db2f3ebe891801832fa1114abda73a6ae8598c"],["p","40b9c85fffeafc1cadf8c30a4e5c88660ff6e4971a0dc723d5ab674b5e61b451"],["p","a5ee447568a2372226d647013a55251eac7dd37ab40804a5121a63ce2ca75401"],["p","abd277d90caba20aee0f1f05a68d24cd117badc1205d0d1b1a0451357d32b92f"],["p","b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc"],["p","ce9cfafcdc8425dc90c7045f263f966d077dbde96940b7fab4c9403998c5b49b"],["t","technology"]],"content":"","sig":"271c27e39c2cb24977a1f04e7df0c234b0fd35624abbbb0df35cf5ae6ae4e4b40f00acbcb2fd964edc40167fd0b45598d3879c01c0645938c1686f103285cb0f"}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"category": "suggested_users_nostr",
|
|
||||||
"users": [
|
|
||||||
"ba2f394833658475e91680b898f9be0f1d850166c6a839dbe084d0266ad6e20a",
|
|
||||||
"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2",
|
|
||||||
"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
|
|
||||||
"b708f7392f588406212c3882e7b3bc0d9b08d62f95fa170d099127ece2770e5e"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "suggested_users_permaculture_livestock_gardening",
|
|
||||||
"users": [
|
|
||||||
"4b1804c90d59dff195ee0e8f692b98a7c762bf1793b3e126c546d730dcb04477",
|
|
||||||
"2c24e1af571fb4ccfeca3981649c1b09c695cd83b129709eb3b41c7ad2854899",
|
|
||||||
"296842eaaed9be5ae0668da09fe48aac0521c4af859ad547d93145e5ac34c17e"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "suggested_users_music",
|
|
||||||
"users": [
|
|
||||||
"23708a76e7090cb108d33e8801fd36262c475e5499b23eb87eee4a31f4f0144e",
|
|
||||||
"ca9d68eb25620fc755e1b8c76b5f155f4c7e96d99c532c109a8b36d208bdce55"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "suggested_users_books",
|
|
||||||
"users": [
|
|
||||||
"2652f3af10de63bc10a2628871a3fce62e08655e4fcf90a58be16f246bb65da3",
|
|
||||||
"b83a28b7e4e5d20bd960c5faeb6625f95529166b8bdb045d42634a2f35919450"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "suggested_users_art_photography",
|
|
||||||
"users": [
|
|
||||||
"f96c3d76497074c4c83a7b3823380e77dc73d5a9494fd2e053e4a1453e17824b",
|
|
||||||
"11b2d93b26d7e56fb57f0afce0d33bfa7fb35b913e4c0aeb7706464befb9ca97",
|
|
||||||
"f4db5270bd991b17bea1e6d035f45dee392919c29474bbac10342d223c74e0d0",
|
|
||||||
"af146f51634a5fb0abc592fbc2bed42cf740700990344a766a8999fe55eed1c6",
|
|
||||||
"8816e8938fe60a3a925433c77410f39500fe1320787acb2165f9345e70464592",
|
|
||||||
"8d0638d3e8d9b23e337fb4017bb7d5a97def40bdc258e8121ee7d51e623ca065",
|
|
||||||
"ece8ed2111f73b92251e74a3da22dee1d6c6d9b497040acc68a37ac6bbf5a105",
|
|
||||||
"64bfa9abffe5b18d0731eed57b38173adc2ba89bf87c168da90517f021e722b5",
|
|
||||||
"546bffcb9317edf919828de272dd8eba11f2aadc07969cba6c9bb894c07712ef",
|
|
||||||
"20998a8d433181cc5e778903db528d162f5579918a3437be40fdba0130b3469c",
|
|
||||||
"37c962bfb34d32e667d5075f7d55f3a7ca9bcddbafeff59a4003c276ad147352",
|
|
||||||
"387fa328198e67a400caf00947cc91e0c166d24d71d8b4b78a6c3ef91ff4d058",
|
|
||||||
"87deef9034c52d87ae327af3a76358e58e7ee2e03d80c738ee1a2be399c23e79"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "suggested_users_ai_art",
|
|
||||||
"users": [
|
|
||||||
"431fa2f340f0adf8963d6d7c6e2c20d913278c691fe609fd3857db13d8f39feb",
|
|
||||||
"9936a53def39d712f886ac7e2ed509ce223b534834dd29d95caba9f6bc01ef35",
|
|
||||||
"1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b",
|
|
||||||
"693c2832de939b4af8ccd842b17f05df2edd551e59989d3c4ef9a44957b2f1fb",
|
|
||||||
"55f04590674f3648f4cdc9dc8ce32da2a282074cd0b020596ee033d12d385185"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "suggested_users_parenting",
|
|
||||||
"users": [
|
|
||||||
"c7c8f645fd45b09055fb6c26d148737ad7ed12ddecde0d4c877b88f8d4196865",
|
|
||||||
"fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52",
|
|
||||||
"e451c4e82e272ef912a04f490373fce9054a044b11a2aa847823b3686c8eba77",
|
|
||||||
"261c7e18545aee4eb55e8052297fd93bd886c2f96b10d599cfdad4f3477b87ab",
|
|
||||||
"22a193fb86c793d7f73348126a5fe55ab8a4c975805ebc2ff81bf290f7805f7e"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"category": "suggested_users_food",
|
|
||||||
"users": [
|
|
||||||
"cbb2f023b6aa09626d51d2f4ea99fa9138ea80ec7d5ffdce9feef8dcd6352031"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
+16
-10
@@ -798,18 +798,18 @@ private func isAlphanumeric(_ char: Character) -> Bool {
|
|||||||
return char.isLetter || char.isNumber
|
return char.isLetter || char.isNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] {
|
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair, relayURL: RelayURL?) -> [[String]] {
|
||||||
guard let nip10 = replying_to.thread_reply() else {
|
guard let nip10 = replying_to.thread_reply() else {
|
||||||
// we're replying to a post that isn't in a thread,
|
// we're replying to a post that isn't in a thread,
|
||||||
// just add a single reply-to-root tag
|
// just add a single reply-to-root tag
|
||||||
return [["e", replying_to.id.hex(), "", "root"]]
|
return [["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "root"]]
|
||||||
}
|
}
|
||||||
|
|
||||||
// otherwise use the root tag from the parent's nip10 reply and include the note
|
// otherwise use the root tag from the parent's nip10 reply and include the note
|
||||||
// that we are replying to's note id.
|
// that we are replying to's note id.
|
||||||
let tags = [
|
let tags = [
|
||||||
["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"],
|
["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"],
|
||||||
["e", replying_to.id.hex(), "", "reply"]
|
["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "reply"]
|
||||||
]
|
]
|
||||||
|
|
||||||
return tags
|
return tags
|
||||||
@@ -863,7 +863,9 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
|
|||||||
func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost {
|
func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost {
|
||||||
let post = NSMutableAttributedString(attributedString: post)
|
let post = NSMutableAttributedString(attributedString: post)
|
||||||
post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in
|
post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in
|
||||||
if let link = attributes[.link] as? String {
|
let linkValue = attributes[.link]
|
||||||
|
let link = (linkValue as? String) ?? (linkValue as? URL)?.absoluteString
|
||||||
|
if let link {
|
||||||
let nextCharIndex = range.upperBound
|
let nextCharIndex = range.upperBound
|
||||||
if nextCharIndex < post.length,
|
if nextCharIndex < post.length,
|
||||||
let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first,
|
let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first,
|
||||||
@@ -900,15 +902,19 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
|
|||||||
switch action {
|
switch action {
|
||||||
case .replying_to(let replying_to):
|
case .replying_to(let replying_to):
|
||||||
// start off with the reply tags
|
// start off with the reply tags
|
||||||
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
|
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair, relayURL: state.nostrNetwork.relaysForEvent(event: replying_to).first)
|
||||||
|
|
||||||
case .quoting(let ev):
|
case .quoting(let ev):
|
||||||
content.append("\n\nnostr:" + bech32_note_id(ev.id))
|
let relay_urls = state.nostrNetwork.relaysForEvent(event: ev)
|
||||||
|
let nevent = Bech32Object.encode(.nevent(NEvent(event: ev, relays: relay_urls.prefix(4).map { $0.absoluteString })))
|
||||||
|
content.append("\n\nnostr:\(nevent)")
|
||||||
|
|
||||||
tags.append(["q", ev.id.hex()]);
|
if let first_relay = relay_urls.first?.absoluteString {
|
||||||
|
tags.append(["q", ev.id.hex(), first_relay, ev.pubkey.hex()]);
|
||||||
if let quoted_ev = state.events.lookup(ev.id) {
|
tags.append(["p", ev.pubkey.hex(), first_relay])
|
||||||
tags.append(["p", quoted_ev.pubkey.hex()])
|
} else {
|
||||||
|
tags.append(["q", ev.id.hex(), "", ev.pubkey.hex()]);
|
||||||
|
tags.append(["p", ev.pubkey.hex()])
|
||||||
}
|
}
|
||||||
case .posting, .highlighting, .sharing:
|
case .posting, .highlighting, .sharing:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import SwiftUI
|
|||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
let BANNER_HEIGHT: CGFloat = 150.0;
|
let BANNER_HEIGHT: CGFloat = 150.0;
|
||||||
|
fileprivate let Scroll_height: CGFloat = 700.0
|
||||||
|
|
||||||
struct EditMetadataView: View {
|
struct EditMetadataView: View {
|
||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
@@ -79,11 +80,14 @@ struct EditMetadataView: View {
|
|||||||
func topSection(topLevelGeo: GeometryProxy) -> some View {
|
func topSection(topLevelGeo: GeometryProxy) -> some View {
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
|
let offset = geo.frame(in: .global).minY
|
||||||
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:), safeAreaInsets: topLevelGeo.safeAreaInsets, banner_image: URL(string: banner))
|
EditBannerImageView(damus_state: damus_state, viewModel: bannerUploadObserver, callback: uploadedBanner(image_url:), safeAreaInsets: topLevelGeo.safeAreaInsets, banner_image: URL(string: banner))
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(width: geo.size.width, height: BANNER_HEIGHT)
|
.frame(width: geo.size.width, height: offset > 0 ? BANNER_HEIGHT + offset : BANNER_HEIGHT)
|
||||||
.clipped()
|
.clipped()
|
||||||
}.frame(height: BANNER_HEIGHT)
|
.offset(y: offset > 0 ? -offset : 0) // Pin the top
|
||||||
|
}
|
||||||
|
.frame(height: BANNER_HEIGHT)
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
let pfp_size: CGFloat = 90.0
|
let pfp_size: CGFloat = 90.0
|
||||||
|
|
||||||
@@ -129,7 +133,9 @@ struct EditMetadataView: View {
|
|||||||
|
|
||||||
func content(topLevelGeo: GeometryProxy) -> some View {
|
func content(topLevelGeo: GeometryProxy) -> some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
self.topSection(topLevelGeo: topLevelGeo)
|
self.topSection(topLevelGeo: topLevelGeo)
|
||||||
|
|
||||||
Form {
|
Form {
|
||||||
Section(NSLocalizedString("Your Name", comment: "Label for Your Name section of user profile form.")) {
|
Section(NSLocalizedString("Your Name", comment: "Label for Your Name section of user profile form.")) {
|
||||||
let display_name_placeholder = "Satoshi Nakamoto"
|
let display_name_placeholder = "Satoshi Nakamoto"
|
||||||
@@ -198,6 +204,8 @@ struct EditMetadataView: View {
|
|||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
.frame(height: Scroll_height)
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if !ln.isEmpty && !is_ln_valid(ln: ln) {
|
if !ln.isEmpty && !is_ln_valid(ln: ln) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Created by William Casarin on 2022-04-16.
|
// Created by William Casarin on 2022-04-16.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import FaviconFinder
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum FriendType {
|
enum FriendType {
|
||||||
@@ -43,6 +44,7 @@ struct ProfileName: View {
|
|||||||
@State var nip05: NIP05?
|
@State var nip05: NIP05?
|
||||||
@State var donation: Int?
|
@State var donation: Int?
|
||||||
@State var purple_account: DamusPurple.Account?
|
@State var purple_account: DamusPurple.Account?
|
||||||
|
@State var nip05_domain_favicon: FaviconURL?
|
||||||
|
|
||||||
init(pubkey: Pubkey, prefix: String = "", damus: DamusState, show_nip5_domain: Bool = true, supporterBadgeStyle: SupporterBadge.Style = .compact) {
|
init(pubkey: Pubkey, prefix: String = "", damus: DamusState, show_nip5_domain: Bool = true, supporterBadgeStyle: SupporterBadge.Style = .compact) {
|
||||||
self.pubkey = pubkey
|
self.pubkey = pubkey
|
||||||
@@ -101,7 +103,7 @@ struct ProfileName: View {
|
|||||||
.fontWeight(prefix == "@" ? .none : .bold)
|
.fontWeight(prefix == "@" ? .none : .bold)
|
||||||
|
|
||||||
if let nip05 = current_nip05 {
|
if let nip05 = current_nip05 {
|
||||||
NIP05Badge(nip05: nip05, pubkey: pubkey, contacts: damus_state.contacts, show_domain: show_nip5_domain, profiles: damus_state.profiles)
|
NIP05Badge(nip05: nip05, pubkey: pubkey, damus_state: damus_state, show_domain: show_nip5_domain, nip05_domain_favicon: nip05_domain_favicon)
|
||||||
}
|
}
|
||||||
|
|
||||||
if let friend = friend_type, current_nip05 == nil {
|
if let friend = friend_type, current_nip05 == nil {
|
||||||
@@ -122,6 +124,12 @@ struct ProfileName: View {
|
|||||||
self.purple_account = try? await damus_state.purple.get_maybe_cached_account(pubkey: pubkey)
|
self.purple_account = try? await damus_state.purple.get_maybe_cached_account(pubkey: pubkey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
if let domain = current_nip05?.host {
|
||||||
|
self.nip05_domain_favicon = try? await damus_state.favicon_cache.lookup(domain)
|
||||||
|
.largest()
|
||||||
|
}
|
||||||
|
}
|
||||||
.onReceive(handle_notify(.profile_updated)) { update in
|
.onReceive(handle_notify(.profile_updated)) { update in
|
||||||
if update.pubkey != pubkey {
|
if update.pubkey != pubkey {
|
||||||
return
|
return
|
||||||
@@ -151,6 +159,24 @@ struct ProfileName: View {
|
|||||||
let nip05 = damus_state.profiles.is_validated(pubkey)
|
let nip05 = damus_state.profiles.is_validated(pubkey)
|
||||||
if nip05 != self.nip05 {
|
if nip05 != self.nip05 {
|
||||||
self.nip05 = nip05
|
self.nip05 = nip05
|
||||||
|
|
||||||
|
if let domain = nip05?.host {
|
||||||
|
Task {
|
||||||
|
let favicon = try? await damus_state.favicon_cache.lookup(domain)
|
||||||
|
.filter {
|
||||||
|
if let size = $0.size {
|
||||||
|
return size.width <= 128 && size.height <= 128
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.largest()
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.nip05_domain_favicon = favicon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if donation != profile.damus_donation {
|
if donation != profile.damus_donation {
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ func pfp_line_width(_ h: Highlight) -> CGFloat {
|
|||||||
struct InnerProfilePicView: View {
|
struct InnerProfilePicView: View {
|
||||||
let url: URL?
|
let url: URL?
|
||||||
let fallbackUrl: URL?
|
let fallbackUrl: URL?
|
||||||
let pubkey: Pubkey
|
|
||||||
let size: CGFloat
|
let size: CGFloat
|
||||||
let highlight: Highlight
|
let highlight: Highlight
|
||||||
let disable_animation: Bool
|
let disable_animation: Bool
|
||||||
@@ -65,16 +64,19 @@ struct InnerProfilePicView: View {
|
|||||||
|
|
||||||
|
|
||||||
struct ProfilePicView: View {
|
struct ProfilePicView: View {
|
||||||
|
@Environment(\.redactionReasons) var redactionReasons
|
||||||
|
|
||||||
let pubkey: Pubkey
|
let pubkey: Pubkey
|
||||||
let size: CGFloat
|
let size: CGFloat
|
||||||
let highlight: Highlight
|
let highlight: Highlight
|
||||||
let profiles: Profiles
|
let profiles: Profiles
|
||||||
let disable_animation: Bool
|
let disable_animation: Bool
|
||||||
let zappability_indicator: Bool
|
let zappability_indicator: Bool
|
||||||
|
let privacy_sensitive: Bool
|
||||||
|
|
||||||
@State var picture: String?
|
@State var picture: String?
|
||||||
|
|
||||||
init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil) {
|
init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil, privacy_sensitive: Bool = false) {
|
||||||
self.pubkey = pubkey
|
self.pubkey = pubkey
|
||||||
self.profiles = profiles
|
self.profiles = profiles
|
||||||
self.size = size
|
self.size = size
|
||||||
@@ -82,6 +84,15 @@ struct ProfilePicView: View {
|
|||||||
self._picture = State(initialValue: picture)
|
self._picture = State(initialValue: picture)
|
||||||
self.disable_animation = disable_animation
|
self.disable_animation = disable_animation
|
||||||
self.zappability_indicator = show_zappability ?? false
|
self.zappability_indicator = show_zappability ?? false
|
||||||
|
self.privacy_sensitive = privacy_sensitive
|
||||||
|
}
|
||||||
|
|
||||||
|
var privacy_sensitive_pubkey: Pubkey {
|
||||||
|
if privacy_sensitive && redactionReasons.contains(.privacy) {
|
||||||
|
ANON_PUBKEY
|
||||||
|
} else {
|
||||||
|
pubkey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func get_lnurl() -> String? {
|
func get_lnurl() -> String? {
|
||||||
@@ -90,7 +101,7 @@ struct ProfilePicView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack (alignment: Alignment(horizontal: .trailing, vertical: .bottom)) {
|
ZStack (alignment: Alignment(horizontal: .trailing, vertical: .bottom)) {
|
||||||
InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(pubkey)), pubkey: pubkey, size: size, highlight: highlight, disable_animation: disable_animation)
|
InnerProfilePicView(url: get_profile_url(picture: picture, pubkey: privacy_sensitive_pubkey, profiles: profiles), fallbackUrl: URL(string: robohash(privacy_sensitive_pubkey)), size: size, highlight: highlight, disable_animation: disable_animation)
|
||||||
.onReceive(handle_notify(.profile_updated)) { updated in
|
.onReceive(handle_notify(.profile_updated)) { updated in
|
||||||
guard updated.pubkey == self.pubkey else {
|
guard updated.pubkey == self.pubkey else {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ struct ProfileView: View {
|
|||||||
var filters = ContentFilters.defaults(damus_state: damus_state)
|
var filters = ContentFilters.defaults(damus_state: damus_state)
|
||||||
filters.append(fstate.filter)
|
filters.append(fstate.filter)
|
||||||
switch fstate {
|
switch fstate {
|
||||||
case .posts, .posts_and_replies:
|
case .posts, .posts_and_replies, .follow_list:
|
||||||
filters.append({ profile.pubkey == $0.pubkey })
|
filters.append({ profile.pubkey == $0.pubkey })
|
||||||
case .conversations:
|
case .conversations:
|
||||||
filters.append({ profile.conversation_events.contains($0.id) } )
|
filters.append({ profile.conversation_events.contains($0.id) } )
|
||||||
@@ -464,13 +464,13 @@ struct ProfileView: View {
|
|||||||
.background(colorScheme == .dark ? Color.black : Color.white)
|
.background(colorScheme == .dark ? Color.black : Color.white)
|
||||||
|
|
||||||
if filter_state == FilterState.posts {
|
if filter_state == FilterState.posts {
|
||||||
InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts))
|
InnerTimelineView(events: profile.events, pinned_events: profile.pinned_events, damus: damus_state, filter: content_filter(FilterState.posts))
|
||||||
}
|
}
|
||||||
if filter_state == FilterState.posts_and_replies {
|
if filter_state == FilterState.posts_and_replies {
|
||||||
InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts_and_replies))
|
InnerTimelineView(events: profile.events, pinned_events: profile.pinned_events, damus: damus_state, filter: content_filter(FilterState.posts_and_replies))
|
||||||
}
|
}
|
||||||
if filter_state == FilterState.conversations && !profile.conversation_events.isEmpty {
|
if filter_state == FilterState.conversations && !profile.conversation_events.isEmpty {
|
||||||
InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.conversations))
|
InnerTimelineView(events: profile.events, pinned_events: EventHolder(), damus: damus_state, filter: content_filter(FilterState.conversations))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Theme.safeAreaInsets?.left)
|
.padding(.horizontal, Theme.safeAreaInsets?.left)
|
||||||
|
|||||||
@@ -12,9 +12,19 @@ struct QuoteRepostsView: View {
|
|||||||
@ObservedObject var model: EventsModel
|
@ObservedObject var model: EventsModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: ContentFilters.default_filters(damus_state: damus_state).filter(ev:))
|
TimelineView(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: ContentFilters.default_filters(damus_state: damus_state).filter(ev:)) {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
DamusBackground(maxHeight: 250)
|
||||||
|
.mask(LinearGradient(gradient: Gradient(colors: [.black, .black, .black, .clear]), startPoint: .top, endPoint: .bottom))
|
||||||
|
Text("Quotes", comment: "Navigation bar title for Quote Reposts view.")
|
||||||
|
.foregroundStyle(DamusLogoGradient.gradient)
|
||||||
|
.font(.title.bold())
|
||||||
|
.padding(.leading, 30)
|
||||||
|
.padding(.top, 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
.padding(.bottom, tabHeight)
|
.padding(.bottom, tabHeight)
|
||||||
.navigationBarTitle(NSLocalizedString("Quotes", comment: "Navigation bar title for Quote Reposts view."))
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
model.subscribe()
|
model.subscribe()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ struct SearchHomeView: View {
|
|||||||
@State var search: String = ""
|
@State var search: String = ""
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
var content_filter: (NostrEvent) -> Bool {
|
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
|
||||||
let filters = ContentFilters.defaults(damus_state: self.damus_state)
|
var filters = ContentFilters.defaults(damus_state: damus_state)
|
||||||
|
filters.append(fstate.filter)
|
||||||
return ContentFilters(filters: filters).filter
|
return ContentFilters(filters: filters).filter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,21 +53,20 @@ struct SearchHomeView: View {
|
|||||||
loading: $model.loading,
|
loading: $model.loading,
|
||||||
damus: damus_state,
|
damus: damus_state,
|
||||||
show_friend_icon: true,
|
show_friend_icon: true,
|
||||||
filter: { ev in
|
filter:content_filter(FilterState.posts),
|
||||||
if !content_filter(ev) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let event_muted = damus_state.mutelist_manager.is_event_muted(ev)
|
|
||||||
if event_muted {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
content: {
|
content: {
|
||||||
AnyView(VStack {
|
AnyView(VStack(alignment: .leading) {
|
||||||
SuggestedHashtagsView(damus_state: damus_state, max_items: 5, events: model.events)
|
HStack {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.foregroundStyle(PinkGradient)
|
||||||
|
Text("Follow Packs", comment: "A label indicating that the items below it are follow packs")
|
||||||
|
.foregroundStyle(PinkGradient)
|
||||||
|
}
|
||||||
|
.padding(.top)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
FollowPackTimelineView<AnyView>(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true,filter:content_filter(FilterState.follow_list)
|
||||||
|
).padding(.bottom)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.frame(height: 1)
|
.frame(height: 1)
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ struct AppearanceSettingsView: View {
|
|||||||
header: Text("Content filters", comment: "Section title for content filtering/moderation configuration."),
|
header: Text("Content filters", comment: "Section title for content filtering/moderation configuration."),
|
||||||
footer: Text("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Section footer clarifying what #nsfw (not safe for work) tags mean")
|
footer: Text("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Section footer clarifying what #nsfw (not safe for work) tags mean")
|
||||||
) {
|
) {
|
||||||
|
Toggle(NSLocalizedString("Show replies from your trusted network first", comment: "Setting to show replies in threads from the current user's trusted network first."), isOn: $settings.show_trusted_replies_first)
|
||||||
|
.toggleStyle(.switch)
|
||||||
Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with the #nsfw (not safe for work) tags"), isOn: $settings.hide_nsfw_tagged_content)
|
Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with the #nsfw (not safe for work) tags"), isOn: $settings.hide_nsfw_tagged_content)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ struct DeveloperSettingsView: View {
|
|||||||
|
|
||||||
Toggle(NSLocalizedString("Enable experimental Purple In-app purchase support", comment: "Developer mode setting to enable experimental Purple In-app purchase support."), isOn: $settings.enable_experimental_purple_iap_support)
|
Toggle(NSLocalizedString("Enable experimental Purple In-app purchase support", comment: "Developer mode setting to enable experimental Purple In-app purchase support."), isOn: $settings.enable_experimental_purple_iap_support)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
if #available(iOS 17, *) {
|
||||||
|
Toggle(NSLocalizedString("Reset tips on launch", comment: "Developer mode setting to reset tips upon app first launch. Tips are visual contextual hints that highlight new, interesting, or unused features users have not discovered yet."), isOn: $settings.reset_tips_on_launch)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ struct KeySettingsView: View {
|
|||||||
.disabled(true)
|
.disabled(true)
|
||||||
} else {
|
} else {
|
||||||
Text(sec)
|
Text(sec)
|
||||||
|
.privacySensitive()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ struct ZapSettingsView: View {
|
|||||||
Section(NSLocalizedString("NWC wallet", comment: "Title for section in zap settings that controls general NWC wallet settings.")) {
|
Section(NSLocalizedString("NWC wallet", comment: "Title for section in zap settings that controls general NWC wallet settings.")) {
|
||||||
Toggle(NSLocalizedString("Disable high balance warning", comment: "Setting to disable high balance warnings on the user's wallet"), isOn: $settings.dismiss_wallet_high_balance_warning)
|
Toggle(NSLocalizedString("Disable high balance warning", comment: "Setting to disable high balance warnings on the user's wallet"), isOn: $settings.dismiss_wallet_high_balance_warning)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
|
Toggle(NSLocalizedString("Hide balance", comment: "Setting to hide wallet balance."), isOn: $settings.hide_wallet_balance)
|
||||||
|
.toggleStyle(.switch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(NSLocalizedString("Zaps", comment: "Navigation title for zap settings."))
|
.navigationTitle(NSLocalizedString("Zaps", comment: "Navigation title for zap settings."))
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import SwiftUI
|
|||||||
|
|
||||||
struct InnerTimelineView: View {
|
struct InnerTimelineView: View {
|
||||||
@ObservedObject var events: EventHolder
|
@ObservedObject var events: EventHolder
|
||||||
|
@ObservedObject var pinned_events: EventHolder
|
||||||
let state: DamusState
|
let state: DamusState
|
||||||
let filter: (NostrEvent) -> Bool
|
let filter: (NostrEvent) -> Bool
|
||||||
|
|
||||||
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
|
init(events: EventHolder, pinned_events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
|
||||||
self.events = events
|
self.events = events
|
||||||
|
self.pinned_events = pinned_events
|
||||||
self.state = damus
|
self.state = damus
|
||||||
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
|
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
|
||||||
}
|
}
|
||||||
@@ -29,7 +31,7 @@ struct InnerTimelineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
let events = self.events.events
|
let events = self.pinned_events.events + self.events.events
|
||||||
if events.isEmpty {
|
if events.isEmpty {
|
||||||
EmptyTimelineView()
|
EmptyTimelineView()
|
||||||
} else {
|
} else {
|
||||||
@@ -38,7 +40,7 @@ struct InnerTimelineView: View {
|
|||||||
ForEach(indexed, id: \.0.id) { tup in
|
ForEach(indexed, id: \.0.id) { tup in
|
||||||
let ev = tup.0
|
let ev = tup.0
|
||||||
let ind = tup.1
|
let ind = tup.1
|
||||||
EventView(damus: state, event: ev, options: event_options)
|
EventView(damus: state, event: ev, pinned: Set(self.pinned_events.events.map { $0.id }), options: event_options)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
let event = ev.get_inner_event(cache: state.events) ?? ev
|
let event = ev.get_inner_event(cache: state.events) ?? ev
|
||||||
let thread = ThreadModel(event: event, damus_state: state)
|
let thread = ThreadModel(event: event, damus_state: state)
|
||||||
@@ -69,7 +71,7 @@ struct InnerTimelineView: View {
|
|||||||
|
|
||||||
struct InnerTimelineView_Previews: PreviewProvider {
|
struct InnerTimelineView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
InnerTimelineView(events: test_event_holder, damus: test_damus_state, filter: { _ in true })
|
InnerTimelineView(events: test_event_holder, pinned_events: EventHolder(), damus: test_damus_state, filter: { _ in true })
|
||||||
.frame(width: 300, height: 500)
|
.frame(width: 300, height: 500)
|
||||||
.border(Color.red)
|
.border(Color.red)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ struct TimelineView<Content: View>: View {
|
|||||||
.id("startblock")
|
.id("startblock")
|
||||||
.frame(height: 0)
|
.frame(height: 0)
|
||||||
|
|
||||||
InnerTimelineView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
|
InnerTimelineView(events: events, pinned_events: EventHolder(), damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
|
||||||
.redacted(reason: loading ? .placeholder : [])
|
.redacted(reason: loading ? .placeholder : [])
|
||||||
.shimmer(loading)
|
.shimmer(loading)
|
||||||
.disabled(loading)
|
.disabled(loading)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// TrustedNetworkButtonTip.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 6/4/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import TipKit
|
||||||
|
|
||||||
|
@available(iOS 17, *)
|
||||||
|
struct TrustedNetworkButtonTip: Tip {
|
||||||
|
static let shared = TrustedNetworkButtonTip()
|
||||||
|
|
||||||
|
var title: Text {
|
||||||
|
Text("Toggle visibility of content from outside your trusted network", comment: "Title of tip that informs users what trusted network means and that they can toggle the visibility of content from outside their trusted network.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var message: Text? {
|
||||||
|
Text("Your trusted network is comprised of profiles you follow and profiles that they follow.", comment: "Description of the tip that informs users what trusted network means.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var image: Image? {
|
||||||
|
Image(systemName: "network.badge.shield.half.filled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// TrustedNetworkButtonTipViewStyle.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 6/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import TipKit
|
||||||
|
|
||||||
|
// (tyiu): Apple's native popover tips have a lot of rendering and race condition issues --
|
||||||
|
// text being rendered in the wrong locations or not at all, or the tip gets opened in full screen.
|
||||||
|
//
|
||||||
|
// Instead, we are introducing this custom popover tip view style to emulate a similar look and feel.
|
||||||
|
// The main thing needed from this view style is really just an arrow on the top right corner
|
||||||
|
// to point to the TrustedNetworkButton on the NotificationsView and DirectMessagesview.
|
||||||
|
@available(iOS 17, *)
|
||||||
|
struct TrustedNetworkButtonTipViewStyle: TipViewStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Arrow pointing up to the button (positioned at top right)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Triangle()
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
.frame(width: 24, height: 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
// Icon
|
||||||
|
configuration.image
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.font(.title2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
configuration.title
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
configuration.message
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button(action: { configuration.tip.invalidate(reason: .tipClosed) }) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(Color(.tertiaryLabel))
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(Color(.secondarySystemBackground))
|
||||||
|
.clipShape(
|
||||||
|
.rect(
|
||||||
|
topLeadingRadius: 20,
|
||||||
|
bottomLeadingRadius: 20,
|
||||||
|
bottomTrailingRadius: 20,
|
||||||
|
topTrailingRadius: 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom triangle shape for the popover arrow
|
||||||
|
struct Triangle: Shape {
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||||
|
path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//
|
||||||
|
// TrustedNetworkRepliesTip.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Terry Yiu on 6/7/25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import TipKit
|
||||||
|
|
||||||
|
@available(iOS 17, *)
|
||||||
|
struct TrustedNetworkRepliesTip: Tip {
|
||||||
|
static let shared = TrustedNetworkRepliesTip()
|
||||||
|
|
||||||
|
var title: Text {
|
||||||
|
Text("Toggle visibility of replies from outside your trusted network", comment: "Title of tip that informs users what trusted network means and that they can toggle the visibility of threaded replies from outside their trusted network.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var message: Text? {
|
||||||
|
Text("Your trusted network is comprised of profiles you follow and profiles that they follow.", comment: "Description of the tip that informs users what trusted network means.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var image: Image? {
|
||||||
|
Image(systemName: "network.badge.shield.half.filled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,24 +10,46 @@ import SwiftUI
|
|||||||
struct BalanceView: View {
|
struct BalanceView: View {
|
||||||
var balance: Int64?
|
var balance: Int64?
|
||||||
|
|
||||||
|
@Binding var hide_balance: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 5) {
|
VStack(spacing: 5) {
|
||||||
Text("Current balance", comment: "Label for displaying current wallet balance")
|
Text("Current balance", comment: "Label for displaying current wallet balance")
|
||||||
.foregroundStyle(DamusColors.neutral6)
|
.foregroundStyle(DamusColors.neutral6)
|
||||||
if let balance {
|
if let balance {
|
||||||
self.numericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal))
|
NumericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal), hide_balance: $hide_balance)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Make sure we do not show any numeric value to the user when still loading (or when failed to load)
|
// Make sure we do not show any numeric value to the user when still loading (or when failed to load)
|
||||||
// This is important because if we show a numeric value like "zero" when things are not loaded properly, we risk scaring the user into thinking that they have lost funds.
|
// This is important because if we show a numeric value like "zero" when things are not loaded properly, we risk scaring the user into thinking that they have lost funds.
|
||||||
self.numericalBalanceView(text: "??")
|
Text(verbatim: "??")
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.70)
|
||||||
|
.font(.veryVeryLargeTitle)
|
||||||
|
.fontWeight(.heavy)
|
||||||
|
.foregroundStyle(PinkGradient)
|
||||||
.redacted(reason: .placeholder)
|
.redacted(reason: .placeholder)
|
||||||
.shimmer(true)
|
.shimmer(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func numericalBalanceView(text: String) -> some View {
|
struct NumericalBalanceView: View {
|
||||||
|
let text: String
|
||||||
|
@Binding var hide_balance: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if hide_balance {
|
||||||
|
Text(verbatim: "*****")
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.70)
|
||||||
|
.font(.veryVeryLargeTitle)
|
||||||
|
.fontWeight(.heavy)
|
||||||
|
.foregroundStyle(PinkGradient)
|
||||||
|
|
||||||
|
} else {
|
||||||
HStack {
|
HStack {
|
||||||
Text(verbatim: text)
|
Text(verbatim: text)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -43,14 +65,21 @@ struct BalanceView: View {
|
|||||||
.foregroundStyle(PinkGradient)
|
.foregroundStyle(PinkGradient)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.privacySensitive()
|
||||||
.padding(.bottom)
|
.padding(.bottom)
|
||||||
|
.onTapGesture {
|
||||||
|
hide_balance.toggle()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BalanceView_Previews: PreviewProvider {
|
struct BalanceView_Previews: PreviewProvider {
|
||||||
|
@State private static var hide_balance: Bool = false
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
BalanceView(balance: 100000000)
|
BalanceView(balance: 100000000, hide_balance: $hide_balance)
|
||||||
BalanceView(balance: nil)
|
BalanceView(balance: nil, hide_balance: $hide_balance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
//
|
||||||
|
// LnurlAmountView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-06-18
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class LnurlAmountModel: ObservableObject {
|
||||||
|
@Published var custom_amount: String = "0"
|
||||||
|
@Published var custom_amount_sats: Int? = 0
|
||||||
|
@Published var processing: Bool = false
|
||||||
|
@Published var error: String? = nil
|
||||||
|
@Published var invoice: String? = nil
|
||||||
|
@Published var zap_amounts: [ZapAmountItem] = []
|
||||||
|
|
||||||
|
func set_defaults(settings: UserSettingsStore) {
|
||||||
|
let default_amount = settings.default_zap_amount
|
||||||
|
custom_amount = String(default_amount)
|
||||||
|
custom_amount_sats = default_amount
|
||||||
|
zap_amounts = get_zap_amount_items(default_amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables the user to enter a Bitcoin amount to be sent. Based on `CustomizeZapView`.
|
||||||
|
struct LnurlAmountView: View {
|
||||||
|
let damus_state: DamusState
|
||||||
|
let lnurlString: String
|
||||||
|
let onInvoiceFetched: (Invoice) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
@StateObject var model: LnurlAmountModel = LnurlAmountModel()
|
||||||
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@FocusState var isAmountFocused: Bool
|
||||||
|
|
||||||
|
init(damus_state: DamusState, lnurlString: String, onInvoiceFetched: @escaping (Invoice) -> Void, onCancel: @escaping () -> Void) {
|
||||||
|
self.damus_state = damus_state
|
||||||
|
self.lnurlString = lnurlString
|
||||||
|
self.onInvoiceFetched = onInvoiceFetched
|
||||||
|
self.onCancel = onCancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func AmountButton(zapAmountItem: ZapAmountItem) -> some View {
|
||||||
|
let isSelected = model.custom_amount_sats == zapAmountItem.amount
|
||||||
|
|
||||||
|
return Button(action: {
|
||||||
|
model.custom_amount_sats = zapAmountItem.amount
|
||||||
|
model.custom_amount = String(zapAmountItem.amount)
|
||||||
|
}) {
|
||||||
|
let fmt = format_msats_abbrev(Int64(zapAmountItem.amount) * 1000)
|
||||||
|
Text(verbatim: "\(zapAmountItem.icon)\n\(fmt)")
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.font(.headline)
|
||||||
|
.frame(width: 70, height: 70)
|
||||||
|
.foregroundColor(DamusColors.adaptableBlack)
|
||||||
|
.background(isSelected ? DamusColors.adaptableWhite : DamusColors.adaptableGrey)
|
||||||
|
.cornerRadius(15)
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 15)
|
||||||
|
.stroke(DamusColors.purple.opacity(isSelected ? 1.0 : 0.0), lineWidth: 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func amount_parts(_ n: Int) -> [ZapAmountItem] {
|
||||||
|
var i: Int = -1
|
||||||
|
let start = n * 4
|
||||||
|
let end = start + 4
|
||||||
|
|
||||||
|
return model.zap_amounts.filter { _ in
|
||||||
|
i += 1
|
||||||
|
return i >= start && i < end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AmountsPart(n: Int) -> some View {
|
||||||
|
HStack(alignment: .center, spacing: 15) {
|
||||||
|
ForEach(amount_parts(n)) { entry in
|
||||||
|
AmountButton(zapAmountItem: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var AmountGrid: some View {
|
||||||
|
VStack {
|
||||||
|
AmountsPart(n: 0)
|
||||||
|
|
||||||
|
AmountsPart(n: 1)
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
var CustomAmountTextField: some View {
|
||||||
|
VStack(alignment: .center, spacing: 0) {
|
||||||
|
TextField("", text: $model.custom_amount)
|
||||||
|
.focused($isAmountFocused)
|
||||||
|
.task {
|
||||||
|
self.isAmountFocused = true
|
||||||
|
}
|
||||||
|
.font(.system(size: 72, weight: .heavy))
|
||||||
|
.minimumScaleFactor(0.01)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.onChange(of: model.custom_amount) { newValue in
|
||||||
|
if let parsed = handle_string_amount(new_value: newValue) {
|
||||||
|
model.custom_amount = parsed.formatted()
|
||||||
|
model.custom_amount_sats = parsed
|
||||||
|
} else {
|
||||||
|
model.custom_amount = "0"
|
||||||
|
model.custom_amount_sats = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let noun = pluralizedString(key: "sats", count: model.custom_amount_sats ?? 0)
|
||||||
|
Text(noun)
|
||||||
|
.font(.system(size: 18, weight: .heavy))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchInvoice() {
|
||||||
|
guard let amount = model.custom_amount_sats, amount > 0 else {
|
||||||
|
model.error = NSLocalizedString("Please enter a valid amount", comment: "Error message when no valid amount is entered for LNURL payment")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model.processing = true
|
||||||
|
model.error = nil
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
// For LNURL payments without zaps, we use nil for zapreq and comment
|
||||||
|
// We just need the invoice for payment
|
||||||
|
let msats = Int64(amount) * 1000
|
||||||
|
|
||||||
|
// First get the payment request from the LNURL
|
||||||
|
guard let payreq = await fetch_static_payreq(lnurlString) else {
|
||||||
|
model.processing = false
|
||||||
|
model.error = NSLocalizedString("Error fetching LNURL payment information", comment: "Error message when LNURL fetch fails")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then fetch the invoice with the amount
|
||||||
|
guard let invoiceStr = await fetch_zap_invoice(payreq, zapreq: nil, msats: msats, zap_type: .non_zap, comment: nil) else {
|
||||||
|
model.processing = false
|
||||||
|
model.error = NSLocalizedString("Error fetching lightning invoice", comment: "Error message when there was an error fetching a lightning invoice")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the invoice to validate it
|
||||||
|
guard let invoice = decode_bolt11(invoiceStr) else {
|
||||||
|
model.processing = false
|
||||||
|
model.error = NSLocalizedString("Invalid lightning invoice received", comment: "Error message when the lightning invoice received from LNURL is invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// All good, pass the invoice back to the parent view
|
||||||
|
model.processing = false
|
||||||
|
onInvoiceFetched(invoice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var PayButton: some View {
|
||||||
|
VStack {
|
||||||
|
if model.processing {
|
||||||
|
Text("Processing...", comment: "Text to indicate that the app is in the process of fetching an invoice.")
|
||||||
|
.padding()
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Button(action: {
|
||||||
|
fetchInvoice()
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("Continue", comment: "Button to proceed with LNURL payment process.")
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
}
|
||||||
|
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
.buttonStyle(GradientButtonStyle())
|
||||||
|
.disabled(model.custom_amount_sats == 0 || model.custom_amount == "0")
|
||||||
|
.opacity(model.custom_amount_sats == 0 || model.custom_amount == "0" ? 0.5 : 1.0)
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = model.error {
|
||||||
|
Text(error)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var CancelButton: some View {
|
||||||
|
Button(action: onCancel) {
|
||||||
|
HStack {
|
||||||
|
Text("Cancel", comment: "Button to cancel the LNURL payment process.")
|
||||||
|
.font(.headline)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
.buttonStyle(NeutralButtonStyle())
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .center, spacing: 20) {
|
||||||
|
ScrollView {
|
||||||
|
VStack {
|
||||||
|
Text("Enter Amount", comment: "Header text for LNURL payment amount entry screen")
|
||||||
|
.font(.title)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Text("How much would you like to send?", comment: "Instruction text for LNURL payment amount")
|
||||||
|
.font(.headline)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.bottom)
|
||||||
|
|
||||||
|
CustomAmountTextField
|
||||||
|
|
||||||
|
AmountGrid
|
||||||
|
|
||||||
|
PayButton
|
||||||
|
|
||||||
|
CancelButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
model.set_defaults(settings: damus_state.settings)
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
hideKeyboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LnurlAmountView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
LnurlAmountView(
|
||||||
|
damus_state: test_damus_state,
|
||||||
|
lnurlString: "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns",
|
||||||
|
onInvoiceFetched: { _ in },
|
||||||
|
onCancel: {}
|
||||||
|
)
|
||||||
|
.frame(width: 400, height: 600)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
|
||||||
struct NWCSettings: View {
|
struct NWCSettings: View {
|
||||||
|
|
||||||
@@ -16,6 +17,18 @@ struct NWCSettings: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
// Budget sync state tracking
|
||||||
|
@State private var isCoinosWallet: Bool = false
|
||||||
|
@State private var maxWeeklyBudget: UInt64? = nil
|
||||||
|
@State private var budgetSyncState: BudgetSyncState = .undefined
|
||||||
|
|
||||||
|
// Min/max budget values for slider
|
||||||
|
private let minBudget: UInt64 = 100
|
||||||
|
private let maxBudget: UInt64 = 10_000_000
|
||||||
|
|
||||||
|
// Slider min/max values for logarithmic scale (0-1 range)
|
||||||
|
private let sliderMin: Double = 0.0
|
||||||
|
private let sliderMax: Double = 1.0
|
||||||
|
|
||||||
func donation_binding() -> Binding<Double> {
|
func donation_binding() -> Binding<Double> {
|
||||||
return Binding(get: {
|
return Binding(get: {
|
||||||
@@ -139,6 +152,78 @@ struct NWCSettings: View {
|
|||||||
Toggle(NSLocalizedString("Disable high balance warning", comment: "Setting to disable high balance warnings on the user's wallet"), isOn: $settings.dismiss_wallet_high_balance_warning)
|
Toggle(NSLocalizedString("Disable high balance warning", comment: "Setting to disable high balance warnings on the user's wallet"), isOn: $settings.dismiss_wallet_high_balance_warning)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
Toggle(NSLocalizedString("Hide balance", comment: "Setting to hide wallet balance."), isOn: $settings.hide_wallet_balance)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
|
||||||
|
if isCoinosWallet, let maxWeeklyBudget {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Max weekly budget", comment: "Label for setting the maximum weekly budget for Coinos wallet")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
Text("The maximum amount of funds that are allowed to be sent out from this wallet each week.", comment: "Description explaining the purpose of the 'Max weekly budget' setting for Coinos one-click setup wallets")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Slider(
|
||||||
|
// Use a logarithmic scale for this slider to give more control to different kinds of users:
|
||||||
|
//
|
||||||
|
// - Users with higher budget tolerance can select very high amounts (e.g. Easy to go up to 5M or 10M sats)
|
||||||
|
// - Conservative users can still have fine-grained control over lower amounts (e.g. Easy to switch between 500 and 1.5K sats)
|
||||||
|
value: Binding(
|
||||||
|
get: {
|
||||||
|
// Convert from budget value to slider position (0-1)
|
||||||
|
budgetToSliderPosition(budget: maxWeeklyBudget)
|
||||||
|
},
|
||||||
|
set: {
|
||||||
|
// Convert from slider position to budget value
|
||||||
|
let newValue = sliderPositionToBudget(position: $0)
|
||||||
|
if self.maxWeeklyBudget != newValue {
|
||||||
|
self.maxWeeklyBudget = newValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
in: sliderMin...sliderMax,
|
||||||
|
onEditingChanged: { editing in
|
||||||
|
if !editing {
|
||||||
|
updateMaxWeeklyBudget()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(verbatim: format_msats(Int64(maxWeeklyBudget) * 1000))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.frame(width: 150, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget sync status
|
||||||
|
HStack {
|
||||||
|
switch budgetSyncState {
|
||||||
|
case .undefined:
|
||||||
|
EmptyView()
|
||||||
|
case .success:
|
||||||
|
HStack {
|
||||||
|
Image("check-circle.fill")
|
||||||
|
.foregroundStyle(.damusGreen)
|
||||||
|
Text("Successfully updated", comment: "Label indicating success in updating budget")
|
||||||
|
}
|
||||||
|
case .syncing:
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Updating", comment: "Label indicating budget update is in progress")
|
||||||
|
}
|
||||||
|
case .failure(let error):
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(.damusDangerPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
self.model.disconnect()
|
self.model.disconnect()
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -153,6 +238,10 @@ struct NWCSettings: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.onAppear() {
|
.onAppear() {
|
||||||
model.initial_percent = model.settings.donation_percent
|
model.initial_percent = model.settings.donation_percent
|
||||||
|
checkIfCoinosWallet()
|
||||||
|
if isCoinosWallet {
|
||||||
|
fetchCurrentBudget()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: model.settings.donation_percent) { p in
|
.onChange(of: model.settings.donation_percent) { p in
|
||||||
let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
|
let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
|
||||||
@@ -183,6 +272,79 @@ struct NWCSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the current wallet is a Coinos one-click wallet
|
||||||
|
private func checkIfCoinosWallet() {
|
||||||
|
// Check condition 1: Relay is coinos.io
|
||||||
|
let isRelayCoinos = nwc.relay.absoluteString == "wss://relay.coinos.io"
|
||||||
|
|
||||||
|
// Check condition 2: LUD16 matches expected format
|
||||||
|
guard let keypair = damus_state.keypair.to_full() else {
|
||||||
|
isCoinosWallet = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = CoinosDeterministicAccountClient(userKeypair: keypair)
|
||||||
|
let expectedLud16 = client.expectedLud16
|
||||||
|
|
||||||
|
isCoinosWallet = isRelayCoinos && nwc.lud16 == expectedLud16
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the current max weekly budget from Coinos
|
||||||
|
private func fetchCurrentBudget() {
|
||||||
|
guard let keypair = damus_state.keypair.to_full() else { return }
|
||||||
|
|
||||||
|
let client = CoinosDeterministicAccountClient(userKeypair: keypair)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
if let config = try await client.getNWCAppConnectionConfig(),
|
||||||
|
let maxAmount = config.max_amount {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.maxWeeklyBudget = maxAmount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.budgetSyncState = .failure(error: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the max weekly budget on Coinos
|
||||||
|
private func updateMaxWeeklyBudget() {
|
||||||
|
guard let maxWeeklyBudget else { return }
|
||||||
|
guard let keypair = damus_state.keypair.to_full() else { return }
|
||||||
|
|
||||||
|
budgetSyncState = .syncing
|
||||||
|
|
||||||
|
let client = CoinosDeterministicAccountClient(userKeypair: keypair)
|
||||||
|
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
// First ensure we're logged in
|
||||||
|
try await client.loginIfNeeded()
|
||||||
|
|
||||||
|
// Update the connection with the new budget
|
||||||
|
_ = try await client.updateNWCConnection(maxAmount: maxWeeklyBudget)
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.budgetSyncState = .success
|
||||||
|
|
||||||
|
// Reset success state after a delay
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
||||||
|
if case .success = self.budgetSyncState {
|
||||||
|
self.budgetSyncState = .undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.budgetSyncState = .failure(error: error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct AccountDetailsView: View {
|
struct AccountDetailsView: View {
|
||||||
let nwc: WalletConnect.ConnectURL
|
let nwc: WalletConnect.ConnectURL
|
||||||
let damus_state: DamusState?
|
let damus_state: DamusState?
|
||||||
@@ -230,6 +392,40 @@ struct NWCSettings: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Logarithmic scale conversions
|
||||||
|
|
||||||
|
/// Converts from budget value to a slider position (0-1 range)
|
||||||
|
func budgetToSliderPosition(budget: UInt64) -> Double {
|
||||||
|
// Ensure budget is within bounds
|
||||||
|
let clampedBudget = max(minBudget, min(maxBudget, budget))
|
||||||
|
|
||||||
|
// Calculate the log scale position
|
||||||
|
let minLog = log10(Double(minBudget))
|
||||||
|
let maxLog = log10(Double(maxBudget))
|
||||||
|
let budgetLog = log10(Double(clampedBudget))
|
||||||
|
|
||||||
|
// Convert to 0-1 range
|
||||||
|
return (budgetLog - minLog) / (maxLog - minLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert from slider position (0-1) to budget value
|
||||||
|
func sliderPositionToBudget(position: Double) -> UInt64 {
|
||||||
|
// Ensure position is within bounds
|
||||||
|
let clampedPosition = max(sliderMin, min(sliderMax, position))
|
||||||
|
|
||||||
|
// Calculate the log scale value
|
||||||
|
let minLog = log10(Double(minBudget))
|
||||||
|
let maxLog = log10(Double(maxBudget))
|
||||||
|
let valueLog = minLog + clampedPosition * (maxLog - minLog)
|
||||||
|
|
||||||
|
// Convert to budget value and round to nearest 100 to make the number look "cleaner"
|
||||||
|
let exactValue = pow(10, valueLog)
|
||||||
|
let roundedValue = round(exactValue / 100) * 100
|
||||||
|
|
||||||
|
return UInt64(roundedValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct NWCSettings_Previews: PreviewProvider {
|
struct NWCSettings_Previews: PreviewProvider {
|
||||||
@@ -238,3 +434,16 @@ struct NWCSettings_Previews: PreviewProvider {
|
|||||||
NWCSettings(damus_state: tds, nwc: test_wallet_connect_url, model: WalletModel(state: .existing(test_wallet_connect_url), settings: tds.settings), settings: tds.settings)
|
NWCSettings(damus_state: tds, nwc: test_wallet_connect_url, model: WalletModel(state: .existing(test_wallet_connect_url), settings: tds.settings), settings: tds.settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension NWCSettings {
|
||||||
|
enum BudgetSyncState: Equatable {
|
||||||
|
/// State is unknown
|
||||||
|
case undefined
|
||||||
|
/// Budget is successfully updated
|
||||||
|
case success
|
||||||
|
/// Budget is being updated
|
||||||
|
case syncing
|
||||||
|
/// There was a failure during update
|
||||||
|
case failure(error: String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
//
|
||||||
|
// SendPaymentView.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-06-13.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import CodeScanner
|
||||||
|
|
||||||
|
fileprivate let SEND_PAYMENT_TIMEOUT: Duration = .seconds(10)
|
||||||
|
|
||||||
|
/// A view that allows a user to pay a lightning invoice
|
||||||
|
struct SendPaymentView: View {
|
||||||
|
|
||||||
|
// MARK: - Helper structures
|
||||||
|
|
||||||
|
/// Represents the state of the invoice payment process
|
||||||
|
enum SendState {
|
||||||
|
case enterInvoice(scannerMessage: String?)
|
||||||
|
case confirmPayment(invoice: Invoice)
|
||||||
|
case enterLnurlAmount(lnurl: String)
|
||||||
|
case processing
|
||||||
|
case completed
|
||||||
|
case failed(error: HumanReadableError)
|
||||||
|
}
|
||||||
|
|
||||||
|
typealias HumanReadableError = ErrorView.UserPresentableError
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Immutable members
|
||||||
|
|
||||||
|
let damus_state: DamusState
|
||||||
|
let model: WalletModel
|
||||||
|
let nwc: WalletConnectURL
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - State management
|
||||||
|
|
||||||
|
@State private var sendState: SendState = .enterInvoice(scannerMessage: nil) {
|
||||||
|
didSet {
|
||||||
|
switch sendState {
|
||||||
|
case .enterInvoice, .confirmPayment, .processing, .enterLnurlAmount:
|
||||||
|
break
|
||||||
|
case .completed:
|
||||||
|
// Refresh wallet to reflect new balance after payment
|
||||||
|
Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) }
|
||||||
|
case .failed:
|
||||||
|
// Even when a wallet says it has failed, update balance just in case it is a false negative,
|
||||||
|
// This might prevent the user from accidentally sending a payment twice in case of a bug.
|
||||||
|
Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var isShowingScanner: Bool {
|
||||||
|
if case .enterInvoice = sendState { true } else { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Views
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .center) {
|
||||||
|
switch sendState {
|
||||||
|
case .enterInvoice(let scannerMessage):
|
||||||
|
invoiceInputView(scannerMessage: scannerMessage)
|
||||||
|
.padding(40)
|
||||||
|
case .confirmPayment(let invoice):
|
||||||
|
confirmationView(invoice: invoice)
|
||||||
|
.padding(40)
|
||||||
|
case .enterLnurlAmount(let lnurl):
|
||||||
|
LnurlAmountView(
|
||||||
|
damus_state: damus_state,
|
||||||
|
lnurlString: lnurl,
|
||||||
|
onInvoiceFetched: { invoice in
|
||||||
|
sendState = .confirmPayment(invoice: invoice)
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
sendState = .enterInvoice(scannerMessage: nil)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
case .processing:
|
||||||
|
processingView
|
||||||
|
.padding(40)
|
||||||
|
case .completed:
|
||||||
|
completedView
|
||||||
|
.padding(40)
|
||||||
|
case .failed(error: let error):
|
||||||
|
failedView(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invoiceInputView(scannerMessage: String?) -> some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Text("Scan Lightning Invoice", comment: "Title for the invoice scanning screen")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
|
||||||
|
CodeScannerView(
|
||||||
|
codeTypes: [.qr],
|
||||||
|
scanMode: .continuous,
|
||||||
|
showViewfinder: true, // The scan only seems to work if it fits the bounding box, so let's make this visible to hint that to the users
|
||||||
|
simulatedData: "lightning:lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r",
|
||||||
|
completion: handleScan
|
||||||
|
)
|
||||||
|
.frame(height: 300)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.stroke(Color.accentColor, lineWidth: 2)
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
VStack(spacing: 15) {
|
||||||
|
Button(action: {
|
||||||
|
if let pastedInvoice = getPasteboardContent() {
|
||||||
|
processUserInput(pastedInvoice)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "doc.on.clipboard")
|
||||||
|
Text("Paste from Clipboard", comment: "Button to paste invoice from clipboard")
|
||||||
|
}
|
||||||
|
.frame(minWidth: 250, maxWidth: .infinity, alignment: .center)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.buttonStyle(NeutralButtonStyle())
|
||||||
|
.accessibilityLabel(NSLocalizedString("Paste invoice from clipboard", comment: "Accessibility label for the invoice paste button"))
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if let scannerMessage {
|
||||||
|
Text(scannerMessage)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmationView(invoice: Invoice) -> some View {
|
||||||
|
let insufficientFunds: Bool = (invoice.amount.amount_sats() ?? 0) > (model.balance ?? 0)
|
||||||
|
return VStack(spacing: 20) {
|
||||||
|
Text("Confirm Payment", comment: "Title for payment confirmation screen")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
|
||||||
|
VStack(spacing: 15) {
|
||||||
|
Text("Amount", comment: "Label for invoice payment amount in confirmation screen")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if case .specific(let amount) = invoice.amount {
|
||||||
|
NumericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(value: (Double(amount) / 1000.0)), number: .decimal), hide_balance: .constant(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Bolt11 Invoice", comment: "Label for the bolt11 invoice string in confirmation screen")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
Text(verbatim: invoice.abbreviated)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.padding()
|
||||||
|
.background(DamusColors.adaptableGrey)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 15) {
|
||||||
|
Button(action: {
|
||||||
|
sendState = .enterInvoice(scannerMessage: nil)
|
||||||
|
}) {
|
||||||
|
Text("Back", comment: "Button to go back to invoice input")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(minWidth: 140)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.buttonStyle(NeutralButtonStyle())
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
sendState = .processing
|
||||||
|
|
||||||
|
// Process payment
|
||||||
|
guard let payRequestEv = WalletConnect.pay(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: invoice.string, zap_request: nil, delay: nil) else {
|
||||||
|
sendState = .failed(error: .init(
|
||||||
|
user_visible_description: NSLocalizedString("The payment request could not be made to your wallet provider.", comment: "A human-readable error message"),
|
||||||
|
tip: NSLocalizedString("Check if your wallet looks configured correctly and try again. If the error persists, please contact support.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."),
|
||||||
|
technical_info: "Cannot form Nostr Event to send to the NWC provider when calling `pay` from the \"send payment\" feature. Wallet provider relay: \"\(nwc.relay)\""
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let result = try await model.waitForResponse(for: payRequestEv.id, timeout: SEND_PAYMENT_TIMEOUT)
|
||||||
|
guard case .pay_invoice(_) = result else {
|
||||||
|
sendState = .failed(error: .init(
|
||||||
|
user_visible_description: NSLocalizedString("Received an incorrect or unexpected response from the wallet provider. This looks like an issue with your wallet provider.", comment: "A human-readable error message"),
|
||||||
|
tip: NSLocalizedString("Try again. If the error persists, please contact your wallet provider and/or our support team.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."),
|
||||||
|
technical_info: "Expected a `pay_invoice` response for the request, but received a different type of response from the NWC wallet provider. Wallet provider relay: \"\(nwc.relay)\""
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendState = .completed
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if let error = error as? WalletModel.WaitError {
|
||||||
|
switch error {
|
||||||
|
case .timeout:
|
||||||
|
sendState = .failed(error: .init(
|
||||||
|
user_visible_description: NSLocalizedString("The payment request did not receive a response and the request timed-out.", comment: "A human-readable error message"),
|
||||||
|
tip: NSLocalizedString("Check if the invoice is valid, your wallet is online, configured correctly, and try again. If the error persists, please contact support and/or your wallet provider.", comment: "A human-readable tip guiding the user on what to do when seeing a timeout error while sending a wallet payment."),
|
||||||
|
technical_info: "Payment request timed-out. Wallet provider relay: \"\(nwc.relay)\""
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if let error = error as? WalletConnect.WalletResponseErr,
|
||||||
|
let humanReadableError = error.humanReadableError {
|
||||||
|
sendState = .failed(error: humanReadableError)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
sendState = .failed(error: .init(
|
||||||
|
user_visible_description: NSLocalizedString("An unexpected error occurred.", comment: "A human-readable error message"),
|
||||||
|
tip: NSLocalizedString("Please try again. If the error persists, please contact support.", comment: "A human-readable tip guiding the user on what to do when seeing an unexpected error while sending a wallet payment."),
|
||||||
|
technical_info: "Unexpected error thrown while waiting for payment request response. Wallet provider relay: \"\(nwc.relay)\"; Error: \(error)"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Confirm", comment: "Button to confirm payment")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(minWidth: 140)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.buttonStyle(GradientButtonStyle(padding: 0))
|
||||||
|
.disabled(insufficientFunds)
|
||||||
|
.opacity(insufficientFunds ? 0.5 : 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if insufficientFunds {
|
||||||
|
Text("You do not have enough funds to pay for this invoice.", comment: "Label on invoice payment screen, indicating user has insufficient funds")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.top, 10)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var processingView: some View {
|
||||||
|
VStack(spacing: 30) {
|
||||||
|
Text("Processing Payment", comment: "Title for payment processing screen")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(1.5)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Text("Please wait while your payment is being processed…", comment: "Message while payment is being processed")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var completedView: some View {
|
||||||
|
VStack(spacing: 30) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
|
||||||
|
Text("Payment Sent!", comment: "Title for successful payment screen")
|
||||||
|
.font(.title2)
|
||||||
|
.bold()
|
||||||
|
|
||||||
|
Text("Your payment has been successfully sent.", comment: "Message for successful payment")
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
dismiss()
|
||||||
|
}) {
|
||||||
|
Text("Done", comment: "Button to dismiss successful payment screen")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(minWidth: 200)
|
||||||
|
}
|
||||||
|
.buttonStyle(GradientButtonStyle())
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failedView(error: HumanReadableError) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack {
|
||||||
|
ErrorView(damus_state: damus_state, error: error)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
sendState = .enterInvoice(scannerMessage: nil)
|
||||||
|
}) {
|
||||||
|
Text("Try Again", comment: "Button to retry payment")
|
||||||
|
.font(.headline)
|
||||||
|
.frame(minWidth: 200)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.buttonStyle(GradientButtonStyle(padding: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScan(result: Result<ScanResult, ScanError>) {
|
||||||
|
switch result {
|
||||||
|
case .success(let result):
|
||||||
|
processUserInput(result.string)
|
||||||
|
case .failure(let error):
|
||||||
|
sendState = .enterInvoice(scannerMessage: NSLocalizedString("Failed to scan QR code, please try again.", comment: "Error message for failed QR scan"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processUserInput(_ text: String) {
|
||||||
|
if let result = parseScanData(text) {
|
||||||
|
switch result {
|
||||||
|
case .invoice(let invoice):
|
||||||
|
if invoice.amount == .any {
|
||||||
|
sendState = .enterInvoice(scannerMessage: NSLocalizedString("Sorry, we do not support paying invoices without amount yet. Please scan an invoice with an amount.", comment: "A user-readable message indicating that the lightning invoice they scanned or pasted is not supported and is missing an amount."))
|
||||||
|
} else {
|
||||||
|
sendState = .confirmPayment(invoice: invoice)
|
||||||
|
}
|
||||||
|
case .lnurl(let lnurlString):
|
||||||
|
sendState = .enterLnurlAmount(lnurl: lnurlString)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sendState = .enterInvoice(scannerMessage: NSLocalizedString("This does not appear to be a valid Lightning invoice or LNURL.", comment: "A user-readable message indicating that the scanned or pasted content was not a valid lightning invoice or LNURL."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScanData(_ text: String) -> ScanData? {
|
||||||
|
let processedString = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
|
||||||
|
if let invoice = Invoice.from(string: processedString) {
|
||||||
|
return .invoice(invoice)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let _ = processedString.range(of: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", options: .regularExpression) {
|
||||||
|
guard let lnurl = lnaddress_to_lnurl(processedString) else { return nil }
|
||||||
|
return .lnurl(lnurl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if processedString.hasPrefix("lnurl") {
|
||||||
|
return .lnurl(processedString)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScanData {
|
||||||
|
case invoice(Invoice)
|
||||||
|
case lnurl(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get pasteboard content
|
||||||
|
func getPasteboardContent() -> String? {
|
||||||
|
return UIPasteboard.general.string
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,29 +8,36 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TransactionView: View {
|
struct TransactionView: View {
|
||||||
|
@Environment(\.redactionReasons) var redactionReasons
|
||||||
|
|
||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
var transaction: WalletConnect.Transaction
|
var transaction: WalletConnect.Transaction
|
||||||
|
|
||||||
|
@Binding var hide_balance: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let redactedForPrivacy = redactionReasons.contains(.privacy)
|
||||||
let isIncomingTransaction = transaction.type == "incoming"
|
let isIncomingTransaction = transaction.type == "incoming"
|
||||||
let txType = isIncomingTransaction ? "arrow-bottom-left" : "arrow-top-right"
|
let txType = isIncomingTransaction ? "arrow-bottom-left" : "arrow-top-right"
|
||||||
let txColor = isIncomingTransaction ? DamusColors.success : Color.gray
|
let txColor = (isIncomingTransaction && !hide_balance && !redactedForPrivacy) ? DamusColors.success : Color.gray
|
||||||
let txOp = isIncomingTransaction ? "+" : "-"
|
let txOp = isIncomingTransaction ? "+" : "-"
|
||||||
let created_at = Date.init(timeIntervalSince1970: TimeInterval(transaction.created_at))
|
let created_at = Date.init(timeIntervalSince1970: TimeInterval(transaction.created_at))
|
||||||
let formatter = RelativeDateTimeFormatter()
|
let formatter = RelativeDateTimeFormatter()
|
||||||
let relativeDate = formatter.localizedString(for: created_at, relativeTo: Date.now)
|
let relativeDate = formatter.localizedString(for: created_at, relativeTo: Date.now)
|
||||||
let event = decode_nostr_event_json(transaction.description ?? "") ?? transaction.metadata?.nostr
|
let event = decode_nostr_event_json(transaction.description ?? "") ?? transaction.metadata?.nostr
|
||||||
let pubkey = self.pubkeyToDisplay(for: event, isIncomingTransaction: isIncomingTransaction) ?? ANON_PUBKEY
|
let pubkey = self.pubkeyToDisplay(for: event, isIncomingTransaction: isIncomingTransaction)
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
ZStack {
|
ZStack {
|
||||||
ProfilePicView(pubkey: pubkey, size: 45, highlight: .custom(.damusAdaptableBlack, 0.1), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
|
ProfilePicView(pubkey: pubkey ?? ANON_PUBKEY, size: 45, highlight: .custom(.damusAdaptableBlack, 0.1), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, privacy_sensitive: true)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
|
if let pubkey {
|
||||||
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hide_balance && !redactedForPrivacy {
|
||||||
Image(txType)
|
Image(txType)
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 18, height: 18)
|
.frame(width: 18, height: 18)
|
||||||
@@ -42,6 +49,7 @@ struct TransactionView: View {
|
|||||||
.padding(.top, 25)
|
.padding(.top, 25)
|
||||||
.padding(.leading, 35)
|
.padding(.leading, 35)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|
||||||
@@ -58,11 +66,18 @@ struct TransactionView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
if hide_balance {
|
||||||
|
Text(verbatim: "*****")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(txColor)
|
||||||
|
.bold()
|
||||||
|
} else {
|
||||||
Text(verbatim: "\(txOp) \(format_msats(transaction.amount))")
|
Text(verbatim: "\(txOp) \(format_msats(transaction.amount))")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.foregroundColor(txColor)
|
.foregroundColor(txColor)
|
||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.frame(maxWidth: .infinity, minHeight: 75, alignment: .center)
|
.frame(maxWidth: .infinity, minHeight: 75, alignment: .center)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.background(DamusColors.neutral1)
|
.background(DamusColors.neutral1)
|
||||||
@@ -84,17 +99,15 @@ struct TransactionView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func userDisplayName(pubkey: Pubkey) -> String {
|
func userDisplayName(pubkey: Pubkey?) -> String {
|
||||||
|
guard let pubkey else {
|
||||||
|
return NSLocalizedString("Unknown", comment: "A name label for an unknown user")
|
||||||
|
}
|
||||||
|
|
||||||
let profile_txn = damus_state.profiles.lookup(id: pubkey, txn_name: "txview-profile")
|
let profile_txn = damus_state.profiles.lookup(id: pubkey, txn_name: "txview-profile")
|
||||||
let profile = profile_txn?.unsafeUnownedValue
|
let profile = profile_txn?.unsafeUnownedValue
|
||||||
|
|
||||||
if let display_name = profile?.display_name {
|
return Profile.displayName(profile: profile, pubkey: pubkey).displayName
|
||||||
return display_name
|
|
||||||
} else if let name = profile?.name {
|
|
||||||
return "@" + name
|
|
||||||
} else {
|
|
||||||
return NSLocalizedString("Unknown", comment: "A name label for an unknown user")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -107,17 +120,20 @@ struct TransactionsView: View {
|
|||||||
transactions?.sorted(by: { $0.created_at > $1.created_at })
|
transactions?.sorted(by: { $0.created_at > $1.created_at })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Binding var hide_balance: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Latest transactions", comment: "Heading for latest wallet transactions list")
|
Text("Latest transactions", comment: "Heading for latest wallet transactions list")
|
||||||
.foregroundStyle(DamusColors.neutral6)
|
.foregroundStyle(DamusColors.neutral6)
|
||||||
|
|
||||||
|
Group {
|
||||||
if let sortedTransactions {
|
if let sortedTransactions {
|
||||||
if sortedTransactions.isEmpty {
|
if sortedTransactions.isEmpty {
|
||||||
emptyTransactions
|
emptyTransactions
|
||||||
} else {
|
} else {
|
||||||
ForEach(sortedTransactions, id: \.self) { transaction in
|
ForEach(sortedTransactions, id: \.self) { transaction in
|
||||||
TransactionView(damus_state: damus_state, transaction: transaction)
|
TransactionView(damus_state: damus_state, transaction: transaction, hide_balance: $hide_balance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +145,8 @@ struct TransactionsView: View {
|
|||||||
.shimmer(true)
|
.shimmer(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.privacySensitive()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var emptyTransactions: some View {
|
var emptyTransactions: some View {
|
||||||
@@ -155,7 +173,9 @@ struct TransactionsView_Previews: PreviewProvider {
|
|||||||
static let transaction4: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "1234567890662", amount: 720000, fees_paid: 0, created_at: 1737090300, expires_at: 0, settled_at: 0, metadata: nil)
|
static let transaction4: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "1234567890662", amount: 720000, fees_paid: 0, created_at: 1737090300, expires_at: 0, settled_at: 0, metadata: nil)
|
||||||
static var test_transactions: [WalletConnect.Transaction] = [transaction1, transaction2, transaction3, transaction4]
|
static var test_transactions: [WalletConnect.Transaction] = [transaction1, transaction2, transaction3, transaction4]
|
||||||
|
|
||||||
|
@State private static var hide_balance: Bool = false
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
TransactionsView(damus_state: tds, transactions: test_transactions)
|
TransactionsView(damus_state: tds, transactions: test_transactions, hide_balance: $hide_balance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ let WALLET_WARNING_THRESHOLD: UInt64 = 100000
|
|||||||
struct WalletView: View {
|
struct WalletView: View {
|
||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
@State var show_settings: Bool = false
|
@State var show_settings: Bool = false
|
||||||
|
@State var show_send_sheet: Bool = false
|
||||||
@ObservedObject var model: WalletModel
|
@ObservedObject var model: WalletModel
|
||||||
@ObservedObject var settings: UserSettingsStore
|
@ObservedObject var settings: UserSettingsStore
|
||||||
|
@State private var showBalance: Bool = false
|
||||||
|
|
||||||
init(damus_state: DamusState, model: WalletModel? = nil) {
|
init(damus_state: DamusState, model: WalletModel? = nil) {
|
||||||
self.damus_state = damus_state
|
self.damus_state = damus_state
|
||||||
@@ -47,6 +49,7 @@ struct WalletView: View {
|
|||||||
.bold()
|
.bold()
|
||||||
.foregroundStyle(.damusWarningTertiary)
|
.foregroundStyle(.damusWarningTertiary)
|
||||||
}
|
}
|
||||||
|
.privacySensitive()
|
||||||
.padding()
|
.padding()
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: 20)
|
RoundedRectangle(cornerRadius: 20)
|
||||||
@@ -56,9 +59,22 @@ struct WalletView: View {
|
|||||||
|
|
||||||
VStack(spacing: 5) {
|
VStack(spacing: 5) {
|
||||||
|
|
||||||
BalanceView(balance: model.balance)
|
BalanceView(balance: model.balance, hide_balance: $settings.hide_wallet_balance)
|
||||||
|
|
||||||
TransactionsView(damus_state: damus_state, transactions: model.transactions)
|
Button(action: {
|
||||||
|
show_send_sheet = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "paperplane.fill")
|
||||||
|
Text("Send", comment: "Button label to send bitcoin payment from wallet")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(GradientButtonStyle())
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
|
||||||
|
TransactionsView(damus_state: damus_state, transactions: model.transactions, hide_balance: $settings.hide_wallet_balance)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(NSLocalizedString("Wallet", comment: "Navigation title for Wallet view"))
|
.navigationTitle(NSLocalizedString("Wallet", comment: "Navigation title for Wallet view"))
|
||||||
@@ -102,23 +118,17 @@ struct WalletView: View {
|
|||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
.presentationDetents([.large])
|
.presentationDetents([.large])
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $show_send_sheet) {
|
||||||
|
SendPaymentView(damus_state: damus_state, model: model, nwc: nwc)
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func updateWalletInformation() async {
|
func updateWalletInformation() async {
|
||||||
guard let url = damus_state.settings.nostr_wallet_connect,
|
await WalletConnect.update_wallet_information(damus_state: damus_state)
|
||||||
let nwc = WalletConnectURL(str: url) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let flusher: OnFlush? = nil
|
|
||||||
|
|
||||||
let delay = 0.0 // We don't need a delay when fetching a transaction list or balance
|
|
||||||
|
|
||||||
WalletConnect.request_transaction_list(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
|
||||||
WalletConnect.request_balance_information(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user