diff --git a/web/src/App.css b/web/src/App.css index db17295..6adac61 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -55,6 +55,12 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; } .sidebar-section { display: flex; flex-direction: column; padding: 8px 0; border-bottom: 1px solid var(--border); } .sidebar-section:last-child { border-bottom: none; flex: 1; } .sidebar-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); padding: 4px 12px 2px; } +.sidebar-label-row { display: flex; align-items: center; justify-content: space-between; padding-right: 8px; } +.sidebar-label-row .sidebar-label { padding-right: 0; } +.sidebar-add { background: none; color: var(--muted); font-size: 14px; padding: 0 4px; line-height: 1; } +.sidebar-add:hover { color: var(--accent); background: none; } +.sidebar-new-room { padding: 2px 8px 4px; } +.sidebar-new-room input { font-size: 12px; padding: 4px 8px; } .sidebar-item { background: none; color: var(--text); text-align: left; padding: 5px 12px; border-radius: 0; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; } .sidebar-item:hover { background: rgba(255,255,255,0.04); } .sidebar-item.active { background: var(--accent); color: #fff; } @@ -88,6 +94,19 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; } .compose input { flex: 1; width: auto; } .compose button { flex-shrink: 0; } +/* ── transfers panel (in sidebar) ── */ +.transfer-row { display: flex; flex-direction: column; gap: 3px; padding: 6px 12px; border-bottom: 1px solid var(--border); } +.transfer-row:last-child { border-bottom: none; } +.transfer-name { font-size: 12px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.transfer-meta { font-size: 10px; color: var(--muted); } +.transfer-actions { display: flex; gap: 4px; margin-top: 2px; } +.transfer-btn { font-size: 11px; padding: 2px 8px; } +.transfer-btn.accept { background: #3a7a3a; } +.transfer-btn.reject { background: #7a3a3a; } +.transfer-btn:hover { opacity: 0.85; } +.transfer-progress { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 2px; } +.transfer-progress-bar { height: 100%; background: var(--accent); transition: width 0.1s; } + /* ── file browser panel ── */ .chat-layout.has-file-browser { grid-template-columns: var(--sidebar-w) 1fr 280px; } .file-browser { display: flex; flex-direction: column; background: var(--surface); border-left: 1px solid var(--border); } diff --git a/web/src/adapter/browser.ts b/web/src/adapter/browser.ts index 40d9b9c..f442bd8 100644 --- a/web/src/adapter/browser.ts +++ b/web/src/adapter/browser.ts @@ -247,9 +247,11 @@ class PeerConn { private nick: string share: Map - // in-flight receives: xid → {name, size, buf, have, done} - private _recv: Record = {} - // in-flight sends: xid → Uint8Array + // in-flight receives: xid → state + private _recv: Record = {} + // open receive DataChannels for cancellation + private _recvChannels: Record = {} + // in-flight sends: xid → marker private _send: Record = {} // push-initiated files waiting for file-accept: xid → File private _pushQueue: Map = 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 diff --git a/web/src/components/FolderPicker.tsx b/web/src/components/FolderPicker.tsx index bbc0459..cee210b 100644 --- a/web/src/components/FolderPicker.tsx +++ b/web/src/components/FolderPicker.tsx @@ -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(null) - const [label, setLabel] = useState(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) { 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() { diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 92f331c..1085061 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -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() {
- Rooms +
+ Rooms + +
{rooms.map(r => (
{adapterMode === 'browser' && ( @@ -94,6 +121,8 @@ export function Sidebar() { )} + +
Peers · {connectedPeers.length + 1} diff --git a/web/src/components/Transfers.tsx b/web/src/components/Transfers.tsx new file mode 100644 index 0000000..7eb0387 --- /dev/null +++ b/web/src/components/Transfers.tsx @@ -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 ( +
+ Transfers + + {Object.entries(pendingOffers).map(([xid, offer]) => ( +
+ {offer.name} + {fmt(offer.size)} from {alias(offer.peerId)} +
+ + +
+
+ ))} + + {Object.entries(fileProgress).map(([xid, p]) => { + const pct = p.total > 0 ? Math.round((p.received / p.total) * 100) : 0 + return ( +
+ {p.name} + {fmt(p.received)} / {fmt(p.total)} · {alias(p.peerId)} +
+
+
+ +
+ ) + })} +
+ ) +} diff --git a/web/src/store/index.ts b/web/src/store/index.ts index be73784..ddc50a7 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -33,6 +33,8 @@ interface WasteState { // chat — keyed by room messages: Record activeRoom: string + // user-created rooms, keyed by networkId + customRooms: Record // file listings — keyed by peer id fileLists: Record @@ -45,6 +47,12 @@ interface WasteState { // file browser state activeFilePeer: string | null + // shared files keyed by networkId + sharedFilesByNetwork: Record> + // incoming offers awaiting accept/reject: xid → offer info + pendingOffers: Record + // active in-progress transfers: xid → progress + fileProgress: Record // 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) => void + setSharedFiles: (files: Map, 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((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((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((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((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((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