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:
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
|
||||
51
web/src/components/Transfers.tsx
Normal file
51
web/src/components/Transfers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user