Compare commits

..

1 Commits

Author SHA1 Message Date
tyiu a6835b835b Fix wallet transactions to always show profile display name unless there is no pubkey
Changelog-Fixed: Fixed wallet transactions to always show profile display name unless there is no pubkey
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-05-31 14:46:18 -04:00
130 changed files with 813 additions and 6633 deletions
-1
View File
@@ -6,4 +6,3 @@ damus.xcodeproj/xcshareddata/xcbaselines
TODO.bak
tags
build-git-hash.txt
.build
+3 -19
View File
@@ -1,26 +1,10 @@
<div align="center">
[![Run Test Suite](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml/badge.svg?branch=master)](https://github.com/damus-io/damus/actions/workflows/run-tests.yaml)
<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
# damus
A twitter-like [nostr][nostr] client for iPhone, iPad and MacOS.
[![License: GPL-3.0](https://img.shields.io/github/license/damus-io/damus?labelColor=27303D&color=0877d2)](/LICENSE)
## Download and Install
[![Apple](https://img.shields.io/badge/Apple-%23000000.svg?style=for-the-badge&logo=apple&logoColor=white)](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>
<img src="./ss.png" width="50%" height="50%" />
[nostr]: https://github.com/fiatjaf/nostr
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
{
"originHash" : "1fc7e0b44329ba72cd285eeb022b5b92582cd01586b920d243cb0485c2e69dcc",
"originHash" : "06318d35ee2e6bd681b95591e67da33a9461b48a3c652e58bd9d1a6f0d82bdac",
"pins" : [
{
"identity" : "codescanner",
@@ -35,15 +35,6 @@
"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",
@@ -114,15 +105,6 @@
"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",
@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "blink.png",
"filename" : "bbw.jpg",
"idiom" : "universal"
}
],
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

+9 -45
View File
@@ -162,7 +162,6 @@ 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()
}
}
}
@@ -187,13 +186,6 @@ 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
@@ -215,7 +207,6 @@ class CarouselModel: ObservableObject {
self.observe_video_sizes()
Task {
self.refresh_current_item_fill()
self.refresh_first_item_height()
}
}
@@ -250,17 +241,10 @@ 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() {
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],
if let current_url,
let item_size = self.media_size_information[current_url],
let geo_size {
return ImageFill.calculate_image_fill(
self.current_item_fill = ImageFill.calculate_image_fill(
geo_size: geo_size,
img_size: item_size,
maxHeight: self.max_height,
@@ -268,26 +252,9 @@ class CarouselModel: ObservableObject {
)
}
else {
return nil // Not enough information to compute the proper fill. Default to nil
self.current_item_fill = 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
@@ -319,15 +286,13 @@ struct ImageCarousel<Content: View>: View {
self.content = content
}
/// 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 filling: Bool {
model.current_item_fill?.filling == true
}
var height: CGFloat {
// 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
// Use the calculated fill height if available, otherwise use the default fill height
model.current_item_fill?.height ?? model.default_fill_height
}
func Placeholder(url: URL, geo_size: CGSize, num_urls: Int) -> some View {
@@ -411,7 +376,6 @@ 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
}
+23 -38
View File
@@ -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 damus_state: DamusState
let contacts: Contacts
let show_domain: Bool
let nip05_domain_favicon: FaviconURL?
let profiles: Profiles
init(nip05: NIP05, pubkey: Pubkey, damus_state: DamusState, show_domain: Bool, nip05_domain_favicon: FaviconURL?) {
@Environment(\.openURL) var openURL
init(nip05: NIP05, pubkey: Pubkey, contacts: Contacts, show_domain: Bool, profiles: Profiles) {
self.nip05 = nip05
self.pubkey = pubkey
self.damus_state = damus_state
self.contacts = contacts
self.show_domain = show_domain
self.nip05_domain_favicon = nip05_domain_favicon
self.profiles = profiles
}
var nip05_color: Bool {
return use_nip05_color(pubkey: pubkey, contacts: damus_state.contacts)
return use_nip05_color(pubkey: pubkey, contacts: contacts)
}
var Seal: some View {
@@ -44,23 +44,8 @@ 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 = damus_state.profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
guard let name = profiles.lookup(id: pubkey)?.map({ p in p?.name }).value
else {
return false
}
@@ -80,18 +65,14 @@ 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 {
damus_state.nav.push(route: Route.NIP05DomainEvents(events: NIP05DomainEventsModel(state: damus_state, domain: nip05.host), nip05_domain_favicon: nip05_domain_favicon))
if show_domain {
Text(nip05_string)
.nip05_colorized(gradient: nip05_color)
.onTapGesture {
if let nip5url = nip05.siteUrl {
openURL(nip5url)
}
}
}
}
@@ -117,9 +98,13 @@ 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, damus_state: test_state, show_domain: true, nip05_domain_favicon: nil)
NIP05Badge(nip05: NIP05(username: "jb55", host: "jb55.com"), pubkey: test_state.pubkey, contacts: test_state.contacts, show_domain: true, profiles: test_state.profiles)
NIP05Badge(nip05: NIP05(username: "_", 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)
}
}
}
+3 -33
View File
@@ -9,7 +9,6 @@ import SwiftUI
import AVKit
import MediaPlayer
import EmojiPicker
import TipKit
struct ZapSheet {
let target: ZapTarget
@@ -179,7 +178,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, subtitle: $menu_subtitle)
DirectMessagesView(damus_state: damus_state!, model: damus_state!.dms, settings: damus_state!.settings)
}
}
.background(DamusColors.adaptableWhite)
@@ -334,20 +333,7 @@ struct ContentView: View {
.presentationDetents([.height(550)])
.presentationDragIndicator(.visible)
case .onboardingSuggestions:
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"
)
)
}
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: damus_state!))
case .purple(let purple_url):
DamusPurpleURLSheetView(damus_state: damus_state!, purple_url: purple_url)
case .purple_onboarding:
@@ -700,8 +686,7 @@ struct ContentView: View {
video: DamusVideoCoordinator(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
home.damus_state = self.damus_state!
@@ -719,21 +704,6 @@ 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) {
-77
View File
@@ -1,77 +0,0 @@
//
// Interests.swift
// damus
//
// Created by Daniel DAquino 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")
}
}
}
}
+3 -6
View File
@@ -25,13 +25,12 @@ class ActionBarModel: ObservableObject {
@Published private(set) var zaps: Int
@Published var zap_total: Int64
@Published var replies: Int
@Published var relays: Int
static func empty() -> ActionBarModel {
return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
}
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0, relays: Int = 0) {
init(likes: Int = 0, boosts: Int = 0, zaps: Int = 0, zap_total: Int64 = 0, replies: Int = 0, our_like: NostrEvent? = nil, our_boost: NostrEvent? = nil, our_zap: Zapping? = nil, our_reply: NostrEvent? = nil, our_quote_repost: NostrEvent? = nil, quote_reposts: Int = 0) {
self.likes = likes
self.boosts = boosts
self.zaps = zaps
@@ -43,7 +42,6 @@ class ActionBarModel: ObservableObject {
self.our_reply = our_reply
self.our_quote_repost = our_quote_repost
self.quote_reposts = quote_reposts
self.relays = relays
}
func update(damus: DamusState, evid: NoteId) {
@@ -58,12 +56,11 @@ class ActionBarModel: ObservableObject {
self.our_zap = damus.zaps.our_zaps[evid]?.first
self.our_reply = damus.replies.our_reply(evid)
self.our_quote_repost = damus.quote_reposts.our_events[evid]
self.relays = (damus.nostrNetwork.pool.seen[evid] ?? []).count
self.objectWillChange.send()
}
var is_empty: Bool {
return likes == 0 && boosts == 0 && zaps == 0 && quote_reposts == 0 && relays == 0
return likes == 0 && boosts == 0 && zaps == 0
}
var liked: Bool {
-4
View File
@@ -38,10 +38,6 @@ 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 }))
+1 -4
View File
@@ -13,7 +13,6 @@ 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 {
@@ -23,15 +22,13 @@ 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.caseInsensitiveCompare("nsfw") == .orderedSame }) == nil
return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil
}
func get_repost_of_muted_user_filter(damus_state: DamusState) -> ((_ ev: NostrEvent) -> Bool) {
+4 -8
View File
@@ -36,10 +36,9 @@ 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, favicon_cache: FaviconCache) {
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) {
self.keypair = keypair
self.likes = likes
self.boosts = boosts
@@ -69,8 +68,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)
}
@@ -128,8 +126,7 @@ class DamusState: HeadlessDamusState {
video: DamusVideoCoordinator(),
ndb: ndb,
quote_reposts: .init(our_pubkey: pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
}
@@ -197,8 +194,7 @@ class DamusState: HeadlessDamusState {
video: DamusVideoCoordinator(),
ndb: .empty,
quote_reposts: .init(our_pubkey: empty_pub),
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: FaviconCache()
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
}
}
-44
View File
@@ -1,44 +0,0 @@
//
// 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
}
}
-77
View File
@@ -1,77 +0,0 @@
//
// 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
}
}
}
+2 -2
View File
@@ -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 'trusted network filter' when it is disabled, and therefore is showing all content.")
return NSLocalizedString("All", comment: "Human-readable short description of the 'friends filter' when it is set to 'all'")
case .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.")
return NSLocalizedString("Friends of friends", comment: "Human-readable short description of the 'friends filter' when it is set to 'friends-of-friends'")
}
}
}
+15 -9
View File
@@ -227,12 +227,6 @@ 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 .pinned_notes:
break // FIXME(tyiu)
}
}
@@ -296,12 +290,24 @@ class HomeModel: ContactsDelegate {
Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString)
}
damus_state.wallet.handle_nwc_response(response: resp) // This can handle success or error cases
guard resp.response.error == nil else {
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)
if let humanReadableError = resp.response.error?.humanReadableError {
present_sheet(.error(humanReadableError))
}
return
}
if resp.response.result_type == .list_transactions {
Log.info("Received NWC transaction list from %s", for: .nwc, relay.absoluteString)
damus_state.wallet.handle_nwc_response(response: resp)
return
}
if resp.response.result_type == .get_balance {
Log.info("Received NWC balance information from %s", for: .nwc, relay.absoluteString)
damus_state.wallet.handle_nwc_response(response: resp)
return
}
+3 -50
View File
@@ -64,35 +64,10 @@ enum MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
switch self {
case .pubkey(let pubkey): return ["p", pubkey.hex()]
case .note(let noteId): return ["e", noteId.hex()]
case .nevent(let nevent):
var tagBuilder = ["e", nevent.noteid.hex()]
let relay = nevent.relays.first
if let author = nevent.author?.hex() {
tagBuilder.append(relay ?? "")
tagBuilder.append(author)
} else if let relay {
tagBuilder.append(relay)
}
return tagBuilder
case .nprofile(let nprofile):
var tagBuilder = ["p", nprofile.author.hex()]
if let relay = nprofile.relays.first {
tagBuilder.append(relay)
}
return tagBuilder
case .nevent(let nevent): return ["e", nevent.noteid.hex()]
case .nprofile(let nprofile): return ["p", nprofile.author.hex()]
case .nrelay(let url): return ["r", url]
case .naddr(let naddr):
var tagBuilder = ["a", "\(naddr.kind.description):\(naddr.author.hex()):\(naddr.identifier.string())"]
if let relay = naddr.relays.first {
tagBuilder.append(relay)
}
return tagBuilder
case .naddr(let naddr): return ["a", naddr.kind.description + ":" + naddr.author.hex() + ":" + naddr.identifier.string()]
}
}
@@ -188,10 +163,6 @@ 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):
@@ -200,17 +171,6 @@ 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? {
@@ -232,13 +192,6 @@ 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 {
-97
View File
@@ -1,97 +0,0 @@
//
// NIP05DomainEventsModel.swift
// damus
//
// Created by Terry Yiu on 4/11/25.
//
import FaviconFinder
import Foundation
class NIP05DomainEventsModel: ObservableObject {
let state: DamusState
var events: EventHolder
@Published var loading: Bool = false
let domain: String
var filter: NostrFilter
let sub_id = UUID().description
let profiles_subid = UUID().description
let limit: UInt32 = 500
init(state: DamusState, domain: String) {
self.state = state
self.domain = domain
self.events = EventHolder(on_queue: { ev in
preload_events(state: state, events: [ev])
})
self.filter = NostrFilter()
}
@MainActor func subscribe() {
filter.limit = self.limit
filter.kinds = [.text, .longform, .highlight]
var authors = Set<Pubkey>()
for pubkey in state.contacts.get_friend_of_friends_list() {
let profile_txn = state.profiles.lookup(id: pubkey)
guard let profile = profile_txn?.unsafeUnownedValue,
let nip05_str = profile.nip05,
let nip05 = NIP05.parse(nip05_str),
nip05.host.caseInsensitiveCompare(domain) == .orderedSame else {
continue
}
authors.insert(pubkey)
}
if authors.isEmpty {
return
}
filter.authors = Array(authors)
print("subscribing to notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event)
loading = true
state.nostrNetwork.pool.send(.subscribe(.init(filters: [filter], sub_id: sub_id)))
}
func unsubscribe() {
state.nostrNetwork.pool.unsubscribe(sub_id: sub_id)
loading = false
print("unsubscribing from notes from friends of friends with '\(domain)' NIP-05 domain with sub_id \(sub_id)")
}
func add_event(_ ev: NostrEvent) {
if !event_matches_filter(ev, filter: filter) {
return
}
guard should_show_event(state: state, ev: ev) else {
return
}
if self.events.insert(ev) {
objectWillChange.send()
}
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in
if sub_id == self.sub_id && ev.is_textlike && ev.should_show_event {
self.add_event(ev)
}
}
guard done else {
return
}
self.loading = false
if sub_id == self.sub_id {
guard let txn = NdbTxn(ndb: state.ndb) else { return }
load_profiles(context: "search", profiles_subid: self.profiles_subid, relay_id: relay_id, load: .from_events(self.events.all_events), damus_state: state, txn: txn)
}
}
}
@@ -51,16 +51,6 @@ class NostrNetworkManager {
func connect() {
self.userRelayList.connect()
}
func relaysForEvent(event: NostrEvent) -> [RelayURL] {
// TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences
// and reliability of relays to maximize chances of others finding this event.
if let relays = pool.seen[event.id] {
return Array(relays)
}
return []
}
}
+3 -37
View File
@@ -76,11 +76,6 @@ func render_note_content(ev: NostrEvent, profiles: Profiles, keypair: Keypair) -
return .separated(render_blocks(blocks: blocks, profiles: profiles, can_hide_last_previewable_refs: true))
}
// FIXME(tyiu): There are a lot of hacks to get this function to render the blocks correctly.
// However, the entire note content rendering logic just needs to be rewritten.
// Block previews should actually be rendered in the position of the note content where it was found.
// Currently, we put some previews at the bottom of the note, which is incorrect as they take things out of
// the author's intended context.
func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewable_refs: Bool = false) -> NoteArtifactsSeparated {
var invoices: [Invoice] = []
var urls: [UrlType] = []
@@ -122,11 +117,6 @@ func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewa
}
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 {
// SPECIAL CASE:
// We should keep hashtags at the end sequence but hide all the other previewables around it.
hide_text_index = i
} else {
break
@@ -159,16 +149,7 @@ func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewa
// No need to show the text representation of the block if the only previewables are the sequence of them
// 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
}
}
@@ -177,14 +158,7 @@ func render_blocks(blocks bs: Blocks, profiles: Profiles, can_hide_last_previewa
case .mention(let m):
return str + mention_str(m, profiles: profiles)
case .text(let txt):
if case .hashtag = blocks[safe: ind+1] {
// SPECIAL CASE:
// Do not trim whitespaces from suffix if the following block is a hashtag.
// This is because of the code further up (see "SPECIAL CASE").
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: -1, txt: txt))
} else {
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
}
return str + CompatibleText(stringLiteral: reduce_text_block(ind: ind, hide_text_index: hide_text_index, txt: txt))
case .relay(let relay):
return str + CompatibleText(stringLiteral: relay)
case .hashtag(let htag):
@@ -283,20 +257,12 @@ func mention_str(_ m: Mention<MentionRef>, profiles: Profiles) -> CompatibleText
// trim suffix whitespace and newlines
func trim_suffix(_ str: String) -> String {
var result = str
while result.last?.isWhitespace == true {
result.removeLast()
}
return result
return str.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
}
// trim prefix whitespace and newlines
func trim_prefix(_ str: String) -> String {
var result = str
while result.first?.isWhitespace == true {
result.removeFirst()
}
return result
return str.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression)
}
struct LongformContent {
+16 -61
View File
@@ -23,16 +23,9 @@ class ProfileModel: ObservableObject, Equatable {
return nil
}
@Published var pinned_notes_list: NostrEvent? = nil
var pinned_note_ids: Set<NoteId> {
if let pinned_notes_list {
return Set(pinned_notes_list.referenced_noterefs.map { $0.note_id })
}
return []
}
private let MAX_SHARE_RELAYS = 4
var events: EventHolder
var pinned_events: EventHolder
let pubkey: Pubkey
let damus: DamusState
@@ -41,7 +34,6 @@ class ProfileModel: ObservableObject, Equatable {
var prof_subid = UUID().description
var conversations_subid = UUID().description
var findRelay_subid = UUID().description
var pinned_subid = UUID().description
var conversation_events: Set<NoteId> = Set()
init(pubkey: Pubkey, damus: DamusState) {
@@ -50,9 +42,6 @@ class ProfileModel: ObservableObject, Equatable {
self.events = EventHolder(on_queue: { ev in
preload_events(state: damus, events: [ev])
})
self.pinned_events = EventHolder(on_queue: { ev in
preload_events(state: damus, events: [ev])
})
}
func follows(pubkey: Pubkey) -> Bool {
@@ -87,18 +76,20 @@ class ProfileModel: ObservableObject, Equatable {
}
}
let textKinds: [NostrKind] = [.text, .longform, .highlight]
func subscribe() {
let text_filter = NostrFilter(kinds: textKinds, limit: 500, authors: [pubkey])
let profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost], authors: [pubkey])
let relay_list_filter = NostrFilter(kinds: [.relay_list], authors: [pubkey])
let pinned_notes_filter = NostrFilter(kinds: [.pinned_notes], authors: [pubkey])
var text_filter = NostrFilter(kinds: [.text, .longform, .highlight])
var profile_filter = NostrFilter(kinds: [.contacts, .metadata, .boost])
var relay_list_filter = NostrFilter(kinds: [.relay_list], authors: [pubkey])
profile_filter.authors = [pubkey]
text_filter.authors = [pubkey]
text_filter.limit = 500
print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)")
//print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]])
damus.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event)
damus.nostrNetwork.pool.subscribe(sub_id: prof_subid, filters: [profile_filter, relay_list_filter, pinned_notes_filter], handler: handle_event)
damus.nostrNetwork.pool.subscribe(sub_id: prof_subid, filters: [profile_filter, relay_list_filter], handler: handle_event)
subscribe_to_conversations()
}
@@ -109,7 +100,7 @@ class ProfileModel: ObservableObject, Equatable {
return
}
let conversation_kinds: [NostrKind] = textKinds
let conversation_kinds: [NostrKind] = [.text, .longform, .highlight]
let limit: UInt32 = 500
let conversations_filter_them = NostrFilter(kinds: conversation_kinds, pubkeys: [damus.pubkey], limit: limit, authors: [pubkey])
let conversations_filter_us = NostrFilter(kinds: conversation_kinds, pubkeys: [pubkey], limit: limit, authors: [damus.pubkey])
@@ -117,15 +108,6 @@ class ProfileModel: ObservableObject, Equatable {
damus.nostrNetwork.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event)
}
private func subscribe_to_pinned_notes() {
guard let pinned_notes_list, pinned_notes_list.referenced_noterefs.first != nil else {
return
}
let pinned_filter = NostrFilter(ids: Array(pinned_note_ids), kinds: [.text], authors: [pubkey])
damus.nostrNetwork.pool.subscribe(sub_id: pinned_subid, filters: [pinned_filter], handler: handle_event)
}
func handle_profile_contact_event(_ ev: NostrEvent) {
process_contact_event(state: damus, ev: ev)
@@ -146,24 +128,11 @@ class ProfileModel: ObservableObject, Equatable {
if self.events.insert(ev) {
self.objectWillChange.send()
}
if pinned_note_ids.contains(ev.id) && self.pinned_events.insert(ev) {
self.objectWillChange.send()
}
} else if ev.known_kind == .contacts {
handle_profile_contact_event(ev)
} else if ev.known_kind == .relay_list {
}
else if ev.known_kind == .relay_list {
self.relay_list = try? NIP65.RelayList(event: ev) // Whether another user's list is malformatted is something beyond our control. Probably best to suppress errors
} else if ev.known_kind == .pinned_notes {
if let current_ev = self.pinned_notes_list {
guard ev.created_at > current_ev.created_at else {
return
}
pinned_events.incoming.removeAll()
pinned_events.events.removeAll()
}
self.pinned_notes_list = ev
subscribe_to_pinned_notes()
}
seen_event.insert(ev.id)
}
@@ -181,8 +150,6 @@ class ProfileModel: ObservableObject, Equatable {
default:
return false
}
} else if sub_id == self.pinned_subid {
return self.pubkey == ev.pubkey && pinned_note_ids.contains(ev.id)
}
return self.pubkey == ev.pubkey
@@ -193,7 +160,7 @@ class ProfileModel: ObservableObject, Equatable {
case .ws_event:
return
case .nostr_event(let resp):
guard [self.sub_id, self.prof_subid, self.conversations_subid, self.pinned_subid].contains(resp.subid) else {
guard resp.subid == self.sub_id || resp.subid == self.prof_subid || resp.subid == self.conversations_subid else {
return
}
switch resp {
@@ -214,24 +181,12 @@ class ProfileModel: ObservableObject, Equatable {
if resp.subid == self.conversations_subid {
conversation_events.insert(ev.id)
}
if resp.subid == self.pinned_subid, self.pinned_events.insert(ev) {
self.objectWillChange.send()
}
} else if resp.subid == self.conversations_subid && !conversation_events.contains(ev.id) {
guard relay_filtered_correctly(ev, subid: resp.subid) else {
break
}
conversation_events.insert(ev.id)
} else if resp.subid == self.pinned_subid {
guard relay_filtered_correctly(ev, subid: resp.subid) else {
break
}
if resp.subid == self.pinned_subid, self.pinned_events.insert(ev) {
self.objectWillChange.send()
}
}
case .notice:
break
@@ -267,7 +222,7 @@ class ProfileModel: ObservableObject, Equatable {
}
func getCappedRelayStrings() -> [String] {
return self.relay_urls?.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
return self.relay_urls?.prefix(MAX_SHARE_RELAYS).map { $0.absoluteString } ?? []
}
}
+2 -8
View File
@@ -1,3 +1,4 @@
//
// SearchHomeModel.swift
// damus
//
@@ -15,7 +16,6 @@ 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,18 +42,12 @@ 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) {
@@ -63,7 +57,7 @@ class SearchHomeModel: ObservableObject {
switch event {
case .event(let sub_id, let ev):
guard sub_id == self.base_subid || sub_id == self.profiles_subid || sub_id == self.follow_pack_subid else {
guard sub_id == self.base_subid || sub_id == self.profiles_subid else {
return
}
if ev.is_textlike && should_show_event(state: damus_state, ev: ev) && !ev.is_reply()
+1 -1
View File
@@ -36,7 +36,7 @@ class SearchModel: ObservableObject {
func subscribe() {
// since 1 month
search.limit = self.limit
search.kinds = [.text, .like, .longform, .highlight, .follow_list]
search.kinds = [.text, .like, .longform, .highlight]
//likes_filter.ids = ref_events.referenced_ids!
+2 -14
View File
@@ -115,10 +115,7 @@ class UserSettingsStore: ObservableObject {
@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
@@ -127,19 +124,10 @@ 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
+2 -4
View File
@@ -83,10 +83,8 @@ 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:
// 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")
return .init(index: 10, tag: "bitcoinbeach", displayName: "Bitcoin Beach", link: "bitcoinbeach://",
appStoreLink: "https://apps.apple.com/sv/app/bitcoin-beach-wallet/id1531383905", image: "bbw")
case .blixtwallet:
return .init(index: 11, tag: "blixtwallet", displayName: "Blixt Wallet", link: "blixtwallet:lightning:",
appStoreLink: "https://testflight.apple.com/join/EXvGhRzS", image: "blixt-wallet")
+4 -50
View File
@@ -27,11 +27,6 @@ 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
@@ -80,16 +75,12 @@ class WalletModel: ObservableObject {
///
/// - Parameter response: The NWC response received from the network
func handle_nwc_response(response: WalletConnect.FullWalletResponse) {
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 {
switch response.response.result {
case .get_balance(let balanceResp):
self.balance = balanceResp.balance / 1000
case .pay_invoice(_):
case .none:
return
case .some(.pay_invoice(_)):
return
case .list_transactions(let transactionsResp):
self.transactions = transactionsResp.transactions
@@ -100,41 +91,4 @@ 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
}
}
-111
View File
@@ -1,111 +0,0 @@
//
// 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)
)
}
}
}
+6 -24
View File
@@ -448,26 +448,17 @@ func random_bytes(count: Int) -> Data {
return Data(bytes: bytes, count: count)
}
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent, relayURL: RelayURL?) -> NostrEvent? {
func make_boost_event(keypair: FullKeypair, boosted: NostrEvent) -> NostrEvent? {
var tags = Array(boosted.referenced_pubkeys).map({ pk in pk.tag })
var eTagBuilder = ["e", boosted.id.hex()]
var pTagBuilder = ["p", boosted.pubkey.hex()]
let relayURLString = relayURL?.absoluteString
if let relayURLString {
pTagBuilder.append(relayURLString)
}
eTagBuilder.append(contentsOf: [relayURLString ?? "", "root", boosted.pubkey.hex()])
tags.append(eTagBuilder)
tags.append(pTagBuilder)
tags.append(["e", boosted.id.hex(), "", "root"])
tags.append(["p", boosted.pubkey.hex()])
let content = event_to_json(ev: boosted)
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 6, tags: tags)
}
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙", relayURL: RelayURL?) -> NostrEvent? {
func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String = "🤙") -> NostrEvent? {
var tags = liked.tags.reduce(into: [[String]]()) { ts, tag in
guard tag.count >= 2,
(tag[0].matches_char("e") || tag[0].matches_char("p")) else {
@@ -476,17 +467,8 @@ func make_like_event(keypair: FullKeypair, liked: NostrEvent, content: String =
ts.append(tag.strings())
}
var eTagBuilder = ["e", liked.id.hex()]
var pTagBuilder = ["p", liked.pubkey.hex()]
let relayURLString = relayURL?.absoluteString
if let relayURLString {
pTagBuilder.append(relayURLString)
}
eTagBuilder.append(contentsOf: [relayURLString ?? "", liked.pubkey.hex()])
tags.append(eTagBuilder)
tags.append(pTagBuilder)
tags.append(["e", liked.id.hex()])
tags.append(["p", liked.pubkey.hex()])
return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 7, tags: tags)
}
-3
View File
@@ -19,9 +19,7 @@ enum NostrKind: UInt32, Codable {
case like = 7
case chat = 42
case mute_list = 10000
case pinned_notes = 10001
case relay_list = 10002
case interest_list = 10015
case list_deprecated = 30000
case draft = 31234
case longform = 30023
@@ -32,5 +30,4 @@ enum NostrKind: UInt32, Codable {
case nwc_response = 23195
case http_auth = 27235
case status = 30315
case follow_list = 39089
}
-1
View File
@@ -35,7 +35,6 @@ class Profiles {
@MainActor
private var profiles: [Pubkey: ProfileData] = [:]
// Map of validated NIP-05 address to pubkey.
@MainActor
var nip05_pubkey: [String: Pubkey] = [:]
+14 -6
View File
@@ -19,12 +19,17 @@ struct QueuedRequest {
let skip_ephemeral: Bool
}
struct SeenEvent: Hashable {
let relay_id: RelayURL
let evid: NoteId
}
/// Establishes and manages connections and subscriptions to a list of relays.
class RelayPool {
private(set) var relays: [Relay] = []
var handlers: [RelayHandler] = []
var request_queue: [QueuedRequest] = []
var seen: [NoteId: Set<RelayURL>] = [:]
var seen: Set<SeenEvent> = Set()
var counts: [RelayURL: UInt64] = [:]
var ndb: Ndb
/// The keypair used to authenticate with relays
@@ -352,12 +357,15 @@ class RelayPool {
func record_seen(relay_id: RelayURL, event: NostrConnectionEvent) {
if case .nostr_event(let ev) = event {
if case .event(_, let nev) = ev {
if seen[nev.id]?.contains(relay_id) == true {
return
let k = SeenEvent(relay_id: relay_id, evid: nev.id)
if !seen.contains(k) {
seen.insert(k)
if counts[relay_id] == nil {
counts[relay_id] = 1
} else {
counts[relay_id] = (counts[relay_id] ?? 0) + 1
}
}
seen[nev.id, default: Set()].insert(relay_id)
counts[relay_id, default: 0] += 1
notify(.update_stats(note_id: nev.id))
}
}
}
+1 -2
View File
@@ -106,8 +106,7 @@ var test_damus_state: DamusState = ({
video: .init(),
ndb: ndb,
quote_reposts: .init(our_pubkey: our_pubkey),
emoji_provider: DefaultEmojiProvider(showAllVariations: true),
favicon_cache: .init()
emoji_provider: DefaultEmojiProvider(showAllVariations: true)
)
/*
-10
View File
@@ -202,13 +202,3 @@ extension Block {
}
}
}
extension Block {
var asInvoice: Invoice? {
switch self {
case .invoice(let invoice):
return invoice
default:
return nil
}
}
}
+1
View File
@@ -45,3 +45,4 @@ struct Pubkey: IdType, TagKey, TagConvertible, Identifiable {
}
}
-4
View File
@@ -47,10 +47,6 @@ struct NEvent : Equatable, Hashable {
self.author = author
self.kind = kind
}
init(event: NostrEvent, relays: [String]) {
self.init(noteid: event.id, relays: relays, author: event.pubkey, kind: event.kind)
}
}
struct NProfile : Equatable, Hashable {
@@ -42,11 +42,6 @@ class CoinosDeterministicAccountClient {
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
@@ -168,50 +163,6 @@ class CoinosDeterministicAccountClient {
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 }
-4
View File
@@ -18,9 +18,6 @@ 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")!
@@ -45,5 +42,4 @@ class Constants {
// MARK: General constants
static let GIF_IMAGE_TYPE: String = "com.compuserve.gif"
static let MAX_SHARE_RELAYS = 4
}
+11 -16
View File
@@ -29,15 +29,15 @@ extension KFOptionSetter {
options.onlyLoadFirstFrame = disable_animation
switch imageContext {
case .pfp, .favicon:
options.diskCacheExpiration = .days(60)
break
case .banner:
options.diskCacheExpiration = .days(5)
break
case .note:
options.diskCacheExpiration = .days(1)
break
case .pfp:
options.diskCacheExpiration = .days(60)
break
case .banner:
options.diskCacheExpiration = .days(5)
break
case .note:
options.diskCacheExpiration = .days(1)
break
}
return self
@@ -82,14 +82,11 @@ 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
}
@@ -97,8 +94,6 @@ 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:
-41
View File
@@ -1,41 +0,0 @@
//
// 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
}
}
-1
View File
@@ -21,7 +21,6 @@ enum LogCategory: String {
case damus_purple
case image_uploading
case video_coordination
case tips
}
/// Damus structured logger
-19
View File
@@ -5,7 +5,6 @@
// Created by Scott Penrose on 5/7/23.
//
import FaviconFinder
import SwiftUI
enum Route: Hashable {
@@ -47,9 +46,6 @@ 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 {
@@ -131,12 +127,6 @@ 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)
}
}
@@ -241,15 +231,6 @@ 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)
}
}
}
+2 -2
View File
@@ -52,7 +52,7 @@ extension WalletConnect {
let req_id: NoteId
let response: Response
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) throws(InitializationError) {
init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) async throws(InitializationError) {
guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey }
guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference }
@@ -85,7 +85,7 @@ extension WalletConnect {
}
}
struct WalletResponseErr: Codable, Error {
struct WalletResponseErr: Codable {
let code: Code?
let message: String?
@@ -105,28 +105,6 @@ extension WalletConnect {
post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush)
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
+3 -14
View File
@@ -217,16 +217,7 @@ struct EventActionBar: View {
AnyView(self.action_bar_content)
}
}
var event_relay_url_strings: [String] {
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
}
return userProfile.getCappedRelayStrings()
}
var body: some View {
self.content
.onAppear {
@@ -242,9 +233,7 @@ struct EventActionBar: View {
}
}
.sheet(isPresented: $show_share_sheet, onDismiss: { self.show_share_sheet = false }) {
if let url = URL(string: "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))) {
ShareSheet(activityItems: [url])
}
ShareSheet(activityItems: [URL(string: "https://damus.io/" + event.id.bech32)!])
}
.sheet(isPresented: $show_repost_action, onDismiss: { self.show_repost_action = false }) {
@@ -273,7 +262,7 @@ struct EventActionBar: View {
func send_like(emoji: String) {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
return
}
@@ -59,16 +59,6 @@ struct EventDetailBar: View {
}
.buttonStyle(PlainButtonStyle())
}
if bar.relays > 0 {
let relays = Array(state.nostrNetwork.pool.seen[target] ?? [])
NavigationLink(value: Route.UserRelays(relays: relays)) {
let nounString = pluralizedString(key: "relays_count", count: bar.relays)
let noun = Text(nounString).foregroundColor(.gray)
Text("\(Text(verbatim: bar.relays.formatted()).font(.body.bold())) \(noun)", comment: "Sentence composed of 2 variables to describe how many relays a note was found on. In source English, the first variable is the number of relays, and the second variable is 'Relay' or 'Relays'.")
}
.buttonStyle(PlainButtonStyle())
}
}
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ struct RepostAction: View {
dismiss()
guard let keypair = self.damus_state.keypair.to_full(),
let boost = make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else {
let boost = make_boost_event(keypair: keypair, boosted: self.event) else {
return
}
+2 -11
View File
@@ -26,16 +26,7 @@ struct ShareAction: View {
self.userProfile = userProfile
self._show_share = show_share
}
var event_relay_url_strings: [String] {
let relays = userProfile.damus.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
}
return userProfile.getCappedRelayStrings()
}
var body: some View {
VStack {
@@ -49,7 +40,7 @@ struct ShareAction: View {
ShareActionButton(img: "link", text: NSLocalizedString("Copy Link", comment: "Button to copy link to note")) {
dismiss()
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
UIPasteboard.general.string = "https://damus.io/" + Bech32Object.encode(.nevent(NEvent(noteid: event.id, relays: userProfile.getCappedRelayStrings())))
}
let bookmarkImg = isBookmarked ? "bookmark.fill" : "bookmark"
@@ -28,15 +28,6 @@ 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 -1
View File
@@ -37,7 +37,7 @@ struct BookmarksView: View {
}
} else {
ScrollView {
InnerTimelineView(events: EventHolder(events: bookmarks, incoming: []), pinned_events: EventHolder(), damus: state, filter: noneFilter)
InnerTimelineView(events: EventHolder(events: bookmarks, incoming: []), damus: state, filter: noneFilter)
}
.padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
}
+44
View File
@@ -0,0 +1,44 @@
//
// 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)
}
}
@@ -1,54 +0,0 @@
//
// 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)
}
}
+7 -1
View File
@@ -235,7 +235,7 @@ struct ChatEventView: View {
func send_like(emoji: String) {
guard let keypair = damus_state.keypair.to_full(),
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else {
let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji) else {
return
}
@@ -337,6 +337,12 @@ 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)
+94 -201
View File
@@ -7,7 +7,6 @@
import SwiftUI
import SwipeActions
import TipKit
struct ChatroomThreadView: View {
@Environment(\.dismiss) var dismiss
@@ -16,20 +15,11 @@ 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) {
let adjustedAnchor: UnitPoint = showStickyHeader ? ChatroomThreadView.sticky_header_adjusted_anchor : .top
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: adjustedAnchor)
scroll_to_event(scroller: scroller, id: note_id, delay: 0, animate: true, anchor: .top)
highlighted_note_id = note_id
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: {
withAnimation {
@@ -37,7 +27,7 @@ struct ChatroomThreadView: View {
}
})
}
func set_active_event(scroller: ScrollViewProxy, ev: NdbNote) {
withAnimation {
self.thread.select(event: ev)
@@ -45,202 +35,93 @@ 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
ForEach(thread.parent_events, id: \.id) { parent_event in
EventMutingContainerView(damus_state: damus, event: parent_event) {
EventView(damus: damus, event: parent_event)
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
}
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: 8) {
// MARK: - Parents events view
ForEach(thread.parent_events, id: \.id) { parent_event in
EventMutingContainerView(damus_state: damus, event: parent_event) {
EventView(damus: damus, event: parent_event)
.matchedGeometryEffect(id: parent_event.id.hex(), in: animation, anchor: .center)
}
.padding(.horizontal)
.onTapGesture {
self.set_active_event(scroller: scroller, ev: parent_event)
}
.id(parent_event.id)
Divider()
.padding(.top, 4)
.padding(.leading, 25 * 2)
}.background(GeometryReader { geometry in
// get the height and width of the EventView view
let eventHeight = geometry.frame(in: .global).height
// let eventWidth = geometry.frame(in: .global).width
// vertical gray line in the background
Rectangle()
.fill(Color.gray.opacity(0.25))
.frame(width: 2, height: eventHeight)
.offset(x: 40, y: 40)
})
// MARK: - Actual event view
EventMutingContainerView(
damus_state: damus,
event: self.thread.selected_event,
muteBox: { event_shown, muted_reason in
AnyView(
EventMutedBoxView(shown: event_shown, reason: muted_reason)
.padding(5)
)
}
) {
SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected)
.matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center)
}
.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)
.onTapGesture {
self.set_active_event(scroller: scroller, ev: parent_event)
}
.id(parent_event.id)
Divider()
.padding(.top, 4)
.padding(.leading, 25 * 2)
}.background(GeometryReader { geometry in
let eventHeight = geometry.frame(in: .global).height
Rectangle()
.fill(Color.gray.opacity(0.25))
.frame(width: 2, height: eventHeight)
.offset(x: 40, y: 40)
})
// MARK: - Actual event view
EventMutingContainerView(
damus_state: damus,
event: self.thread.selected_event,
muteBox: { event_shown, muted_reason in
AnyView(
EventMutedBoxView(shown: event_shown, reason: muted_reason)
.padding(5)
)
}
) {
SelectedEventView(damus: damus, event: self.thread.selected_event, size: .selected)
.matchedGeometryEffect(id: self.thread.selected_event.id.hex(), in: animation, anchor: .center)
}
.id(self.thread.selected_event.id)
// MARK: - Children view - inside trusted network
if !trusted_events.isEmpty {
ThreadedSwipeViewGroup(scroller: scroller, events: trusted_events)
.id(ev.id)
.matchedGeometryEffect(id: ev.id.hex(), in: animation, anchor: .center)
}
}
.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)
}
.padding(.top)
EndBlock()
HStack {}
.frame(height: tabHeight + getSafeAreaBottom())
}
.onReceive(handle_notify(.post), perform: { notify in
switch notify {
case .post(_):
user_just_posted_flag = true
case .cancel:
return
case .post(_):
user_just_posted_flag = true
case .cancel:
return
}
})
.onReceive(thread.objectWillChange) {
@@ -258,8 +139,15 @@ 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 {
@@ -279,3 +167,8 @@ 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)
}
+4 -24
View File
@@ -6,7 +6,6 @@
//
import SwiftUI
import TipKit
enum DMType: Hashable {
case rando
@@ -19,7 +18,6 @@ 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 {
@@ -74,15 +72,7 @@ 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),
@@ -102,22 +92,12 @@ struct DirectMessagesView: View {
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
if showTrustedButton {
TrustedNetworkButton(filter: $settings.friend_filter) {
if #available(iOS 17, *) {
TrustedNetworkButtonTip.shared.invalidate(reason: .actionPerformed)
}
}
if would_filter_non_friends_from_dms(contacts: damus_state.contacts, dms: self.model.dms) {
FriendsButton(filter: $settings.friend_filter)
}
}
}
.onAppear {
self.subtitle = settings.friend_filter.description()
}
.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."))
}
}
@@ -135,6 +115,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, subtitle: .constant(nil))
DirectMessagesView(damus_state: ds, model: ds.dms, settings: ds.settings)
}
}
+2 -6
View File
@@ -21,21 +21,17 @@ struct EventView: View {
let options: EventViewOptions
let damus: DamusState
let pubkey: Pubkey
let pinned: Set<NoteId>
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, pinned: Set<NoteId> = [], options: EventViewOptions = []) {
init(damus: DamusState, event: NostrEvent, pubkey: Pubkey? = nil, options: EventViewOptions = []) {
self.event = event
self.options = options
self.damus = damus
self.pubkey = pubkey ?? event.pubkey
self.pinned = pinned
}
var body: some View {
VStack {
if pinned.contains(event.id) {
PinnedEventView(damus: damus, event: event, options: options)
} else if event.known_kind == .boost {
if event.known_kind == .boost {
if let inner_ev = event.get_inner_event(cache: damus.events) {
RepostedEvent(damus: damus, event: event, inner_ev: inner_ev, options: options)
} else {
+2 -11
View File
@@ -63,16 +63,7 @@ struct MenuItems: View {
self.target_pubkey = target_pubkey
self.profileModel = profileModel
}
var event_relay_url_strings: [String] {
let relays = damus_state.nostrNetwork.relaysForEvent(event: event)
if !relays.isEmpty {
return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0.absoluteString }
}
return profileModel.getCappedRelayStrings()
}
var body: some View {
Group {
Button {
@@ -88,7 +79,7 @@ struct MenuItems: View {
}
Button {
UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings)))
UIPasteboard.general.string = event.id.bech32
} label: {
Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book")
}
@@ -1,242 +0,0 @@
//
// 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)
}
}
@@ -1,135 +0,0 @@
//
// 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)
}
}
@@ -1,176 +0,0 @@
//
// FollowPackView.swift
// damus
//
// Created by eric on 4/30/25.
//
import SwiftUI
import Kingfisher
struct FollowPackView: View {
let state: DamusState
let event: FollowPackEvent
@StateObject var model: FollowPackModel
@State var blur_imgs: Bool
@Environment(\.colorScheme) var colorScheme
@ObservedObject var artifacts: NoteArtifactsModel
init(state: DamusState, ev: FollowPackEvent, model: FollowPackModel, blur_imgs: Bool) {
self.state = state
self.event = ev
self._model = StateObject(wrappedValue: model)
self.blur_imgs = blur_imgs
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.event.id).artifacts_model)
}
init(state: DamusState, ev: NostrEvent, model: FollowPackModel, blur_imgs: Bool) {
self.state = state
self.event = FollowPackEvent.parse(from: ev)
self._model = StateObject(wrappedValue: model)
self.blur_imgs = blur_imgs
self._artifacts = ObservedObject(wrappedValue: state.events.get_cache_data(ev.id).artifacts_model)
}
func content_filter(_ pubkeys: [Pubkey]) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: self.state)
filters.append({ pubkeys.contains($0.pubkey) })
return ContentFilters(filters: filters).filter
}
enum FollowPackTabSelection: Int {
case people = 0
case posts = 1
}
@State var tab_selection: FollowPackTabSelection = .people
var body: some View {
ZStack {
ScrollView {
FollowPackHeader
FollowPackTabs
}
}
.onAppear {
if model.events.events.isEmpty {
model.subscribe(follow_pack_users: event.publicKeys)
}
}
.onDisappear {
model.unsubscribe()
}
}
var tabs: [(String, FollowPackTabSelection)] {
let tabs = [
(NSLocalizedString("People", comment: "Label for filter for seeing the people in this follow pack."), FollowPackTabSelection.people),
(NSLocalizedString("Posts", comment: "Label for filter for seeing the posts from the people in this follow pack."), FollowPackTabSelection.posts)
]
return tabs
}
var FollowPackTabs: some View {
VStack(spacing: 0) {
VStack(spacing: 0) {
CustomPicker(tabs: tabs, selection: $tab_selection)
Divider()
.frame(height: 1)
}
.background(colorScheme == .dark ? Color.black : Color.white)
if tab_selection == FollowPackTabSelection.people {
LazyVStack(alignment: .leading) {
ForEach(event.publicKeys.reversed(), id: \.self) { pk in
FollowUserView(target: .pubkey(pk), damus_state: state)
}
}
.padding()
.padding(.bottom, 50)
.tag(FollowPackTabSelection.people)
.id(FollowPackTabSelection.people)
}
if tab_selection == FollowPackTabSelection.posts {
InnerTimelineView(events: model.events, pinned_events: EventHolder(), damus: state, filter: content_filter(event.publicKeys))
}
}
.onAppear() {
model.subscribe(follow_pack_users: event.publicKeys)
}
.onDisappear {
model.unsubscribe()
}
}
var FollowPackHeader: some View {
VStack(alignment: .leading, spacing: 10) {
if state.settings.media_previews {
FollowPackBannerImage(state: state, options: EventViewOptions(), image: event.image, preview: false, blur_imgs: blur_imgs)
}
Text(event.title ?? NSLocalizedString("Untitled", comment: "Title of follow list event if it is untitled."))
.font(.title)
.padding(.horizontal, 10)
.padding(.top, 5)
if let description = event.description {
Text(description)
.font(.body)
.foregroundColor(.gray)
.padding(.horizontal, 10)
} else {
EmptyView()
}
HStack(alignment: .center) {
ProfilePicView(pubkey: event.event.pubkey, size: 25, highlight: .none, profiles: state.profiles, disable_animation: state.settings.disable_animation, show_zappability: true)
.onTapGesture {
state.nav.push(route: Route.ProfileByKey(pubkey: event.event.pubkey))
}
let profile_txn = state.profiles.lookup(id: event.event.pubkey)
let profile = profile_txn?.unsafeUnownedValue
let displayName = Profile.displayName(profile: profile, pubkey: event.event.pubkey)
switch displayName {
case .one(let one):
Text("Created by \(one)", comment: "Lets the user know who created this follow pack.")
.font(.subheadline).foregroundColor(.gray)
case .both(username: let username, displayName: let displayName):
HStack(spacing: 6) {
Text("Created by \(displayName)", comment: "Lets the user know who created this follow pack.")
.font(.subheadline).foregroundColor(.gray)
Text(verbatim: "@\(username)")
.font(.subheadline).foregroundColor(.gray)
}
}
}
.padding(.horizontal, 10)
.padding(.bottom, 20)
HStack(alignment: .center) {
FollowPackUsers(state: state, publicKeys: event.publicKeys)
}
.padding(.horizontal, 10)
.padding(.bottom, 20)
}
}
}
struct FollowPackView_Previews: PreviewProvider {
static var previews: some View {
VStack {
FollowPackView(state: test_damus_state, ev: test_follow_list_event, model: FollowPackModel(damus_state: test_damus_state), blur_imgs: false)
}
.frame(height: 400)
}
}
@@ -1,28 +0,0 @@
//
// PinnedEventView.swift
// damus
//
// Created by Terry Yiu on 7/21/25.
//
import SwiftUI
struct PinnedEventView: View {
let damus: DamusState
let event: NostrEvent
let options: EventViewOptions
var body: some View {
VStack(alignment: .leading) {
PinnedHeaderView(damus: damus, pubkey: event.pubkey)
.padding(.horizontal)
.buttonStyle(PlainButtonStyle())
TextEvent(damus: damus, event: event, pubkey: event.pubkey, options: options)
}
}
}
#Preview {
PinnedEventView(damus: test_damus_state, event: test_note, options: [])
}
@@ -1,33 +0,0 @@
//
// PinnedHeaderView.swift
// damus
//
// Created by Terry Yiu on 7/21/25.
//
import SwiftUI
struct PinnedHeaderView: View {
let damus: DamusState
let pubkey: Pubkey
init(damus: DamusState, pubkey: Pubkey) {
self.damus = damus
self.pubkey = pubkey
}
var body: some View {
HStack(alignment: .center) {
Image("pin")
.foregroundColor(Color.gray)
Text("Pinned", comment: "FIXME")
.font(.subheadline)
.foregroundColor(.gray)
}
}
}
#Preview {
PinnedHeaderView(damus: test_damus_state, pubkey: test_pubkey)
}
+1 -1
View File
@@ -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, .follow_list, .interest_list, .pinned_notes:
case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list:
return .unknown_or_unsupported_kind
}
case .naddr(let naddr):
+18 -18
View File
@@ -47,6 +47,20 @@ 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 {
@@ -72,7 +86,10 @@ struct MutelistView: View {
}
}
}
Section(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")) {
Section(
header: Text(NSLocalizedString("Threads", comment: "Section header title for a list of threads that are muted.")),
footer: Text("").padding(.bottom, 10 + tabHeight + getSafeAreaBottom())
) {
ForEach(threads, id: \.self) { item in
if case let MuteItem.thread(note_id, _) = item {
if let event = damus_state.events.lookup(note_id) {
@@ -87,23 +104,6 @@ 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 {
-51
View File
@@ -1,51 +0,0 @@
//
// 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)
}
@@ -1,111 +0,0 @@
//
// 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)
}
-64
View File
@@ -1,64 +0,0 @@
//
// 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)
}
+6 -31
View File
@@ -73,40 +73,15 @@ struct NoteContentView: View {
}
var preview: LinkViewRepresentable? {
guard case .loaded(let preview) = preview_model.state,
guard !blur_images,
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 {
@@ -133,7 +108,7 @@ struct NoteContentView: View {
func previewView(links: [URL]) -> some View {
Group {
if let preview = self.preview {
if let preview = self.preview, !blur_images {
if let preview_height {
preview
.frame(height: preview_height)
@@ -206,7 +181,7 @@ struct NoteContentView: View {
}
}
if has_previews {
if damus_state.settings.media_previews, has_previews {
if with_padding {
previewView(links: artifacts.links).padding(.horizontal)
} else {
@@ -6,7 +6,6 @@
//
import SwiftUI
import TipKit
class NotificationFilter: ObservableObject, Equatable {
@Published var state: NotificationFilterState
@@ -76,11 +75,10 @@ struct NotificationsView: View {
@StateObject var filter = NotificationFilter()
@SceneStorage("NotificationsView.filter_state") var filter_state: NotificationFilterState = .all
@Binding var subtitle: String?
@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(
@@ -117,19 +115,14 @@ struct NotificationsView: View {
Button(
action: { state.nav.push(route: Route.NotificationSettings(settings: state.settings)) },
label: {
Image(systemName: "gearshape")
.frame(width: 24, height: 24)
Image("settings")
.foregroundColor(.gray)
}
)
}
ToolbarItem(placement: .navigationBarTrailing) {
if showTrustedButton {
TrustedNetworkButton(filter: $filter.friend_filter) {
if #available(iOS 17, *) {
TrustedNetworkButtonTip.shared.invalidate(reason: .actionPerformed)
}
}
if would_filter_non_friends_from_notifications(contacts: state.contacts, state: filter_state, items: self.notifications.notifications) {
FriendsButton(filter: $filter.friend_filter)
}
}
}
@@ -147,13 +140,6 @@ 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),
@@ -1,123 +0,0 @@
//
// InterestSelectionView.swift
// damus
//
// Created by Daniel DAquino 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
)
}
}
@@ -1,83 +0,0 @@
//
// OnboardingContentSettings.swift
// damus
//
// Created by Daniel DAquino 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()
}
}
}
}
@@ -26,103 +26,49 @@ struct OnboardingSuggestionsView: View {
current_page += 1
}
}
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"))
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)
.navigationBarItems(leading: Button(action: {
self.next_page()
}, label: {
Text("Skip", comment: "Button to dismiss the suggested users screen")
.font(.subheadline.weight(.semibold))
})
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
)
.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)
.navigationBarItems(leading: Button(action: {
self.next_page()
}, label: {
Text("Skip", comment: "Button to dismiss the suggested users screen")
.font(.subheadline.weight(.semibold))
})
.accessibilityIdentifier(AppAccessibilityIdentifiers.onboarding_sheet_skip_button.rawValue)
PostView(
action: .posting(.user(model.damus_state.pubkey)),
damus_state: model.damus_state,
prompt_view: {
AnyView(
HStack {
Image(systemName: "sparkles")
Text("Add your first post", comment: "Prompt given to the user during onboarding, suggesting them to write their first post")
}
.foregroundColor(.secondary)
.font(.callout)
.padding(.top, 10)
)
.tag(2)
PostView(
action: .posting(.user(model.damus_state.pubkey)),
damus_state: model.damus_state,
prompt_view: {
AnyView(
HStack {
Image(systemName: "sparkles")
Text("Add your first post", comment: "Prompt given to the user during onboarding, suggesting them to write their first post")
}
.foregroundColor(.secondary)
.font(.callout)
.padding(.top, 10)
)
},
placeholder_messages: self.first_post_examples,
initial_text_suffix: self.initial_text_suffix
)
.onReceive(handle_notify(.post)) { _ in
// NOTE: Even though PostView already calls `dismiss`, that is not guaranteed to work under deeply nested views.
// Thus, we should also call `dismiss` from here (a direct subview of a sheet), which is explicitly supported by Apple.
// See https://github.com/damus-io/damus/issues/1726 for more context and information
dismiss()
}
.tag(3)
},
placeholder_messages: self.first_post_examples,
initial_text_suffix: self.initial_text_suffix
)
.onReceive(handle_notify(.post)) { _ in
// NOTE: Even though PostView already calls `dismiss`, that is not guaranteed to work under deeply nested views.
// Thus, we should also call `dismiss` from here (a direct subview of a sheet), which is explicitly supported by Apple.
// See https://github.com/damus-io/damus/issues/1726 for more context and information
dismiss()
}
.tag(1)
}
.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()
}
}
}
}
}
@@ -133,27 +79,20 @@ fileprivate struct SuggestedUsersPageView: View {
var body: some View {
VStack {
if let suggestions = model.suggestions {
List {
ForEach(suggestions, id: \.self) { followPack in
Section {
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)
}
List {
ForEach(model.groups) { group in
Section {
ForEach(group.users, id: \.self) { pk in
if let user = model.suggestedUser(pubkey: pk) {
SuggestedUserView(user: user, damus_state: model.damus_state)
}
} header: {
SuggestedUsersSectionHeader(followPack: followPack, model: model)
}
} header: {
SuggestedUsersSectionHeader(group: group, model: model)
}
}
.listStyle(.plain)
}
else {
ProgressView()
}
.listStyle(.plain)
Spacer()
@@ -171,14 +110,17 @@ fileprivate struct SuggestedUsersPageView: View {
}
struct SuggestedUsersSectionHeader: View {
let followPack: FollowPackEvent
let group: SuggestedUserGroup
let model: SuggestedUsersViewModel
var body: some View {
HStack {
Text(followPack.title ?? NSLocalizedString("Untitled Follow Pack", comment: "Default title for a follow pack if no title is specified"))
let locale = Locale.current
let format = localizedStringFormat(key: group.category, locale: locale)
let categoryName = String(format: format, locale: locale)
Text(categoryName)
Spacer()
Button(NSLocalizedString("Follow All", comment: "Button to follow all users in this section")) {
model.follow(pubkeys: followPack.publicKeys)
model.follow(pubkeys: group.users)
}
.font(.subheadline.weight(.semibold))
}
@@ -187,6 +129,6 @@ struct SuggestedUsersSectionHeader: View {
struct SuggestedUsersView_Previews: PreviewProvider {
static var previews: some View {
OnboardingSuggestionsView(model: try! SuggestedUsersViewModel(damus_state: test_damus_state))
OnboardingSuggestionsView(model: SuggestedUsersViewModel(damus_state: test_damus_state))
}
}
@@ -35,6 +35,7 @@ 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,76 +8,32 @@
import Foundation
import Combine
/// 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
struct SuggestedUserGroup: Identifiable, Codable {
let id = UUID()
let category: String
let users: [Pubkey]
enum CodingKeys: String, CodingKey {
case category, users
}
}
class SuggestedUsersViewModel: ObservableObject {
/// The Damus State
public let damus_state: DamusState
/// 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 {
@Published var groups: [SuggestedUserGroup] = []
private let sub_id = UUID().uuidString
init(damus_state: DamusState) {
self.damus_state = damus_state
self.reduceBitcoinContent = damus_state.settings.reduce_bitcoin_content
self.recomputeAll()
Task.detached {
await self.loadSuggestedFollowPacks()
}
loadSuggestedUserGroups()
let pubkeys = getPubkeys(groups: groups)
subscribeToSuggestedProfiles(pubkeys: pubkeys)
}
// 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,
@@ -87,154 +43,63 @@ 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)))
}
}
// 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()
}
/// 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]) : []
}
/// 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)
private func loadSuggestedUserGroups() {
guard let url = Bundle.main.url(forResource: "suggested_users", withExtension: "json") else {
return
}
// 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
}
}
}
guard let data = try? Data(contentsOf: url) else {
return
}
return packsById
}
/// 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
}
packsById[id] = latestPackForThisId
}
case .eose:
break
}
let decoder = JSONDecoder()
do {
let groups = try decoder.decode([SuggestedUserGroup].self, from: data)
self.groups = groups
} catch {
print(error.localizedDescription.localizedLowercase)
}
}
/// 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)
}
private func getPubkeys(groups: [SuggestedUserGroup]) -> [Pubkey] {
var pubkeys: [Pubkey] = []
for group in groups {
pubkeys.append(contentsOf: group.users)
}
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
}
return pubkeys
}
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)
}
func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) {
guard case .nostr_event(let nev) = ev else {
return
}
switch nev {
case .event:
break
case .notice(let msg):
print("suggested user profiles notice: \(msg)")
case .eose:
self.objectWillChange.send()
case .ok:
break
case .auth:
break
}
}
}
-19
View File
@@ -1,19 +0,0 @@
{"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"}
@@ -0,0 +1,79 @@
[
{
"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"
]
}
]
+10 -16
View File
@@ -798,18 +798,18 @@ private func isAlphanumeric(_ char: Character) -> Bool {
return char.isLetter || char.isNumber
}
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair, relayURL: RelayURL?) -> [[String]] {
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair) -> [[String]] {
guard let nip10 = replying_to.thread_reply() else {
// we're replying to a post that isn't in a thread,
// just add a single reply-to-root tag
return [["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "root"]]
return [["e", replying_to.id.hex(), "", "root"]]
}
// otherwise use the root tag from the parent's nip10 reply and include the note
// that we are replying to's note id.
let tags = [
["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"],
["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "reply"]
["e", replying_to.id.hex(), "", "reply"]
]
return tags
@@ -863,9 +863,7 @@ 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
let linkValue = attributes[.link]
let link = (linkValue as? String) ?? (linkValue as? URL)?.absoluteString
if let link {
if let link = attributes[.link] as? String {
let nextCharIndex = range.upperBound
if nextCharIndex < post.length,
let nextChar = post.attributedSubstring(from: NSRange(location: nextCharIndex, length: 1)).string.first,
@@ -902,19 +900,15 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction,
switch action {
case .replying_to(let replying_to):
// start off with the reply tags
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair, relayURL: state.nostrNetwork.relaysForEvent(event: replying_to).first)
tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair)
case .quoting(let ev):
let relay_urls = state.nostrNetwork.relaysForEvent(event: ev)
let nevent = Bech32Object.encode(.nevent(NEvent(event: ev, relays: relay_urls.prefix(4).map { $0.absoluteString })))
content.append("\n\nnostr:\(nevent)")
content.append("\n\nnostr:" + bech32_note_id(ev.id))
if let first_relay = relay_urls.first?.absoluteString {
tags.append(["q", ev.id.hex(), first_relay, ev.pubkey.hex()]);
tags.append(["p", ev.pubkey.hex(), first_relay])
} else {
tags.append(["q", ev.id.hex(), "", ev.pubkey.hex()]);
tags.append(["p", ev.pubkey.hex()])
tags.append(["q", ev.id.hex()]);
if let quoted_ev = state.events.lookup(ev.id) {
tags.append(["p", quoted_ev.pubkey.hex()])
}
case .posting, .highlighting, .sharing:
break
+69 -77
View File
@@ -9,7 +9,6 @@ import SwiftUI
import Combine
let BANNER_HEIGHT: CGFloat = 150.0;
fileprivate let Scroll_height: CGFloat = 700.0
struct EditMetadataView: View {
let damus_state: DamusState
@@ -80,14 +79,11 @@ 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: offset > 0 ? BANNER_HEIGHT + offset : BANNER_HEIGHT)
.frame(width: geo.size.width, height: BANNER_HEIGHT)
.clipped()
.offset(y: offset > 0 ? -offset : 0) // Pin the top
}
.frame(height: BANNER_HEIGHT)
}.frame(height: BANNER_HEIGHT)
VStack(alignment: .leading) {
let pfp_size: CGFloat = 90.0
@@ -133,78 +129,74 @@ 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"
TextField(display_name_placeholder, text: $display_name)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section(NSLocalizedString("Username", comment: "Label for Username section of user profile form.")) {
let username_placeholder = "satoshi"
TextField(username_placeholder, text: $name)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section(NSLocalizedString("Website", comment: "Label for Website section of user profile form.")) {
TextField(NSLocalizedString("https://jb55.com", comment: "Placeholder example text for website URL for user profile."), text: $website)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section(NSLocalizedString("About Me", comment: "Label for About Me section of user profile form.")) {
let placeholder = NSLocalizedString("Absolute Boss", comment: "Placeholder text for About Me description.")
ZStack(alignment: .topLeading) {
TextEditor(text: $about)
.textInputAutocapitalization(.sentences)
.frame(minHeight: 45, alignment: .leading)
.multilineTextAlignment(.leading)
Text(about.isEmpty ? placeholder : about)
.padding(4)
.opacity(about.isEmpty ? 1 : 0)
.foregroundColor(Color(uiColor: .placeholderText))
}
}
Section(NSLocalizedString("Bitcoin Lightning Tips", comment: "Label for Bitcoin Lightning Tips section of user profile form.")) {
TextField(NSLocalizedString("Lightning Address or LNURL", comment: "Placeholder text for entry of Lightning Address or LNURL."), text: $ln)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onReceive(Just(ln)) { newValue in
self.ln = newValue.trimmingCharacters(in: .whitespaces)
}
}
Section(content: {
TextField(NSLocalizedString("jb55@jb55.com", comment: "Placeholder example text for identifier used for Nostr addresses."), text: $nip05)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onReceive(Just(nip05)) { newValue in
self.nip05 = newValue.trimmingCharacters(in: .whitespaces)
}
}, header: {
Text("Nostr Address", comment: "Label for the Nostr Address section of user profile form.")
}, footer: {
switch validate_nostr_address(nip05: nip05_parts, nip05_str: nip05) {
case .empty:
// without this, the keyboard dismisses unnecessarily when the footer changes state
Text("")
case .valid:
Text("")
case .invalid:
Text("'\(nip05)' is an invalid Nostr address. It should look like an email address.", comment: "Description of why the Nostr address is invalid.")
}
})
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"
TextField(display_name_placeholder, text: $display_name)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
.frame(height: Scroll_height)
Section(NSLocalizedString("Username", comment: "Label for Username section of user profile form.")) {
let username_placeholder = "satoshi"
TextField(username_placeholder, text: $name)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section(NSLocalizedString("Website", comment: "Label for Website section of user profile form.")) {
TextField(NSLocalizedString("https://jb55.com", comment: "Placeholder example text for website URL for user profile."), text: $website)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
}
Section(NSLocalizedString("About Me", comment: "Label for About Me section of user profile form.")) {
let placeholder = NSLocalizedString("Absolute Boss", comment: "Placeholder text for About Me description.")
ZStack(alignment: .topLeading) {
TextEditor(text: $about)
.textInputAutocapitalization(.sentences)
.frame(minHeight: 45, alignment: .leading)
.multilineTextAlignment(.leading)
Text(about.isEmpty ? placeholder : about)
.padding(4)
.opacity(about.isEmpty ? 1 : 0)
.foregroundColor(Color(uiColor: .placeholderText))
}
}
Section(NSLocalizedString("Bitcoin Lightning Tips", comment: "Label for Bitcoin Lightning Tips section of user profile form.")) {
TextField(NSLocalizedString("Lightning Address or LNURL", comment: "Placeholder text for entry of Lightning Address or LNURL."), text: $ln)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onReceive(Just(ln)) { newValue in
self.ln = newValue.trimmingCharacters(in: .whitespaces)
}
}
Section(content: {
TextField(NSLocalizedString("jb55@jb55.com", comment: "Placeholder example text for identifier used for Nostr addresses."), text: $nip05)
.autocorrectionDisabled(true)
.textInputAutocapitalization(.never)
.onReceive(Just(nip05)) { newValue in
self.nip05 = newValue.trimmingCharacters(in: .whitespaces)
}
}, header: {
Text("Nostr Address", comment: "Label for the Nostr Address section of user profile form.")
}, footer: {
switch validate_nostr_address(nip05: nip05_parts, nip05_str: nip05) {
case .empty:
// without this, the keyboard dismisses unnecessarily when the footer changes state
Text("")
case .valid:
Text("")
case .invalid:
Text("'\(nip05)' is an invalid Nostr address. It should look like an email address.", comment: "Description of why the Nostr address is invalid.")
}
})
}
Button(action: {
+5 -31
View File
@@ -5,7 +5,6 @@
// Created by William Casarin on 2022-04-16.
//
import FaviconFinder
import SwiftUI
enum FriendType {
@@ -44,7 +43,6 @@ 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
@@ -63,7 +61,7 @@ struct ProfileName: View {
var current_nip05: NIP05? {
nip05 ?? damus_state.profiles.is_validated(pubkey)
}
func current_display_name(profile: Profile?) -> DisplayName {
return display_name ?? Profile.displayName(profile: profile, pubkey: pubkey)
}
@@ -103,7 +101,7 @@ struct ProfileName: View {
.fontWeight(prefix == "@" ? .none : .bold)
if let nip05 = current_nip05 {
NIP05Badge(nip05: nip05, pubkey: pubkey, damus_state: damus_state, show_domain: show_nip5_domain, nip05_domain_favicon: nip05_domain_favicon)
NIP05Badge(nip05: nip05, pubkey: pubkey, contacts: damus_state.contacts, show_domain: show_nip5_domain, profiles: damus_state.profiles)
}
if let friend = friend_type, current_nip05 == nil {
@@ -120,15 +118,9 @@ struct ProfileName: View {
}
.task {
if damus_state.purple.enable_purple {
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()
}
if damus_state.purple.enable_purple {
self.purple_account = try? await damus_state.purple.get_maybe_cached_account(pubkey: pubkey)
}
}
.onReceive(handle_notify(.profile_updated)) { update in
if update.pubkey != pubkey {
@@ -159,24 +151,6 @@ 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 {
+5 -16
View File
@@ -31,6 +31,7 @@ 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
@@ -64,19 +65,16 @@ 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, privacy_sensitive: Bool = false) {
init(pubkey: Pubkey, size: CGFloat, highlight: Highlight, profiles: Profiles, disable_animation: Bool, picture: String? = nil, show_zappability: Bool? = nil) {
self.pubkey = pubkey
self.profiles = profiles
self.size = size
@@ -84,24 +82,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? {
return profiles.lookup_with_timestamp(pubkey)?.unsafeUnownedValue?.lnurl
}
var body: some View {
ZStack (alignment: Alignment(horizontal: .trailing, vertical: .bottom)) {
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)
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)
.onReceive(handle_notify(.profile_updated)) { updated in
guard updated.pubkey == self.pubkey else {
return
+4 -4
View File
@@ -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, .follow_list:
case .posts, .posts_and_replies:
filters.append({ profile.pubkey == $0.pubkey })
case .conversations:
filters.append({ profile.conversation_events.contains($0.id) } )
@@ -464,13 +464,13 @@ struct ProfileView: View {
.background(colorScheme == .dark ? Color.black : Color.white)
if filter_state == FilterState.posts {
InnerTimelineView(events: profile.events, pinned_events: profile.pinned_events, damus: damus_state, filter: content_filter(FilterState.posts))
InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts))
}
if filter_state == FilterState.posts_and_replies {
InnerTimelineView(events: profile.events, pinned_events: profile.pinned_events, damus: damus_state, filter: content_filter(FilterState.posts_and_replies))
InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.posts_and_replies))
}
if filter_state == FilterState.conversations && !profile.conversation_events.isEmpty {
InnerTimelineView(events: profile.events, pinned_events: EventHolder(), damus: damus_state, filter: content_filter(FilterState.conversations))
InnerTimelineView(events: profile.events, damus: damus_state, filter: content_filter(FilterState.conversations))
}
}
.padding(.horizontal, Theme.safeAreaInsets?.left)
+2 -12
View File
@@ -12,19 +12,9 @@ struct QuoteRepostsView: View {
@ObservedObject var model: EventsModel
var body: some View {
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()
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:))
.padding(.bottom, tabHeight)
.navigationBarTitle(NSLocalizedString("Quotes", comment: "Navigation bar title for Quote Reposts view."))
.onAppear {
model.subscribe()
}
+16 -16
View File
@@ -15,9 +15,8 @@ struct SearchHomeView: View {
@State var search: String = ""
@FocusState private var isFocused: Bool
func content_filter(_ fstate: FilterState) -> ((NostrEvent) -> Bool) {
var filters = ContentFilters.defaults(damus_state: damus_state)
filters.append(fstate.filter)
var content_filter: (NostrEvent) -> Bool {
let filters = ContentFilters.defaults(damus_state: self.damus_state)
return ContentFilters(filters: filters).filter
}
@@ -53,20 +52,21 @@ struct SearchHomeView: View {
loading: $model.loading,
damus: damus_state,
show_friend_icon: true,
filter:content_filter(FilterState.posts),
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
},
content: {
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)
AnyView(VStack {
SuggestedHashtagsView(damus_state: damus_state, max_items: 5, events: model.events)
Divider()
.frame(height: 1)
@@ -100,8 +100,6 @@ 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,11 +96,6 @@ 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)
}
}
}
}
@@ -89,7 +89,6 @@ struct KeySettingsView: View {
.disabled(true)
} else {
Text(sec)
.privacySensitive()
.clipShape(RoundedRectangle(cornerRadius: 5))
}
@@ -67,8 +67,6 @@ struct ZapSettingsView: View {
Section(NSLocalizedString("NWC wallet", comment: "Title for section in zap settings that controls general NWC wallet settings.")) {
Toggle(NSLocalizedString("Disable high balance warning", comment: "Setting to disable high balance warnings on the user's wallet"), isOn: $settings.dismiss_wallet_high_balance_warning)
.toggleStyle(.switch)
Toggle(NSLocalizedString("Hide balance", comment: "Setting to hide wallet balance."), isOn: $settings.hide_wallet_balance)
.toggleStyle(.switch)
}
}
.navigationTitle(NSLocalizedString("Zaps", comment: "Navigation title for zap settings."))
+4 -6
View File
@@ -10,13 +10,11 @@ import SwiftUI
struct InnerTimelineView: View {
@ObservedObject var events: EventHolder
@ObservedObject var pinned_events: EventHolder
let state: DamusState
let filter: (NostrEvent) -> Bool
init(events: EventHolder, pinned_events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
init(events: EventHolder, damus: DamusState, filter: @escaping (NostrEvent) -> Bool, apply_mute_rules: Bool = true) {
self.events = events
self.pinned_events = pinned_events
self.state = damus
self.filter = apply_mute_rules ? { filter($0) && !damus.mutelist_manager.is_event_muted($0) } : filter
}
@@ -31,7 +29,7 @@ struct InnerTimelineView: View {
var body: some View {
LazyVStack(spacing: 0) {
let events = self.pinned_events.events + self.events.events
let events = self.events.events
if events.isEmpty {
EmptyTimelineView()
} else {
@@ -40,7 +38,7 @@ struct InnerTimelineView: View {
ForEach(indexed, id: \.0.id) { tup in
let ev = tup.0
let ind = tup.1
EventView(damus: state, event: ev, pinned: Set(self.pinned_events.events.map { $0.id }), options: event_options)
EventView(damus: state, event: ev, options: event_options)
.onTapGesture {
let event = ev.get_inner_event(cache: state.events) ?? ev
let thread = ThreadModel(event: event, damus_state: state)
@@ -71,7 +69,7 @@ struct InnerTimelineView: View {
struct InnerTimelineView_Previews: PreviewProvider {
static var previews: some View {
InnerTimelineView(events: test_event_holder, pinned_events: EventHolder(), damus: test_damus_state, filter: { _ in true })
InnerTimelineView(events: test_event_holder, damus: test_damus_state, filter: { _ in true })
.frame(width: 300, height: 500)
.border(Color.red)
}
+1 -1
View File
@@ -61,7 +61,7 @@ struct TimelineView<Content: View>: View {
.id("startblock")
.frame(height: 0)
InnerTimelineView(events: events, pinned_events: EventHolder(), damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
InnerTimelineView(events: events, damus: damus, filter: loading ? { _ in true } : filter, apply_mute_rules: self.apply_mute_rules)
.redacted(reason: loading ? .placeholder : [])
.shimmer(loading)
.disabled(loading)
@@ -1,25 +0,0 @@
//
// TrustedNetworkButtonTip.swift
// damus
//
// Created by Terry Yiu on 6/4/25.
//
import TipKit
@available(iOS 17, *)
struct TrustedNetworkButtonTip: Tip {
static let shared = TrustedNetworkButtonTip()
var title: Text {
Text("Toggle visibility of content from outside your trusted network", comment: "Title of tip that informs users what trusted network means and that they can toggle the visibility of content from outside their trusted network.")
}
var message: Text? {
Text("Your trusted network is comprised of profiles you follow and profiles that they follow.", comment: "Description of the tip that informs users what trusted network means.")
}
var image: Image? {
Image(systemName: "network.badge.shield.half.filled")
}
}
@@ -1,79 +0,0 @@
//
// TrustedNetworkButtonTipViewStyle.swift
// damus
//
// Created by Terry Yiu on 6/7/25.
//
import TipKit
// (tyiu): Apple's native popover tips have a lot of rendering and race condition issues --
// text being rendered in the wrong locations or not at all, or the tip gets opened in full screen.
//
// Instead, we are introducing this custom popover tip view style to emulate a similar look and feel.
// The main thing needed from this view style is really just an arrow on the top right corner
// to point to the TrustedNetworkButton on the NotificationsView and DirectMessagesview.
@available(iOS 17, *)
struct TrustedNetworkButtonTipViewStyle: TipViewStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(spacing: 0) {
// Arrow pointing up to the button (positioned at top right)
HStack {
Spacer()
Triangle()
.fill(Color(.secondarySystemBackground))
.frame(width: 24, height: 14)
}
HStack(alignment: .top, spacing: 12) {
// Icon
configuration.image
.foregroundStyle(.tint)
.font(.title2)
VStack(alignment: .leading, spacing: 4) {
configuration.title
.font(.headline)
.fontWeight(.semibold)
.foregroundStyle(.primary)
configuration.message
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
Button(action: { configuration.tip.invalidate(reason: .tipClosed) }) {
Image(systemName: "xmark")
.fontWeight(.semibold)
.foregroundStyle(Color(.tertiaryLabel))
}
.buttonStyle(PlainButtonStyle())
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.background(Color(.secondarySystemBackground))
.clipShape(
.rect(
topLeadingRadius: 20,
bottomLeadingRadius: 20,
bottomTrailingRadius: 20,
topTrailingRadius: 0
)
)
}
}
}
// Custom triangle shape for the popover arrow
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
return path
}
}
@@ -1,26 +0,0 @@
//
// TrustedNetworkRepliesTip.swift
// damus
//
// Created by Terry Yiu on 6/7/25.
//
import Foundation
import TipKit
@available(iOS 17, *)
struct TrustedNetworkRepliesTip: Tip {
static let shared = TrustedNetworkRepliesTip()
var title: Text {
Text("Toggle visibility of replies from outside your trusted network", comment: "Title of tip that informs users what trusted network means and that they can toggle the visibility of threaded replies from outside their trusted network.")
}
var message: Text? {
Text("Your trusted network is comprised of profiles you follow and profiles that they follow.", comment: "Description of the tip that informs users what trusted network means.")
}
var image: Image? {
Image(systemName: "network.badge.shield.half.filled")
}
}
+17 -46
View File
@@ -9,77 +9,48 @@ import SwiftUI
struct BalanceView: View {
var balance: Int64?
@Binding var hide_balance: Bool
var body: some View {
VStack(spacing: 5) {
Text("Current balance", comment: "Label for displaying current wallet balance")
.foregroundStyle(DamusColors.neutral6)
if let balance {
NumericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal), hide_balance: $hide_balance)
self.numericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal))
}
else {
// Make sure we do not show any numeric value to the user when still loading (or when failed to load)
// This is important because if we show a numeric value like "zero" when things are not loaded properly, we risk scaring the user into thinking that they have lost funds.
Text(verbatim: "??")
.lineLimit(1)
.minimumScaleFactor(0.70)
.font(.veryVeryLargeTitle)
.fontWeight(.heavy)
.foregroundStyle(PinkGradient)
self.numericalBalanceView(text: "??")
.redacted(reason: .placeholder)
.shimmer(true)
}
}
}
}
struct NumericalBalanceView: View {
let text: String
@Binding var hide_balance: Bool
var body: some View {
Group {
if hide_balance {
Text(verbatim: "*****")
.lineLimit(1)
.minimumScaleFactor(0.70)
.font(.veryVeryLargeTitle)
func numericalBalanceView(text: String) -> some View {
HStack {
Text(verbatim: text)
.lineLimit(1)
.minimumScaleFactor(0.70)
.font(.veryVeryLargeTitle)
.fontWeight(.heavy)
.foregroundStyle(PinkGradient)
HStack(alignment: .top) {
Text("SATS", comment: "Abbreviation for Satoshis, smallest bitcoin unit")
.font(.caption)
.fontWeight(.heavy)
.foregroundStyle(PinkGradient)
} else {
HStack {
Text(verbatim: text)
.lineLimit(1)
.minimumScaleFactor(0.70)
.font(.veryVeryLargeTitle)
.fontWeight(.heavy)
.foregroundStyle(PinkGradient)
HStack(alignment: .top) {
Text("SATS", comment: "Abbreviation for Satoshis, smallest bitcoin unit")
.font(.caption)
.fontWeight(.heavy)
.foregroundStyle(PinkGradient)
}
}
}
}
.privacySensitive()
.padding(.bottom)
.onTapGesture {
hide_balance.toggle()
}
}
}
struct BalanceView_Previews: PreviewProvider {
@State private static var hide_balance: Bool = false
static var previews: some View {
BalanceView(balance: 100000000, hide_balance: $hide_balance)
BalanceView(balance: nil, hide_balance: $hide_balance)
BalanceView(balance: 100000000)
BalanceView(balance: nil)
}
}
-246
View File
@@ -1,246 +0,0 @@
//
// LnurlAmountView.swift
// damus
//
// Created by Daniel DAquino on 2025-06-18
//
import SwiftUI
import Combine
class LnurlAmountModel: ObservableObject {
@Published var custom_amount: String = "0"
@Published var custom_amount_sats: Int? = 0
@Published var processing: Bool = false
@Published var error: String? = nil
@Published var invoice: String? = nil
@Published var zap_amounts: [ZapAmountItem] = []
func set_defaults(settings: UserSettingsStore) {
let default_amount = settings.default_zap_amount
custom_amount = String(default_amount)
custom_amount_sats = default_amount
zap_amounts = get_zap_amount_items(default_amount)
}
}
/// Enables the user to enter a Bitcoin amount to be sent. Based on `CustomizeZapView`.
struct LnurlAmountView: View {
let damus_state: DamusState
let lnurlString: String
let onInvoiceFetched: (Invoice) -> Void
let onCancel: () -> Void
@StateObject var model: LnurlAmountModel = LnurlAmountModel()
@Environment(\.colorScheme) var colorScheme
@FocusState var isAmountFocused: Bool
init(damus_state: DamusState, lnurlString: String, onInvoiceFetched: @escaping (Invoice) -> Void, onCancel: @escaping () -> Void) {
self.damus_state = damus_state
self.lnurlString = lnurlString
self.onInvoiceFetched = onInvoiceFetched
self.onCancel = onCancel
}
func AmountButton(zapAmountItem: ZapAmountItem) -> some View {
let isSelected = model.custom_amount_sats == zapAmountItem.amount
return Button(action: {
model.custom_amount_sats = zapAmountItem.amount
model.custom_amount = String(zapAmountItem.amount)
}) {
let fmt = format_msats_abbrev(Int64(zapAmountItem.amount) * 1000)
Text(verbatim: "\(zapAmountItem.icon)\n\(fmt)")
.contentShape(Rectangle())
.font(.headline)
.frame(width: 70, height: 70)
.foregroundColor(DamusColors.adaptableBlack)
.background(isSelected ? DamusColors.adaptableWhite : DamusColors.adaptableGrey)
.cornerRadius(15)
.overlay(RoundedRectangle(cornerRadius: 15)
.stroke(DamusColors.purple.opacity(isSelected ? 1.0 : 0.0), lineWidth: 2))
}
}
func amount_parts(_ n: Int) -> [ZapAmountItem] {
var i: Int = -1
let start = n * 4
let end = start + 4
return model.zap_amounts.filter { _ in
i += 1
return i >= start && i < end
}
}
func AmountsPart(n: Int) -> some View {
HStack(alignment: .center, spacing: 15) {
ForEach(amount_parts(n)) { entry in
AmountButton(zapAmountItem: entry)
}
}
}
var AmountGrid: some View {
VStack {
AmountsPart(n: 0)
AmountsPart(n: 1)
}
.padding(10)
}
var CustomAmountTextField: some View {
VStack(alignment: .center, spacing: 0) {
TextField("", text: $model.custom_amount)
.focused($isAmountFocused)
.task {
self.isAmountFocused = true
}
.font(.system(size: 72, weight: .heavy))
.minimumScaleFactor(0.01)
.keyboardType(.numberPad)
.multilineTextAlignment(.center)
.onChange(of: model.custom_amount) { newValue in
if let parsed = handle_string_amount(new_value: newValue) {
model.custom_amount = parsed.formatted()
model.custom_amount_sats = parsed
} else {
model.custom_amount = "0"
model.custom_amount_sats = nil
}
}
let noun = pluralizedString(key: "sats", count: model.custom_amount_sats ?? 0)
Text(noun)
.font(.system(size: 18, weight: .heavy))
}
}
func fetchInvoice() {
guard let amount = model.custom_amount_sats, amount > 0 else {
model.error = NSLocalizedString("Please enter a valid amount", comment: "Error message when no valid amount is entered for LNURL payment")
return
}
model.processing = true
model.error = nil
Task { @MainActor in
// For LNURL payments without zaps, we use nil for zapreq and comment
// We just need the invoice for payment
let msats = Int64(amount) * 1000
// First get the payment request from the LNURL
guard let payreq = await fetch_static_payreq(lnurlString) else {
model.processing = false
model.error = NSLocalizedString("Error fetching LNURL payment information", comment: "Error message when LNURL fetch fails")
return
}
// Then fetch the invoice with the amount
guard let invoiceStr = await fetch_zap_invoice(payreq, zapreq: nil, msats: msats, zap_type: .non_zap, comment: nil) else {
model.processing = false
model.error = NSLocalizedString("Error fetching lightning invoice", comment: "Error message when there was an error fetching a lightning invoice")
return
}
// Decode the invoice to validate it
guard let invoice = decode_bolt11(invoiceStr) else {
model.processing = false
model.error = NSLocalizedString("Invalid lightning invoice received", comment: "Error message when the lightning invoice received from LNURL is invalid")
return
}
// All good, pass the invoice back to the parent view
model.processing = false
onInvoiceFetched(invoice)
}
}
var PayButton: some View {
VStack {
if model.processing {
Text("Processing...", comment: "Text to indicate that the app is in the process of fetching an invoice.")
.padding()
ProgressView()
} else {
Button(action: {
fetchInvoice()
}) {
HStack {
Text("Continue", comment: "Button to proceed with LNURL payment process.")
.font(.system(size: 20, weight: .bold))
}
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
}
.buttonStyle(GradientButtonStyle())
.disabled(model.custom_amount_sats == 0 || model.custom_amount == "0")
.opacity(model.custom_amount_sats == 0 || model.custom_amount == "0" ? 0.5 : 1.0)
.padding(10)
}
if let error = model.error {
Text(error)
.foregroundColor(.red)
.padding()
}
}
}
var CancelButton: some View {
Button(action: onCancel) {
HStack {
Text("Cancel", comment: "Button to cancel the LNURL payment process.")
.font(.headline)
.padding()
}
.frame(minWidth: 300, maxWidth: .infinity, alignment: .center)
}
.buttonStyle(NeutralButtonStyle())
.padding()
}
var body: some View {
VStack(alignment: .center, spacing: 20) {
ScrollView {
VStack {
Text("Enter Amount", comment: "Header text for LNURL payment amount entry screen")
.font(.title)
.fontWeight(.bold)
.padding()
Text("How much would you like to send?", comment: "Instruction text for LNURL payment amount")
.font(.headline)
.multilineTextAlignment(.center)
.padding(.bottom)
CustomAmountTextField
AmountGrid
PayButton
CancelButton
}
}
}
.onAppear {
model.set_defaults(settings: damus_state.settings)
}
.onTapGesture {
hideKeyboard()
}
}
}
struct LnurlAmountView_Previews: PreviewProvider {
static var previews: some View {
LnurlAmountView(
damus_state: test_damus_state,
lnurlString: "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns",
onInvoiceFetched: { _ in },
onCancel: {}
)
.frame(width: 400, height: 600)
}
}
+1 -210
View File
@@ -6,7 +6,6 @@
//
import SwiftUI
import Combine
struct NWCSettings: View {
@@ -17,18 +16,6 @@ struct NWCSettings: View {
@Environment(\.dismiss) var dismiss
// Budget sync state tracking
@State private var isCoinosWallet: Bool = false
@State private var maxWeeklyBudget: UInt64? = nil
@State private var budgetSyncState: BudgetSyncState = .undefined
// Min/max budget values for slider
private let minBudget: UInt64 = 100
private let maxBudget: UInt64 = 10_000_000
// Slider min/max values for logarithmic scale (0-1 range)
private let sliderMin: Double = 0.0
private let sliderMax: Double = 1.0
func donation_binding() -> Binding<Double> {
return Binding(get: {
@@ -151,79 +138,7 @@ struct NWCSettings: View {
Toggle(NSLocalizedString("Disable high balance warning", comment: "Setting to disable high balance warnings on the user's wallet"), isOn: $settings.dismiss_wallet_high_balance_warning)
.toggleStyle(.switch)
Toggle(NSLocalizedString("Hide balance", comment: "Setting to hide wallet balance."), isOn: $settings.hide_wallet_balance)
.toggleStyle(.switch)
if isCoinosWallet, let maxWeeklyBudget {
VStack(alignment: .leading) {
Text("Max weekly budget", comment: "Label for setting the maximum weekly budget for Coinos wallet")
.font(.headline)
.padding(.bottom, 2)
Text("The maximum amount of funds that are allowed to be sent out from this wallet each week.", comment: "Description explaining the purpose of the 'Max weekly budget' setting for Coinos one-click setup wallets")
.font(.caption)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 10) {
HStack {
Slider(
// Use a logarithmic scale for this slider to give more control to different kinds of users:
//
// - Users with higher budget tolerance can select very high amounts (e.g. Easy to go up to 5M or 10M sats)
// - Conservative users can still have fine-grained control over lower amounts (e.g. Easy to switch between 500 and 1.5K sats)
value: Binding(
get: {
// Convert from budget value to slider position (0-1)
budgetToSliderPosition(budget: maxWeeklyBudget)
},
set: {
// Convert from slider position to budget value
let newValue = sliderPositionToBudget(position: $0)
if self.maxWeeklyBudget != newValue {
self.maxWeeklyBudget = newValue
}
}
),
in: sliderMin...sliderMax,
onEditingChanged: { editing in
if !editing {
updateMaxWeeklyBudget()
}
}
)
Text(verbatim: format_msats(Int64(maxWeeklyBudget) * 1000))
.foregroundColor(.gray)
.frame(width: 150, alignment: .trailing)
}
// Budget sync status
HStack {
switch budgetSyncState {
case .undefined:
EmptyView()
case .success:
HStack {
Image("check-circle.fill")
.foregroundStyle(.damusGreen)
Text("Successfully updated", comment: "Label indicating success in updating budget")
}
case .syncing:
HStack(spacing: 10) {
ProgressView()
Text("Updating", comment: "Label indicating budget update is in progress")
}
case .failure(let error):
Text(error)
.foregroundStyle(.damusDangerPrimary)
}
}
.padding(.top, 5)
}
}
.padding(.vertical, 8)
}
Button(action: {
self.model.disconnect()
dismiss()
@@ -238,10 +153,6 @@ struct NWCSettings: View {
.padding()
.onAppear() {
model.initial_percent = model.settings.donation_percent
checkIfCoinosWallet()
if isCoinosWallet {
fetchCurrentBudget()
}
}
.onChange(of: model.settings.donation_percent) { p in
let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey)
@@ -272,79 +183,6 @@ struct NWCSettings: View {
}
}
// Check if the current wallet is a Coinos one-click wallet
private func checkIfCoinosWallet() {
// Check condition 1: Relay is coinos.io
let isRelayCoinos = nwc.relay.absoluteString == "wss://relay.coinos.io"
// Check condition 2: LUD16 matches expected format
guard let keypair = damus_state.keypair.to_full() else {
isCoinosWallet = false
return
}
let client = CoinosDeterministicAccountClient(userKeypair: keypair)
let expectedLud16 = client.expectedLud16
isCoinosWallet = isRelayCoinos && nwc.lud16 == expectedLud16
}
/// Fetches the current max weekly budget from Coinos
private func fetchCurrentBudget() {
guard let keypair = damus_state.keypair.to_full() else { return }
let client = CoinosDeterministicAccountClient(userKeypair: keypair)
Task {
do {
if let config = try await client.getNWCAppConnectionConfig(),
let maxAmount = config.max_amount {
DispatchQueue.main.async {
self.maxWeeklyBudget = maxAmount
}
}
} catch {
self.budgetSyncState = .failure(error: error.localizedDescription)
}
}
}
/// Updates the max weekly budget on Coinos
private func updateMaxWeeklyBudget() {
guard let maxWeeklyBudget else { return }
guard let keypair = damus_state.keypair.to_full() else { return }
budgetSyncState = .syncing
let client = CoinosDeterministicAccountClient(userKeypair: keypair)
Task {
do {
// First ensure we're logged in
try await client.loginIfNeeded()
// Update the connection with the new budget
_ = try await client.updateNWCConnection(maxAmount: maxWeeklyBudget)
DispatchQueue.main.async {
self.budgetSyncState = .success
// Reset success state after a delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
if case .success = self.budgetSyncState {
self.budgetSyncState = .undefined
}
}
}
} catch {
DispatchQueue.main.async {
self.budgetSyncState = .failure(error: error.localizedDescription)
}
}
}
}
struct AccountDetailsView: View {
let nwc: WalletConnect.ConnectURL
let damus_state: DamusState?
@@ -392,40 +230,6 @@ struct NWCSettings: View {
)
}
}
// MARK: - Logarithmic scale conversions
/// Converts from budget value to a slider position (0-1 range)
func budgetToSliderPosition(budget: UInt64) -> Double {
// Ensure budget is within bounds
let clampedBudget = max(minBudget, min(maxBudget, budget))
// Calculate the log scale position
let minLog = log10(Double(minBudget))
let maxLog = log10(Double(maxBudget))
let budgetLog = log10(Double(clampedBudget))
// Convert to 0-1 range
return (budgetLog - minLog) / (maxLog - minLog)
}
// Convert from slider position (0-1) to budget value
func sliderPositionToBudget(position: Double) -> UInt64 {
// Ensure position is within bounds
let clampedPosition = max(sliderMin, min(sliderMax, position))
// Calculate the log scale value
let minLog = log10(Double(minBudget))
let maxLog = log10(Double(maxBudget))
let valueLog = minLog + clampedPosition * (maxLog - minLog)
// Convert to budget value and round to nearest 100 to make the number look "cleaner"
let exactValue = pow(10, valueLog)
let roundedValue = round(exactValue / 100) * 100
return UInt64(roundedValue)
}
}
struct NWCSettings_Previews: PreviewProvider {
@@ -434,16 +238,3 @@ struct NWCSettings_Previews: PreviewProvider {
NWCSettings(damus_state: tds, nwc: test_wallet_connect_url, model: WalletModel(state: .existing(test_wallet_connect_url), settings: tds.settings), settings: tds.settings)
}
}
extension NWCSettings {
enum BudgetSyncState: Equatable {
/// State is unknown
case undefined
/// Budget is successfully updated
case success
/// Budget is being updated
case syncing
/// There was a failure during update
case failure(error: String)
}
}
-375
View File
@@ -1,375 +0,0 @@
//
// SendPaymentView.swift
// damus
//
// Created by Daniel DAquino on 2025-06-13.
//
import SwiftUI
import CodeScanner
fileprivate let SEND_PAYMENT_TIMEOUT: Duration = .seconds(10)
/// A view that allows a user to pay a lightning invoice
struct SendPaymentView: View {
// MARK: - Helper structures
/// Represents the state of the invoice payment process
enum SendState {
case enterInvoice(scannerMessage: String?)
case confirmPayment(invoice: Invoice)
case enterLnurlAmount(lnurl: String)
case processing
case completed
case failed(error: HumanReadableError)
}
typealias HumanReadableError = ErrorView.UserPresentableError
// MARK: - Immutable members
let damus_state: DamusState
let model: WalletModel
let nwc: WalletConnectURL
@Environment(\.dismiss) var dismiss
// MARK: - State management
@State private var sendState: SendState = .enterInvoice(scannerMessage: nil) {
didSet {
switch sendState {
case .enterInvoice, .confirmPayment, .processing, .enterLnurlAmount:
break
case .completed:
// Refresh wallet to reflect new balance after payment
Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) }
case .failed:
// Even when a wallet says it has failed, update balance just in case it is a false negative,
// This might prevent the user from accidentally sending a payment twice in case of a bug.
Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) }
}
}
}
var isShowingScanner: Bool {
if case .enterInvoice = sendState { true } else { false }
}
// MARK: - Views
var body: some View {
VStack(alignment: .center) {
switch sendState {
case .enterInvoice(let scannerMessage):
invoiceInputView(scannerMessage: scannerMessage)
.padding(40)
case .confirmPayment(let invoice):
confirmationView(invoice: invoice)
.padding(40)
case .enterLnurlAmount(let lnurl):
LnurlAmountView(
damus_state: damus_state,
lnurlString: lnurl,
onInvoiceFetched: { invoice in
sendState = .confirmPayment(invoice: invoice)
},
onCancel: {
sendState = .enterInvoice(scannerMessage: nil)
}
)
case .processing:
processingView
.padding(40)
case .completed:
completedView
.padding(40)
case .failed(error: let error):
failedView(error: error)
}
}
}
func invoiceInputView(scannerMessage: String?) -> some View {
VStack(spacing: 20) {
Text("Scan Lightning Invoice", comment: "Title for the invoice scanning screen")
.font(.title2)
.bold()
CodeScannerView(
codeTypes: [.qr],
scanMode: .continuous,
showViewfinder: true, // The scan only seems to work if it fits the bounding box, so let's make this visible to hint that to the users
simulatedData: "lightning:lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r",
completion: handleScan
)
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color.accentColor, lineWidth: 2)
)
.padding(.horizontal)
VStack(spacing: 15) {
Button(action: {
if let pastedInvoice = getPasteboardContent() {
processUserInput(pastedInvoice)
}
}) {
HStack {
Image(systemName: "doc.on.clipboard")
Text("Paste from Clipboard", comment: "Button to paste invoice from clipboard")
}
.frame(minWidth: 250, maxWidth: .infinity, alignment: .center)
.padding()
}
.buttonStyle(NeutralButtonStyle())
.accessibilityLabel(NSLocalizedString("Paste invoice from clipboard", comment: "Accessibility label for the invoice paste button"))
}
.padding(.horizontal)
if let scannerMessage {
Text(scannerMessage)
.foregroundColor(.secondary)
.padding(.top, 10)
.multilineTextAlignment(.center)
}
Spacer()
}
}
func confirmationView(invoice: Invoice) -> some View {
let insufficientFunds: Bool = (invoice.amount.amount_sats() ?? 0) > (model.balance ?? 0)
return VStack(spacing: 20) {
Text("Confirm Payment", comment: "Title for payment confirmation screen")
.font(.title2)
.bold()
VStack(spacing: 15) {
Text("Amount", comment: "Label for invoice payment amount in confirmation screen")
.font(.headline)
.foregroundStyle(.secondary)
if case .specific(let amount) = invoice.amount {
NumericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(value: (Double(amount) / 1000.0)), number: .decimal), hide_balance: .constant(false))
}
Text("Bolt11 Invoice", comment: "Label for the bolt11 invoice string in confirmation screen")
.font(.headline)
.foregroundStyle(.secondary)
Text(verbatim: invoice.abbreviated)
.font(.system(.body, design: .monospaced))
.padding()
.background(DamusColors.adaptableGrey)
.cornerRadius(10)
.frame(maxWidth: .infinity)
}
HStack(spacing: 15) {
Button(action: {
sendState = .enterInvoice(scannerMessage: nil)
}) {
Text("Back", comment: "Button to go back to invoice input")
.font(.headline)
.frame(minWidth: 140)
.padding()
}
.buttonStyle(NeutralButtonStyle())
Button(action: {
sendState = .processing
// Process payment
guard let payRequestEv = WalletConnect.pay(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: invoice.string, zap_request: nil, delay: nil) else {
sendState = .failed(error: .init(
user_visible_description: NSLocalizedString("The payment request could not be made to your wallet provider.", comment: "A human-readable error message"),
tip: NSLocalizedString("Check if your wallet looks configured correctly and try again. If the error persists, please contact support.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."),
technical_info: "Cannot form Nostr Event to send to the NWC provider when calling `pay` from the \"send payment\" feature. Wallet provider relay: \"\(nwc.relay)\""
))
return
}
Task {
do {
let result = try await model.waitForResponse(for: payRequestEv.id, timeout: SEND_PAYMENT_TIMEOUT)
guard case .pay_invoice(_) = result else {
sendState = .failed(error: .init(
user_visible_description: NSLocalizedString("Received an incorrect or unexpected response from the wallet provider. This looks like an issue with your wallet provider.", comment: "A human-readable error message"),
tip: NSLocalizedString("Try again. If the error persists, please contact your wallet provider and/or our support team.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."),
technical_info: "Expected a `pay_invoice` response for the request, but received a different type of response from the NWC wallet provider. Wallet provider relay: \"\(nwc.relay)\""
))
return
}
sendState = .completed
}
catch {
if let error = error as? WalletModel.WaitError {
switch error {
case .timeout:
sendState = .failed(error: .init(
user_visible_description: NSLocalizedString("The payment request did not receive a response and the request timed-out.", comment: "A human-readable error message"),
tip: NSLocalizedString("Check if the invoice is valid, your wallet is online, configured correctly, and try again. If the error persists, please contact support and/or your wallet provider.", comment: "A human-readable tip guiding the user on what to do when seeing a timeout error while sending a wallet payment."),
technical_info: "Payment request timed-out. Wallet provider relay: \"\(nwc.relay)\""
))
}
}
else if let error = error as? WalletConnect.WalletResponseErr,
let humanReadableError = error.humanReadableError {
sendState = .failed(error: humanReadableError)
}
else {
sendState = .failed(error: .init(
user_visible_description: NSLocalizedString("An unexpected error occurred.", comment: "A human-readable error message"),
tip: NSLocalizedString("Please try again. If the error persists, please contact support.", comment: "A human-readable tip guiding the user on what to do when seeing an unexpected error while sending a wallet payment."),
technical_info: "Unexpected error thrown while waiting for payment request response. Wallet provider relay: \"\(nwc.relay)\"; Error: \(error)"
))
}
}
}
}) {
Text("Confirm", comment: "Button to confirm payment")
.font(.headline)
.frame(minWidth: 140)
.padding()
}
.buttonStyle(GradientButtonStyle(padding: 0))
.disabled(insufficientFunds)
.opacity(insufficientFunds ? 0.5 : 1.0)
}
if insufficientFunds {
Text("You do not have enough funds to pay for this invoice.", comment: "Label on invoice payment screen, indicating user has insufficient funds")
.foregroundColor(.secondary)
.padding(.top, 10)
.multilineTextAlignment(.center)
}
Spacer()
}
}
var processingView: some View {
VStack(spacing: 30) {
Text("Processing Payment", comment: "Title for payment processing screen")
.font(.title2)
.bold()
ProgressView()
.scaleEffect(1.5)
.padding()
Text("Please wait while your payment is being processed…", comment: "Message while payment is being processed")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding()
Spacer()
}
}
var completedView: some View {
VStack(spacing: 30) {
Image(systemName: "checkmark.circle.fill")
.resizable()
.frame(width: 80, height: 80)
.foregroundColor(.green)
Text("Payment Sent!", comment: "Title for successful payment screen")
.font(.title2)
.bold()
Text("Your payment has been successfully sent.", comment: "Message for successful payment")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
.padding()
Button(action: {
dismiss()
}) {
Text("Done", comment: "Button to dismiss successful payment screen")
.font(.headline)
.frame(minWidth: 200)
}
.buttonStyle(GradientButtonStyle())
Spacer()
}
}
func failedView(error: HumanReadableError) -> some View {
ScrollView {
VStack {
ErrorView(damus_state: damus_state, error: error)
Button(action: {
sendState = .enterInvoice(scannerMessage: nil)
}) {
Text("Try Again", comment: "Button to retry payment")
.font(.headline)
.frame(minWidth: 200)
.padding()
}
.buttonStyle(GradientButtonStyle(padding: 0))
}
}
}
func handleScan(result: Result<ScanResult, ScanError>) {
switch result {
case .success(let result):
processUserInput(result.string)
case .failure(let error):
sendState = .enterInvoice(scannerMessage: NSLocalizedString("Failed to scan QR code, please try again.", comment: "Error message for failed QR scan"))
}
}
func processUserInput(_ text: String) {
if let result = parseScanData(text) {
switch result {
case .invoice(let invoice):
if invoice.amount == .any {
sendState = .enterInvoice(scannerMessage: NSLocalizedString("Sorry, we do not support paying invoices without amount yet. Please scan an invoice with an amount.", comment: "A user-readable message indicating that the lightning invoice they scanned or pasted is not supported and is missing an amount."))
} else {
sendState = .confirmPayment(invoice: invoice)
}
case .lnurl(let lnurlString):
sendState = .enterLnurlAmount(lnurl: lnurlString)
}
} else {
sendState = .enterInvoice(scannerMessage: NSLocalizedString("This does not appear to be a valid Lightning invoice or LNURL.", comment: "A user-readable message indicating that the scanned or pasted content was not a valid lightning invoice or LNURL."))
}
}
func parseScanData(_ text: String) -> ScanData? {
let processedString = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if let invoice = Invoice.from(string: processedString) {
return .invoice(invoice)
}
if let _ = processedString.range(of: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", options: .regularExpression) {
guard let lnurl = lnaddress_to_lnurl(processedString) else { return nil }
return .lnurl(lnurl)
}
if processedString.hasPrefix("lnurl") {
return .lnurl(processedString)
}
return nil
}
enum ScanData {
case invoice(Invoice)
case lnurl(String)
}
// Helper function to get pasteboard content
func getPasteboardContent() -> String? {
return UIPasteboard.general.string
}
}
+35 -55
View File
@@ -8,18 +8,14 @@
import SwiftUI
struct TransactionView: View {
@Environment(\.redactionReasons) var redactionReasons
let damus_state: DamusState
var transaction: WalletConnect.Transaction
@Binding var hide_balance: Bool
var body: some View {
let redactedForPrivacy = redactionReasons.contains(.privacy)
let isIncomingTransaction = transaction.type == "incoming"
let txType = isIncomingTransaction ? "arrow-bottom-left" : "arrow-top-right"
let txColor = (isIncomingTransaction && !hide_balance && !redactedForPrivacy) ? DamusColors.success : Color.gray
let txColor = isIncomingTransaction ? DamusColors.success : Color.gray
let txOp = isIncomingTransaction ? "+" : "-"
let created_at = Date.init(timeIntervalSince1970: TimeInterval(transaction.created_at))
let formatter = RelativeDateTimeFormatter()
@@ -30,25 +26,23 @@ struct TransactionView: View {
VStack(alignment: .leading) {
HStack(alignment: .center) {
ZStack {
ProfilePicView(pubkey: pubkey ?? ANON_PUBKEY, size: 45, highlight: .custom(.damusAdaptableBlack, 0.1), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation, privacy_sensitive: true)
ProfilePicView(pubkey: pubkey ?? ANON_PUBKEY, size: 45, highlight: .custom(.damusAdaptableBlack, 0.1), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation)
.onTapGesture {
if let pubkey {
damus_state.nav.push(route: Route.ProfileByKey(pubkey: pubkey))
}
}
if !hide_balance && !redactedForPrivacy {
Image(txType)
.resizable()
.frame(width: 18, height: 18)
.foregroundColor(.white)
.padding(2)
.background(txColor)
.clipShape(Circle())
.overlay(Circle().stroke(Color.damusAdaptableWhite, lineWidth: 1.0))
.padding(.top, 25)
.padding(.leading, 35)
}
Image(txType)
.resizable()
.frame(width: 18, height: 18)
.foregroundColor(.white)
.padding(2)
.background(txColor)
.clipShape(Circle())
.overlay(Circle().stroke(Color.damusAdaptableWhite, lineWidth: 1.0))
.padding(.top, 25)
.padding(.leading, 35)
}
VStack(alignment: .leading, spacing: 10) {
@@ -66,17 +60,10 @@ struct TransactionView: View {
Spacer()
if hide_balance {
Text(verbatim: "*****")
.font(.headline)
.foregroundColor(txColor)
.bold()
} else {
Text(verbatim: "\(txOp) \(format_msats(transaction.amount))")
.font(.headline)
.foregroundColor(txColor)
.bold()
}
Text(verbatim: "\(txOp) \(format_msats(transaction.amount))")
.font(.headline)
.foregroundColor(txColor)
.bold()
}
.frame(maxWidth: .infinity, minHeight: 75, alignment: .center)
.padding(.horizontal, 10)
@@ -120,32 +107,27 @@ struct TransactionsView: View {
transactions?.sorted(by: { $0.created_at > $1.created_at })
}
@Binding var hide_balance: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Latest transactions", comment: "Heading for latest wallet transactions list")
.foregroundStyle(DamusColors.neutral6)
Group {
if let sortedTransactions {
if sortedTransactions.isEmpty {
emptyTransactions
} else {
ForEach(sortedTransactions, id: \.self) { transaction in
TransactionView(damus_state: damus_state, transaction: transaction, hide_balance: $hide_balance)
}
if let sortedTransactions {
if sortedTransactions.isEmpty {
emptyTransactions
} else {
ForEach(sortedTransactions, id: \.self) { transaction in
TransactionView(damus_state: damus_state, transaction: transaction)
}
}
else {
// Make sure we do not show "No transactions yet" to the user when still loading (or when failed to load)
// This is important because if we show that when things are not loaded properly, we risk scaring the user into thinking that they have lost funds.
emptyTransactions
.redacted(reason: .placeholder)
.shimmer(true)
}
}
.privacySensitive()
else {
// Make sure we do not show "No transactions yet" to the user when still loading (or when failed to load)
// This is important because if we show that when things are not loaded properly, we risk scaring the user into thinking that they have lost funds.
emptyTransactions
.redacted(reason: .placeholder)
.shimmer(true)
}
}
}
@@ -172,10 +154,8 @@ struct TransactionsView_Previews: PreviewProvider {
static let transaction3: WalletConnect.Transaction = WalletConnect.Transaction(type: "outgoing", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "123456789042", amount: 303000, fees_paid: 0, created_at: 1737590101, expires_at: 0, settled_at: 0, metadata: nil)
static let transaction4: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "1234567890662", amount: 720000, fees_paid: 0, created_at: 1737090300, expires_at: 0, settled_at: 0, metadata: nil)
static var test_transactions: [WalletConnect.Transaction] = [transaction1, transaction2, transaction3, transaction4]
@State private static var hide_balance: Bool = false
static var previews: some View {
TransactionsView(damus_state: tds, transactions: test_transactions, hide_balance: $hide_balance)
TransactionsView(damus_state: tds, transactions: test_transactions)
}
}
+15 -25
View File
@@ -12,11 +12,9 @@ let WALLET_WARNING_THRESHOLD: UInt64 = 100000
struct WalletView: View {
let damus_state: DamusState
@State var show_settings: Bool = false
@State var show_send_sheet: Bool = false
@ObservedObject var model: WalletModel
@ObservedObject var settings: UserSettingsStore
@State private var showBalance: Bool = false
init(damus_state: DamusState, model: WalletModel? = nil) {
self.damus_state = damus_state
self._model = ObservedObject(wrappedValue: model ?? damus_state.wallet)
@@ -49,7 +47,6 @@ struct WalletView: View {
.bold()
.foregroundStyle(.damusWarningTertiary)
}
.privacySensitive()
.padding()
.overlay(
RoundedRectangle(cornerRadius: 20)
@@ -59,22 +56,9 @@ struct WalletView: View {
VStack(spacing: 5) {
BalanceView(balance: model.balance, hide_balance: $settings.hide_wallet_balance)
BalanceView(balance: model.balance)
Button(action: {
show_send_sheet = true
}) {
HStack {
Image(systemName: "paperplane.fill")
Text("Send", comment: "Button label to send bitcoin payment from wallet")
.font(.headline)
}
.padding(.horizontal, 10)
}
.buttonStyle(GradientButtonStyle())
.padding(.bottom, 20)
TransactionsView(damus_state: damus_state, transactions: model.transactions, hide_balance: $settings.hide_wallet_balance)
TransactionsView(damus_state: damus_state, transactions: model.transactions)
}
}
.navigationTitle(NSLocalizedString("Wallet", comment: "Navigation title for Wallet view"))
@@ -118,17 +102,23 @@ struct WalletView: View {
.presentationDragIndicator(.visible)
.presentationDetents([.large])
}
.sheet(isPresented: $show_send_sheet) {
SendPaymentView(damus_state: damus_state, model: model, nwc: nwc)
.presentationDragIndicator(.visible)
.presentationDetents([.large])
}
}
}
@MainActor
func updateWalletInformation() async {
await WalletConnect.update_wallet_information(damus_state: damus_state)
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
}
}

Some files were not shown because too many files have changed in this diff Show More