Add rooms, per-network file sharing, and file transfer UX

Additional rooms:
- Store tracks customRooms per networkId; createRoom action slugifies and
  deduplicates, switches active room on creation
- Sidebar: + button next to Rooms label opens an inline input; Escape
  dismisses; Enter creates the room

Per-network share directories:
- Store tracks sharedFilesByNetwork keyed by networkId instead of a single
  global map; setSharedFiles(files, networkId?) defaults to activeNetworkId
- FolderPicker reads per-network map to display accurate file count label

File transfer UX:
- Incoming file-offer now emits pending_offer instead of auto-accepting;
  user sees Accept / Reject buttons in the sidebar Transfers section
- Progress events emitted per chunk during receive; Transfers panel shows
  a live progress bar with received/total sizes
- file-cancel protocol message: sender or receiver can cancel an in-flight
  transfer; DataChannel is closed and buffers discarded
- PeerConn: acceptOffer(), rejectOffer(), cancelRecv(), cancelSend()
- BrowserAdapter: acceptOffer(), rejectOffer(), cancelTransfer()
- Store: pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer
- Transfers component renders in sidebar; auto-hides when no activity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-25 20:49:15 +02:00
parent 5421352e62
commit fb14ca82af
6 changed files with 269 additions and 17 deletions

View File

@@ -247,9 +247,11 @@ class PeerConn {
private nick: string
share: Map<string, File>
// in-flight receives: xid → {name, size, buf, have, done}
private _recv: Record<string, { name: string; size: number; sha: string; buf: ArrayBuffer[]; have: number; done: boolean }> = {}
// in-flight sends: xid → Uint8Array
// in-flight receives: xid → state
private _recv: Record<string, { name: string; size: number; sha: string; buf: ArrayBuffer[]; have: number; done: boolean; cancelled?: boolean }> = {}
// open receive DataChannels for cancellation
private _recvChannels: Record<string, RTCDataChannel> = {}
// in-flight sends: xid → marker
private _send: Record<string, Uint8Array> = {}
// push-initiated files waiting for file-accept: xid → File
private _pushQueue: Map<string, File> = new Map()
@@ -374,13 +376,16 @@ class PeerConn {
if (!(ev.data instanceof ArrayBuffer)) return
rx.buf.push(ev.data)
rx.have += ev.data.byteLength
this.on('file_progress', { peer: this.peerId, xid, name: rx.name, received: rx.have, total: rx.size })
}
channel.onclose = async () => {
if (rx.cancelled) { delete this._recv[xid]; return }
const blob = new Blob(rx.buf)
const url = URL.createObjectURL(blob)
this.on('file_recv', { peer: this.peerId, name: rx.name, size: rx.size, url })
delete this._recv[xid]
}
this._recvChannels[xid] = channel
}
}
@@ -432,14 +437,21 @@ class PeerConn {
const name = m['name'] as string
const size = m['size'] as number
const xid = m['xid'] as string
this._recv[xid] = { name, size, sha: '', buf: [], have: 0, done: false }
this._dc({ type: 'file-accept', xid })
this.on('file_offer', { peer: this.peerId, name, size, xid })
} else if (m['type'] === 'file-accept') {
const xid = m['xid'] as string
if (this._pushQueue.has(xid) || this.share.has(xid)) void this._stream(xid)
} else if (m['type'] === 'file-done') {
const xid = m['xid'] as string
if (this._recv[xid]) this._recv[xid].done = true
} else if (m['type'] === 'file-cancel') {
const xid = m['xid'] as string
if (this._recv[xid]) {
this._recv[xid].cancelled = true
this._recvChannels[xid]?.close()
delete this._recvChannels[xid]
this.on('file_cancelled', { peer: this.peerId, xid })
}
}
}
@@ -451,6 +463,28 @@ class PeerConn {
this._dc({ type: 'get', name })
}
acceptOffer(xid: string, name: string, size: number) {
this._recv[xid] = { name, size, sha: '', buf: [], have: 0, done: false }
this._dc({ type: 'file-accept', xid })
}
rejectOffer(xid: string) {
this._dc({ type: 'file-cancel', xid })
}
cancelRecv(xid: string) {
if (this._recv[xid]) this._recv[xid].cancelled = true
this._recvChannels[xid]?.close()
delete this._recvChannels[xid]
this._dc({ type: 'file-cancel', xid })
}
cancelSend(xid: string) {
this._pushQueue.delete(xid)
delete this._send[xid]
this._dc({ type: 'file-cancel', xid })
}
sendFilePush(file: File) {
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
this._pushQueue.set(xid, file)
@@ -738,6 +772,27 @@ export class BrowserAdapter {
candidate_type: data['candidateType'] as import('../types').CandidateType,
remote_address: data['remoteAddress'] as string,
})
} else if (event === 'file_offer') {
this.emit({
type: 'incoming_file',
peer_id: data['peer'] as string,
offer: { xid: data['xid'] as string, name: data['name'] as string, size: data['size'] as number, sha256: '' },
})
} else if (event === 'file_progress') {
this.emit({
type: 'file_progress',
peer_id: data['peer'] as string,
transfer_id: data['xid'] as string,
offer: { xid: data['xid'] as string, name: data['name'] as string, size: data['total'] as number, sha256: '' },
bytes_received: data['received'] as number,
total_bytes: data['total'] as number,
})
} else if (event === 'file_cancelled') {
this.emit({
type: 'error',
peer_id: data['peer'] as string,
error_message: `transfer ${(data['xid'] as string).slice(0, 8)} cancelled`,
})
} else if (event === 'files') {
const raw = data['files'] as Array<{ name: string; size: number; mime?: string }>
this.emit({
@@ -772,6 +827,19 @@ export class BrowserAdapter {
this.peers.get(peerId)?.sendFilePush(file)
}
acceptOffer(peerId: string, xid: string, name: string, size: number) {
this.peers.get(peerId)?.acceptOffer(xid, name, size)
}
rejectOffer(peerId: string, xid: string) {
this.peers.get(peerId)?.rejectOffer(xid)
}
cancelTransfer(peerId: string, xid: string, direction: 'recv' | 'send') {
if (direction === 'recv') this.peers.get(peerId)?.cancelRecv(xid)
else this.peers.get(peerId)?.cancelSend(xid)
}
send(msg: IpcMessage) {
if (!this.identity) return