commit 04759107a28a8517c64599c9caf34a76a4420e6e
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
Changelog-Added: Add screen to select individual relays when posting/broadcasting
Closes: #525
Diffstat:
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()
+            }
+        }
+    }
+}