diff --git a/Peered/ContentView.swift b/Peered/ContentView.swift index 78484ea..0375381 100644 --- a/Peered/ContentView.swift +++ b/Peered/ContentView.swift @@ -12,20 +12,34 @@ extension EnvironmentValues { } struct ContentView: View { - @AppStorage("peered_username") private var username: String = "" + @AppStorage("peered_username") private var username: String = "fallback_user" @State private var notes = [Note]() @State private var notesClient: NoteEditingSessionClient? @State private var ownPeer: OwnPeer? var body: some View { NavigationStack { - List(notes) { note in - NavigationLink(note.name) { - let peer = ownPeer ?? .init(peer: .init(displayName: username)) - if ownPeer == nil { - ownPeer = peer + List { + Section("Your notes") { + ForEach(notes) { note in + NavigationLink(note.name) { + let peer = ownPeer ?? .init(peer: .init(displayName: username)) + if ownPeer == nil { + ownPeer = peer + } + return NoteEditorScreen(note: note, peer: peer) + } + } + } + + if let notesClient { + Section("External notes") { + ForEach(notesClient.invitations) { invitation in + NavigationLink(invitation.noteName) { + SharedNoteEditor(invitation: invitation, noteClient: notesClient) + } + } } - return NoteEditorScreen(note: note, peer: peer) } } .environment(\.ownPeer, ownPeer ?? .fallback) @@ -44,7 +58,7 @@ struct ContentView: View { } notesClient?.startBrowsingForNotes() } - .sheet(isPresented: .constant(username.isEmpty)) { + .sheet(isPresented: .constant(username == "fallback_user" || username.isEmpty)) { SetUserNameBottomSheetView(username: $username) } } diff --git a/Peered/NoteEditor/ManageMembersView.swift b/Peered/NoteEditor/ManageMembersView.swift index 55f29a5..8b703e0 100644 --- a/Peered/NoteEditor/ManageMembersView.swift +++ b/Peered/NoteEditor/ManageMembersView.swift @@ -8,6 +8,8 @@ import SwiftUI struct ManageMembersView: View { @Bindable var noteAdvertiser: NoteEditingSessionServer + let noteTitle: String + @Binding var noteContent: String var body: some View { List(noteAdvertiser.visiblePeers) { peer in @@ -17,7 +19,7 @@ struct ManageMembersView: View { Spacer() PeerStateButton(peerState: peer.state) { - noteAdvertiser.invite(peer: peer) + noteAdvertiser.invite(peer: peer, to: .init(title: noteTitle, noteSnapshot: noteContent)) } } } diff --git a/Peered/NoteEditor/NoteEditingSessionServer.swift b/Peered/NoteEditor/NoteEditingSessionServer.swift index 8becc69..d577c38 100644 --- a/Peered/NoteEditor/NoteEditingSessionServer.swift +++ b/Peered/NoteEditor/NoteEditingSessionServer.swift @@ -1,5 +1,6 @@ import MultipeerConnectivity import Foundation +import Combine struct OwnPeer { let peer: MCPeerID @@ -29,6 +30,7 @@ final class NoteEditingSessionServer: NSObject { private let ownPeer: OwnPeer var visiblePeers: [Peer] = [] + let noteChangesEmitter = PassthroughSubject() init(peer: OwnPeer) { ownPeer = peer @@ -36,6 +38,7 @@ final class NoteEditingSessionServer: NSObject { session = .init(peer: peer.peer) super.init() browser.delegate = self + session.delegate = self } func startServer() { @@ -46,14 +49,17 @@ final class NoteEditingSessionServer: NSObject { browser.stopBrowsingForPeers() } - func invite(peer: Peer) { + func invite(peer: Peer, to note: NoteInvitation.NoteContent) { guard peer.state == .available else { return } browser.invitePeer( peer.mcPeer, to: session, - withContext: nil, // FIXME: put note here? + withContext: try! JSONEncoder().encode(note), timeout: 5 ) + + let idxToUpdate = visiblePeers.firstIndex(where: { $0.mcPeer == peer.mcPeer })! + visiblePeers[idxToUpdate].state = .invitationPending } } @@ -73,3 +79,46 @@ extension NoteEditingSessionServer: MCNearbyServiceBrowserDelegate { visiblePeers.remove(at: peerIdx) } } + +extension NoteEditingSessionServer: MCSessionDelegate { + func session( + _ session: MCSession, + peer peerID: MCPeerID, + didChange state: MCSessionState + ) { + + } + + func session( + _ session: MCSession, + didReceive stream: InputStream, + withName streamName: String, + fromPeer peerID: MCPeerID + ) { + + } + + func session( + _ session: MCSession, + didStartReceivingResourceWithName resourceName: String, + fromPeer peerID: MCPeerID, + with progress: Progress + ) { + + } + + func session( + _ session: MCSession, + didFinishReceivingResourceWithName resourceName: String, + fromPeer peerID: MCPeerID, + at localURL: URL?, + withError error: (any Error)? + ) { + + } + + func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) { + guard let note = String(data: data, encoding: .utf8) else { fatalError() } + noteChangesEmitter.send(note) + } +} diff --git a/Peered/NoteEditor/NoteEditorScreen.swift b/Peered/NoteEditor/NoteEditorScreen.swift index 60f0219..ca78c65 100644 --- a/Peered/NoteEditor/NoteEditorScreen.swift +++ b/Peered/NoteEditor/NoteEditorScreen.swift @@ -32,9 +32,16 @@ struct NoteEditorScreen: View { } .sheet(isPresented: $showManageMembers) { NavigationStack { - ManageMembersView(noteAdvertiser: noteAdvertiser) + ManageMembersView( + noteAdvertiser: noteAdvertiser, + noteTitle: note.name, + noteContent: $noteContent + ) } } + .onReceive(noteAdvertiser.noteChangesEmitter) { updatedNote in + self.noteContent = updatedNote + } .onAppear { noteContent = try! String(contentsOf: note.path, encoding: .utf8) noteAdvertiser.startServer() diff --git a/Peered/NoteEditor/SharedNoteEditor.swift b/Peered/NoteEditor/SharedNoteEditor.swift new file mode 100644 index 0000000..1fb6499 --- /dev/null +++ b/Peered/NoteEditor/SharedNoteEditor.swift @@ -0,0 +1,48 @@ +// +// SharedNoteEditor.swift +// Peered +// +// Created by Oskar Chybowski on 07/10/2025. +// +import SwiftUI + +struct SharedNoteEditor: View { + @State var note: String? + @State var sendTask: Task? + @State var invitation: NoteInvitation + @Bindable var noteClient: NoteEditingSessionClient + + init( + invitation: NoteInvitation, + noteClient: NoteEditingSessionClient + ) { + self._invitation = .init(initialValue: invitation) + self._noteClient = .init(noteClient) + } + + var body: some View { + ZStack { + if let note = Binding($note) { + TextEditor(text: note) + } else { + ProgressView { + Text("Fetching note...") + } + } + } + .onChange(of: note) { _, newValue in + sendTask?.cancel() + sendTask = Task { + try? await Task.sleep(nanoseconds: 500000000) + guard let note else { return } + DispatchQueue.main.async { + noteClient.send(note: note, to: invitation.invitatorID) + } + } + } + .onAppear { + invitation.accept() + note = invitation.note.noteSnapshot + } + } +} diff --git a/Peered/NotesList/NoteEditingSessionClient.swift b/Peered/NotesList/NoteEditingSessionClient.swift index c00e3a3..c3795e7 100644 --- a/Peered/NotesList/NoteEditingSessionClient.swift +++ b/Peered/NotesList/NoteEditingSessionClient.swift @@ -1,19 +1,52 @@ -// -// NoteEditingSessionClient.swift -// Peered -// -// Created by Oskar Chybowski on 05/10/2025. -// - - import MultipeerConnectivity import Foundation +struct NoteInvitation: Identifiable { + struct NoteContent: Codable { + let title: String + let noteSnapshot: String + } + + var id: MCPeerID { invitatorID } + let noteName: String + let invitatorID: MCPeerID + let note: NoteContent + private var invitationHandler: ((Bool) -> Void)? + + init( + noteName: String, + invitatorID: MCPeerID, + note: NoteContent, + invitationHandler: ((Bool) -> Void)? = nil + ) { + self.noteName = noteName + self.invitatorID = invitatorID + self.note = note + self.invitationHandler = invitationHandler + } + + mutating func accept() { + invitationHandler?(true) + invitationHandler = nil + } + + mutating func decline() { + invitationHandler?(false) + invitationHandler = nil + } +} + @Observable final class NoteEditingSessionClient: NSObject { private let session: MCSession private let advertiser: MCNearbyServiceAdvertiser private let ownPeer: MCPeerID + + var invitations: [NoteInvitation] = [] { + didSet { + print(invitations) + } + } init(peer: MCPeerID) { ownPeer = peer @@ -38,6 +71,14 @@ final class NoteEditingSessionClient: NSObject { func stopBrowsingForNotes() { advertiser.stopAdvertisingPeer() } + + func send(note: String, to peer: MCPeerID) { + try! session.send( + note.data(using: .utf8)!, + toPeers: [peer], + with: .reliable + ) + } } extension NoteEditingSessionClient: MCNearbyServiceAdvertiserDelegate { @@ -47,8 +88,26 @@ extension NoteEditingSessionClient: MCNearbyServiceAdvertiserDelegate { withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void ) { - DispatchQueue.main.async { - invitationHandler(true, self.session) - } + guard + let context, + let noteContent = try? JSONDecoder().decode(NoteInvitation.NoteContent.self, from: context) + else { fatalError() } + + invitations.append( + .init( + noteName: noteContent.title, + invitatorID: peerID, + note: noteContent, + invitationHandler: { [weak self, invitationHandler] accepted in + guard let self else { return } + invitationHandler(accepted, self.session) + + DispatchQueue.main.async { + guard let idx = self.invitations.firstIndex(where: { $0.id == peerID }) else { return } + self.invitations.remove(at: idx) + } + } + ) + ) } } diff --git a/Peered/NotesList/NoteInvitationView.swift b/Peered/NotesList/NoteInvitationView.swift new file mode 100644 index 0000000..f210ee9 --- /dev/null +++ b/Peered/NotesList/NoteInvitationView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct NoteInvitationView: View { + @Binding var invitation: NoteInvitation + let joinTapped: () -> Void + + var body: some View { + HStack { + Text(invitation.noteName) + Spacer() + Button("Join", action: joinTapped) + } + } +}