Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b8c2896468
|
|||
| 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
|
|||
| e9c1671d06 | |||
| d02847d466 | |||
| 580fa954b2 | |||
| aef516ae9f | |||
| eb4e3b692b | |||
| fe52381d63 | |||
| ab8d52e685 | |||
| 1d32200ae3 | |||
| 309b00380d | |||
| 7fa2118480 | |||
| 1a6c17e308 | |||
| 82a6046620 | |||
| 241755c8c4 | |||
| b26f66f15c | |||
| 28bd0c81e8 | |||
| 0bd1814877 | |||
| ee94f67b94 | |||
| 3a25075473 | |||
| d16ff8f78f | |||
| 38dc90cb33 | |||
| 52bbc698b2 | |||
| 496a11f597 | |||
| 4a8a0ea1bd | |||
| c424d4da99 | |||
| 69d5fc1553 | |||
| bcb59896db | |||
| e1e6d9eb3d | |||
| f1fdae5957 | |||
| f96647fa40 | |||
| 5ea522d306 | |||
| 54d6161acd | |||
| b1fd84fd75 | |||
| 9dbdf7928a | |||
| 67f0e3d296 | |||
| e498418c2d | |||
| 33150a42c5 |
@@ -6,3 +6,4 @@ damus.xcodeproj/xcshareddata/xcbaselines
|
|||||||
TODO.bak
|
TODO.bak
|
||||||
tags
|
tags
|
||||||
build-git-hash.txt
|
build-git-hash.txt
|
||||||
|
.build
|
||||||
|
|||||||
@@ -1,3 +1,51 @@
|
|||||||
|
## [1.14] - 2025-05-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added safety reminder to wallets with higher balance (Daniel D’Aquino)
|
||||||
|
- Added one-click Coinos wallet setup (Daniel D’Aquino)
|
||||||
|
- Add notification setting to hide hellthreads (Terry Yiu)
|
||||||
|
- Added separated first aid option for relay lists that does not need a contact list reset (Daniel D’Aquino)
|
||||||
|
- Added NIP-65 relay list support (Daniel D’Aquino)
|
||||||
|
- Added Unicode 16 emoji reactions for iOS 18.4+ by upgrading EmojiPicker (Terry Yiu)
|
||||||
|
- Added a search interface to the settings screen (SanjaySiddharth)
|
||||||
|
- Added view introducing users to Zaps (ericholguin)
|
||||||
|
- Added new wallet view with balance and transactions list (ericholguin)
|
||||||
|
- Added copy technical info button to user visible errors, so that users can more easily share errors with developers (Daniel D’Aquino)
|
||||||
|
- Add dismiss button to wallet high balance reminders (Daniel D’Aquino)
|
||||||
|
- Zap receiver information now included for outgoing zaps (Daniel D’Aquino)
|
||||||
|
- Added inline note rendering of invoices to pull up wallet selector sheet (Terry Yiu)
|
||||||
|
- Added route to profile page from wallet tx list (ericholguin)
|
||||||
|
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added additional information on top of blurred images (SanjaySiddharth)
|
||||||
|
- Improved robustness of relay list handling (Daniel D’Aquino)
|
||||||
|
- Updated image cache for better stability (Daniel D’Aquino)
|
||||||
|
- Improved integration with Nostr Wallet Connect wallets (ericholguin)
|
||||||
|
- Added relay connectivity information to NWC settings (Daniel D’Aquino)
|
||||||
|
- Improved handling around NWC responses (Daniel D’Aquino)
|
||||||
|
- Added more human visible errors on NWC wallets to aid with troubleshooting (Daniel D’Aquino)
|
||||||
|
- Re-enabled note zaps as permitted by the new App Store guidelines (Daniel D’Aquino)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hide future notes from timeline (Terry Yiu)
|
||||||
|
- Fixed issue where profiles with a NIP-65 relay list would not display on Damus (Daniel D’Aquino)
|
||||||
|
- Fix quote notes to include missing q tag (Terry Yiu)
|
||||||
|
- Fixed issue where the side menu would close when copying the npub (SanjaySiddharth)
|
||||||
|
- Fixed issue where cached images would be backed up to iCloud (Daniel D’Aquino)
|
||||||
|
- Optimized classify_url function (Terry Yiu)
|
||||||
|
- Fixed note rendering for those that contain previewable items or leading and trailing whitespaces (Terry Yiu)
|
||||||
|
- Fixed issue where some videos would become unplayable after some time using the app (Daniel D’Aquino)
|
||||||
|
|
||||||
|
|
||||||
|
[1.14]: https://github.com/damus-io/damus/releases/tag/v1.14
|
||||||
|
|
||||||
|
|
||||||
## [1.13.1] - 2025-03-21
|
## [1.13.1] - 2025-03-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+267
-18
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,26 +94,30 @@ enum OpenWalletError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
func open_with_wallet(wallet: Wallet.Model, invoice: String) throws {
|
||||||
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
|
let url = try getUrlToOpen(invoice: invoice, with: wallet)
|
||||||
this_app.open(url)
|
this_app.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUrlToOpen(invoice: String, with wallet: Wallet.Model) throws(OpenWalletError) -> URL {
|
||||||
|
if let url = URL(string: "\(wallet.link)\(invoice)"), this_app.canOpenURL(url) {
|
||||||
|
return url
|
||||||
} else {
|
} else {
|
||||||
guard let store_link = wallet.appStoreLink else {
|
guard let store_link = wallet.appStoreLink else {
|
||||||
throw OpenWalletError.no_wallet_to_open
|
throw .no_wallet_to_open
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let url = URL(string: store_link) else {
|
guard let url = URL(string: store_link) else {
|
||||||
throw OpenWalletError.store_link_invalid
|
throw .store_link_invalid
|
||||||
}
|
}
|
||||||
|
|
||||||
guard this_app.canOpenURL(url) else {
|
guard this_app.canOpenURL(url) else {
|
||||||
throw OpenWalletError.system_cannot_open_store_link
|
throw .system_cannot_open_store_link
|
||||||
}
|
}
|
||||||
|
|
||||||
this_app.open(url)
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
|
let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119)
|
||||||
|
|
||||||
struct InvoiceView_Previews: PreviewProvider {
|
struct InvoiceView_Previews: PreviewProvider {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust
|
|||||||
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
|
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
|
||||||
let delay = damus_state.settings.nozaps ? nil : 5.0
|
let delay = damus_state.settings.nozaps ? nil : 5.0
|
||||||
|
|
||||||
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, delay: delay, on_flush: flusher)
|
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, zap_request: zapreq, delay: delay, on_flush: flusher)
|
||||||
|
|
||||||
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
|
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
|
||||||
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
|
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ func translate_note(profiles: Profiles, keypair: Keypair, event: NostrEvent, set
|
|||||||
|
|
||||||
// Render translated note
|
// Render translated note
|
||||||
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
|
let translated_blocks = parse_note_content(content: .content(translated_note, event.tags))
|
||||||
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles)
|
let artifacts = render_blocks(blocks: translated_blocks, profiles: profiles, can_hide_last_previewable_refs: true)
|
||||||
|
|
||||||
// and cache it
|
// and cache it
|
||||||
return .translated(Translated(artifacts: artifacts, language: note_lang))
|
return .translated(Translated(artifacts: artifacts, language: note_lang))
|
||||||
|
|||||||
+37
-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) {
|
||||||
@@ -742,6 +772,8 @@ struct ContentView: View {
|
|||||||
case route(Route)
|
case route(Route)
|
||||||
/// Open a sheet
|
/// Open a sheet
|
||||||
case sheet(Sheets)
|
case sheet(Sheets)
|
||||||
|
/// Open an external URL
|
||||||
|
case external_url(URL)
|
||||||
/// Do nothing.
|
/// Do nothing.
|
||||||
///
|
///
|
||||||
/// ## Implementation notes
|
/// ## Implementation notes
|
||||||
@@ -758,6 +790,8 @@ struct ContentView: View {
|
|||||||
navigationCoordinator.push(route: route)
|
navigationCoordinator.push(route: route)
|
||||||
case .sheet(let sheet):
|
case .sheet(let sheet):
|
||||||
self.active_sheet = sheet
|
self.active_sheet = sheet
|
||||||
|
case .external_url(let url):
|
||||||
|
this_app.open(url)
|
||||||
case .no_action:
|
case .no_action:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,34 +265,41 @@ class HomeModel: ContactsDelegate {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
|
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
|
||||||
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
|
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
|
||||||
let nwc = WalletConnectURL(str: nwc_str),
|
let nwc = WalletConnectURL(str: nwc_str) else {
|
||||||
let resp = await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard nwc.relay == relay else { return } // Don't process NWC responses coming from relays other than our designated one
|
||||||
|
guard ev.referenced_pubkeys.first == nwc.keypair.pubkey else {
|
||||||
|
return // This message is not for us. Ignore it.
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp: WalletConnect.FullWalletResponse? = nil
|
||||||
|
do {
|
||||||
|
resp = try await WalletConnect.FullWalletResponse(from: ev, nwc: nwc)
|
||||||
|
} catch {
|
||||||
|
Log.error("HomeModel: Error on NWC wallet response handling: %s", for: .nwc, error.localizedDescription)
|
||||||
|
if let initError = error as? WalletConnect.FullWalletResponse.InitializationError,
|
||||||
|
let humanReadableError = initError.humanReadableError {
|
||||||
|
present_sheet(.error(humanReadableError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let resp else { return }
|
||||||
|
|
||||||
// since command results are not returned for ephemeral events,
|
// since command results are not returned for ephemeral events,
|
||||||
// remove the request from the postbox which is likely failing over and over
|
// remove the request from the postbox which is likely failing over and over
|
||||||
if damus_state.nostrNetwork.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
|
if damus_state.nostrNetwork.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) {
|
||||||
print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]")
|
Log.debug("HomeModel: got NWC response, removed %s from the postbox [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
|
||||||
} else {
|
} else {
|
||||||
print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]")
|
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 {
|
||||||
print("nwc error: \(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)
|
||||||
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 []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+115
-50
@@ -73,85 +73,143 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -
|
|||||||
return .longform(LongformContent(ev.content))
|
return .longform(LongformContent(ev.content))
|
||||||
}
|
}
|
||||||
|
|
||||||
return .separated(render_blocks(blocks: blocks, profiles: profiles))
|
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
|
||||||
}
|
}
|
||||||
|
|
||||||
func render_blocks(blocks bs: Blocks, profiles: Profiles) -> 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] = []
|
||||||
let blocks = bs.blocks
|
let blocks = bs.blocks
|
||||||
|
|
||||||
let one_note_ref = blocks
|
var end_mention_count = 0
|
||||||
.filter({
|
var end_url_count = 0
|
||||||
if case .mention(let mention) = $0,
|
|
||||||
case .note = mention.ref {
|
// Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
|
||||||
return true
|
var hide_text_index = blocks.endIndex
|
||||||
|
if can_hide_last_previewable_refs {
|
||||||
|
outerLoop: for (i, block) in blocks.enumerated().reversed() {
|
||||||
|
if block.is_previewable {
|
||||||
|
switch block {
|
||||||
|
case .mention:
|
||||||
|
end_mention_count += 1
|
||||||
|
|
||||||
|
// If there is more than one previewable mention,
|
||||||
|
// do not hide anything because we allow rich rendering of only one mention currently.
|
||||||
|
// This should be fixed in the future to show events inline instead.
|
||||||
|
if end_mention_count > 1 {
|
||||||
|
hide_text_index = blocks.endIndex
|
||||||
|
break outerLoop
|
||||||
|
}
|
||||||
|
case .url(let url):
|
||||||
|
let url_type = classify_url(url)
|
||||||
|
if case .link = url_type {
|
||||||
|
end_url_count += 1
|
||||||
|
|
||||||
|
// If there is more than one link, do not hide anything because we allow rich rendering of only
|
||||||
|
// one link.
|
||||||
|
if end_url_count > 1 {
|
||||||
|
hide_text_index = blocks.endIndex
|
||||||
|
break outerLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
hide_text_index = i
|
||||||
|
} 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 {
|
||||||
|
// We should keep hashtags at the end sequence but hide all the other previewables around it.
|
||||||
|
hide_text_index = i
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.count == 1
|
|
||||||
|
|
||||||
var ind: Int = -1
|
var ind: Int = -1
|
||||||
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
|
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
|
||||||
ind = ind + 1
|
ind = ind + 1
|
||||||
|
|
||||||
|
// Add the rendered previewable blocks to their type-specific lists.
|
||||||
switch block {
|
switch block {
|
||||||
case .mention(let m):
|
case .invoice(let invoice):
|
||||||
if case .note = m.ref, one_note_ref {
|
invoices.append(invoice)
|
||||||
|
case .url(let url):
|
||||||
|
let url_type = classify_url(url)
|
||||||
|
urls.append(url_type)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if can_hide_last_previewable_refs {
|
||||||
|
// If there are previewable blocks that occur before the consecutive sequence of them at the end of the content,
|
||||||
|
// we should not hide the text representation of any previewable block to avoid altering the format of the note.
|
||||||
|
if ind < hide_text_index && block.is_previewable {
|
||||||
|
hide_text_index = blocks.endIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// 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 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
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch block {
|
||||||
|
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):
|
||||||
return str + CompatibleText(stringLiteral: reduce_text_block(blocks: blocks, ind: ind, txt: txt, one_note_ref: one_note_ref))
|
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):
|
||||||
return str + hashtag_str(htag)
|
return str + hashtag_str(htag)
|
||||||
case .invoice(let invoice):
|
case .invoice(let invoice):
|
||||||
invoices.append(invoice)
|
return str + invoice_str(invoice)
|
||||||
return str
|
|
||||||
case .url(let url):
|
case .url(let url):
|
||||||
let url_type = classify_url(url)
|
|
||||||
switch url_type {
|
|
||||||
case .media:
|
|
||||||
urls.append(url_type)
|
|
||||||
return str
|
|
||||||
case .link(let url):
|
|
||||||
urls.append(url_type)
|
|
||||||
return str + url_str(url)
|
return str + url_str(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
|
return NoteArtifactsSeparated(content: txt, words: bs.words, urls: urls, invoices: invoices)
|
||||||
}
|
}
|
||||||
|
|
||||||
func reduce_text_block(blocks: [Block], ind: Int, txt: String, one_note_ref: Bool) -> String {
|
func reduce_text_block(ind: Int, hide_text_index: Int, txt: String) -> String {
|
||||||
var trimmed = txt
|
var trimmed = txt
|
||||||
|
|
||||||
if let prev = blocks[safe: ind-1],
|
// Trim leading whitespaces.
|
||||||
case .url(let u) = prev,
|
if ind == 0 {
|
||||||
classify_url(u).is_media != nil {
|
trimmed = trim_prefix(trimmed)
|
||||||
trimmed = " " + trim_prefix(trimmed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let next = blocks[safe: ind+1] {
|
// Trim trailing whitespaces if the following blocks will be hidden or if this is the last block.
|
||||||
if case .url(let u) = next, classify_url(u).is_media != nil {
|
if ind == hide_text_index - 1 {
|
||||||
trimmed = trim_suffix(trimmed)
|
trimmed = trim_suffix(trimmed)
|
||||||
} else if case .mention(let m) = next,
|
|
||||||
case .note = m.ref,
|
|
||||||
one_note_ref {
|
|
||||||
trimmed = trim_suffix(trimmed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func invoice_str(_ invoice: Invoice) -> CompatibleText {
|
||||||
|
var attributedString = AttributedString(stringLiteral: abbrev_identifier(invoice.string))
|
||||||
|
attributedString.link = URL(string: "damus:lightning:\(invoice.string)")
|
||||||
|
attributedString.foregroundColor = DamusColors.purple
|
||||||
|
|
||||||
|
return CompatibleText(attributed: attributedString)
|
||||||
|
}
|
||||||
|
|
||||||
func url_str(_ url: URL) -> CompatibleText {
|
func url_str(_ url: URL) -> CompatibleText {
|
||||||
var attributedString = AttributedString(stringLiteral: url.absoluteString)
|
var attributedString = AttributedString(stringLiteral: url.absoluteString)
|
||||||
attributedString.link = url
|
attributedString.link = url
|
||||||
@@ -161,18 +219,17 @@ func url_str(_ url: URL) -> CompatibleText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func classify_url(_ url: URL) -> UrlType {
|
func classify_url(_ url: URL) -> UrlType {
|
||||||
let str = url.lastPathComponent.lowercased()
|
let fileExtension = url.lastPathComponent.lowercased().components(separatedBy: ".").last
|
||||||
|
|
||||||
if str.hasSuffix(".png") || str.hasSuffix(".jpg") || str.hasSuffix(".jpeg") || str.hasSuffix(".gif") || str.hasSuffix(".webp") {
|
switch fileExtension {
|
||||||
|
case "png", "jpg", "jpeg", "gif", "webp":
|
||||||
return .media(.image(url))
|
return .media(.image(url))
|
||||||
}
|
case "mp4", "mov", "m3u8":
|
||||||
|
|
||||||
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
|
|
||||||
return .media(.video(url))
|
return .media(.video(url))
|
||||||
}
|
default:
|
||||||
|
|
||||||
return .link(url)
|
return .link(url)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
|
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
|
||||||
let attachment = NSTextAttachment()
|
let attachment = NSTextAttachment()
|
||||||
@@ -194,11 +251,11 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
|
|||||||
let display_str: String = {
|
let display_str: String = {
|
||||||
switch m.ref {
|
switch m.ref {
|
||||||
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
|
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
|
||||||
case .note: return abbrev_pubkey(bech32String)
|
case .note: return abbrev_identifier(bech32String)
|
||||||
case .nevent: return abbrev_pubkey(bech32String)
|
case .nevent: return abbrev_identifier(bech32String)
|
||||||
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
|
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
|
||||||
case .nrelay(let url): return url
|
case .nrelay(let url): return url
|
||||||
case .naddr: return abbrev_pubkey(bech32String)
|
case .naddr: return abbrev_identifier(bech32String)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -213,12 +270,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,8 +23,6 @@ class ProfileModel: ObservableObject, Equatable {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private let MAX_SHARE_RELAYS = 4
|
|
||||||
|
|
||||||
var events: EventHolder
|
var events: EventHolder
|
||||||
let pubkey: Pubkey
|
let pubkey: Pubkey
|
||||||
let damus: DamusState
|
let damus: DamusState
|
||||||
@@ -222,7 +220,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!
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ struct DamusURLHandler {
|
|||||||
return .route(.Script(script: model))
|
return .route(.Script(script: model))
|
||||||
case .purple(let purple_url):
|
case .purple(let purple_url):
|
||||||
return await damus_state.purple.handle(purple_url: purple_url)
|
return await damus_state.purple.handle(purple_url: purple_url)
|
||||||
|
case .invoice(let invoice):
|
||||||
|
if damus_state.settings.show_wallet_selector {
|
||||||
|
return .sheet(.select_wallet(invoice: invoice.string))
|
||||||
|
} else {
|
||||||
|
guard let url = try? getUrlToOpen(invoice: invoice.string, with: damus_state.settings.default_wallet.model) else {
|
||||||
|
return .sheet(.select_wallet(invoice: invoice.string))
|
||||||
|
}
|
||||||
|
return .external_url(url)
|
||||||
|
}
|
||||||
case nil:
|
case nil:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -91,6 +100,11 @@ struct DamusURLHandler {
|
|||||||
return .filter(filt)
|
return .filter(filt)
|
||||||
case .script(let script):
|
case .script(let script):
|
||||||
return .script(script)
|
return .script(script)
|
||||||
|
case .invoice(let bolt11):
|
||||||
|
if let invoice = decode_bolt11(bolt11) {
|
||||||
|
return .invoice(invoice)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -103,5 +117,6 @@ struct DamusURLHandler {
|
|||||||
case wallet_connect(WalletConnectURL)
|
case wallet_connect(WalletConnectURL)
|
||||||
case script([UInt8])
|
case script([UInt8])
|
||||||
case purple(DamusPurpleURL)
|
case purple(DamusPurpleURL)
|
||||||
|
case invoice(Invoice)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ class UserSettingsStore: ObservableObject {
|
|||||||
@Setting(key: "show_wallet_selector", default_value: false)
|
@Setting(key: "show_wallet_selector", default_value: false)
|
||||||
var show_wallet_selector: Bool
|
var show_wallet_selector: Bool
|
||||||
|
|
||||||
|
@Setting(key: "dismiss_wallet_high_balance_warning", default_value: false)
|
||||||
|
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
|
||||||
|
|
||||||
@@ -122,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
|
||||||
|
|
||||||
@@ -174,8 +189,12 @@ class UserSettingsStore: ObservableObject {
|
|||||||
var truncate_timeline_text: Bool
|
var truncate_timeline_text: Bool
|
||||||
|
|
||||||
/// Nozaps mode gimps note zapping to fit into apple's content-tipping guidelines. It can not be configurable to end-users on the app store
|
/// Nozaps mode gimps note zapping to fit into apple's content-tipping guidelines. It can not be configurable to end-users on the app store
|
||||||
@Setting(key: "nozaps", default_value: true)
|
///
|
||||||
var nozaps: Bool
|
/// Update 2025-05-12: This can be re-enabled 🥳. See https://github.com/damus-io/damus/issues/3016
|
||||||
|
// @Setting(key: "nozaps", default_value: true)
|
||||||
|
var nozaps: Bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
@Setting(key: "truncate_mention_text", default_value: true)
|
@Setting(key: "truncate_mention_text", default_value: true)
|
||||||
var truncate_mention_text: Bool
|
var truncate_mention_text: 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,4 +52,28 @@ extension NIP04 {
|
|||||||
|
|
||||||
return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4)
|
return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decrypts string content
|
||||||
|
static func decryptContent(recipientPrivateKey: Privkey, senderPubkey: Pubkey, content: String, encoding: EncEncoding) throws(NIP04DecryptionError) -> String {
|
||||||
|
guard let shared_sec = get_shared_secret(privkey: recipientPrivateKey, pubkey: senderPubkey) else {
|
||||||
|
throw .failedToComputeSharedSecret
|
||||||
|
}
|
||||||
|
guard let dat = (encoding == .base64 ? decode_dm_base64(content) : decode_dm_bech32(content)) else {
|
||||||
|
throw .failedToDecodeEncryptedContent
|
||||||
|
}
|
||||||
|
guard let dat = aes_decrypt(data: dat.content, iv: dat.iv, shared_sec: shared_sec) else {
|
||||||
|
throw .failedToDecryptAES
|
||||||
|
}
|
||||||
|
guard let decryptedString = String(data: dat, encoding: .utf8) else {
|
||||||
|
throw .utf8DecodingFailedOnDecryptedPayload
|
||||||
|
}
|
||||||
|
return decryptedString
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NIP04DecryptionError: Error {
|
||||||
|
case failedToComputeSharedSecret
|
||||||
|
case failedToDecodeEncryptedContent
|
||||||
|
case failedToDecryptAES
|
||||||
|
case utf8DecodingFailedOnDecryptedPayload
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,9 +143,17 @@ struct ReplaceableParam: TagConvertible {
|
|||||||
var keychar: AsciiCharacter { "d" }
|
var keychar: AsciiCharacter { "d" }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Signature: Hashable, Equatable {
|
struct Signature: Codable, Hashable, Equatable {
|
||||||
let data: Data
|
let data: Data
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
self.init(try hex_decoder(decoder, expected_len: 64))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
try hex_encoder(to: encoder, data: self.data)
|
||||||
|
}
|
||||||
|
|
||||||
init(_ p: Data) {
|
init(_ p: Data) {
|
||||||
self.data = p
|
self.data = p
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -379,6 +379,10 @@ func decode_json<T: Decodable>(_ val: String) -> T? {
|
|||||||
return try? JSONDecoder().decode(T.self, from: Data(val.utf8))
|
return try? JSONDecoder().decode(T.self, from: Data(val.utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decode_json_throwing<T: Decodable>(_ val: String) throws -> T {
|
||||||
|
return try JSONDecoder().decode(T.self, from: Data(val.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
func decode_data<T: Decodable>(_ data: Data) -> T? {
|
func decode_data<T: Decodable>(_ data: Data) -> T? {
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
do {
|
do {
|
||||||
@@ -444,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 {
|
||||||
@@ -463,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)
|
||||||
}
|
}
|
||||||
@@ -539,6 +561,7 @@ func event_to_json(ev: NostrEvent) -> String {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@available(*, deprecated, renamed: "NIP04.decryptContent", message: "Deprecated, please use NIP04.decryptContent instead")
|
||||||
func decrypt_dm(_ privkey: Privkey?, pubkey: Pubkey, content: String, encoding: EncEncoding) -> String? {
|
func decrypt_dm(_ privkey: Privkey?, pubkey: Pubkey, content: String, encoding: EncEncoding) -> String? {
|
||||||
guard let privkey = privkey else {
|
guard let privkey = privkey else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ enum NostrKind: UInt32, Codable {
|
|||||||
case chat = 42
|
case chat = 42
|
||||||
case mute_list = 10000
|
case mute_list = 10000
|
||||||
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 +31,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ enum NostrLink: Equatable {
|
|||||||
case ref(RefId)
|
case ref(RefId)
|
||||||
case filter(NostrFilter)
|
case filter(NostrFilter)
|
||||||
case script([UInt8])
|
case script([UInt8])
|
||||||
|
case invoice(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode_pubkey_uri(_ pubkey: Pubkey) -> String {
|
func encode_pubkey_uri(_ pubkey: Pubkey) -> String {
|
||||||
@@ -93,8 +94,15 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if parts.count >= 2 && parts[0] == "t" {
|
if parts.count >= 2 {
|
||||||
|
switch parts[0] {
|
||||||
|
case "t":
|
||||||
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
|
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
|
||||||
|
case "lightning":
|
||||||
|
return .invoice(parts[1])
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard parts.count == 1 else {
|
guard parts.count == 1 else {
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -38,6 +38,22 @@ enum Block: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var is_previewable: Bool {
|
||||||
|
switch self {
|
||||||
|
case .mention(let m):
|
||||||
|
switch m.ref {
|
||||||
|
case .note, .nevent: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
case .invoice:
|
||||||
|
return true
|
||||||
|
case .url:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case text(String)
|
case text(String)
|
||||||
case mention(Mention<MentionRef>)
|
case mention(Mention<MentionRef>)
|
||||||
case hashtag(String)
|
case hashtag(String)
|
||||||
@@ -186,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 {
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
//
|
||||||
|
// CoinosDeterministicClient.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-04-14.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Implements a client that can talk to the Coinos API server with a deterministic account derived from the user's private key.
|
||||||
|
///
|
||||||
|
/// This is NOT a general-purpose Coinos client, and only works with the user's own deterministic "one-click setup" Coinos wallet account.
|
||||||
|
class CoinosDeterministicAccountClient {
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
/// The user's normal keypair for using Nostr
|
||||||
|
private let userKeypair: FullKeypair
|
||||||
|
/// The JWT authentication token with Coinos
|
||||||
|
private var jwtAuthToken: String? = nil
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Computed properties for a deterministic wallet
|
||||||
|
|
||||||
|
/// A deterministic keypair for the NWC connection derived from the user's private key
|
||||||
|
private var nwcKeypair: FullKeypair? {
|
||||||
|
let nwcPrivateKey: Privkey = Privkey(sha256(self.userKeypair.privkey.id)) // SHA256 is an irreversible operation, user's nsec should not be deriveable from this new private key
|
||||||
|
return FullKeypair(privkey: nwcPrivateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic username for a Coinos account
|
||||||
|
private var username: String? {
|
||||||
|
// Derive from private key because deriving from a pubkey would mean that anyone could compute the username and take that username before our user
|
||||||
|
// Add some prefix so that we can ensure this will NOT match the password nor the NWC keypair
|
||||||
|
guard let fullText = sha256Hex(text: "coinos_username:" + self.userKeypair.privkey.hex()) else { return nil }
|
||||||
|
// There is very little risk of a birthday attack on getting only the first 16 characters, because:
|
||||||
|
// 1. before this user creates an account, no one else knows the private key in order to know the expected username and create an account before them
|
||||||
|
// 2. after the account is created and username is revealed, finding collisions is pointless as duplicate usernames will be rejected by Coinos
|
||||||
|
//
|
||||||
|
// In terms of the risk of an accidental collision due to the birthday problem, 16 characters should be enough to pragmatically avoid any collision.
|
||||||
|
// According to `https://en.wikipedia.org/wiki/Birthday_problem#Probability_table`,
|
||||||
|
// even if we have 610 million Damus users connected to Coinos, the probability of even a single collision is still as low as 1%.
|
||||||
|
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
|
||||||
|
private var password: String? {
|
||||||
|
// Add some prefix so that we can ensure this will NOT match the user nor the NWC private key
|
||||||
|
return sha256Hex(text: "coinos_password:" + self.userKeypair.privkey.hex())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A deterministic NWC app connection name
|
||||||
|
private var nwcConnectionName: String { return "Damus" }
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/// Initializes the client with the user's keypair
|
||||||
|
init(userKeypair: FullKeypair) {
|
||||||
|
self.userKeypair = userKeypair
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Authentication and registration
|
||||||
|
|
||||||
|
/// Tries to login to the user's deterministic account. If it cannot be found, it will register for one and log into that.
|
||||||
|
func loginOrRegister() async throws {
|
||||||
|
do {
|
||||||
|
// Check if client has an account
|
||||||
|
try await self.login()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
guard let error = error as? CoinosDeterministicAccountClient.ClientError, error == .unauthorized else { throw error }
|
||||||
|
// Client does not seem to have an account, create one
|
||||||
|
try await self.register()
|
||||||
|
try await self.login()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers for a Coinos account using deterministic account details.
|
||||||
|
///
|
||||||
|
/// It succeeds if it returns without throwing errors.
|
||||||
|
func register() async throws {
|
||||||
|
guard let username, let password else { throw ClientError.errorFormingRequest }
|
||||||
|
let registerPayload = RegisterRequest(user: UserCredentials(username: username, password: password))
|
||||||
|
let jsonData = try JSONEncoder().encode(registerPayload)
|
||||||
|
|
||||||
|
let url = URL(string: "https://coinos.io/api/register")!
|
||||||
|
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
throw ClientError.unexpectedHTTPResponse(status_code: (response as? HTTPURLResponse)?.statusCode ?? -1, response: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs into the deterministic account, if an auth token is not present
|
||||||
|
func loginIfNeeded() async throws {
|
||||||
|
if self.jwtAuthToken == nil { try await self.login() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logs into to our deterministic account.
|
||||||
|
///
|
||||||
|
/// Succeeds if it returns without returning errors.
|
||||||
|
///
|
||||||
|
/// Mutating function, will update the client's internal state.
|
||||||
|
func login() async throws {
|
||||||
|
self.jwtAuthToken = try await sendLoginRequest().token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the login request and return the response
|
||||||
|
///
|
||||||
|
/// Does NOT update the internal login state.
|
||||||
|
private func sendLoginRequest() async throws -> AuthResponse {
|
||||||
|
guard let url = URL(string: "https://coinos.io/api/login") else { throw ClientError.errorFormingRequest }
|
||||||
|
guard let username, let password else { throw ClientError.errorFormingRequest }
|
||||||
|
let credentials = UserCredentials(username: username, password: password)
|
||||||
|
let jsonData = try JSONEncoder().encode(credentials)
|
||||||
|
|
||||||
|
let (data, response) = try await makeRequest(method: .post, url: url, payload: jsonData, payload_type: .json)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200: return try JSONDecoder().decode(AuthResponse.self, from: data)
|
||||||
|
case 401: throw ClientError.unauthorized
|
||||||
|
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ClientError.errorProcessingResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Managing NWC connections
|
||||||
|
|
||||||
|
/// Creates a new NWC connection
|
||||||
|
///
|
||||||
|
/// Note: Account must exist before calling this endpoint
|
||||||
|
func createNWCConnection() 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()
|
||||||
|
|
||||||
|
let config = try defaultWalletConnectionConfig()
|
||||||
|
let configData = try encode_json_data(config)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
private func defaultWalletConnectionConfig() throws -> NewWalletConnectionConfig {
|
||||||
|
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||||
|
return NewWalletConnectionConfig(
|
||||||
|
name: self.nwcConnectionName,
|
||||||
|
secret: nwcKeypair.privkey.hex(),
|
||||||
|
pubkey: nwcKeypair.pubkey.hex(),
|
||||||
|
max_amount: 30000, // 30K sats per week maximum
|
||||||
|
budget_renewal: .weekly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the NWC URL for the deterministic NWC app connection
|
||||||
|
///
|
||||||
|
/// Account must already exist before calling this
|
||||||
|
///
|
||||||
|
/// Returns `nil` if no NWC url is found, (e.g. if app connection has not been configured yet)
|
||||||
|
func getNWCUrl() async throws -> WalletConnectURL? {
|
||||||
|
guard let connectionConfig = try await self.getNWCAppConnectionConfig(), let nwc = connectionConfig.nwc else { return nil }
|
||||||
|
return WalletConnectURL(str: nwc)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the deterministic NWC app connection configuration details, if it exists
|
||||||
|
///
|
||||||
|
/// Account must already exist before calling this
|
||||||
|
///
|
||||||
|
/// Returns `nil` if no connection is found, (e.g. if app connection has not been configured yet)
|
||||||
|
func getNWCAppConnectionConfig() async throws -> WalletConnectionConfig? {
|
||||||
|
guard let nwcKeypair else { throw ClientError.errorFormingRequest }
|
||||||
|
guard let url = URL(string: "https://coinos.io/api/app/" + nwcKeypair.pubkey.hex()) else { throw ClientError.errorFormingRequest }
|
||||||
|
|
||||||
|
try await self.loginIfNeeded()
|
||||||
|
|
||||||
|
let (data, response) = try await self.makeAuthenticatedRequest(
|
||||||
|
method: .get,
|
||||||
|
url: url,
|
||||||
|
payload: nil,
|
||||||
|
payload_type: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200: return try JSONDecoder().decode(WalletConnectionConfig.self, from: data)
|
||||||
|
case 401: throw ClientError.unauthorized
|
||||||
|
case 404: return nil
|
||||||
|
default: throw ClientError.unexpectedHTTPResponse(status_code: httpResponse.statusCode, response: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ClientError.errorProcessingResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Lower level request convenience functions
|
||||||
|
|
||||||
|
/// Makes a request without any authorization
|
||||||
|
func makeRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.httpBody = payload
|
||||||
|
|
||||||
|
if let payload_type {
|
||||||
|
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
|
return try await URLSession.shared.data(for: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes an authenticated request with our JWT auth token.
|
||||||
|
///
|
||||||
|
/// Client must be logged-in before calling this, otherwise an error will be thrown.
|
||||||
|
func makeAuthenticatedRequest(method: HTTPMethod, url: URL, payload: Data?, payload_type: HTTPPayloadType?) async throws -> (data: Data, response: URLResponse) {
|
||||||
|
guard let jwtAuthToken else { throw ClientError.errorFormingRequest }
|
||||||
|
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = method.rawValue
|
||||||
|
request.httpBody = payload
|
||||||
|
|
||||||
|
request.setValue("Bearer " + jwtAuthToken, forHTTPHeaderField: "Authorization")
|
||||||
|
if let payload_type {
|
||||||
|
request.setValue(payload_type.rawValue, forHTTPHeaderField: "Content-Type")
|
||||||
|
}
|
||||||
|
return try await URLSession.shared.data(for: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Helper structures
|
||||||
|
|
||||||
|
/// Payload for registering for a new Coinos account
|
||||||
|
struct RegisterRequest: Codable {
|
||||||
|
/// New user credentials
|
||||||
|
let user: UserCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload for user credentials (sign-up and login)
|
||||||
|
struct UserCredentials: Codable {
|
||||||
|
/// The username
|
||||||
|
let username: String
|
||||||
|
/// The user password
|
||||||
|
let password: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A successful response to a login auth endpoint
|
||||||
|
struct AuthResponse: Codable {
|
||||||
|
/// The JWT token to be applied to any authenticated API calls
|
||||||
|
let token: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used by the client to define new NWC configurations
|
||||||
|
struct NewWalletConnectionConfig: Codable {
|
||||||
|
/// The name of the connection
|
||||||
|
let name: String
|
||||||
|
/// 32 Hex-encoded bytes containing a shared private key secret
|
||||||
|
let secret: String
|
||||||
|
/// 32 Hex-encoded bytes containing the pubkey for the secret
|
||||||
|
let pubkey: String
|
||||||
|
/// Max amount that can be spent in each renewal period (measured in sats)
|
||||||
|
let max_amount: UInt64
|
||||||
|
/// The period of time it takes for the budget limits to reset
|
||||||
|
let budget_renewal: BudgetRenewalPeriod
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The NWC connection configuration details
|
||||||
|
///
|
||||||
|
/// ## Implementation notes
|
||||||
|
///
|
||||||
|
/// - All items defined as optionals because the Coinos API may change in the future, so this may help increase future compatibility.
|
||||||
|
struct WalletConnectionConfig: Codable {
|
||||||
|
/// The name of the connection
|
||||||
|
let name: String?
|
||||||
|
/// 32 Hex-encoded bytes containing a shared private key secret
|
||||||
|
let secret: String?
|
||||||
|
/// 32 Hex-encoded bytes containing the pubkey for the secret
|
||||||
|
let pubkey: String?
|
||||||
|
/// Max amount that can be spent in every renewal period (measured in sats)
|
||||||
|
let max_amount: UInt64?
|
||||||
|
/// The NWC url generated by the server
|
||||||
|
let nwc: String?
|
||||||
|
/// Budget renewal information
|
||||||
|
let budget_renewal: BudgetRenewalPeriod?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A period of time it takes for budget limits to be reset
|
||||||
|
enum BudgetRenewalPeriod: String, Codable {
|
||||||
|
/// Resets once a week
|
||||||
|
case weekly
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client error occured
|
||||||
|
enum ClientError: Error, Equatable {
|
||||||
|
/// Received an unexpected HTTP response
|
||||||
|
///
|
||||||
|
/// Could be for a variety of reasons.
|
||||||
|
case unexpectedHTTPResponse(status_code: Int, response: Data)
|
||||||
|
/// Error forming the request, generally due to missing or inconsistent internal data
|
||||||
|
///
|
||||||
|
/// Probably caused by a programming error.
|
||||||
|
case errorFormingRequest
|
||||||
|
/// The client could not process the response from the server
|
||||||
|
///
|
||||||
|
/// Might be a sign of an incompatibility bug
|
||||||
|
case errorProcessingResponse
|
||||||
|
/// The action performed is not authorized
|
||||||
|
/// Generally thrown if user does not exist, credentials do not match what Coinos has on file, or programming error
|
||||||
|
case unauthorized
|
||||||
|
/// Client not logged in on a call that expected login
|
||||||
|
case notLoggedIn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes a SHA256 hash digest from a piece of UTF-8 text, and returns the result as a "hex" string
|
||||||
|
///
|
||||||
|
/// When working only with strings, this can be more convenient than transforming text to data, and data back to text
|
||||||
|
fileprivate func sha256Hex(text: String) -> String? {
|
||||||
|
guard let data = text.data(using: .utf8) else { return nil }
|
||||||
|
return sha256(data).toHexString()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
func abbrev_bech32_pubkey(pubkey: Pubkey) -> String {
|
func abbrev_bech32_pubkey(pubkey: Pubkey) -> String {
|
||||||
return abbrev_pubkey(String(pubkey.npub.dropFirst(4)))
|
return abbrev_identifier(String(pubkey.npub.dropFirst(4)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func abbrev_pubkey(_ pubkey: String, amount: Int = 8) -> String {
|
func abbrev_identifier(_ pubkey: String, amount: Int = 8) -> String {
|
||||||
return pubkey.prefix(amount) + ":" + pubkey.suffix(amount)
|
return pubkey.prefix(amount) + ":" + pubkey.suffix(amount)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// ImageCacheMigrations.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-04-26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
|
struct ImageCacheMigrations {
|
||||||
|
static func migrateKingfisherCacheIfNeeded() {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let migration1Key = "KingfisherCacheMigrated" // Never ever changes
|
||||||
|
let migration2Key = "KingfisherCacheMigratedV2" // Never ever changes
|
||||||
|
|
||||||
|
let migration1Done = defaults.bool(forKey: migration1Key)
|
||||||
|
let migration2Done = defaults.bool(forKey: migration2Key)
|
||||||
|
|
||||||
|
guard !migration1Done || !migration2Done else {
|
||||||
|
// All migrations are already done. Skip.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let oldCachePath = migration1Done ? migration1KingfisherCachePath() : migration0KingfisherCachePath()
|
||||||
|
|
||||||
|
// New shared cache location
|
||||||
|
let newCachePath = kingfisherCachePath().path
|
||||||
|
|
||||||
|
if fileManager.fileExists(atPath: oldCachePath) {
|
||||||
|
do {
|
||||||
|
// Move the old cache to the new location
|
||||||
|
try fileManager.moveItem(atPath: oldCachePath, toPath: newCachePath)
|
||||||
|
Log.info("Successfully migrated Kingfisher cache to %s", for: .storage, newCachePath)
|
||||||
|
} catch {
|
||||||
|
do {
|
||||||
|
// Cache data is not essential, fallback to deleting the cache and starting all over
|
||||||
|
// It's better than leaving significant garbage data stuck indefinitely on the user's phone
|
||||||
|
try fileManager.removeItem(atPath: newCachePath)
|
||||||
|
try fileManager.removeItem(atPath: oldCachePath)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Log.error("Failed to migrate cache: %s", for: .storage, error.localizedDescription)
|
||||||
|
return // Do not mark them as complete, we can try again next time the user reloads the app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark migrations as complete
|
||||||
|
defaults.set(true, forKey: migration1Key)
|
||||||
|
defaults.set(true, forKey: migration2Key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static private func migration0KingfisherCachePath() -> String {
|
||||||
|
// Implementation note: These are old, so they should not be changed
|
||||||
|
let defaultCache = ImageCache.default
|
||||||
|
return defaultCache.diskStorage.directoryURL.path
|
||||||
|
}
|
||||||
|
|
||||||
|
static private func migration1KingfisherCachePath() -> String {
|
||||||
|
// Implementation note: These are old, so they are hard-coded on purpose, because we can't change these values from the past.
|
||||||
|
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.damus")!
|
||||||
|
return groupURL.appendingPathComponent("ImageCache").path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest path for kingfisher to store cached images on.
|
||||||
|
///
|
||||||
|
/// Documentation references:
|
||||||
|
/// - https://developer.apple.com/documentation/foundation/filemanager/containerurl(forsecurityapplicationgroupidentifier:)#:~:text=The%20system%20creates%20only%20the%20Library/Caches%20subdirectory%20automatically
|
||||||
|
/// - https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#:~:text=Put%20data%20cache,files%20as%20needed.
|
||||||
|
static func kingfisherCachePath() -> URL {
|
||||||
|
let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: Constants.DAMUS_APP_GROUP_IDENTIFIER)!
|
||||||
|
return groupURL
|
||||||
|
.appendingPathComponent("Library")
|
||||||
|
.appendingPathComponent("Caches")
|
||||||
|
.appendingPathComponent(Constants.IMAGE_CACHE_DIRNAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ func remove_nostr_uri_prefix(_ s: String) -> String {
|
|||||||
return uri
|
return uri
|
||||||
}
|
}
|
||||||
|
|
||||||
func abbreviateURL(_ url: URL) -> String {
|
func abbreviateURL(_ url: URL, maxLength: Int = MAX_CHAR_URL) -> String {
|
||||||
let urlString = url.absoluteString
|
let urlString = url.absoluteString
|
||||||
|
|
||||||
if urlString.count > MAX_CHAR_URL {
|
if urlString.count > maxLength {
|
||||||
return String(urlString.prefix(MAX_CHAR_URL)) + "..."
|
return String(urlString.prefix(maxLength)) + "…"
|
||||||
}
|
}
|
||||||
return urlString
|
return urlString
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// HumanReadableErrors.swift
|
||||||
|
// damus
|
||||||
|
//
|
||||||
|
// Created by Daniel D’Aquino on 2025-05-05.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension WalletConnect.FullWalletResponse.InitializationError {
|
||||||
|
var humanReadableError: ErrorView.UserPresentableError? {
|
||||||
|
switch self {
|
||||||
|
case .incorrectAuthorPubkey:
|
||||||
|
nil // Anyone can send a response event with an incorrect author pubkey, it is not really an "error". We should silently ignore it.
|
||||||
|
case .missingRequestIdReference:
|
||||||
|
.init(
|
||||||
|
user_visible_description: NSLocalizedString("Wallet provider returned an invalid response.", comment: "Error description shown to the user when a response from the wallet provider is invalid"),
|
||||||
|
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
|
||||||
|
technical_info: "Wallet response does not make a reference to any request; No request ID `e` tag was found."
|
||||||
|
)
|
||||||
|
case .failedToDecodeJSON(let error):
|
||||||
|
.init(
|
||||||
|
user_visible_description: NSLocalizedString("Wallet provider returned a response that we do not understand.", comment: "Error description shown to the user when a response from the wallet provider contains data the app does not understand"),
|
||||||
|
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
|
||||||
|
technical_info: "Failed to decode NWC Wallet response JSON. Error: \(error)"
|
||||||
|
)
|
||||||
|
case .failedToDecrypt(let error):
|
||||||
|
.init(
|
||||||
|
user_visible_description: NSLocalizedString("Wallet provider returned a response that we could not decrypt.", comment: "Error description shown to the user when a response from the wallet provider contains data the app could not decrypt."),
|
||||||
|
tip: NSLocalizedString("Please copy the technical info and send it to our support team.", comment: "Tip on how to resolve issue when wallet returns an invalid response"),
|
||||||
|
technical_info: "Failed to decrypt NWC Wallet response. Error: \(error)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WalletConnect.WalletResponseErr {
|
||||||
|
var humanReadableError: ErrorView.UserPresentableError? {
|
||||||
|
guard let code = self.code else {
|
||||||
|
return .init(
|
||||||
|
user_visible_description: String(format: NSLocalizedString("Your connected wallet raised an unknown error. Message: %s", comment: "Human readable error description for unknown error"), self.message ?? NSLocalizedString("Empty error message", comment: "A human readable placeholder to indicate that the error message is empty")),
|
||||||
|
tip: NSLocalizedString("Please contact the developer of your wallet provider for help.", comment: "Human readable error description for an unknown error raised by a wallet provider."),
|
||||||
|
technical_info: "NWC wallet provider returned an error response without a valid reason code. Message: \(self.message ?? "Empty error message")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
switch code {
|
||||||
|
case .rateLimited:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("Your wallet is temporarily being rate limited.", comment: "Error description for rate limit error"),
|
||||||
|
tip: NSLocalizedString("Wait a few moments, and then try again.", comment: "Tip for rate limit error"),
|
||||||
|
technical_info: "Wallet returned a rate limit error with message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .notImplemented:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("This feature is not implemented by your wallet.", comment: "Error description for not implemented feature"),
|
||||||
|
tip: NSLocalizedString("Please check for updates or contact your wallet provider.", comment: "Tip for not implemented error"),
|
||||||
|
technical_info: "Wallet reported a not implemented error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .insufficientBalance:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("Your wallet does not have sufficient balance for this transaction.", comment: "Error description for insufficient balance"),
|
||||||
|
tip: NSLocalizedString("Please deposit more funds and try again.", comment: "Tip for insufficient balance errors"),
|
||||||
|
technical_info: "Wallet returned an insufficient balance error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .quotaExceeded:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("Your transaction quota has been exceeded.", comment: "Error description for quota exceeded"),
|
||||||
|
tip: NSLocalizedString("Wait for the quota to reset, or configure your wallet provider to allow a higher limit.", comment: "Tip for quota exceeded"),
|
||||||
|
technical_info: "Wallet reported a quota exceeded error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .restricted:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("This operation is restricted by your wallet.", comment: "Error description for restricted operation"),
|
||||||
|
tip: NSLocalizedString("Check your account permissions or contact support.", comment: "Tip for restricted operation"),
|
||||||
|
technical_info: "Wallet returned a restricted error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .unauthorized:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("You are not authorized to perform this action with your wallet.", comment: "Error description for unauthorized access"),
|
||||||
|
tip: NSLocalizedString("Please verify your credentials or permissions.", comment: "Tip for unauthorized access"),
|
||||||
|
technical_info: "Wallet returned an unauthorized error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .internalError:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("An internal error occurred in your wallet.", comment: "Error description for an internal error"),
|
||||||
|
tip: NSLocalizedString("Try restarting your wallet or contacting support if the problem persists.", comment: "Tip for internal error"),
|
||||||
|
technical_info: "Wallet reported an internal error. Message: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
case .other:
|
||||||
|
return .init(
|
||||||
|
user_visible_description: NSLocalizedString("An unspecified error occurred in your wallet.", comment: "Error description for an unspecified error"),
|
||||||
|
tip: NSLocalizedString("Please try again or contact your wallet provider for further assistance.", comment: "Tip for unspecified error"),
|
||||||
|
technical_info: "Wallet returned an error: \(self.message ?? "No further details provided")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,11 @@ extension WalletConnect {
|
|||||||
/// Pay an invoice
|
/// Pay an invoice
|
||||||
case payInvoice(
|
case payInvoice(
|
||||||
/// bolt-11 invoice string
|
/// bolt-11 invoice string
|
||||||
invoice: String
|
invoice: String,
|
||||||
|
/// The full description of the invoice (If description does not fit in the BOLT-11 invoice, this is the pre-image of the description hash)
|
||||||
|
description: String?,
|
||||||
|
/// Optional metadata object containing more information
|
||||||
|
metadata: Metadata?
|
||||||
)
|
)
|
||||||
/// Get the current wallet balance
|
/// Get the current wallet balance
|
||||||
case getBalance
|
case getBalance
|
||||||
@@ -33,6 +37,38 @@ extension WalletConnect {
|
|||||||
type: String?
|
type: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
|
static func payZapRequest(invoice: String, zapRequest: NostrEvent?) -> Self {
|
||||||
|
guard let zapRequest, let zapRequestEncoded = encode_json(zapRequest) else {
|
||||||
|
return WalletConnect.Request.payInvoice(
|
||||||
|
invoice: invoice,
|
||||||
|
description: nil,
|
||||||
|
metadata: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return WalletConnect.Request.payInvoice(
|
||||||
|
invoice: invoice,
|
||||||
|
description: zapRequestEncoded,
|
||||||
|
metadata: .init(nostr: zapRequest)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Metadata: Codable, Equatable, Hashable {
|
||||||
|
/// NIP-57-compliant `kind:9734` zap request event
|
||||||
|
let nostr: NostrEvent?
|
||||||
|
|
||||||
|
init(nostr: NostrEvent?) {
|
||||||
|
self.nostr = nostr
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
let container: KeyedDecodingContainer<WalletConnect.Request.Metadata.CodingKeys> = try decoder.container(keyedBy: WalletConnect.Request.Metadata.CodingKeys.self)
|
||||||
|
guard let decodedZapRequest = try? container.decodeIfPresent(NostrEvent.self, forKey: WalletConnect.Request.Metadata.CodingKeys.nostr) else {
|
||||||
|
self.nostr = nil // Be lenient and fallback to nil if the NWC provider provided something invalid, since metadata is not strictly spec'd yet.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.nostr = decodedZapRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Interface
|
// MARK: - Interface
|
||||||
|
|
||||||
@@ -61,7 +97,7 @@ extension WalletConnect {
|
|||||||
|
|
||||||
/// Keys for the JSON inside the "params" object
|
/// Keys for the JSON inside the "params" object
|
||||||
private enum ParamKeys: String, CodingKey {
|
private enum ParamKeys: String, CodingKey {
|
||||||
case invoice
|
case invoice, description, metadata
|
||||||
case from, until, limit, offset, unpaid, type
|
case from, until, limit, offset, unpaid, type
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +118,9 @@ extension WalletConnect {
|
|||||||
case Method.payInvoice.rawValue:
|
case Method.payInvoice.rawValue:
|
||||||
let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
||||||
let invoice = try paramsContainer.decode(String.self, forKey: .invoice)
|
let invoice = try paramsContainer.decode(String.self, forKey: .invoice)
|
||||||
self = .payInvoice(invoice: invoice)
|
let description: String? = try paramsContainer.decodeIfPresent(String.self, forKey: .description)
|
||||||
|
let metadata: Metadata? = try paramsContainer.decodeIfPresent(Metadata.self, forKey: .metadata)
|
||||||
|
self = .payInvoice(invoice: invoice, description: description, metadata: metadata)
|
||||||
|
|
||||||
case Method.getBalance.rawValue:
|
case Method.getBalance.rawValue:
|
||||||
// No params to decode
|
// No params to decode
|
||||||
@@ -112,10 +150,12 @@ extension WalletConnect {
|
|||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
switch self {
|
switch self {
|
||||||
case .payInvoice(let invoice):
|
case .payInvoice(let invoice, let description, let metadata):
|
||||||
try container.encode(Method.payInvoice.rawValue, forKey: .method)
|
try container.encode(Method.payInvoice.rawValue, forKey: .method)
|
||||||
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
||||||
try paramsContainer.encode(invoice, forKey: .invoice)
|
try paramsContainer.encode(invoice, forKey: .invoice)
|
||||||
|
try paramsContainer.encodeIfPresent(description, forKey: .description)
|
||||||
|
try paramsContainer.encodeIfPresent(metadata, forKey: .metadata)
|
||||||
|
|
||||||
case .getBalance:
|
case .getBalance:
|
||||||
try container.encode(Method.getBalance.rawValue, forKey: .method)
|
try container.encode(Method.getBalance.rawValue, forKey: .method)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// Created by Daniel D’Aquino on 2025-03-10.
|
// Created by Daniel D’Aquino on 2025-03-10.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
|
||||||
extension WalletConnect {
|
extension WalletConnect {
|
||||||
/// Models a response from the NWC provider
|
/// Models a response from the NWC provider
|
||||||
struct Response: Decodable {
|
struct Response: Decodable {
|
||||||
@@ -50,35 +52,80 @@ extension WalletConnect {
|
|||||||
let req_id: NoteId
|
let req_id: NoteId
|
||||||
let response: Response
|
let response: Response
|
||||||
|
|
||||||
init?(from: NostrEvent, nwc: WalletConnect.ConnectURL) async {
|
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) throws(InitializationError) {
|
||||||
guard let note_id = from.referenced_ids.first else {
|
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
|
||||||
return nil
|
|
||||||
|
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
|
||||||
|
|
||||||
|
self.req_id = referencedNoteId
|
||||||
|
|
||||||
|
var json = ""
|
||||||
|
do {
|
||||||
|
json = try NIP04.decryptContent(
|
||||||
|
recipientPrivateKey: nwc.keypair.privkey,
|
||||||
|
senderPubkey: nwc.pubkey,
|
||||||
|
content: event.content,
|
||||||
|
encoding: .base64
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch { throw .failedToDecrypt(error) }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response: WalletConnect.Response = try decode_json_throwing(json)
|
||||||
|
self.response = response
|
||||||
|
}
|
||||||
|
catch { throw .failedToDecodeJSON(error) }
|
||||||
}
|
}
|
||||||
|
|
||||||
self.req_id = note_id
|
enum InitializationError: Error {
|
||||||
|
case incorrectAuthorPubkey
|
||||||
let ares = Task {
|
case missingRequestIdReference
|
||||||
guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64),
|
case failedToDecodeJSON(any Error)
|
||||||
let resp: WalletConnect.Response = decode_json(json)
|
case failedToDecrypt(any Error)
|
||||||
else {
|
|
||||||
let resp: WalletConnect.Response? = nil
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let res = await ares.value else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
self.response = res
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WalletResponseErr: Codable {
|
struct WalletResponseErr: Codable, Error {
|
||||||
let code: String?
|
let code: Code?
|
||||||
let message: String?
|
let message: String?
|
||||||
|
|
||||||
|
enum Code: String, Codable {
|
||||||
|
/// The client is sending commands too fast. It should retry in a few seconds.
|
||||||
|
case rateLimited = "RATE_LIMITED"
|
||||||
|
/// The command is not known or is intentionally not implemented.
|
||||||
|
case notImplemented = "NOT_IMPLEMENTED"
|
||||||
|
/// The wallet does not have enough funds to cover a fee reserve or the payment amount.
|
||||||
|
case insufficientBalance = "INSUFFICIENT_BALANCE"
|
||||||
|
/// The wallet has exceeded its spending quota.
|
||||||
|
case quotaExceeded = "QUOTA_EXCEEDED"
|
||||||
|
/// This public key is not allowed to do this operation.
|
||||||
|
case restricted = "RESTRICTED"
|
||||||
|
/// This public key has no wallet connected.
|
||||||
|
case unauthorized = "UNAUTHORIZED"
|
||||||
|
/// An internal error.
|
||||||
|
case internalError = "INTERNAL"
|
||||||
|
/// Other error.
|
||||||
|
case other = "OTHER"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case code, message
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
// Attempt to decode the code as a String
|
||||||
|
if let codeString = try container.decodeIfPresent(String.self, forKey: .code),
|
||||||
|
let validCode = Code(rawValue: codeString) {
|
||||||
|
self.code = validCode
|
||||||
|
} else {
|
||||||
|
// If the code is either missing or not one of the allowed cases, set it to nil
|
||||||
|
self.code = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.message = try container.decodeIfPresent(String.self, forKey: .message)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ extension WalletConnect {
|
|||||||
static func subscribe(url: WalletConnectURL, pool: RelayPool) {
|
static func subscribe(url: WalletConnectURL, pool: RelayPool) {
|
||||||
var filter = NostrFilter(kinds: [.nwc_response])
|
var filter = NostrFilter(kinds: [.nwc_response])
|
||||||
filter.authors = [url.pubkey]
|
filter.authors = [url.pubkey]
|
||||||
|
filter.pubkeys = [url.keypair.pubkey]
|
||||||
filter.limit = 0
|
filter.limit = 0
|
||||||
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
|
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
|
||||||
|
|
||||||
@@ -40,8 +41,9 @@ extension WalletConnect {
|
|||||||
/// - on_flush: A callback to call after the event has been flushed to the network
|
/// - on_flush: A callback to call after the event has been flushed to the network
|
||||||
/// - Returns: The Nostr Event that was sent to the network, representing the request that was made
|
/// - Returns: The Nostr Event that was sent to the network, representing the request that was made
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
|
static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, zap_request: NostrEvent?, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
|
||||||
let req = WalletConnect.Request.payInvoice(invoice: invoice)
|
|
||||||
|
let req = WalletConnect.Request.payZapRequest(invoice: invoice, zapRequest: zap_request)
|
||||||
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
|
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -104,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 {
|
||||||
@@ -142,7 +166,7 @@ extension WalletConnect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
print("damus-donation donating...")
|
print("damus-donation donating...")
|
||||||
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil)
|
WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, zap_request: nil, delay: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles a received Nostr Wallet Connect error
|
/// Handles a received Nostr Wallet Connect error
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ extension WalletConnect {
|
|||||||
let created_at: UInt64 // unixtimestamp, // invoice/payment creation time
|
let created_at: UInt64 // unixtimestamp, // invoice/payment creation time
|
||||||
let expires_at: UInt64? // unixtimestamp, // invoice expiration time, optional if not applicable
|
let expires_at: UInt64? // unixtimestamp, // invoice expiration time, optional if not applicable
|
||||||
let settled_at: UInt64? // unixtimestamp, // invoice/payment settlement time, optional if unpaid
|
let settled_at: UInt64? // unixtimestamp, // invoice/payment settlement time, optional if unpaid
|
||||||
//"metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc.
|
let metadata: WalletConnect.Request.Metadata? // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ struct ErrorView: View {
|
|||||||
.cornerRadius(10)
|
.cornerRadius(10)
|
||||||
.padding(.vertical, 30)
|
.padding(.vertical, 30)
|
||||||
|
|
||||||
|
if let technical_info = error.technical_info {
|
||||||
|
ErrorTechInfoCopyButton(errorInfo: technical_info)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if let damus_state, damus_state.is_privkey_user {
|
if let damus_state, damus_state.is_privkey_user {
|
||||||
@@ -69,6 +73,39 @@ struct ErrorView: View {
|
|||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ErrorTechInfoCopyButton: View {
|
||||||
|
let errorInfo: String
|
||||||
|
@State var copied: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
if !copied {
|
||||||
|
Button(action: {
|
||||||
|
UIPasteboard.general.string = errorInfo
|
||||||
|
copied = true
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||||
|
copied = false
|
||||||
|
}
|
||||||
|
}, label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "square.on.square.dashed")
|
||||||
|
Text("Copy technical information", comment: "Button label to allow user to copy technical information from an error screen (usually to provide our support team for further troubleshooting)")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
Text("Copied!", comment: "Label indicating that the error technical information was successfully copied to the clipboard, which shows up as soon as the user clicks the copy button.")
|
||||||
|
}
|
||||||
|
.foregroundStyle(.damusGreen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// An error that is displayed to the user, and can be sent to the Developers as well.
|
/// An error that is displayed to the user, and can be sent to the Developers as well.
|
||||||
struct UserPresentableError {
|
struct UserPresentableError {
|
||||||
/// The description of the error to be shown to the user
|
/// The description of the error to be shown to the user
|
||||||
@@ -113,7 +150,7 @@ struct ErrorView: View {
|
|||||||
error: .init(
|
error: .init(
|
||||||
user_visible_description: "We are still too early",
|
user_visible_description: "We are still too early",
|
||||||
tip: "Stay humble, keep building, stack sats",
|
tip: "Stay humble, keep building, stack sats",
|
||||||
technical_info: nil
|
technical_info: "UTXOs too small, must stack more sats"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,10 +122,7 @@ struct LongformPreviewBody: View {
|
|||||||
} else if blur_images || (blur_images && !state.settings.media_previews) {
|
} else if blur_images || (blur_images && !state.settings.media_previews) {
|
||||||
ZStack {
|
ZStack {
|
||||||
titleImage(url: url)
|
titleImage(url: url)
|
||||||
Blur()
|
BlurOverlayView(blur_images: $blur_images, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView)
|
||||||
.onTapGesture {
|
|
||||||
blur_images = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
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)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ struct Blur: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct NoteContentView: View {
|
struct NoteContentView: View {
|
||||||
|
|
||||||
let damus_state: DamusState
|
let damus_state: DamusState
|
||||||
@@ -72,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 {
|
||||||
@@ -107,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)
|
||||||
@@ -166,10 +192,7 @@ struct NoteContentView: View {
|
|||||||
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
|
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
|
||||||
fullscreen_preview(dismiss: dismiss)
|
fullscreen_preview(dismiss: dismiss)
|
||||||
}
|
}
|
||||||
Blur()
|
BlurOverlayView(blur_images: $blur_images, artifacts: artifacts, size: size, damus_state: damus_state, parentView: .noteContentView)
|
||||||
.onTapGesture {
|
|
||||||
blur_images = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,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 {
|
||||||
@@ -384,6 +407,64 @@ func lookup_cached_preview_size(previews: PreviewCache, evid: NoteId) -> CGFloat
|
|||||||
return height
|
return height
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BlurOverlayView: View {
|
||||||
|
@Binding var blur_images: Bool
|
||||||
|
let artifacts: NoteArtifactsSeparated?
|
||||||
|
let size: EventViewKind?
|
||||||
|
let damus_state: DamusState?
|
||||||
|
let parentView: ParentViewType
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
|
||||||
|
Color.black
|
||||||
|
.opacity(0.54)
|
||||||
|
|
||||||
|
Blur()
|
||||||
|
|
||||||
|
VStack(alignment: .center) {
|
||||||
|
Image(systemName: "eye.slash")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.bold()
|
||||||
|
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
|
||||||
|
Text(NSLocalizedString("Media from someone you don't follow", comment: "Label on the image blur mask"))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.font(.title2)
|
||||||
|
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
|
||||||
|
Button(NSLocalizedString("Tap to load", comment: "Label for button that allows user to dismiss media content warning and unblur the image")) {
|
||||||
|
blur_images = false
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(EdgeInsets(top: 5, leading: 10, bottom: 0, trailing: 10))
|
||||||
|
|
||||||
|
if parentView == .noteContentView,
|
||||||
|
let artifacts = artifacts,
|
||||||
|
let size = size,
|
||||||
|
let damus_state = damus_state
|
||||||
|
{
|
||||||
|
switch artifacts.media[0] {
|
||||||
|
case .image(let url), .video(let url):
|
||||||
|
Text(abbreviateURL(url, maxLength: 30))
|
||||||
|
.font(eventviewsize_to_font(size, font_size: damus_state.settings.font_size * 0.8))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(EdgeInsets(top: 20, leading: 10, bottom: 5, trailing: 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onTapGesture {
|
||||||
|
blur_images = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ParentViewType {
|
||||||
|
case noteContentView, longFormView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct NoteContentView_Previews: PreviewProvider {
|
struct NoteContentView_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
let state = test_damus_state
|
let state = test_damus_state
|
||||||
@@ -401,7 +482,7 @@ struct NoteContentView_Previews: PreviewProvider {
|
|||||||
.previewDisplayName("Super short note")
|
.previewDisplayName("Super short note")
|
||||||
|
|
||||||
VStack {
|
VStack {
|
||||||
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: false, size: .normal, options: [])
|
NoteContentView(damus_state: state, event: test_encoded_note_with_image!, blur_images: true, size: .normal, options: [])
|
||||||
}
|
}
|
||||||
.previewDisplayName("Note with image")
|
.previewDisplayName("Note with image")
|
||||||
|
|
||||||
@@ -434,4 +515,3 @@ func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
|
|||||||
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
|
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
|
||||||
return mediaUrls.isEmpty ? nil : mediaUrls
|
return mediaUrls.isEmpty ? nil : mediaUrls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
+44
-16
@@ -79,6 +79,7 @@ struct PostView: View {
|
|||||||
var autoSaveModel: AutoSaveIndicatorView.AutoSaveViewModel
|
var autoSaveModel: AutoSaveIndicatorView.AutoSaveViewModel
|
||||||
|
|
||||||
@State var preUploadedMedia: [PreUploadedMedia] = []
|
@State var preUploadedMedia: [PreUploadedMedia] = []
|
||||||
|
@State var mediaUploadUnderProgress: MediaUpload? = nil
|
||||||
|
|
||||||
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
|
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
|
||||||
@StateObject var tagModel: TagModel = TagModel()
|
@StateObject var tagModel: TagModel = TagModel()
|
||||||
@@ -330,11 +331,6 @@ struct PostView: View {
|
|||||||
PostButton
|
PostButton
|
||||||
}
|
}
|
||||||
|
|
||||||
if let progress = image_upload.progress {
|
|
||||||
ProgressView(value: progress, total: 1.0)
|
|
||||||
.progressViewStyle(.linear)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.foregroundColor(DamusColors.neutral3)
|
.foregroundColor(DamusColors.neutral3)
|
||||||
.padding(.top, 5)
|
.padding(.top, 5)
|
||||||
@@ -346,6 +342,7 @@ struct PostView: View {
|
|||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func handle_upload(media: MediaUpload) async -> Bool {
|
func handle_upload(media: MediaUpload) async -> Bool {
|
||||||
|
mediaUploadUnderProgress = media
|
||||||
let uploader = damus_state.settings.default_media_uploader
|
let uploader = damus_state.settings.default_media_uploader
|
||||||
|
|
||||||
let img = getImage(media: media)
|
let img = getImage(media: media)
|
||||||
@@ -354,6 +351,7 @@ struct PostView: View {
|
|||||||
async let blurhash = calculate_blurhash(img: img)
|
async let blurhash = calculate_blurhash(img: img)
|
||||||
let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair)
|
let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair)
|
||||||
|
|
||||||
|
mediaUploadUnderProgress = nil
|
||||||
switch res {
|
switch res {
|
||||||
case .success(let url):
|
case .success(let url):
|
||||||
guard let url = URL(string: url) else {
|
guard let url = URL(string: url) else {
|
||||||
@@ -401,7 +399,10 @@ struct PostView: View {
|
|||||||
}
|
}
|
||||||
.id("post")
|
.id("post")
|
||||||
|
|
||||||
PVImageCarouselView(media: $uploadedMedias, deviceWidth: deviceSize.size.width)
|
PVImageCarouselView(media: $uploadedMedias,
|
||||||
|
mediaUnderProgress: $mediaUploadUnderProgress,
|
||||||
|
imageUploadModel: image_upload,
|
||||||
|
deviceWidth: deviceSize.size.width)
|
||||||
.onChange(of: uploadedMedias) { media in
|
.onChange(of: uploadedMedias) { media in
|
||||||
post_changed(post: post, media: media)
|
post_changed(post: post, media: media)
|
||||||
}
|
}
|
||||||
@@ -620,6 +621,8 @@ struct PostView_Previews: PreviewProvider {
|
|||||||
|
|
||||||
struct PVImageCarouselView: View {
|
struct PVImageCarouselView: View {
|
||||||
@Binding var media: [UploadedMedia]
|
@Binding var media: [UploadedMedia]
|
||||||
|
@Binding var mediaUnderProgress: MediaUpload?
|
||||||
|
@ObservedObject var imageUploadModel: ImageUploadModel
|
||||||
|
|
||||||
let deviceWidth: CGFloat
|
let deviceWidth: CGFloat
|
||||||
|
|
||||||
@@ -667,6 +670,25 @@ struct PVImageCarouselView: View {
|
|||||||
.padding(.bottom, 35)
|
.padding(.bottom, 35)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let mediaUP = mediaUnderProgress, let progress = imageUploadModel.progress {
|
||||||
|
ZStack {
|
||||||
|
// Media under upload-progress
|
||||||
|
Image(uiImage: getImage(media: mediaUP))
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: media.count == 0 ? deviceWidth * 0.8 : 250, height: media.count == 0 ? 400 : 250)
|
||||||
|
.cornerRadius(10)
|
||||||
|
.opacity(0.3)
|
||||||
|
.padding()
|
||||||
|
// Circle showing progress on top of media
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: CGFloat(progress))
|
||||||
|
.stroke(Color.damusPurple, lineWidth: 5.0)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.frame(width: 30, height: 30)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -776,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
|
||||||
@@ -841,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,
|
||||||
@@ -878,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) } )
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ struct PubkeyView: View {
|
|||||||
let bech32 = pubkey.npub
|
let bech32 = pubkey.npub
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(verbatim: "\(abbrev_pubkey(bech32, amount: sidemenu ? 12 : 16))")
|
Text(verbatim: "\(abbrev_identifier(bech32, amount: sidemenu ? 12 : 16))")
|
||||||
.font(sidemenu ? .system(size: 10) : .footnote)
|
.font(sidemenu ? .system(size: 10) : .footnote)
|
||||||
.foregroundColor(keyColor())
|
.foregroundColor(keyColor())
|
||||||
.padding(5)
|
.padding(5)
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ struct RelayView: View {
|
|||||||
let state: DamusState
|
let state: DamusState
|
||||||
let relay: RelayURL
|
let relay: RelayURL
|
||||||
let recommended: Bool
|
let recommended: Bool
|
||||||
|
/// Disables navigation link
|
||||||
|
let disableNavLink: Bool
|
||||||
@ObservedObject private var model_cache: RelayModelCache
|
@ObservedObject private var model_cache: RelayModelCache
|
||||||
|
|
||||||
@State var relay_state: Bool
|
@State var relay_state: Bool
|
||||||
@Binding var showActionButtons: Bool
|
@Binding var showActionButtons: Bool
|
||||||
|
|
||||||
init(state: DamusState, relay: RelayURL, showActionButtons: Binding<Bool>, recommended: Bool) {
|
init(state: DamusState, relay: RelayURL, showActionButtons: Binding<Bool>, recommended: Bool, disableNavLink: Bool = false) {
|
||||||
self.state = state
|
self.state = state
|
||||||
self.relay = relay
|
self.relay = relay
|
||||||
self.recommended = recommended
|
self.recommended = recommended
|
||||||
@@ -24,6 +26,7 @@ struct RelayView: View {
|
|||||||
_showActionButtons = showActionButtons
|
_showActionButtons = showActionButtons
|
||||||
let relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: relay)
|
let relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: relay)
|
||||||
self._relay_state = State(initialValue: relay_state)
|
self._relay_state = State(initialValue: relay_state)
|
||||||
|
self.disableNavLink = disableNavLink
|
||||||
}
|
}
|
||||||
|
|
||||||
static func get_relay_state(pool: RelayPool, relay: RelayURL) -> Bool {
|
static func get_relay_state(pool: RelayPool, relay: RelayURL) -> Bool {
|
||||||
@@ -96,21 +99,25 @@ struct RelayView: View {
|
|||||||
RelayStatusView(connection: relay_connection)
|
RelayStatusView(connection: relay_connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !disableNavLink {
|
||||||
Image("chevron-large-right")
|
Image("chevron-large-right")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 15, height: 15)
|
.frame(width: 15, height: 15)
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.onReceive(handle_notify(.relays_changed)) { _ in
|
.onReceive(handle_notify(.relays_changed)) { _ in
|
||||||
self.relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: self.relay)
|
self.relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: self.relay)
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
|
if !disableNavLink {
|
||||||
state.nav.push(route: Route.RelayDetail(relay: relay, metadata: model_cache.model(with_relay_id: relay)?.metadata))
|
state.nav.push(route: Route.RelayDetail(relay: relay, metadata: model_cache.model(with_relay_id: relay)?.metadata))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var relay_connection: RelayConnection? {
|
private var relay_connection: RelayConnection? {
|
||||||
state.nostrNetwork.pool.get_relay(relay)?.connection
|
state.nostrNetwork.pool.get_relay(relay)?.connection
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user