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

@@ -1,10 +1,14 @@
import { useRef, useState } from 'react'
import { useRef } from 'react'
import { useWaste } from '../store'
export function FolderPicker() {
const { setSharedFiles } = useWaste()
const { setSharedFiles, activeNetworkId, sharedFilesByNetwork } = useWaste()
const inputRef = useRef<HTMLInputElement>(null)
const [label, setLabel] = useState<string | null>(null)
const current = activeNetworkId ? sharedFilesByNetwork[activeNetworkId] : undefined
const label = current && current.size > 0
? `${current.size} file${current.size !== 1 ? 's' : ''} shared`
: null
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const fileList = e.target.files
@@ -15,9 +19,7 @@ export function FolderPicker() {
map.set(f.name, f)
}
setSharedFiles(map)
const first = fileList[0]
const parts = (first.webkitRelativePath || first.name).split('/')
setLabel(`${parts[0]} (${map.size} file${map.size !== 1 ? 's' : ''})`)
e.target.value = ''
}
return (
@@ -34,7 +36,7 @@ export function FolderPicker() {
<button
className="folder-picker-btn"
onClick={() => inputRef.current?.click()}
title="Share a folder with peers"
title="Share a folder with peers on this network"
>
{label ?? '+ Share folder'}
</button>

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useWaste } from '../store'
import type { PeerStatus } from '../store'
import { FolderPicker } from './FolderPicker'
import { Transfers } from './Transfers'
function makeYawCard(id: string, alias: string): string {
const nick = encodeURIComponent(alias.trim().slice(0, 40))
@@ -30,13 +32,24 @@ export function Sidebar() {
networks, activeNetworkId, activeRoom,
connectedPeers, peerStatus,
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
customRooms, createRoom,
} = useWaste()
const [addingRoom, setAddingRoom] = useState(false)
const [newRoomName, setNewRoomName] = useState('')
const rooms = ['general']
const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : []
const rooms = ['general', ...netCustomRooms]
Object.keys(messages).forEach(r => {
if (r.startsWith('dm:') && !rooms.includes(r)) rooms.push(r)
})
function submitNewRoom(e: React.FormEvent) {
e.preventDefault()
if (newRoomName.trim()) createRoom(newRoomName)
setNewRoomName('')
setAddingRoom(false)
}
const displayAlias = localPeer?.alias ?? masterAlias ?? ''
const displayId = localPeer?.id ?? masterId ?? ''
const card = displayId ? makeYawCard(displayId, displayAlias) : null
@@ -75,7 +88,10 @@ export function Sidebar() {
</div>
<div className="sidebar-section">
<span className="sidebar-label">Rooms</span>
<div className="sidebar-label-row">
<span className="sidebar-label">Rooms</span>
<button className="sidebar-add" onClick={() => setAddingRoom(v => !v)} title="New room">+</button>
</div>
{rooms.map(r => (
<button
key={r}
@@ -85,6 +101,17 @@ export function Sidebar() {
{r.startsWith('dm:') ? `@ ${r.slice(3, 11)}` : `# ${r}`}
</button>
))}
{addingRoom && (
<form className="sidebar-new-room" onSubmit={submitNewRoom}>
<input
autoFocus
value={newRoomName}
onChange={e => setNewRoomName(e.target.value)}
placeholder="room-name"
onKeyDown={e => e.key === 'Escape' && (setAddingRoom(false), setNewRoomName(''))}
/>
</form>
)}
</div>
{adapterMode === 'browser' && (
@@ -94,6 +121,8 @@ export function Sidebar() {
</div>
)}
<Transfers />
<div className="sidebar-section sidebar-peers">
<span className="sidebar-label">Peers · {connectedPeers.length + 1}</span>

View File

@@ -0,0 +1,51 @@
import { useWaste } from '../store'
function fmt(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function Transfers() {
const { pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
const hasPending = Object.keys(pendingOffers).length > 0
const hasActive = Object.keys(fileProgress).length > 0
if (!hasPending && !hasActive) return null
function alias(peerId: string) {
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
}
return (
<div className="sidebar-section">
<span className="sidebar-label">Transfers</span>
{Object.entries(pendingOffers).map(([xid, offer]) => (
<div key={xid} className="transfer-row">
<span className="transfer-name" title={offer.name}>{offer.name}</span>
<span className="transfer-meta">{fmt(offer.size)} from {alias(offer.peerId)}</span>
<div className="transfer-actions">
<button className="transfer-btn accept" onClick={() => acceptOffer(offer.peerId, xid, offer.name, offer.size)}>Accept</button>
<button className="transfer-btn reject" onClick={() => rejectOffer(offer.peerId, xid)}>Reject</button>
</div>
</div>
))}
{Object.entries(fileProgress).map(([xid, p]) => {
const pct = p.total > 0 ? Math.round((p.received / p.total) * 100) : 0
return (
<div key={xid} className="transfer-row">
<span className="transfer-name" title={p.name}>{p.name}</span>
<span className="transfer-meta">{fmt(p.received)} / {fmt(p.total)} · {alias(p.peerId)}</span>
<div className="transfer-progress">
<div className="transfer-progress-bar" style={{ width: `${pct}%` }} />
</div>
<button className="transfer-btn reject" onClick={() => cancelTransfer(p.peerId, xid, 'recv')}>Cancel</button>
</div>
)
})}
</div>
)
}