Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
922c705dd0
|
|||
| 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
|
||||
tags
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -26,7 +26,7 @@ struct NotificationFormatter {
|
||||
content.title = NSLocalizedString("Someone posted a note", comment: "Title label for push notification where someone posted a note")
|
||||
content.body = event.content
|
||||
break
|
||||
case .dm:
|
||||
case .deprecated_dm:
|
||||
content.title = NSLocalizedString("New message", comment: "Title label for push notifications where a direct message was sent to the user")
|
||||
content.body = NSLocalizedString("(Contents are encrypted)", comment: "Label on push notification indicating that the contents of the message are encrypted")
|
||||
break
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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
|
||||
|
||||
|
||||
+299
-18
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "06318d35ee2e6bd681b95591e67da33a9461b48a3c652e58bd9d1a6f0d82bdac",
|
||||
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "codescanner",
|
||||
@@ -35,6 +35,15 @@
|
||||
"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",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -105,6 +114,15 @@
|
||||
"version" : "0.1.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftsoup",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||
"state" : {
|
||||
"revision" : "bba848db50462894e7fc0891d018dfecad4ef11e",
|
||||
"version" : "2.8.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftycrop",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "bbw.jpg",
|
||||
"filename" : "blink.png",
|
||||
"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
|
||||
if oldValue[current_url] != media_size_information[current_url] {
|
||||
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.
|
||||
@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
|
||||
|
||||
@@ -207,6 +215,7 @@ class CarouselModel: ObservableObject {
|
||||
self.observe_video_sizes()
|
||||
Task {
|
||||
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.
|
||||
/// **Implementation note:** This should be called using `didSet` observers on properties that affect the fill
|
||||
private func refresh_current_item_fill() {
|
||||
if let current_url,
|
||||
let item_size = self.media_size_information[current_url],
|
||||
self.current_item_fill = self.compute_item_fill(url: 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 {
|
||||
self.current_item_fill = ImageFill.calculate_image_fill(
|
||||
return ImageFill.calculate_image_fill(
|
||||
geo_size: geo_size,
|
||||
img_size: item_size,
|
||||
maxHeight: self.max_height,
|
||||
@@ -252,9 +268,26 @@ class CarouselModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
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
|
||||
@@ -286,13 +319,15 @@ struct ImageCarousel<Content: View>: View {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var filling: Bool {
|
||||
model.current_item_fill?.filling == true
|
||||
}
|
||||
/// Determines if the image should fill its container.
|
||||
/// 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 {
|
||||
// Use the calculated fill height if available, otherwise use the default fill height
|
||||
model.current_item_fill?.height ?? model.default_fill_height
|
||||
// Use the first image height (to prevent height from jumping when swiping), then default to the 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 {
|
||||
@@ -376,6 +411,7 @@ struct ImageCarousel<Content: View>: View {
|
||||
}
|
||||
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
|
||||
.frame(height: height)
|
||||
.clipped() // Prevents content from overflowing the frame, ensuring clean edges in the carousel
|
||||
.onChange(of: model.selectedIndex) { value in
|
||||
model.selectedIndex = value
|
||||
}
|
||||
|
||||
@@ -94,26 +94,30 @@ enum OpenWalletError: Error {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
throw OpenWalletError.store_link_invalid
|
||||
throw .store_link_invalid
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
struct InvoiceView_Previews: PreviewProvider {
|
||||
|
||||
@@ -5,27 +5,27 @@
|
||||
// Created by William Casarin on 2023-01-11.
|
||||
//
|
||||
|
||||
import FaviconFinder
|
||||
import Kingfisher
|
||||
import SwiftUI
|
||||
|
||||
struct NIP05Badge: View {
|
||||
let nip05: NIP05
|
||||
let pubkey: Pubkey
|
||||
let contacts: Contacts
|
||||
let damus_state: DamusState
|
||||
let show_domain: Bool
|
||||
let profiles: Profiles
|
||||
let nip05_domain_favicon: FaviconURL?
|
||||
|
||||
@Environment(\.openURL) var openURL
|
||||
|
||||
init(nip05: NIP05, pubkey: Pubkey, contacts: Contacts, show_domain: Bool, profiles: Profiles) {
|
||||
init(nip05: NIP05, pubkey: Pubkey, damus_state: DamusState, show_domain: Bool, nip05_domain_favicon: FaviconURL?) {
|
||||
self.nip05 = nip05
|
||||
self.pubkey = pubkey
|
||||
self.contacts = contacts
|
||||
self.damus_state = damus_state
|
||||
self.show_domain = show_domain
|
||||
self.profiles = profiles
|
||||
self.nip05_domain_favicon = nip05_domain_favicon
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -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 {
|
||||
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 {
|
||||
return false
|
||||
}
|
||||
@@ -65,14 +80,18 @@ struct NIP05Badge: View {
|
||||
HStack(spacing: 2) {
|
||||
Seal
|
||||
|
||||
Group {
|
||||
if show_domain {
|
||||
Text(nip05_string)
|
||||
.nip05_colorized(gradient: nip05_color)
|
||||
}
|
||||
|
||||
if nip05_domain_favicon != nil {
|
||||
domainBadge
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
if let nip5url = nip05.siteUrl {
|
||||
openURL(nip5url)
|
||||
}
|
||||
}
|
||||
damus_state.nav.push(route: Route.NIP05DomainEvents(events: NIP05DomainEventsModel(state: damus_state, domain: nip05.host), nip05_domain_favicon: nip05_domain_favicon))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,13 +117,9 @@ struct NIP05Badge_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let test_state = test_damus_state
|
||||
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: "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)
|
||||
NIP05Badge(nip05: NIP05(username: "_", host: "jb55.com"), pubkey: test_state.pubkey, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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 {
|
||||
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
|
||||
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
|
||||
return .translated(Translated(artifacts: artifacts, language: note_lang))
|
||||
|
||||
@@ -12,7 +12,7 @@ enum NoteContent {
|
||||
case content(String, TagsSequence?)
|
||||
|
||||
init(note: NostrEvent, keypair: Keypair) {
|
||||
if note.known_kind == .dm || note.known_kind == .highlight {
|
||||
if note.known_kind == .deprecated_dm || note.known_kind == .highlight {
|
||||
self = .content(note.get_content(keypair), note.tags)
|
||||
} else {
|
||||
self = .note(note)
|
||||
|
||||
+37
-3
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import AVKit
|
||||
import MediaPlayer
|
||||
import EmojiPicker
|
||||
import TipKit
|
||||
|
||||
struct ZapSheet {
|
||||
let target: ZapTarget
|
||||
@@ -178,7 +179,7 @@ struct ContentView: View {
|
||||
NotificationsView(state: damus, notifications: home.notifications, subtitle: $menu_subtitle)
|
||||
|
||||
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)
|
||||
@@ -333,7 +334,20 @@ struct ContentView: View {
|
||||
.presentationDetents([.height(550)])
|
||||
.presentationDragIndicator(.visible)
|
||||
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):
|
||||
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
|
||||
case .purple_onboarding:
|
||||
@@ -686,7 +700,8 @@ struct ContentView: View {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: ndb,
|
||||
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!
|
||||
@@ -704,6 +719,21 @@ struct ContentView: View {
|
||||
|
||||
damus_state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: home.handle_event)
|
||||
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) {
|
||||
@@ -742,6 +772,8 @@ struct ContentView: View {
|
||||
case route(Route)
|
||||
/// Open a sheet
|
||||
case sheet(Sheets)
|
||||
/// Open an external URL
|
||||
case external_url(URL)
|
||||
/// Do nothing.
|
||||
///
|
||||
/// ## Implementation notes
|
||||
@@ -758,6 +790,8 @@ struct ContentView: View {
|
||||
navigationCoordinator.push(route: route)
|
||||
case .sheet(let sheet):
|
||||
self.active_sheet = sheet
|
||||
case .external_url(let url):
|
||||
this_app.open(url)
|
||||
case .no_action:
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ class Contacts {
|
||||
return friends
|
||||
}
|
||||
|
||||
func get_friend_of_friends_list() -> Set<Pubkey> {
|
||||
return friend_of_friends
|
||||
}
|
||||
|
||||
func get_followed_hashtags() -> Set<String> {
|
||||
guard let ev = self.event else { return Set() }
|
||||
return Set(ev.referenced_hashtags.map({ $0.hashtag }))
|
||||
|
||||
@@ -13,6 +13,7 @@ enum FilterState : Int {
|
||||
case posts = 0
|
||||
case posts_and_replies = 1
|
||||
case conversations = 2
|
||||
case follow_list = 3
|
||||
|
||||
func filter(ev: NostrEvent) -> Bool {
|
||||
switch self {
|
||||
@@ -22,13 +23,15 @@ enum FilterState : Int {
|
||||
return true
|
||||
case .conversations:
|
||||
return true
|
||||
case .follow_list:
|
||||
return ev.known_kind == .follow_list
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple filter to determine whether to show posts with #nsfw tags
|
||||
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) {
|
||||
|
||||
@@ -36,9 +36,10 @@ class DamusState: HeadlessDamusState {
|
||||
var purple: DamusPurple
|
||||
var push_notification_client: PushNotificationClient
|
||||
let emoji_provider: EmojiProvider
|
||||
let favicon_cache: FaviconCache
|
||||
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.likes = likes
|
||||
self.boosts = boosts
|
||||
@@ -68,6 +69,7 @@ class DamusState: HeadlessDamusState {
|
||||
self.quote_reposts = quote_reposts
|
||||
self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings)
|
||||
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)
|
||||
self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate)
|
||||
@@ -126,7 +128,8 @@ class DamusState: HeadlessDamusState {
|
||||
video: DamusVideoCoordinator(),
|
||||
ndb: ndb,
|
||||
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(),
|
||||
ndb: .empty,
|
||||
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 {
|
||||
switch self {
|
||||
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:
|
||||
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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ class HomeModel: ContactsDelegate {
|
||||
handle_boost_event(sub_id: sub_id, ev)
|
||||
case .like:
|
||||
handle_like_event(ev)
|
||||
case .dm:
|
||||
case .deprecated_dm:
|
||||
handle_dm(ev)
|
||||
case .delete:
|
||||
handle_delete_event(ev)
|
||||
@@ -227,6 +227,17 @@ class HomeModel: ContactsDelegate {
|
||||
break
|
||||
case .relay_list:
|
||||
break // This will be handled by `UserRelayListManager`
|
||||
case .follow_list:
|
||||
break
|
||||
case .interest_list:
|
||||
break // Don't care for now
|
||||
case .dm:
|
||||
break // We should never receive a kind 14 DM. It will always be sealed (kind 13) and then gift wrapped (kind 1059).
|
||||
case .seal:
|
||||
break // We should never receive a kind 13 seal. It will always be gift wrapped (kind 1059)
|
||||
case .gift_wrap:
|
||||
handle_gift_wrap(ev)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,34 +272,41 @@ class HomeModel: ContactsDelegate {
|
||||
Task { @MainActor in
|
||||
// TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time
|
||||
guard let nwc_str = damus_state.settings.nostr_wallet_connect,
|
||||
let nwc = WalletConnectURL(str: nwc_str),
|
||||
let resp = await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) else {
|
||||
let nwc = WalletConnectURL(str: nwc_str) else {
|
||||
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,
|
||||
// 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) {
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -552,9 +570,9 @@ class HomeModel: ContactsDelegate {
|
||||
var our_blocklist_filter = NostrFilter(kinds: [.mute_list])
|
||||
our_blocklist_filter.authors = [damus_state.pubkey]
|
||||
|
||||
var dms_filter = NostrFilter(kinds: [.dm])
|
||||
var dms_filter = NostrFilter(kinds: [.deprecated_dm, .gift_wrap])
|
||||
|
||||
var our_dms_filter = NostrFilter(kinds: [.dm])
|
||||
var our_dms_filter = NostrFilter(kinds: [.deprecated_dm])
|
||||
|
||||
// friends only?...
|
||||
//dms_filter.authors = friends
|
||||
@@ -803,6 +821,19 @@ class HomeModel: ContactsDelegate {
|
||||
self.incoming_dms = []
|
||||
}
|
||||
}
|
||||
|
||||
func handle_gift_wrap(_ ev: NostrEvent) {
|
||||
guard ev.known_kind == .gift_wrap,
|
||||
let privateKey = damus_state.keypair.privkey else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let rumor = try? NIP59GiftWrap.unsealedRumor(giftWrapEvent: ev, using: privateKey) else {
|
||||
return
|
||||
}
|
||||
|
||||
handle_dm(rumor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -163,6 +163,10 @@ struct LightningInvoice<T> {
|
||||
let payment_hash: Data
|
||||
let created_at: UInt64
|
||||
|
||||
var abbreviated: String {
|
||||
return self.string.prefix(8) + "…" + self.string.suffix(8)
|
||||
}
|
||||
|
||||
var description_string: String {
|
||||
switch description {
|
||||
case .description(let string):
|
||||
@@ -171,6 +175,17 @@ struct LightningInvoice<T> {
|
||||
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? {
|
||||
@@ -192,6 +207,13 @@ enum Amount: Equatable {
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
-50
@@ -73,85 +73,143 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -
|
||||
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 urls: [UrlType] = []
|
||||
let blocks = bs.blocks
|
||||
|
||||
let one_note_ref = blocks
|
||||
.filter({
|
||||
if case .mention(let mention) = $0,
|
||||
case .note = mention.ref {
|
||||
return true
|
||||
var end_mention_count = 0
|
||||
var end_url_count = 0
|
||||
|
||||
// Search backwards until we find the beginning index of the chain of previewables that reach the end of the content.
|
||||
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
|
||||
let txt: CompatibleText = blocks.reduce(CompatibleText()) { str, block in
|
||||
ind = ind + 1
|
||||
|
||||
// Add the rendered previewable blocks to their type-specific lists.
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
if case .note = m.ref, one_note_ref {
|
||||
case .invoice(let invoice):
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
switch block {
|
||||
case .mention(let m):
|
||||
return str + mention_str(m, profiles: profiles)
|
||||
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):
|
||||
return str + CompatibleText(stringLiteral: relay)
|
||||
|
||||
case .hashtag(let htag):
|
||||
return str + hashtag_str(htag)
|
||||
case .invoice(let invoice):
|
||||
invoices.append(invoice)
|
||||
return str
|
||||
return str + invoice_str(invoice)
|
||||
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 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
|
||||
|
||||
if let prev = blocks[safe: ind-1],
|
||||
case .url(let u) = prev,
|
||||
classify_url(u).is_media != nil {
|
||||
trimmed = " " + trim_prefix(trimmed)
|
||||
// Trim leading whitespaces.
|
||||
if ind == 0 {
|
||||
trimmed = trim_prefix(trimmed)
|
||||
}
|
||||
|
||||
if let next = blocks[safe: ind+1] {
|
||||
if case .url(let u) = next, classify_url(u).is_media != nil {
|
||||
// Trim trailing whitespaces if the following blocks will be hidden or if this is the last block.
|
||||
if ind == hide_text_index - 1 {
|
||||
trimmed = trim_suffix(trimmed)
|
||||
} else if case .mention(let m) = next,
|
||||
case .note = m.ref,
|
||||
one_note_ref {
|
||||
trimmed = trim_suffix(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 {
|
||||
var attributedString = AttributedString(stringLiteral: url.absoluteString)
|
||||
attributedString.link = url
|
||||
@@ -161,17 +219,16 @@ func url_str(_ url: URL) -> CompatibleText {
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
if str.hasSuffix(".mp4") || str.hasSuffix(".mov") || str.hasSuffix(".m3u8") {
|
||||
case "mp4", "mov", "m3u8":
|
||||
return .media(.video(url))
|
||||
}
|
||||
|
||||
default:
|
||||
return .link(url)
|
||||
}
|
||||
}
|
||||
|
||||
func attributed_string_attach_icon(_ astr: inout AttributedString, img: UIImage) {
|
||||
@@ -194,11 +251,11 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
|
||||
let display_str: String = {
|
||||
switch m.ref {
|
||||
case .pubkey(let pk): return getDisplayName(pk: pk, profiles: profiles)
|
||||
case .note: return abbrev_pubkey(bech32String)
|
||||
case .nevent: return abbrev_pubkey(bech32String)
|
||||
case .note: return abbrev_identifier(bech32String)
|
||||
case .nevent: return abbrev_identifier(bech32String)
|
||||
case .nprofile(let nprofile): return getDisplayName(pk: nprofile.author, profiles: profiles)
|
||||
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
|
||||
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
|
||||
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 {
|
||||
|
||||
@@ -113,7 +113,7 @@ func generate_local_notification_object(from ev: NostrEvent, state: HeadlessDamu
|
||||
return LocalNotification(type: .like, event: ev, target: .note_id(evid), content: "")
|
||||
}
|
||||
}
|
||||
else if type == .dm,
|
||||
else if type == .deprecated_dm,
|
||||
state.settings.dm_notification {
|
||||
let convo = ev.decrypted(keypair: state.keypair) ?? NSLocalizedString("New encrypted direct message", comment: "Notification that the user has received a new direct message")
|
||||
return LocalNotification(type: .dm, event: ev, target: .note(ev), content: convo)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
//
|
||||
// SearchHomeModel.swift
|
||||
// damus
|
||||
//
|
||||
@@ -16,6 +15,7 @@ class SearchHomeModel: ObservableObject {
|
||||
var seen_pubkey: Set<Pubkey> = Set()
|
||||
let damus_state: DamusState
|
||||
let base_subid = UUID().description
|
||||
let follow_pack_subid = UUID().description
|
||||
let profiles_subid = UUID().description
|
||||
let limit: UInt32 = 500
|
||||
//let multiple_events_per_pubkey: Bool = false
|
||||
@@ -42,12 +42,18 @@ class SearchHomeModel: ObservableObject {
|
||||
func subscribe() {
|
||||
loading = true
|
||||
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: follow_pack_subid, filters: [follow_list_filter], handler: handle_event, to: to_relays)
|
||||
}
|
||||
|
||||
func unsubscribe(to: RelayURL? = nil) {
|
||||
loading = false
|
||||
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) {
|
||||
@@ -57,7 +63,7 @@ class SearchHomeModel: ObservableObject {
|
||||
|
||||
switch event {
|
||||
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
|
||||
}
|
||||
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
|
||||
|
||||
@@ -36,7 +36,7 @@ class SearchModel: ObservableObject {
|
||||
func subscribe() {
|
||||
// since 1 month
|
||||
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!
|
||||
|
||||
|
||||
@@ -43,6 +43,15 @@ struct DamusURLHandler {
|
||||
return .route(.Script(script: model))
|
||||
case .purple(let 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:
|
||||
break
|
||||
}
|
||||
@@ -91,6 +100,11 @@ struct DamusURLHandler {
|
||||
return .filter(filt)
|
||||
case .script(let script):
|
||||
return .script(script)
|
||||
case .invoice(let bolt11):
|
||||
if let invoice = decode_bolt11(bolt11) {
|
||||
return .invoice(invoice)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -103,5 +117,6 @@ struct DamusURLHandler {
|
||||
case wallet_connect(WalletConnectURL)
|
||||
case script([UInt8])
|
||||
case purple(DamusPurpleURL)
|
||||
case invoice(Invoice)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,12 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "show_wallet_selector", default_value: false)
|
||||
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)
|
||||
var left_handed: Bool
|
||||
|
||||
@@ -122,9 +128,18 @@ class UserSettingsStore: ObservableObject {
|
||||
@Setting(key: "media_previews", default_value: true)
|
||||
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)
|
||||
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)
|
||||
var show_profile_action_sheet_on_pfp_click: Bool
|
||||
|
||||
@@ -174,8 +189,12 @@ class UserSettingsStore: ObservableObject {
|
||||
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
|
||||
@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)
|
||||
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:",
|
||||
appStoreLink: "https://apps.apple.com/us/app/breez-lightning-client-pos/id1463604142", image: "breez")
|
||||
case .bitcoinbeach:
|
||||
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://",
|
||||
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
|
||||
// Blink used to be called Bitcoin Beach.
|
||||
// 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:
|
||||
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
|
||||
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
|
||||
|
||||
@@ -27,6 +27,11 @@ class WalletModel: ObservableObject {
|
||||
|
||||
@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) {
|
||||
self.connect_state = state
|
||||
self.previous_state = .none
|
||||
@@ -75,12 +80,16 @@ class WalletModel: ObservableObject {
|
||||
///
|
||||
/// - Parameter response: The NWC response received from the network
|
||||
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):
|
||||
self.balance = balanceResp.balance / 1000
|
||||
case .none:
|
||||
return
|
||||
case .some(.pay_invoice(_)):
|
||||
case .pay_invoice(_):
|
||||
return
|
||||
case .list_transactions(let transactionsResp):
|
||||
self.transactions = transactionsResp.transactions
|
||||
@@ -91,4 +100,41 @@ class WalletModel: ObservableObject {
|
||||
self.transactions = 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)
|
||||
}
|
||||
|
||||
/// 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,27 @@
|
||||
//
|
||||
// NIP17.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 6/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Functions and utilities for the NIP-04 spec
|
||||
struct NIP17 {}
|
||||
|
||||
extension NIP17 {
|
||||
/// Creates a sealed and gift wrapped kind 14 direct message event. The kind 14 direct message will not be signed because the message might leak to relays and become fully public.
|
||||
static func giftWrappedDirectMessage(message: String, senderKeypair: FullKeypair, receiverPubkey: Pubkey) -> NostrEvent? {
|
||||
let tags = [
|
||||
["p", receiverPubkey.hex()] // TODO add receiver relay URL
|
||||
]
|
||||
|
||||
guard let unsignedDM = NostrEvent(content: message, keypair: .just_pubkey(senderKeypair.pubkey), kind: NostrKind.dm.rawValue, tags: tags)
|
||||
else {
|
||||
return nil;
|
||||
}
|
||||
|
||||
return try? NIP59GiftWrap.giftWrap(withRumor: unsignedDM, toRecipient: receiverPubkey, signedBy: senderKeypair)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//
|
||||
// NIP59GiftWrap.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Terry Yiu on 6/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NIP59GiftWrap {
|
||||
/// Creates a ``NostrEvent`` gift wrap of kind 1059 that takes a rumor, an unsigned ``NostrEvent``, and seals it in a signed ``NostrEvent`` seal event of kind 13, and then wraps that seal encrypted in the content of the gift wrap.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - rumor: a ``NostrEvent`` that is not signed.
|
||||
/// - recipient: the ``Pubkey`` of the receiver of the event. This pubkey will be used to encrypt the rumor. If `recipientAlias` is not provided, this pubkey will automatically be added as a tag to the ``NostrEvent`` gift wrap event.
|
||||
/// - recipientAlias: optional ``Pubkey`` of the receiver's alias used to receive gift wraps without exposing the receiver's identity. It is not used to encrypt the rumor. If it is provided, this pubkey will automatically be added as a tag to the ``NostrEvent`` gift wrap event.
|
||||
/// - tags: the list of tags to add to the ``NostrEvent`` gift wrap event in addition to the pubkey tag from `toRecipient`. This list should include any information needed to route the event to its intended recipient, such as [NIP-13 Proof of Work](https://github.com/nostr-protocol/nips/blob/master/13.md).
|
||||
/// - createdAt: the creation timestamp of the seal. Note that this timestamp SHOULD be tweaked to thwart time-analysis attacks. Note that some relays don't serve events dated in the future, so all timestamps SHOULD be in the past. By default, if `createdAt` is not provided, a random timestamp within 2 days in the past will be chosen.
|
||||
/// - keypair: The real ``FullKeypair`` to encrypt the rumor and sign the seal with. Note that a different random one-time use key is used to sign the gift wrap.
|
||||
static func giftWrap(
|
||||
withRumor rumor: NostrEvent,
|
||||
toRecipient recipient: Pubkey,
|
||||
recipientAlias: Pubkey? = nil,
|
||||
tags: [Tag] = [],
|
||||
createdAt: UInt32 = UInt32(max(0, Date.now.timeIntervalSince1970 - TimeInterval.random(in: 0...172800))),
|
||||
signedBy fullKeypair: FullKeypair
|
||||
) throws -> NostrEvent? {
|
||||
guard let seal = try seal(withRumor: rumor, toRecipient: recipient, signedBy: fullKeypair) else {
|
||||
throw SealEventError.sealFailed
|
||||
}
|
||||
return try giftWrap(withSeal: seal, toRecipient: recipient, recipientAlias: recipientAlias, tags: tags, createdAt: createdAt)
|
||||
}
|
||||
|
||||
/// Creates a ``NostrEvent`` gift wrap of kind 1059 that takes a signed ``NostrEvent`` of kind 13, and then wraps that seal encrypted in the content of the gift wrap.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - seal: a signed ``NostrEvent`` seal event of kind 13.
|
||||
/// - recipient: the ``Pubkey`` of the receiver of the event. This pubkey will be used to encrypt the rumor. If `recipientAlias` is not provided, this pubkey will automatically be added as a tag to the ``GiftWrapEvent``.
|
||||
/// - recipientAlias: optional ``Pubkey`` of the receiver's alias used to receive gift wraps without exposing the receiver's identity. It is not used to encrypt the rumor. If it is provided, this pubkey will automatically be added as a tag to the ``GiftWrapEvent``.
|
||||
/// - tags: the list of tags.
|
||||
/// - createdAt: the creation timestamp of the seal. Note that this timestamp SHOULD be tweaked to thwart time-analysis attacks. Note that some relays don't serve events dated in the future, so all timestamps SHOULD be in the past. By default, if `createdAt` is not provided, a random timestamp within 2 days in the past will be chosen.
|
||||
static func giftWrap(
|
||||
withSeal seal: NostrEvent,
|
||||
toRecipient recipient: Pubkey,
|
||||
recipientAlias: Pubkey? = nil,
|
||||
tags: [Tag] = [],
|
||||
createdAt: UInt32 = UInt32(max(0, Date.now.timeIntervalSince1970 - TimeInterval.random(in: 0...172800))),
|
||||
) throws -> NostrEvent? {
|
||||
guard seal.known_kind == .seal else {
|
||||
throw GiftWrapError.sealInvalid
|
||||
}
|
||||
|
||||
let jsonData = try JSONEncoder().encode(seal)
|
||||
guard let stringifiedJSON = String(data: jsonData, encoding: .utf8) else {
|
||||
throw GiftWrapError.utf8EncodingFailed
|
||||
}
|
||||
|
||||
let randomFullKeypair = generate_new_keypair()
|
||||
|
||||
let combinedTags = [["p", (recipientAlias ?? recipient).hex()]] + tags.map { $0.strings() }
|
||||
|
||||
let encryptedSeal = try NIP44v2Encryption.encrypt(plaintext: stringifiedJSON, privateKeyA: randomFullKeypair.privkey, publicKeyB: recipient)
|
||||
return NostrEvent(content: encryptedSeal, keypair: randomFullKeypair.to_keypair(), kind: NostrKind.gift_wrap.rawValue, tags: combinedTags, createdAt: createdAt)
|
||||
}
|
||||
|
||||
/// Unwraps the content of the gift wrap event and decrypts it into a ``NostrEvent`` seal event.
|
||||
/// - Parameters:
|
||||
/// - giftWrapEvent: The ``NostrEvent`` gift wrap kind 1059 to unwrap.
|
||||
/// - privateKey: The ``Privkey`` to decrypt the content.
|
||||
/// - Returns: The ``SealEvent``.
|
||||
static func unwrappedSeal(giftWrapEvent: NostrEvent, using privateKey: Privkey) throws -> NostrEvent? {
|
||||
guard giftWrapEvent.known_kind == .gift_wrap else {
|
||||
throw GiftWrapError.giftWrapInvalid
|
||||
}
|
||||
|
||||
guard let unwrappedSeal = try? NIP44v2Encryption.decrypt(payload: giftWrapEvent.content, privateKeyA: privateKey, publicKeyB: giftWrapEvent.pubkey) else {
|
||||
throw GiftWrapError.decryptionFailed
|
||||
}
|
||||
|
||||
guard let sealJSONData = unwrappedSeal.data(using: .utf8) else {
|
||||
throw GiftWrapError.utf8EncodingFailed
|
||||
}
|
||||
|
||||
guard let sealEvent = try? JSONDecoder().decode(NostrEvent.self, from: sealJSONData) else {
|
||||
throw GiftWrapError.jsonDecodingFailed
|
||||
}
|
||||
|
||||
return sealEvent
|
||||
}
|
||||
|
||||
/// Creates a ``NostrEvent`` seal event of kind 13 that encrypts a rumor with the sender's private key and receiver's public key.
|
||||
/// There is no p tag pointing to the receiver. There is no way to know who the rumor is for without the receiver's or the sender's private key.
|
||||
/// The only public information in this event is who is signing it.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - withRumor: a ``NostrEvent`` that is not signed.
|
||||
/// - toRecipient: the ``PublicKey`` of the receiver of the event.
|
||||
/// - createdAt: the creation timestamp of the seal. Note that this timestamp SHOULD be tweaked to thwart time-analysis attacks. Note that some relays don't serve events dated in the future, so all timestamps SHOULD be in the past. By default, if `createdAt` is not provided, a random timestamp within 2 days in the past will be chosen.
|
||||
/// - keypair: The ``FullKeypair`` to sign with.
|
||||
static func seal(
|
||||
withRumor rumor: NostrEvent,
|
||||
toRecipient recipient: Pubkey,
|
||||
createdAt: UInt32 = UInt32(max(0, Date.now.timeIntervalSince1970 - TimeInterval.random(in: 0...172800))),
|
||||
signedBy fullKeypair: FullKeypair
|
||||
) throws -> NostrEvent? {
|
||||
guard rumor.isRumor else {
|
||||
throw SealEventError.sealSignedEvent
|
||||
}
|
||||
|
||||
let jsonData = try JSONEncoder().encode(rumor)
|
||||
guard let stringifiedJSON = String(data: jsonData, encoding: .utf8) else {
|
||||
throw SealEventError.utf8EncodingFailed
|
||||
}
|
||||
|
||||
let encryptedRumor = try NIP44v2Encryption.encrypt(plaintext: stringifiedJSON, privateKeyA: fullKeypair.privkey, publicKeyB: recipient)
|
||||
return NostrEvent(content: encryptedRumor, keypair: fullKeypair.to_keypair(), kind: NostrKind.seal.rawValue, createdAt: createdAt)
|
||||
}
|
||||
|
||||
/// Unseals the content of this seal event into a decrypted rumor.
|
||||
/// - Parameters:
|
||||
/// - giftWrapEvent: The ``NostrEvent`` gift wrap kind 1059 to unwrap into a seal, and then unseal to reveal the decrypted rumor.
|
||||
/// - privateKey: The `PrivateKey` to decrypt the rumor.
|
||||
/// - Returns: The decrypted ``NostrEvent`` rumor, where its `signature` is absent.
|
||||
static func unsealedRumor(giftWrapEvent: NostrEvent, using privateKey: Privkey) throws -> NostrEvent? {
|
||||
guard let sealEvent = try unwrappedSeal(giftWrapEvent: giftWrapEvent, using: privateKey) else {
|
||||
return nil
|
||||
}
|
||||
return try unsealedRumor(sealEvent: sealEvent, using: privateKey)
|
||||
}
|
||||
|
||||
static func unsealedRumor(
|
||||
sealEvent: NostrEvent,
|
||||
using privateKey: Privkey
|
||||
) throws -> NostrEvent? {
|
||||
guard let unsealedRumor = try? NIP44v2Encryption.decrypt(payload: sealEvent.content, privateKeyA: privateKey, publicKeyB: sealEvent.pubkey) else {
|
||||
throw SealEventError.decryptionFailed
|
||||
}
|
||||
|
||||
guard let data = unsealedRumor.data(using: .utf8) else {
|
||||
throw SealEventError.rumorInvalid
|
||||
}
|
||||
|
||||
guard let dict = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
|
||||
let content = dict["content"] as? String,
|
||||
let pubkey = dict["pubkey"] as? String,
|
||||
let author = Pubkey(hex: pubkey),
|
||||
let kind = dict["kind"] as? UInt32,
|
||||
let tags = dict["tags"] as? [[String]],
|
||||
let createdAt = dict["created_at"] as? UInt32,
|
||||
let id = dict["id"] as? String,
|
||||
let noteId = NoteId(hex: id) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// guard let ev = NostrEvent(content: content, author: author, kind: kind, tags: tags, createdAt: createdAt, id: noteId, sig: Signature(Data())) else {
|
||||
// return nil
|
||||
// }
|
||||
guard let ev = NostrEvent(content: content, keypair: .just_pubkey(author), kind: kind, tags: tags, createdAt: createdAt) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ev
|
||||
}
|
||||
}
|
||||
|
||||
extension NostrEvent {
|
||||
var isRumor: Bool {
|
||||
return sig.data == Data(repeating: 0, count: 128)
|
||||
}
|
||||
}
|
||||
|
||||
enum GiftWrapError: Error {
|
||||
case decryptionFailed
|
||||
case jsonDecodingFailed
|
||||
case keypairGenerationFailed
|
||||
case pubkeyInvalid
|
||||
case utf8EncodingFailed
|
||||
case sealInvalid
|
||||
case giftWrapInvalid
|
||||
}
|
||||
|
||||
enum SealEventError: Error {
|
||||
case decryptionFailed
|
||||
case jsonDecodingFailed
|
||||
case pubkeyInvalid
|
||||
case sealSignedEvent
|
||||
case utf8EncodingFailed
|
||||
case sealFailed
|
||||
case rumorInvalid
|
||||
}
|
||||
@@ -143,9 +143,17 @@ struct ReplaceableParam: TagConvertible {
|
||||
var keychar: AsciiCharacter { "d" }
|
||||
}
|
||||
|
||||
struct Signature: Hashable, Equatable {
|
||||
struct Signature: Codable, Hashable, Equatable {
|
||||
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) {
|
||||
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))
|
||||
}
|
||||
|
||||
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? {
|
||||
let decoder = JSONDecoder()
|
||||
do {
|
||||
@@ -539,6 +543,7 @@ func event_to_json(ev: NostrEvent) -> String {
|
||||
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? {
|
||||
guard let privkey = privkey else {
|
||||
return nil
|
||||
|
||||
@@ -13,21 +13,26 @@ enum NostrKind: UInt32, Codable {
|
||||
case metadata = 0
|
||||
case text = 1
|
||||
case contacts = 3
|
||||
case dm = 4
|
||||
case deprecated_dm = 4
|
||||
case delete = 5
|
||||
case boost = 6
|
||||
case like = 7
|
||||
case seal = 13
|
||||
case dm = 14
|
||||
case chat = 42
|
||||
case mute_list = 10000
|
||||
case relay_list = 10002
|
||||
case interest_list = 10015
|
||||
case list_deprecated = 30000
|
||||
case draft = 31234
|
||||
case longform = 30023
|
||||
case zap = 9735
|
||||
case zap_request = 9734
|
||||
case highlight = 9802
|
||||
case gift_wrap = 1059
|
||||
case nwc_request = 23194
|
||||
case nwc_response = 23195
|
||||
case http_auth = 27235
|
||||
case status = 30315
|
||||
case follow_list = 39089
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ enum NostrLink: Equatable {
|
||||
case ref(RefId)
|
||||
case filter(NostrFilter)
|
||||
case script([UInt8])
|
||||
case invoice(String)
|
||||
}
|
||||
|
||||
func encode_pubkey_uri(_ pubkey: Pubkey) -> String {
|
||||
@@ -93,8 +94,15 @@ func decode_nostr_uri(_ s: String) -> NostrLink? {
|
||||
return
|
||||
}
|
||||
|
||||
if parts.count >= 2 && parts[0] == "t" {
|
||||
if parts.count >= 2 {
|
||||
switch parts[0] {
|
||||
case "t":
|
||||
return .filter(NostrFilter(hashtag: [parts[1].lowercased()]))
|
||||
case "lightning":
|
||||
return .invoice(parts[1])
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard parts.count == 1 else {
|
||||
|
||||
@@ -35,6 +35,7 @@ class Profiles {
|
||||
@MainActor
|
||||
private var profiles: [Pubkey: ProfileData] = [:]
|
||||
|
||||
// Map of validated NIP-05 address to pubkey.
|
||||
@MainActor
|
||||
var nip05_pubkey: [String: Pubkey] = [:]
|
||||
|
||||
|
||||
@@ -106,7 +106,8 @@ var test_damus_state: DamusState = ({
|
||||
video: .init(),
|
||||
ndb: ndb,
|
||||
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 mention(Mention<MentionRef>)
|
||||
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 {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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
|
||||
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")!
|
||||
|
||||
@@ -80,9 +80,9 @@ func parse_display_name(name: String?, display_name: String?, pubkey: Pubkey) ->
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ extension KFOptionSetter {
|
||||
options.onlyLoadFirstFrame = disable_animation
|
||||
|
||||
switch imageContext {
|
||||
case .pfp:
|
||||
case .pfp, .favicon:
|
||||
options.diskCacheExpiration = .days(60)
|
||||
break
|
||||
case .banner:
|
||||
@@ -82,11 +82,14 @@ enum ImageContext {
|
||||
case pfp
|
||||
case banner
|
||||
case note
|
||||
case favicon
|
||||
|
||||
func maxMebibyteSize() -> Int {
|
||||
switch self {
|
||||
case .favicon:
|
||||
return 512_000 // 500KiB
|
||||
case .pfp:
|
||||
return 5_242_880 // 5Mib
|
||||
return 5_242_880 // 5MiB
|
||||
case .banner, .note:
|
||||
return 20_971_520 // 20MiB
|
||||
}
|
||||
@@ -94,6 +97,8 @@ enum ImageContext {
|
||||
|
||||
func downsampleSize() -> CGSize {
|
||||
switch self {
|
||||
case .favicon:
|
||||
return CGSize(width: 18, height: 18)
|
||||
case .pfp:
|
||||
return CGSize(width: 200, height: 200)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ enum LocalNotificationType: String {
|
||||
switch nostr_kind {
|
||||
case .text:
|
||||
return .mention
|
||||
case .dm:
|
||||
case .deprecated_dm:
|
||||
return .dm
|
||||
case .like:
|
||||
return .like
|
||||
|
||||
@@ -21,6 +21,7 @@ enum LogCategory: String {
|
||||
case damus_purple
|
||||
case image_uploading
|
||||
case video_coordination
|
||||
case tips
|
||||
}
|
||||
|
||||
/// Damus structured logger
|
||||
|
||||
@@ -165,6 +165,12 @@ class PostBox {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't add event if it's a NIP-17 direct message kind or a NIP-59 seal event kind to avoid leaking private information.
|
||||
// DMs should be sealed and gift wrapped.
|
||||
if event.known_kind == .dm || event.known_kind == .seal {
|
||||
return
|
||||
}
|
||||
|
||||
let remaining = to ?? pool.our_descriptors.map { $0.url }
|
||||
let after = delay.map { d in Date.now.addingTimeInterval(d) }
|
||||
let posted_ev = PostedEvent(event: event, remaining: remaining, skip_ephemeral: skip_ephemeral, flush_after: after, on_flush: on_flush)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Scott Penrose on 5/7/23.
|
||||
//
|
||||
|
||||
import FaviconFinder
|
||||
import SwiftUI
|
||||
|
||||
enum Route: Hashable {
|
||||
@@ -46,6 +47,9 @@ enum Route: Hashable {
|
||||
case Wallet(wallet: WalletModel)
|
||||
case WalletScanner(result: Binding<WalletScanResult>)
|
||||
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
|
||||
func view(navigationCoordinator: NavigationCoordinator, damusState: DamusState) -> some View {
|
||||
@@ -127,6 +131,12 @@ enum Route: Hashable {
|
||||
FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers)
|
||||
case .Script(let 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):
|
||||
hasher.combine("script")
|
||||
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
|
||||
}
|
||||
|
||||
func abbreviateURL(_ url: URL) -> String {
|
||||
func abbreviateURL(_ url: URL, maxLength: Int = MAX_CHAR_URL) -> String {
|
||||
let urlString = url.absoluteString
|
||||
|
||||
if urlString.count > MAX_CHAR_URL {
|
||||
return String(urlString.prefix(MAX_CHAR_URL)) + "..."
|
||||
if urlString.count > maxLength {
|
||||
return String(urlString.prefix(maxLength)) + "…"
|
||||
}
|
||||
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
|
||||
case payInvoice(
|
||||
/// 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
|
||||
case getBalance
|
||||
@@ -33,6 +37,38 @@ extension WalletConnect {
|
||||
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
|
||||
|
||||
@@ -61,7 +97,7 @@ extension WalletConnect {
|
||||
|
||||
/// Keys for the JSON inside the "params" object
|
||||
private enum ParamKeys: String, CodingKey {
|
||||
case invoice
|
||||
case invoice, description, metadata
|
||||
case from, until, limit, offset, unpaid, type
|
||||
}
|
||||
|
||||
@@ -82,7 +118,9 @@ extension WalletConnect {
|
||||
case Method.payInvoice.rawValue:
|
||||
let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
||||
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:
|
||||
// No params to decode
|
||||
@@ -112,10 +150,12 @@ extension WalletConnect {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
switch self {
|
||||
case .payInvoice(let invoice):
|
||||
case .payInvoice(let invoice, let description, let metadata):
|
||||
try container.encode(Method.payInvoice.rawValue, forKey: .method)
|
||||
var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params)
|
||||
try paramsContainer.encode(invoice, forKey: .invoice)
|
||||
try paramsContainer.encodeIfPresent(description, forKey: .description)
|
||||
try paramsContainer.encodeIfPresent(metadata, forKey: .metadata)
|
||||
|
||||
case .getBalance:
|
||||
try container.encode(Method.getBalance.rawValue, forKey: .method)
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
// Created by Daniel D’Aquino on 2025-03-10.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
extension WalletConnect {
|
||||
/// Models a response from the NWC provider
|
||||
struct Response: Decodable {
|
||||
@@ -50,35 +52,80 @@ extension WalletConnect {
|
||||
let req_id: NoteId
|
||||
let response: Response
|
||||
|
||||
init?(from: NostrEvent, nwc: WalletConnect.ConnectURL) async {
|
||||
guard let note_id = from.referenced_ids.first else {
|
||||
return nil
|
||||
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) throws(InitializationError) {
|
||||
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
|
||||
|
||||
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
|
||||
|
||||
let ares = Task {
|
||||
guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64),
|
||||
let resp: WalletConnect.Response = decode_json(json)
|
||||
else {
|
||||
let resp: WalletConnect.Response? = nil
|
||||
return resp
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
guard let res = await ares.value else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.response = res
|
||||
enum InitializationError: Error {
|
||||
case incorrectAuthorPubkey
|
||||
case missingRequestIdReference
|
||||
case failedToDecodeJSON(any Error)
|
||||
case failedToDecrypt(any Error)
|
||||
}
|
||||
}
|
||||
|
||||
struct WalletResponseErr: Codable {
|
||||
let code: String?
|
||||
struct WalletResponseErr: Codable, Error {
|
||||
let code: Code?
|
||||
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) {
|
||||
var filter = NostrFilter(kinds: [.nwc_response])
|
||||
filter.authors = [url.pubkey]
|
||||
filter.pubkeys = [url.keypair.pubkey]
|
||||
filter.limit = 0
|
||||
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
|
||||
/// - Returns: The Nostr Event that was sent to the network, representing the request that was made
|
||||
@discardableResult
|
||||
static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? {
|
||||
let req = WalletConnect.Request.payInvoice(invoice: invoice)
|
||||
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.payZapRequest(invoice: invoice, zapRequest: zap_request)
|
||||
guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else {
|
||||
return nil
|
||||
}
|
||||
@@ -104,6 +106,28 @@ extension WalletConnect {
|
||||
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) {
|
||||
// find the pending zap and mark it as pending-confirmed
|
||||
for kv in state.zaps.our_zaps {
|
||||
@@ -142,7 +166,7 @@ extension WalletConnect {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -86,7 +86,7 @@ extension WalletConnect {
|
||||
let created_at: UInt64 // unixtimestamp, // invoice/payment creation time
|
||||
let expires_at: UInt64? // unixtimestamp, // invoice expiration time, optional if not applicable
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,15 @@ enum AppAccessibilityIdentifiers: String {
|
||||
// MARK: 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -337,12 +337,6 @@ struct ChatEventView: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static var toggle_thread_view: Notification.Name {
|
||||
return Notification.Name("convert_to_thread")
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let bar = make_actionbar_model(ev: test_note.id, damus: test_damus_state)
|
||||
return ChatEventView(event: test_note, selected_event: test_note, prev_ev: nil, next_ev: nil, damus_state: test_damus_state, thread: ThreadModel(event: test_note, damus_state: test_damus_state), scroll_to_event: nil, focus_event: nil, highlight_bubble: false, bar: bar)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import SwipeActions
|
||||
import TipKit
|
||||
|
||||
struct ChatroomThreadView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -15,11 +16,20 @@ struct ChatroomThreadView: View {
|
||||
@ObservedObject var thread: ThreadModel
|
||||
@State var highlighted_note_id: NoteId? = nil
|
||||
@State var user_just_posted_flag: Bool = false
|
||||
@State var untrusted_network_expanded: Bool = true
|
||||
@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) {
|
||||
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
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
|
||||
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 {
|
||||
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) {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
// MARK: - Parents events view
|
||||
@@ -56,11 +127,8 @@ struct ChatroomThreadView: View {
|
||||
.padding(.leading, 25 * 2)
|
||||
|
||||
}.background(GeometryReader { geometry in
|
||||
// get the height and width of the EventView view
|
||||
let eventHeight = geometry.frame(in: .global).height
|
||||
// let eventWidth = geometry.frame(in: .global).width
|
||||
|
||||
// vertical gray line in the background
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.25))
|
||||
.frame(width: 2, height: eventHeight)
|
||||
@@ -83,39 +151,90 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
.id(self.thread.selected_event.id)
|
||||
|
||||
|
||||
// MARK: - Children view
|
||||
let events = thread.sorted_child_events
|
||||
let count = events.count
|
||||
SwipeViewGroup {
|
||||
ForEach(Array(zip(events, events.indices)), id: \.0.id) { (ev, ind) in
|
||||
ChatEventView(event: events[ind],
|
||||
selected_event: self.thread.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)
|
||||
}
|
||||
// MARK: - Children view - inside trusted network
|
||||
if !trusted_events.isEmpty {
|
||||
ThreadedSwipeViewGroup(scroller: scroller, events: trusted_events)
|
||||
}
|
||||
}
|
||||
.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()
|
||||
|
||||
HStack {}
|
||||
.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
|
||||
switch notify {
|
||||
case .post(_):
|
||||
@@ -139,15 +258,8 @@ struct ChatroomThreadView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggle_thread_view() {
|
||||
NotificationCenter.default.post(name: .toggle_thread_view, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
struct ChatroomView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ struct DMChatView: View, KeyboardReadable {
|
||||
Button(
|
||||
role: .none,
|
||||
action: {
|
||||
send_message()
|
||||
send_message_nip04()
|
||||
}
|
||||
) {
|
||||
Label("", image: "send")
|
||||
@@ -124,7 +124,33 @@ struct DMChatView: View, KeyboardReadable {
|
||||
*/
|
||||
}
|
||||
|
||||
func send_message() {
|
||||
func send_message_nip17() {
|
||||
guard let fullKeypair = damus_state.keypair.to_full() else {
|
||||
return
|
||||
}
|
||||
|
||||
let tags = [["p", pubkey.hex()]]
|
||||
let post_blocks = parse_post_blocks(content: dms.draft)
|
||||
let content = post_blocks
|
||||
.map(\.asString)
|
||||
.joined(separator: "")
|
||||
|
||||
guard let fullKeypair = damus_state.keypair.to_full(),
|
||||
let dm = NIP17.giftWrappedDirectMessage(message: content, senderKeypair: fullKeypair, receiverPubkey: pubkey)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
dms.draft = ""
|
||||
|
||||
damus_state.nostrNetwork.postbox.send(dm)
|
||||
|
||||
handle_incoming_dm(ev: dm, our_pubkey: damus_state.pubkey, dms: damus_state.dms, prev_events: NewEventsBits())
|
||||
|
||||
end_editing()
|
||||
}
|
||||
|
||||
func send_message_nip04() {
|
||||
let tags = [["p", pubkey.hex()]]
|
||||
let post_blocks = parse_post_blocks(content: dms.draft)
|
||||
let content = post_blocks
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
enum DMType: Hashable {
|
||||
case rando
|
||||
@@ -18,6 +19,7 @@ struct DirectMessagesView: View {
|
||||
@State var dm_type: DMType = .friend
|
||||
@ObservedObject var model: DirectMessagesModel
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@Binding var subtitle: String?
|
||||
|
||||
func MainContent(requests: Bool) -> some View {
|
||||
ScrollView {
|
||||
@@ -72,7 +74,15 @@ struct DirectMessagesView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let showTrustedButton = would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms)
|
||||
VStack(spacing: 0) {
|
||||
if #available(iOS 17, *), showTrustedButton {
|
||||
TipView(TrustedNetworkButtonTip.shared)
|
||||
.tipBackground(.clear)
|
||||
.tipViewStyle(TrustedNetworkButtonTipViewStyle())
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("DMs", comment: "Picker option for DM selector for seeing only DMs that have been responded to. DM is the English abbreviation for Direct Message."), DMType.friend),
|
||||
(NSLocalizedString("Requests", comment: "Picker option for DM selector for seeing only message requests (DMs that someone else sent the user which has not been responded to yet"), DMType.rando),
|
||||
@@ -92,11 +102,21 @@ struct DirectMessagesView: View {
|
||||
}
|
||||
.toolbar {
|
||||
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."))
|
||||
}
|
||||
@@ -115,6 +135,6 @@ func would_filter_non_friends_from_dms(contacts: Contacts, dms: [DirectMessageMo
|
||||
struct DirectMessagesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
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)
|
||||
.padding(.vertical, 30)
|
||||
|
||||
if let technical_info = error.technical_info {
|
||||
ErrorTechInfoCopyButton(errorInfo: technical_info)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let damus_state, damus_state.is_privkey_user {
|
||||
@@ -69,6 +73,39 @@ struct ErrorView: View {
|
||||
.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.
|
||||
struct UserPresentableError {
|
||||
/// The description of the error to be shown to the user
|
||||
@@ -113,7 +150,7 @@ struct ErrorView: View {
|
||||
error: .init(
|
||||
user_visible_description: "We are still too early",
|
||||
tip: "Stay humble, keep building, stack sats",
|
||||
technical_info: nil
|
||||
technical_info: "UTXOs too small, must stack more sats"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ struct MenuItems: View {
|
||||
Label(NSLocalizedString("Broadcast", comment: "Context menu option for broadcasting the user's note to all of the user's connected relay servers."), image: "globe")
|
||||
}
|
||||
// Mute thread - relocated to below Broadcast, as to move further away from Add Bookmark to prevent accidental muted threads
|
||||
if event.known_kind != .dm {
|
||||
if event.known_kind != .deprecated_dm && event.known_kind != .dm && event.known_kind != .seal {
|
||||
MuteDurationMenu { duration in
|
||||
if let full_keypair = self.damus_state.keypair.to_full(),
|
||||
let new_mutelist_ev = toggle_from_mutelist(keypair: full_keypair, prev: damus_state.mutelist_manager.event, to_toggle: .thread(event.thread_id(), duration?.date_from_now)) {
|
||||
|
||||
@@ -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) {
|
||||
ZStack {
|
||||
titleImage(url: url)
|
||||
Blur()
|
||||
.onTapGesture {
|
||||
blur_images = false
|
||||
}
|
||||
BlurOverlayView(blur_images: $blur_images, artifacts: nil, size: nil, damus_state: nil, parentView: .longFormView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class LoadableNostrEventViewModel: ObservableObject {
|
||||
switch known_kind {
|
||||
case .text, .highlight:
|
||||
return .loaded(route: Route.Thread(thread: ThreadModel(event: ev, damus_state: damus_state)))
|
||||
case .dm:
|
||||
case .deprecated_dm: // FIXME(tyiu)
|
||||
let dm_model = damus_state.dms.lookup_or_create(ev.pubkey)
|
||||
return .loaded(route: Route.DMChat(dms: dm_model))
|
||||
case .like:
|
||||
@@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject {
|
||||
case .zap, .zap_request:
|
||||
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
|
||||
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, .dm, .seal, .gift_wrap:
|
||||
return .unknown_or_unsupported_kind
|
||||
}
|
||||
case .naddr(let naddr):
|
||||
|
||||
@@ -47,20 +47,6 @@ struct MutelistView: View {
|
||||
|
||||
var body: some View {
|
||||
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.")) {
|
||||
ForEach(hashtags, id: \.self) { item in
|
||||
if case let MuteItem.hashtag(hashtag, _) = item {
|
||||
@@ -86,10 +72,7 @@ struct MutelistView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Section(
|
||||
header: Text(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")),
|
||||
footer: Text("").padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
|
||||
) {
|
||||
Section(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")) {
|
||||
ForEach(threads, id: \.self) { item in
|
||||
if case let MuteItem.thread(note_id, _) = item {
|
||||
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."))
|
||||
.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 {
|
||||
|
||||
let damus_state: DamusState
|
||||
@@ -72,15 +73,40 @@ struct NoteContentView: View {
|
||||
}
|
||||
|
||||
var preview: LinkViewRepresentable? {
|
||||
guard !blur_images,
|
||||
case .loaded(let preview) = preview_model.state,
|
||||
guard case .loaded(let preview) = preview_model.state,
|
||||
case .value(let cached) = preview else {
|
||||
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))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Group {
|
||||
if truncate_very_short {
|
||||
@@ -107,7 +133,7 @@ struct NoteContentView: View {
|
||||
|
||||
func previewView(links: [URL]) -> some View {
|
||||
Group {
|
||||
if let preview = self.preview, !blur_images {
|
||||
if let preview = self.preview {
|
||||
if let preview_height {
|
||||
preview
|
||||
.frame(height: preview_height)
|
||||
@@ -166,10 +192,7 @@ struct NoteContentView: View {
|
||||
ImageCarousel(state: damus_state, evid: event.id, urls: artifacts.media) { dismiss in
|
||||
fullscreen_preview(dismiss: dismiss)
|
||||
}
|
||||
Blur()
|
||||
.onTapGesture {
|
||||
blur_images = false
|
||||
}
|
||||
BlurOverlayView(blur_images: $blur_images, artifacts: artifacts, size: size, damus_state: damus_state, parentView: .noteContentView)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,7 +206,7 @@ struct NoteContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if damus_state.settings.media_previews, has_previews {
|
||||
if has_previews {
|
||||
if with_padding {
|
||||
previewView(links: artifacts.links).padding(.horizontal)
|
||||
} else {
|
||||
@@ -384,6 +407,64 @@ func lookup_cached_preview_size(previews: PreviewCache, evid: NoteId) -> CGFloat
|
||||
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 {
|
||||
static var previews: some View {
|
||||
let state = test_damus_state
|
||||
@@ -401,7 +482,7 @@ struct NoteContentView_Previews: PreviewProvider {
|
||||
.previewDisplayName("Super short note")
|
||||
|
||||
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")
|
||||
|
||||
@@ -434,4 +515,3 @@ func separate_images(ev: NostrEvent, keypair: Keypair) -> [MediaUrl]? {
|
||||
let mediaUrls = urlBlocks.map { MediaUrl.image($0) }
|
||||
return mediaUrls.isEmpty ? nil : mediaUrls
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
class NotificationFilter: ObservableObject, Equatable {
|
||||
@Published var state: NotificationFilterState
|
||||
@@ -79,6 +80,7 @@ struct NotificationsView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
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) {
|
||||
NotificationTab(
|
||||
NotificationFilter(
|
||||
@@ -115,14 +117,19 @@ struct NotificationsView: View {
|
||||
Button(
|
||||
action: { state.nav.push(route: Route.NotificationSettings(settings: state.settings)) },
|
||||
label: {
|
||||
Image("settings")
|
||||
Image(systemName: "gearshape")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
|
||||
FriendsButton(filter: $filter.friend_filter)
|
||||
if showTrustedButton {
|
||||
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) {
|
||||
VStack(spacing: 0) {
|
||||
if #available(iOS 17, *), showTrustedButton {
|
||||
TipView(TrustedNetworkButtonTip.shared)
|
||||
.tipBackground(.clear)
|
||||
.tipViewStyle(TrustedNetworkButtonTipViewStyle())
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
CustomPicker(tabs: [
|
||||
(NSLocalizedString("All", comment: "Label for filter for all notifications."), NotificationFilterState.all),
|
||||
(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 {
|
||||
NavigationView {
|
||||
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)
|
||||
.navigationTitle(NSLocalizedString("Who to Follow", comment: "Title for a screen displaying suggestions of who to follow"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -41,7 +87,7 @@ struct OnboardingSuggestionsView: View {
|
||||
})
|
||||
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
|
||||
)
|
||||
.tag(0)
|
||||
.tag(2)
|
||||
|
||||
PostView(
|
||||
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
|
||||
dismiss()
|
||||
}
|
||||
.tag(1)
|
||||
.tag(3)
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
VStack {
|
||||
if let suggestions = model.suggestions {
|
||||
List {
|
||||
ForEach(model.groups) { group in
|
||||
ForEach(suggestions, id: \.self) { followPack in
|
||||
Section {
|
||||
ForEach(group.users, id: \.self) { pk in
|
||||
if let user = model.suggestedUser(pubkey: pk) {
|
||||
ForEach(followPack.publicKeys, id: \.self) { pk in
|
||||
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)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
SuggestedUsersSectionHeader(group: group, model: model)
|
||||
SuggestedUsersSectionHeader(followPack: followPack, model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
else {
|
||||
ProgressView()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
@@ -110,17 +171,14 @@ fileprivate struct SuggestedUsersPageView: View {
|
||||
}
|
||||
|
||||
struct SuggestedUsersSectionHeader: View {
|
||||
let group: SuggestedUserGroup
|
||||
let followPack: FollowPackEvent
|
||||
let model: SuggestedUsersViewModel
|
||||
var body: some View {
|
||||
HStack {
|
||||
let locale = Locale.current
|
||||
let format = localizedStringFormat(key: group.category, locale: locale)
|
||||
let categoryName = String(format: format, locale: locale)
|
||||
Text(categoryName)
|
||||
Text(followPack.title ?? NSLocalizedString("Untitled Follow Pack", comment: "Default title for a follow pack if no title is specified"))
|
||||
Spacer()
|
||||
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))
|
||||
}
|
||||
@@ -129,6 +187,6 @@ struct SuggestedUsersSectionHeader: View {
|
||||
|
||||
struct SuggestedUsersView_Previews: PreviewProvider {
|
||||
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)
|
||||
InnerProfilePicView(url: user.pfp,
|
||||
fallbackUrl: nil,
|
||||
pubkey: target.pubkey,
|
||||
size: 50,
|
||||
highlight: .none,
|
||||
disable_animation: false)
|
||||
|
||||
@@ -8,32 +8,76 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
struct SuggestedUserGroup: Identifiable, Codable {
|
||||
let id = UUID()
|
||||
let category: String
|
||||
let users: [Pubkey]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case category, users
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// This model does the following:
|
||||
///
|
||||
/// - It loads follow packs (From the network, with a local copy fallback), and related profiles
|
||||
/// - It tracks the interests and disinterests as selected by the user via an interface
|
||||
/// - It computes publishes suggestions for users based on selected interests
|
||||
@MainActor
|
||||
class SuggestedUsersViewModel: ObservableObject {
|
||||
|
||||
/// The Damus State
|
||||
public let damus_state: DamusState
|
||||
|
||||
@Published var groups: [SuggestedUserGroup] = []
|
||||
|
||||
private let sub_id = UUID().uuidString
|
||||
|
||||
init(damus_state: DamusState) {
|
||||
self.damus_state = damus_state
|
||||
loadSuggestedUserGroups()
|
||||
let pubkeys = getPubkeys(groups: groups)
|
||||
subscribeToSuggestedProfiles(pubkeys: pubkeys)
|
||||
/// Keeps all the suggested follow packs available. For internal use only.
|
||||
private var allSuggestions: [FollowPackEvent]? = nil {
|
||||
didSet { self.recomputeSuggestions() }
|
||||
}
|
||||
|
||||
/// 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? {
|
||||
let profile_txn = damus_state.profiles.lookup(id: pubkey)
|
||||
if let profile = profile_txn?.unsafeUnownedValue,
|
||||
@@ -43,63 +87,154 @@ class SuggestedUsersViewModel: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Allows the user to follow a list of other users
|
||||
func follow(pubkeys: [Pubkey]) {
|
||||
for pubkey in pubkeys {
|
||||
notify(.follow(.pubkey(pubkey)))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSuggestedUserGroups() {
|
||||
guard let url = Bundle.main.url(forResource: "suggested_users", withExtension: "json") else {
|
||||
return
|
||||
|
||||
// MARK: - Internal state management logic
|
||||
|
||||
/// 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 {
|
||||
return
|
||||
/// State management function that recomputes `disinterests` based its logical dependencies
|
||||
///
|
||||
/// 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()
|
||||
do {
|
||||
let groups = try decoder.decode([SuggestedUserGroup].self, from: data)
|
||||
self.groups = groups
|
||||
} catch {
|
||||
print(error.localizedDescription.localizedLowercase)
|
||||
/// State management function that recomputes `suggestions` based its logical dependencies
|
||||
///
|
||||
/// This helps ensure views get instant updates everytime the suggestions are supposed to be updated.
|
||||
private func recomputeSuggestions() {
|
||||
self.suggestions = Self.computeSuggestions(basedOn: allSuggestions, interests: interests, disinterests: disinterests)
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
var pubkeys: [Pubkey] = []
|
||||
for group in groups {
|
||||
pubkeys.append(contentsOf: group.users)
|
||||
}
|
||||
return pubkeys
|
||||
return packsById
|
||||
}
|
||||
|
||||
private func subscribeToSuggestedProfiles(pubkeys: [Pubkey]) {
|
||||
let filter = NostrFilter(kinds: [.metadata], authors: pubkeys)
|
||||
damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event)
|
||||
/// Loads the newest follow packs from the network, and overwrites the provided follow packs where appropriate
|
||||
private func loadSuggestedFollowPacksFromNetwork(packsById: inout [FollowPackID: FollowPackEvent]) async {
|
||||
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) {
|
||||
guard case .nostr_event(let nev) = ev else {
|
||||
return
|
||||
packsById[id] = latestPackForThisId
|
||||
}
|
||||
|
||||
switch nev {
|
||||
case .event:
|
||||
break
|
||||
|
||||
case .notice(let msg):
|
||||
print("suggested user profiles notice: \(msg)")
|
||||
|
||||
case .eose:
|
||||
self.objectWillChange.send()
|
||||
|
||||
case .ok:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .auth:
|
||||
/// 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ struct PostView: View {
|
||||
var autoSaveModel: AutoSaveIndicatorView.AutoSaveViewModel
|
||||
|
||||
@State var preUploadedMedia: [PreUploadedMedia] = []
|
||||
@State var mediaUploadUnderProgress: MediaUpload? = nil
|
||||
|
||||
@StateObject var image_upload: ImageUploadModel = ImageUploadModel()
|
||||
@StateObject var tagModel: TagModel = TagModel()
|
||||
@@ -330,11 +331,6 @@ struct PostView: View {
|
||||
PostButton
|
||||
}
|
||||
|
||||
if let progress = image_upload.progress {
|
||||
ProgressView(value: progress, total: 1.0)
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.foregroundColor(DamusColors.neutral3)
|
||||
.padding(.top, 5)
|
||||
@@ -346,6 +342,7 @@ struct PostView: View {
|
||||
|
||||
@discardableResult
|
||||
func handle_upload(media: MediaUpload) async -> Bool {
|
||||
mediaUploadUnderProgress = media
|
||||
let uploader = damus_state.settings.default_media_uploader
|
||||
|
||||
let img = getImage(media: media)
|
||||
@@ -354,6 +351,7 @@ struct PostView: View {
|
||||
async let blurhash = calculate_blurhash(img: img)
|
||||
let res = await image_upload.start(media: media, uploader: uploader, mediaType: .normal, keypair: damus_state.keypair)
|
||||
|
||||
mediaUploadUnderProgress = nil
|
||||
switch res {
|
||||
case .success(let url):
|
||||
guard let url = URL(string: url) else {
|
||||
@@ -401,7 +399,10 @@ struct PostView: View {
|
||||
}
|
||||
.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
|
||||
post_changed(post: post, media: media)
|
||||
}
|
||||
@@ -620,6 +621,8 @@ struct PostView_Previews: PreviewProvider {
|
||||
|
||||
struct PVImageCarouselView: View {
|
||||
@Binding var media: [UploadedMedia]
|
||||
@Binding var mediaUnderProgress: MediaUpload?
|
||||
@ObservedObject var imageUploadModel: ImageUploadModel
|
||||
|
||||
let deviceWidth: CGFloat
|
||||
|
||||
@@ -667,6 +670,25 @@ struct PVImageCarouselView: View {
|
||||
.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()
|
||||
}
|
||||
@@ -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 {
|
||||
let post = NSMutableAttributedString(attributedString: post)
|
||||
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
|
||||
if nextCharIndex < post.length,
|
||||
let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first,
|
||||
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import Combine
|
||||
|
||||
let BANNER_HEIGHT: CGFloat = 150.0;
|
||||
fileprivate let Scroll_height: CGFloat = 700.0
|
||||
|
||||
struct EditMetadataView: View {
|
||||
let damus_state: DamusState
|
||||
@@ -79,11 +80,14 @@ struct EditMetadataView: View {
|
||||
func topSection(topLevelGeo: GeometryProxy) -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
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))
|
||||
.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()
|
||||
}.frame(height: BANNER_HEIGHT)
|
||||
.offset(y: offset > 0 ? -offset : 0) // Pin the top
|
||||
}
|
||||
.frame(height: BANNER_HEIGHT)
|
||||
VStack(alignment: .leading) {
|
||||
let pfp_size: CGFloat = 90.0
|
||||
|
||||
@@ -129,7 +133,9 @@ struct EditMetadataView: View {
|
||||
|
||||
func content(topLevelGeo: GeometryProxy) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
self.topSection(topLevelGeo: topLevelGeo)
|
||||
|
||||
Form {
|
||||
Section(NSLocalizedString("Your Name", comment: "Label for Your Name section of user profile form.")) {
|
||||
let display_name_placeholder = "Satoshi Nakamoto"
|
||||
@@ -198,6 +204,8 @@ struct EditMetadataView: View {
|
||||
|
||||
|
||||
}
|
||||
.frame(height: Scroll_height)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
if !ln.isEmpty && !is_ln_valid(ln: ln) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by William Casarin on 2022-04-16.
|
||||
//
|
||||
|
||||
import FaviconFinder
|
||||
import SwiftUI
|
||||
|
||||
enum FriendType {
|
||||
@@ -43,6 +44,7 @@ struct ProfileName: View {
|
||||
@State var nip05: NIP05?
|
||||
@State var donation: Int?
|
||||
@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) {
|
||||
self.pubkey = pubkey
|
||||
@@ -101,7 +103,7 @@ struct ProfileName: View {
|
||||
.fontWeight(prefix == "@" ? .none : .bold)
|
||||
|
||||
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 {
|
||||
@@ -122,6 +124,12 @@ struct ProfileName: View {
|
||||
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
|
||||
if update.pubkey != pubkey {
|
||||
return
|
||||
@@ -151,6 +159,24 @@ struct ProfileName: View {
|
||||
let nip05 = damus_state.profiles.is_validated(pubkey)
|
||||
if nip05 != self.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 {
|
||||
|
||||
@@ -31,7 +31,6 @@ func pfp_line_width(_ h: Highlight) -> CGFloat {
|
||||
struct InnerProfilePicView: View {
|
||||
let url: URL?
|
||||
let fallbackUrl: URL?
|
||||
let pubkey: Pubkey
|
||||
let size: CGFloat
|
||||
let highlight: Highlight
|
||||
let disable_animation: Bool
|
||||
@@ -65,16 +64,19 @@ struct InnerProfilePicView: View {
|
||||
|
||||
|
||||
struct ProfilePicView: View {
|
||||
@Environment(\.redactionReasons) var redactionReasons
|
||||
|
||||
let pubkey: Pubkey
|
||||
let size: CGFloat
|
||||
let highlight: Highlight
|
||||
let profiles: Profiles
|
||||
let disable_animation: Bool
|
||||
let zappability_indicator: Bool
|
||||
let privacy_sensitive: Bool
|
||||
|
||||
@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.profiles = profiles
|
||||
self.size = size
|
||||
@@ -82,6 +84,15 @@ struct ProfilePicView: View {
|
||||
self._picture = State(initialValue: picture)
|
||||
self.disable_animation = disable_animation
|
||||
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? {
|
||||
@@ -90,7 +101,7 @@ struct ProfilePicView: View {
|
||||
|
||||
var body: some View {
|
||||
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
|
||||
guard updated.pubkey == self.pubkey else {
|
||||
return
|
||||
|
||||
@@ -123,7 +123,7 @@ struct ProfileView: View {
|
||||
var filters = ContentFilters.defaults(damus_state: damus_state)
|
||||
filters.append(fstate.filter)
|
||||
switch fstate {
|
||||
case .posts, .posts_and_replies:
|
||||
case .posts, .posts_and_replies, .follow_list:
|
||||
filters.append({ profile.pubkey == $0.pubkey })
|
||||
case .conversations:
|
||||
filters.append({ profile.conversation_events.contains($0.id) } )
|
||||
|
||||
@@ -46,7 +46,7 @@ struct PubkeyView: View {
|
||||
let bech32 = pubkey.npub
|
||||
|
||||
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)
|
||||
.foregroundColor(keyColor())
|
||||
.padding(5)
|
||||
|
||||
@@ -11,12 +11,14 @@ struct RelayView: View {
|
||||
let state: DamusState
|
||||
let relay: RelayURL
|
||||
let recommended: Bool
|
||||
/// Disables navigation link
|
||||
let disableNavLink: Bool
|
||||
@ObservedObject private var model_cache: RelayModelCache
|
||||
|
||||
@State var relay_state: 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.relay = relay
|
||||
self.recommended = recommended
|
||||
@@ -24,6 +26,7 @@ struct RelayView: View {
|
||||
_showActionButtons = showActionButtons
|
||||
let relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: relay)
|
||||
self._relay_state = State(initialValue: relay_state)
|
||||
self.disableNavLink = disableNavLink
|
||||
}
|
||||
|
||||
static func get_relay_state(pool: RelayPool, relay: RelayURL) -> Bool {
|
||||
@@ -96,21 +99,25 @@ struct RelayView: View {
|
||||
RelayStatusView(connection: relay_connection)
|
||||
}
|
||||
|
||||
if !disableNavLink {
|
||||
Image("chevron-large-right")
|
||||
.resizable()
|
||||
.frame(width: 15, height: 15)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.onReceive(handle_notify(.relays_changed)) { _ in
|
||||
self.relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: self.relay)
|
||||
}
|
||||
.onTapGesture {
|
||||
if !disableNavLink {
|
||||
state.nav.push(route: Route.RelayDetail(relay: relay, metadata: model_cache.model(with_relay_id: relay)?.metadata))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var relay_connection: RelayConnection? {
|
||||
state.nostrNetwork.pool.get_relay(relay)?.connection
|
||||
|
||||
@@ -12,9 +12,19 @@ struct QuoteRepostsView: View {
|
||||
@ObservedObject var model: EventsModel
|
||||
|
||||
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)
|
||||
.navigationBarTitle(NSLocalizedString("Quotes", comment: "Navigation bar title for Quote Reposts view."))
|
||||
.onAppear {
|
||||
model.subscribe()
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ struct SearchHomeView: View {
|
||||
@State var search: String = ""
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var content_filter: (NostrEvent) -> Bool {
|
||||
let filters = ContentFilters.defaults(damus_state: self.damus_state)
|
||||
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
|
||||
var filters = ContentFilters.defaults(damus_state: damus_state)
|
||||
filters.append(fstate.filter)
|
||||
return ContentFilters(filters: filters).filter
|
||||
}
|
||||
|
||||
@@ -52,21 +53,20 @@ struct SearchHomeView: View {
|
||||
loading: $model.loading,
|
||||
damus: damus_state,
|
||||
show_friend_icon: true,
|
||||
filter: { ev in
|
||||
if !content_filter(ev) {
|
||||
return false
|
||||
}
|
||||
|
||||
let event_muted = damus_state.mutelist_manager.is_event_muted(ev)
|
||||
if event_muted {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
filter:content_filter(FilterState.posts),
|
||||
content: {
|
||||
AnyView(VStack {
|
||||
SuggestedHashtagsView(damus_state: damus_state, max_items: 5, events: model.events)
|
||||
AnyView(VStack(alignment: .leading) {
|
||||
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()
|
||||
.frame(height: 1)
|
||||
|
||||
@@ -100,6 +100,8 @@ struct AppearanceSettingsView: View {
|
||||
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")
|
||||
) {
|
||||
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)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ struct DeveloperSettingsView: View {
|
||||
|
||||
Toggle(NSLocalizedString("Enable experimental Purple In-app purchase support", comment: "Developer mode setting to enable experimental Purple In-app purchase support."), isOn: $settings.enable_experimental_purple_iap_support)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
if #available(iOS 17, *) {
|
||||
Toggle(NSLocalizedString("Reset tips on launch", comment: "Developer mode setting to reset tips upon app first launch. Tips are visual contextual hints that highlight new, interesting, or unused features users have not discovered yet."), isOn: $settings.reset_tips_on_launch)
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user