damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 0d92debb42915fbdcac8c66dfd3c428e5374852b
parent 552402f2b58d8022ef760de76e873e3f1a029225
Author: Andrii Sievrikov <devandsev@gmail.com>
Date:   Sat,  4 Feb 2023 20:30:54 -0500

Add screen to select individual relays when posting/broadcasting

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 8++++++++
Mdamus/ContentView.swift | 10+++++++---
Mdamus/Nostr/Relay.swift | 4++--
Mdamus/Views/DMChatView.swift | 2+-
Mdamus/Views/PostView.swift | 101++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Adamus/Views/Relays/BroadcastToRelaysView.swift | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/SelectableRowView.swift | 36++++++++++++++++++++++++++++++++++++
7 files changed, 244 insertions(+), 40 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -198,6 +198,8 @@ 7C60CAEF298471A1009C80D6 /* CoreSVG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */; }; 7C902AE32981D55B002AB16E /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */; }; 9609F058296E220800069BF3 /* BannerImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9609F057296E220800069BF3 /* BannerImageView.swift */; }; + B02AAD55298CD07300807B3C /* BroadcastToRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B02AAD54298CD07300807B3C /* BroadcastToRelaysView.swift */; }; + B0B92015298F21F0008E39BA /* SelectableRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0B92014298F21F0008E39BA /* SelectableRowView.swift */; }; BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA693073295D649800ADDB87 /* UserSettingsStore.swift */; }; BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; DD597CBD2963D85A00C64D32 /* MarkdownTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD597CBC2963D85A00C64D32 /* MarkdownTests.swift */; }; @@ -483,6 +485,8 @@ 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreSVG.swift; sourceTree = "<group>"; }; 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoomableScrollView.swift; sourceTree = "<group>"; }; 9609F057296E220800069BF3 /* BannerImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BannerImageView.swift; sourceTree = "<group>"; }; + B02AAD54298CD07300807B3C /* BroadcastToRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BroadcastToRelaysView.swift; sourceTree = "<group>"; }; + B0B92014298F21F0008E39BA /* SelectableRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableRowView.swift; sourceTree = "<group>"; }; BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; }; BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; }; DD597CBC2963D85A00C64D32 /* MarkdownTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarkdownTests.swift; sourceTree = "<group>"; }; @@ -681,6 +685,7 @@ 4C3AC7A628369BA200E1F516 /* SearchHomeView.swift */, 4C5C7E69284EDE2E00A22DF5 /* SearchResultsView.swift */, 4C363AA128296A7E006E126D /* SearchView.swift */, + B0B92014298F21F0008E39BA /* SelectableRowView.swift */, BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */, 4C3AC7A02835A81400E1F516 /* SetupView.swift */, E9E4ED0A295867B900DD7078 /* ThreadV2View.swift */, @@ -758,6 +763,7 @@ 4C06670028FC7C5900038D2A /* RelayView.swift */, 4CAAD8AF29888AD200060CEA /* RelayConfigView.swift */, F7908E91298B0F0700AB113A /* RelayDetailView.swift */, + B02AAD54298CD07300807B3C /* BroadcastToRelaysView.swift */, ); path = Relays; sourceTree = "<group>"; @@ -1119,6 +1125,7 @@ 4C75EFAD28049CFB0006080F /* PostButton.swift in Sources */, 4CB55EF5295E679D007FD187 /* UserRelaysView.swift in Sources */, 4C363AA228296A7E006E126D /* SearchView.swift in Sources */, + B02AAD55298CD07300807B3C /* BroadcastToRelaysView.swift in Sources */, 4CC7AAED297F0B9E00430951 /* Highlight.swift in Sources */, 4C285C8A2838B985008A31F1 /* ProfilePictureSelector.swift in Sources */, 4C75EFB92804A2740006080F /* EventView.swift in Sources */, @@ -1135,6 +1142,7 @@ 647D9A8D2968520300A295DE /* SideMenuView.swift in Sources */, F7F0BA272978E54D009531F3 /* ParicipantsView.swift in Sources */, 4C0A3F91280F6528000448DE /* ChatView.swift in Sources */, + B0B92015298F21F0008E39BA /* SelectableRowView.swift in Sources */, 4CF0ABE32981BC7D00D66079 /* UserView.swift in Sources */, 4CF0ABF029857E9200D66079 /* Bech32Object.swift in Sources */, 4C3D52B8298DB5C6001C5831 /* TextEvent.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -28,12 +28,14 @@ enum Sheets: Identifiable { case post case report(ReportTarget) case reply(NostrEvent) + case broadcast(NostrEvent) var id: String { switch self { case .report: return "report" case .post: return "post" case .reply(let ev): return "reply-" + ev.id + case .broadcast(let ev): return "broadcast-" + ev.id } } } @@ -298,6 +300,8 @@ struct ContentView: View { PostView(replying_to: nil, references: [], damus_state: damus_state!) case .reply(let event): ReplyView(replying_to: event, damus: damus_state!) + case .broadcast(let event): + BroadcastToRelaysView(state: .init(state: damus_state!), broadCastEvent: event) } } .onOpenURL { url in @@ -355,7 +359,7 @@ struct ContentView: View { } .onReceive(handle_notify(.broadcast_event)) { obj in let ev = obj.object as! NostrEvent - self.damus_state?.pool.send(.event(ev)) + self.active_sheet = .broadcast(ev) } .onReceive(handle_notify(.unfollow)) { notif in guard let privkey = self.privkey else { @@ -415,10 +419,10 @@ struct ContentView: View { let post_res = obj.object as! NostrPostResult switch post_res { - case .post(let post): + case .post(let post, onlyToRelayIds: let relayIds): print("post \(post.content)") let new_ev = post_to_event(post: post, privkey: privkey, pubkey: pubkey) - self.damus_state?.pool.send(.event(new_ev)) + self.damus_state?.pool.send(.event(new_ev), to: relayIds) case .cancel: active_sheet = nil print("post cancelled") diff --git a/damus/Nostr/Relay.swift b/damus/Nostr/Relay.swift @@ -7,14 +7,14 @@ import Foundation -public struct RelayInfo: Codable { +public struct RelayInfo: Codable, Hashable { let read: Bool let write: Bool static let rw = RelayInfo(read: true, write: true) } -public struct RelayDescriptor: Codable { +public struct RelayDescriptor: Codable, Hashable { public let url: URL public let info: RelayInfo } diff --git a/damus/Views/DMChatView.swift b/damus/Views/DMChatView.swift @@ -133,8 +133,8 @@ struct DMChatView: View { message = "" - damus_state.pool.send(.event(dm)) end_editing() + damus_state.pool.send(.event(dm)) } var body: some View { diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -8,13 +8,15 @@ import SwiftUI enum NostrPostResult { - case post(NostrPost) + case post(NostrPost, onlyToRelayIds: [String]?) case cancel } let POST_PLACEHOLDER = NSLocalizedString("Type your post here...", comment: "Text box prompt to ask user to type their post.") struct PostView: View { + @State var isPresentingRelaysScreen: Bool = false + @StateObject var relaysScreenState: BroadcastToRelaysView.ViewState @State var post: String = "" @FocusState var focus: Bool @State var showPrivateKeyWarning: Bool = false @@ -25,6 +27,14 @@ struct PostView: View { @Environment(\.presentationMode) var presentationMode + init(replying_to: NostrEvent?, references: [ReferencedId], damus_state: DamusState) { + _relaysScreenState = StateObject(wrappedValue: BroadcastToRelaysView.ViewState(state: damus_state)) + + self.replying_to = replying_to + self.references = references + self.damus_state = damus_state + } + enum FocusField: Hashable { case post } @@ -46,7 +56,9 @@ struct PostView: View { let content = self.post.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) let new_post = NostrPost(content: content, references: references, kind: kind) - NotificationCenter.default.post(name: .post, object: NostrPostResult.post(new_post)) + NotificationCenter.default.post(name: .post, + object: NostrPostResult.post(new_post, + onlyToRelayIds: relaysScreenState.limitingRelayIds)) dismiss() } @@ -55,16 +67,55 @@ struct PostView: View { } var body: some View { - VStack { - HStack { - Button(NSLocalizedString("Cancel", comment: "Button to cancel out of posting a note.")) { - self.cancel() + NavigationView { + VStack { + ZStack(alignment: .topLeading) { + TextEditor(text: $post) + .focused($focus) + .textInputAutocapitalization(.sentences) + + if post.isEmpty { + Text(POST_PLACEHOLDER) + .padding(.top, 8) + .padding(.leading, 4) + .foregroundColor(Color(uiColor: .placeholderText)) + .allowsHitTesting(false) + } } - .foregroundColor(.primary) - - Spacer() - - if !is_post_empty { + + // This if-block observes @ for tagging + if let searching = get_searching_string(post) { + VStack { + Spacer() + UserSearch(damus_state: damus_state, search: searching, post: $post) + }.zIndex(1) + } + } + .onAppear() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.focus = true + } + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(NSLocalizedString("Cancel", comment: "Button to cancel out of posting a note.")) { + self.cancel() + } + .foregroundColor(.primary) + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + HStack { + Button(action: { isPresentingRelaysScreen.toggle() }) { + Image(systemName: "network") + .foregroundColor(.primary) + .overlay { + if relaysScreenState.hasExcludedRelays { + Circle() + .stroke(style: StrokeStyle(lineWidth: 2, lineCap: .round, dash: [8, 5])) + } + } + } + } Button(NSLocalizedString("Post", comment: "Button to post a note.")) { showPrivateKeyWarning = contentContainsPrivateKey(self.post) @@ -72,31 +123,13 @@ struct PostView: View { self.send_post() } } + .disabled(is_post_empty) } } - .padding([.top, .bottom], 4) - - ZStack(alignment: .topLeading) { - TextEditor(text: $post) - .focused($focus) - .textInputAutocapitalization(.sentences) - - if post.isEmpty { - Text(POST_PLACEHOLDER) - .padding(.top, 8) - .padding(.leading, 4) - .foregroundColor(Color(uiColor: .placeholderText)) - .allowsHitTesting(false) - } - } - - // This if-block observes @ for tagging - if let searching = get_searching_string(post) { - VStack { - Spacer() - UserSearch(damus_state: damus_state, search: searching, post: $post) - }.zIndex(1) - } + .sheet(isPresented: $isPresentingRelaysScreen, content: { + BroadcastToRelaysView(state: relaysScreenState, broadCastEvent: nil) + }) + .padding() } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/damus/Views/Relays/BroadcastToRelaysView.swift b/damus/Views/Relays/BroadcastToRelaysView.swift @@ -0,0 +1,123 @@ +// +// BroadcastToRelaysView.swift +// damus +// +// Created by devandsev on 2/4/23. +// + +import SwiftUI + +extension BroadcastToRelaysView { + + class ViewState: ObservableObject { + let damusState: DamusState + @Published var relayRows: [RowState] = [] + + var hasExcludedRelays: Bool { relayRows.contains { !$0.isSelected } } + var selectedRelays: [RelayDescriptor] { relayRows.filter { $0.isSelected }.map { $0.relay } } + var limitingRelayIds: [String]? { + guard hasExcludedRelays else { + return nil + } + return selectedRelays.map { get_relay_id($0.url) } + } + + init(state: DamusState) { + damusState = state + relayRows = state.pool.descriptors.map { RowState(relay: $0, isSelected: true) } + } + } + + struct RowState: Equatable { + let relay: RelayDescriptor + var isSelected: Bool + } +} + +/// A list of relays you can select to send your event to +/// +/// Can be presented in 2 modes: +/// - If `broadCastEvent` is nil, user selects relays, goes to the previous screen and the post/reply will be sent to selected relays only. Presented in this mode from `PostView` and `ReplyView` by tapping relays button. +/// - If `broadCastEvent` is not nil, there will be a "Broadcast" button in the navBar to broadcast this event to selected relays. Presented in this mode by long-pressing a post and choosing "Broadcast". +struct BroadcastToRelaysView: View { + @ObservedObject var state: ViewState + + let broadCastEvent: NostrEvent? + + @Environment(\.presentationMode) var presentationMode + + func selectAll() { + $state.relayRows.forEach { $0.wrappedValue.isSelected = true } + } + + func selectOne() { + guard let firstSelectedRow = $state.relayRows.first(where: { $0.wrappedValue.isSelected }) else { + $state.relayRows.forEach { $0.wrappedValue.isSelected = false } + $state.relayRows.first?.wrappedValue.isSelected = true + return + } + + $state.relayRows.forEach { $0.wrappedValue.isSelected = false } + firstSelectedRow.wrappedValue.isSelected = true + } + + func dismiss() { + self.presentationMode.wrappedValue.dismiss() + } + + var body: some View { + NavigationView { + Form { + Section { + List(Array($state.relayRows), id: \.wrappedValue.relay.url) { $relayRow in + SelectableRowView(isSelected: $relayRow.isSelected, + shouldChangeSelection: { + return !relayRow.isSelected || $state.relayRows.filter { $0.wrappedValue.isSelected }.count > 1 + + } ) { + RelayView(state: state.damusState, relay: relayRow.relay.url.absoluteString) + } + } + } + header: { + HStack { + Text("Relays to send to", comment: "Section header text for relay server list. On this screen user can select to which specific relays the post should be sent.") + Spacer() + Button(NSLocalizedString("All", comment: "Button to select all relays in the list to which the post will be sent")) { + self.selectAll() + } + .disabled(!state.hasExcludedRelays) + Button(NSLocalizedString("One", comment: "Button to select only one relay from the list to which the post will be sent")) { + self.selectOne() + } + .disabled(state.selectedRelays.count == 1) + } + .buttonStyle(.bordered) + } + } + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if broadCastEvent != nil { + Button(action: { + dismiss() + }) { + Text("Cancel", comment: "Navigation bar button to cancel broadcasting the user's note to all of the user's connected relay servers.") + }.foregroundColor(.primary) + } + } + ToolbarItem(placement: .navigationBarTrailing) { + if broadCastEvent != nil { + Button(action: { + if let broadCastEvent = broadCastEvent { + state.damusState.pool.send(.event(broadCastEvent), to: state.limitingRelayIds) + dismiss() + } + }) { + Text("Broadcast", comment: "Navigation bar button to confirm broadcasting the user's note to all of the user's connected relay servers.") + } + } + } + } + } + } +} diff --git a/damus/Views/SelectableRowView.swift b/damus/Views/SelectableRowView.swift @@ -0,0 +1,36 @@ +// +// File.swift +// damus +// +// Created by devandsev on 2/4/23. +// + +import SwiftUI + +struct SelectableRowView <Content: View>: View { + + @Binding var isSelected: Bool + var shouldChangeSelection: () -> Bool + var content: () -> Content + + @available(iOS, deprecated: 16, message: "In iOS 15 List rows selection works only in editing mode; with iOS 16 selection doesn't require Edit button at all. Consider using standard selection mechanism when deployment target is iOS 16") + init(isSelected: Binding<Bool>, shouldChangeSelection: @escaping () -> Bool = { true }, @ViewBuilder content: @escaping () -> Content) { + _isSelected = isSelected + self.shouldChangeSelection = shouldChangeSelection + self.content = content + } + + var body: some View { + HStack { + content() + Spacer() + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + } + .contentShape(Rectangle()) + .onTapGesture { + if shouldChangeSelection() { + isSelected.toggle() + } + } + } +}