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

@@ -33,6 +33,8 @@ interface WasteState {
// chat — keyed by room
messages: Record<string, ChatMessage[]>
activeRoom: string
// user-created rooms, keyed by networkId
customRooms: Record<string, string[]>
// file listings — keyed by peer id
fileLists: Record<string, FileEntry[]>
@@ -45,6 +47,12 @@ interface WasteState {
// file browser state
activeFilePeer: string | null
// shared files keyed by networkId
sharedFilesByNetwork: Record<string, Map<string, File>>
// incoming offers awaiting accept/reject: xid → offer info
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
// active in-progress transfers: xid → progress
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
// actions
connect: (url: string) => void
@@ -54,9 +62,13 @@ interface WasteState {
setActiveNetwork: (id: string) => void
setActiveRoom: (room: string) => void
setActiveFilePeer: (peerId: string | null) => void
setSharedFiles: (files: Map<string, File>) => void
setSharedFiles: (files: Map<string, File>, networkId?: string) => void
browseFiles: (peerId: string) => void
sendFileTo: (peerId: string, file: File) => void
acceptOffer: (peerId: string, xid: string, name: string, size: number) => void
rejectOffer: (peerId: string, xid: string) => void
cancelTransfer: (peerId: string, xid: string, direction: 'recv' | 'send') => void
createRoom: (name: string) => void
handleEvent: (msg: IpcMessage) => void
}
@@ -72,10 +84,14 @@ export const useWaste = create<WasteState>((set, get) => ({
connectedPeers: [],
messages: {},
activeRoom: 'general',
customRooms: {},
fileLists: {},
exportedBackup: null,
peerStatus: {},
activeFilePeer: null,
sharedFilesByNetwork: {},
pendingOffers: {},
fileProgress: {},
connect(url: string) {
const adapter = new DaemonAdapter(url)
@@ -116,7 +132,10 @@ export const useWaste = create<WasteState>((set, get) => ({
set({ activeFilePeer: peerId })
},
setSharedFiles(files) {
setSharedFiles(files, networkId) {
const netId = networkId ?? get().activeNetworkId ?? ''
if (!netId) return
set(s => ({ sharedFilesByNetwork: { ...s.sharedFilesByNetwork, [netId]: files } }))
const a = get().adapter
if (a instanceof BrowserAdapter) a.setSharedFiles(files)
},
@@ -126,6 +145,44 @@ export const useWaste = create<WasteState>((set, get) => ({
if (a instanceof BrowserAdapter) a.sendFileTo(peerId, file)
},
acceptOffer(peerId, xid, name, size) {
set(s => {
const p = { ...s.pendingOffers }; delete p[xid]
return { pendingOffers: p, fileProgress: { ...s.fileProgress, [xid]: { peerId, name, received: 0, total: size } } }
})
const a = get().adapter
if (a instanceof BrowserAdapter) a.acceptOffer(peerId, xid, name, size)
},
rejectOffer(peerId, xid) {
set(s => { const p = { ...s.pendingOffers }; delete p[xid]; return { pendingOffers: p } })
const a = get().adapter
if (a instanceof BrowserAdapter) a.rejectOffer(peerId, xid)
},
cancelTransfer(peerId, xid, direction) {
set(s => {
const fp = { ...s.fileProgress }; delete fp[xid]
return { fileProgress: fp }
})
const a = get().adapter
if (a instanceof BrowserAdapter) a.cancelTransfer(peerId, xid, direction)
},
createRoom(name) {
const netId = get().activeNetworkId
if (!netId || !name.trim()) return
const key = name.trim().toLowerCase().replace(/\s+/g, '-')
set(s => {
const existing = s.customRooms[netId] ?? []
if (existing.includes(key)) return s
return {
customRooms: { ...s.customRooms, [netId]: [...existing, key] },
activeRoom: key,
}
})
},
browseFiles(peerId) {
const a = get().adapter
if (a instanceof BrowserAdapter) {
@@ -239,6 +296,30 @@ export const useWaste = create<WasteState>((set, get) => ({
if (msg.backup) set({ exportedBackup: msg.backup })
break
}
case 'incoming_file': {
if (msg.peer_id && msg.offer) {
const { xid, name, size } = msg.offer
set(s => ({ pendingOffers: { ...s.pendingOffers, [xid]: { peerId: String(msg.peer_id), name, size } } }))
}
break
}
case 'file_progress': {
if (msg.transfer_id && msg.peer_id) {
const xid = msg.transfer_id
set(s => ({
fileProgress: {
...s.fileProgress,
[xid]: {
peerId: String(msg.peer_id),
name: msg.offer?.name ?? xid,
received: msg.bytes_received ?? 0,
total: msg.total_bytes ?? 0,
},
},
}))
}
break
}
case 'file_list': {
if (msg.peer_id && msg.files) {
set(s => ({
@@ -248,8 +329,10 @@ export const useWaste = create<WasteState>((set, get) => ({
break
}
case 'file_complete': {
// path holds the blob URL, offer.name holds the filename
if (msg.path && msg.offer?.name) {
// clear progress entry
const xid = msg.offer.xid
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
const a = document.createElement('a')
a.href = msg.path
a.download = msg.offer.name