Add peer-to-peer file transfer in browser mode
Implements both pull (browse & download from a shared folder) and push (send a file directly to a peer) using the yaw2 file protocol over WebRTC DataChannels. - PeerConn: handle browse/files/get/file-offer/file-accept/file-done control messages; stream files in 64KB chunks over f:xid binary DataChannels; auto-accept incoming offers (push and pull) - PeerConn.sendFilePush(): initiate an outbound file transfer without requiring the peer to browse first - BrowserAdapter: sharedFiles map, setSharedFiles(), requestBrowse(), requestGet(), sendFileTo() — propagates share map to all active peers - Store: activeFilePeer, setActiveFilePeer, setSharedFiles, browseFiles, sendFileTo; file_complete handler triggers browser download automatically - FileBrowser component: third-column panel listing a peer's shared files with name, size, and a download button - FolderPicker component: webkitdirectory input in the sidebar (browser mode only) for selecting a local folder to share - Sidebar: 📎 send-file button on peer rows (hover) with inline file picker - App.tsx: use browser mode when WASTE_CONFIG.signalURL is set, even on localhost — allows testing browser mode via npm run dev with config.js - deploy-daemon.sh: kill existing daemon before scp to avoid upload failure when the binary is locked; remove duplicate kill block in restart step Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -81,9 +81,30 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
|
|||||||
.message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
|
.message { display: flex; align-items: baseline; gap: 0; padding: 2px 16px; line-height: 1.5; }
|
||||||
.message:hover { background: rgba(255,255,255,0.02); }
|
.message:hover { background: rgba(255,255,255,0.02); }
|
||||||
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 52px; }
|
.message-ts { color: var(--muted); font-size: 11px; white-space: nowrap; flex-shrink: 0; width: 52px; }
|
||||||
.message-alias { font-weight: 600; font-size: 13px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; width: 90px; padding-right: 10px; }
|
.message-alias { font-weight: 600; font-size: 13px; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex-shrink: 0; width: 90px; padding-right: 10px; }
|
||||||
.message.mine .message-alias { color: var(--accent); }
|
.message.mine .message-alias { color: var(--accent); }
|
||||||
.message-text { word-break: break-word; font-size: 14px; color: var(--text); }
|
.message-text { word-break: break-word; font-size: 14px; color: var(--text); }
|
||||||
.compose { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border); }
|
.compose { display: flex; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border); }
|
||||||
.compose input { flex: 1; width: auto; }
|
.compose input { flex: 1; width: auto; }
|
||||||
.compose button { flex-shrink: 0; }
|
.compose button { flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ── 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); }
|
||||||
|
.file-browser-header { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.file-browser-title { font-size: 13px; font-weight: 600; }
|
||||||
|
.file-browser-close { background: none; color: var(--muted); font-size: 14px; padding: 2px 6px; }
|
||||||
|
.file-browser-close:hover { color: var(--text); background: none; }
|
||||||
|
.file-browser-empty { padding: 16px 12px; color: var(--muted); font-size: 13px; }
|
||||||
|
.file-list { list-style: none; padding: 4px 0; overflow-y: auto; flex: 1; }
|
||||||
|
.file-entry { display: flex; align-items: center; gap: 6px; padding: 5px 12px; }
|
||||||
|
.file-entry:hover { background: rgba(255,255,255,0.04); }
|
||||||
|
.file-entry-name { flex: 1; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.file-entry-size { color: var(--muted); font-size: 11px; flex-shrink: 0; }
|
||||||
|
.file-entry-dl { background: none; color: var(--accent); font-size: 14px; padding: 1px 4px; flex-shrink: 0; }
|
||||||
|
.file-entry-dl:hover { background: rgba(124,106,247,0.15); }
|
||||||
|
|
||||||
|
/* ── folder picker ── */
|
||||||
|
.folder-picker { width: 100%; }
|
||||||
|
.folder-picker-btn { background: var(--surface); color: var(--muted); border: 1px solid var(--border); font-size: 12px; padding: 5px 10px; border-radius: 4px; width: 100%; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.folder-picker-btn:hover { color: var(--text); border-color: var(--accent); background: var(--surface); }
|
||||||
|
|||||||
@@ -4,19 +4,20 @@ import { Onboarding } from './pages/Onboarding'
|
|||||||
import { Chat } from './pages/Chat'
|
import { Chat } from './pages/Chat'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
// When served from the anchor (non-localhost), default to browser mode.
|
|
||||||
// When running locally, try the daemon first.
|
|
||||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||||
const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338'
|
const DAEMON_WS = import.meta.env.VITE_DAEMON_WS ?? 'ws://127.0.0.1:17338'
|
||||||
|
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } }).WASTE_CONFIG
|
||||||
|
// Use browser mode if a signal URL is configured, even on localhost
|
||||||
|
const useBrowser = !isLocal || !!cfg?.signalURL
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
|
const { connect, connectBrowser, daemonStatus, localPeer } = useWaste()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLocal) {
|
if (useBrowser) {
|
||||||
connect(DAEMON_WS)
|
|
||||||
} else {
|
|
||||||
connectBrowser()
|
connectBrowser()
|
||||||
|
} else {
|
||||||
|
connect(DAEMON_WS)
|
||||||
}
|
}
|
||||||
}, [connect, connectBrowser])
|
}, [connect, connectBrowser])
|
||||||
|
|
||||||
|
|||||||
@@ -245,13 +245,22 @@ class PeerConn {
|
|||||||
public peerId: string
|
public peerId: string
|
||||||
private on: PeerCallback
|
private on: PeerCallback
|
||||||
private nick: string
|
private nick: string
|
||||||
|
share: Map<string, File>
|
||||||
|
|
||||||
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string) {
|
// 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
|
||||||
|
private _send: Record<string, Uint8Array> = {}
|
||||||
|
// push-initiated files waiting for file-accept: xid → File
|
||||||
|
private _pushQueue: Map<string, File> = new Map()
|
||||||
|
|
||||||
|
constructor(identity: Identity, sig: Signaling, peerId: string, on: PeerCallback, nick: string, share: Map<string, File>) {
|
||||||
this.identity = identity
|
this.identity = identity
|
||||||
this.sig = sig
|
this.sig = sig
|
||||||
this.peerId = peerId
|
this.peerId = peerId
|
||||||
this.on = on
|
this.on = on
|
||||||
this.nick = nick
|
this.nick = nick
|
||||||
|
this.share = share
|
||||||
this.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] })
|
this.pc = new RTCPeerConnection({ iceServers: [{ urls: STUN }] })
|
||||||
const kp = sodium.crypto_box_keypair()
|
const kp = sodium.crypto_box_keypair()
|
||||||
this._esk = kp.privateKey
|
this._esk = kp.privateKey
|
||||||
@@ -349,11 +358,30 @@ class PeerConn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _wire(channel: RTCDataChannel) {
|
private _wire(channel: RTCDataChannel) {
|
||||||
if (channel.label !== 'yaw') return
|
if (channel.label === 'yaw') {
|
||||||
this.dc = channel
|
this.dc = channel
|
||||||
channel.onopen = () => this._sendHello()
|
channel.onopen = () => this._sendHello()
|
||||||
channel.onmessage = (ev) => this._onControl(ev.data)
|
channel.onmessage = (ev) => this._onControl(ev.data)
|
||||||
if (channel.readyState === 'open') this._sendHello()
|
if (channel.readyState === 'open') this._sendHello()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (channel.label.startsWith('f:')) {
|
||||||
|
const xid = channel.label.slice(2)
|
||||||
|
const rx = this._recv[xid]
|
||||||
|
if (!rx) return
|
||||||
|
channel.binaryType = 'arraybuffer'
|
||||||
|
channel.onmessage = (ev) => {
|
||||||
|
if (!(ev.data instanceof ArrayBuffer)) return
|
||||||
|
rx.buf.push(ev.data)
|
||||||
|
rx.have += ev.data.byteLength
|
||||||
|
}
|
||||||
|
channel.onclose = async () => {
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _sendHello() {
|
private _sendHello() {
|
||||||
@@ -385,9 +413,73 @@ class PeerConn {
|
|||||||
})
|
})
|
||||||
} else if (m['type'] === 'pm') {
|
} else if (m['type'] === 'pm') {
|
||||||
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
|
this.on('pm', { peer: this.peerId, text: m['text'] as string, ts: m['ts'] as number || Date.now() })
|
||||||
|
} else if (m['type'] === 'browse') {
|
||||||
|
const path = (m['path'] as string) || '/'
|
||||||
|
void path // path-based subdirs not supported; always return root
|
||||||
|
const files = Array.from(this.share.values()).map(f => ({
|
||||||
|
name: f.name, size: f.size, mime: f.type
|
||||||
|
}))
|
||||||
|
this._dc({ type: 'files', files })
|
||||||
|
} else if (m['type'] === 'files') {
|
||||||
|
const files = m['files'] as Array<{ name: string; size: number; mime?: string }>
|
||||||
|
this.on('files', { peer: this.peerId, files })
|
||||||
|
} else if (m['type'] === 'get') {
|
||||||
|
const name = m['name'] as string
|
||||||
|
const file = this.share.get(name)
|
||||||
|
if (!file) return
|
||||||
|
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid: name })
|
||||||
|
} else if (m['type'] === 'file-offer') {
|
||||||
|
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 })
|
||||||
|
} 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestBrowse() {
|
||||||
|
this._dc({ type: 'browse', path: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
requestGet(name: string) {
|
||||||
|
this._dc({ type: 'get', name })
|
||||||
|
}
|
||||||
|
|
||||||
|
sendFilePush(file: File) {
|
||||||
|
const xid = `push-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||||
|
this._pushQueue.set(xid, file)
|
||||||
|
this._dc({ type: 'file-offer', name: file.name, size: file.size, xid })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _stream(xid: string) {
|
||||||
|
const file = this._pushQueue.get(xid) ?? this.share.get(xid)
|
||||||
|
if (!file) return
|
||||||
|
this._pushQueue.delete(xid)
|
||||||
|
const dc = this.pc.createDataChannel(`f:${xid}`)
|
||||||
|
dc.binaryType = 'arraybuffer'
|
||||||
|
this._send[xid] = new Uint8Array(0) // marker
|
||||||
|
await new Promise<void>(res => { dc.onopen = () => res() })
|
||||||
|
const CHUNK = 64 * 1024
|
||||||
|
const buf = await file.arrayBuffer()
|
||||||
|
let offset = 0
|
||||||
|
while (offset < buf.byteLength) {
|
||||||
|
while (dc.bufferedAmount > 1024 * 1024) {
|
||||||
|
await new Promise(r => setTimeout(r, 10))
|
||||||
|
}
|
||||||
|
dc.send(buf.slice(offset, offset + CHUNK))
|
||||||
|
offset += CHUNK
|
||||||
|
}
|
||||||
|
dc.close()
|
||||||
|
delete this._send[xid]
|
||||||
|
this._dc({ type: 'file-done', xid })
|
||||||
|
}
|
||||||
|
|
||||||
async reportStats() {
|
async reportStats() {
|
||||||
try {
|
try {
|
||||||
const stats = await this.pc.getStats()
|
const stats = await this.pc.getStats()
|
||||||
@@ -449,6 +541,7 @@ export class BrowserAdapter {
|
|||||||
private present: Set<string> = new Set()
|
private present: Set<string> = new Set()
|
||||||
private networkId = ''
|
private networkId = ''
|
||||||
private networkName = ''
|
private networkName = ''
|
||||||
|
sharedFiles: Map<string, File> = new Map()
|
||||||
|
|
||||||
status: Status = 'disconnected'
|
status: Status = 'disconnected'
|
||||||
onStatusChange?: (s: Status) => void
|
onStatusChange?: (s: Status) => void
|
||||||
@@ -578,7 +671,7 @@ export class BrowserAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const peer = new PeerConn(this.identity, this.sig!, pid,
|
const peer = new PeerConn(this.identity, this.sig!, pid,
|
||||||
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
|
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles)
|
||||||
this.peers.set(pid, peer)
|
this.peers.set(pid, peer)
|
||||||
await peer.startOffer()
|
await peer.startOffer()
|
||||||
}
|
}
|
||||||
@@ -588,7 +681,7 @@ export class BrowserAdapter {
|
|||||||
let peer = this.peers.get(from)
|
let peer = this.peers.get(from)
|
||||||
if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') {
|
if (!peer || peer.pc.connectionState === 'failed' || peer.pc.connectionState === 'closed') {
|
||||||
peer = new PeerConn(this.identity, this.sig!, from,
|
peer = new PeerConn(this.identity, this.sig!, from,
|
||||||
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick)
|
(ev, data) => this._onPeerEvent(ev, data), this.identity.nick, this.sharedFiles)
|
||||||
this.peers.set(from, peer)
|
this.peers.set(from, peer)
|
||||||
}
|
}
|
||||||
await peer.onBox(box)
|
await peer.onBox(box)
|
||||||
@@ -645,9 +738,40 @@ export class BrowserAdapter {
|
|||||||
candidate_type: data['candidateType'] as import('../types').CandidateType,
|
candidate_type: data['candidateType'] as import('../types').CandidateType,
|
||||||
remote_address: data['remoteAddress'] as string,
|
remote_address: data['remoteAddress'] as string,
|
||||||
})
|
})
|
||||||
|
} else if (event === 'files') {
|
||||||
|
const raw = data['files'] as Array<{ name: string; size: number; mime?: string }>
|
||||||
|
this.emit({
|
||||||
|
type: 'file_list',
|
||||||
|
peer_id: data['peer'] as string,
|
||||||
|
files: raw.map(f => ({ name: f.name, size_bytes: f.size })),
|
||||||
|
})
|
||||||
|
} else if (event === 'file_recv') {
|
||||||
|
this.emit({
|
||||||
|
type: 'file_complete',
|
||||||
|
peer_id: data['peer'] as string,
|
||||||
|
path: data['url'] as string,
|
||||||
|
offer: { xid: data['name'] as string, name: data['name'] as string, size: data['size'] as number, sha256: '' },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSharedFiles(files: Map<string, File>) {
|
||||||
|
this.sharedFiles = files
|
||||||
|
this.peers.forEach(p => { p.share = files })
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBrowse(peerId: string) {
|
||||||
|
this.peers.get(peerId)?.requestBrowse()
|
||||||
|
}
|
||||||
|
|
||||||
|
requestGet(peerId: string, name: string) {
|
||||||
|
this.peers.get(peerId)?.requestGet(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendFileTo(peerId: string, file: File) {
|
||||||
|
this.peers.get(peerId)?.sendFilePush(file)
|
||||||
|
}
|
||||||
|
|
||||||
send(msg: IpcMessage) {
|
send(msg: IpcMessage) {
|
||||||
if (!this.identity) return
|
if (!this.identity) return
|
||||||
|
|
||||||
|
|||||||
48
web/src/components/FileBrowser.tsx
Normal file
48
web/src/components/FileBrowser.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useWaste } from '../store'
|
||||||
|
import { BrowserAdapter } from '../adapter/browser'
|
||||||
|
|
||||||
|
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 FileBrowser() {
|
||||||
|
const { activeFilePeer, fileLists, setActiveFilePeer, adapter, connectedPeers } = useWaste()
|
||||||
|
|
||||||
|
if (!activeFilePeer) return null
|
||||||
|
|
||||||
|
const peer = connectedPeers.find(p => p.id === activeFilePeer)
|
||||||
|
const files = fileLists[activeFilePeer] ?? null
|
||||||
|
|
||||||
|
function download(name: string) {
|
||||||
|
if (adapter instanceof BrowserAdapter) {
|
||||||
|
adapter.requestGet(activeFilePeer!, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-browser">
|
||||||
|
<div className="file-browser-header">
|
||||||
|
<span className="file-browser-title">Files · {peer?.alias ?? activeFilePeer.slice(0, 8)}</span>
|
||||||
|
<button className="file-browser-close" onClick={() => setActiveFilePeer(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files === null ? (
|
||||||
|
<div className="file-browser-empty">Loading…</div>
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<div className="file-browser-empty">No files shared</div>
|
||||||
|
) : (
|
||||||
|
<ul className="file-list">
|
||||||
|
{files.map(f => (
|
||||||
|
<li key={f.name} className="file-entry">
|
||||||
|
<span className="file-entry-name">{f.name}</span>
|
||||||
|
<span className="file-entry-size">{fmt(f.size_bytes)}</span>
|
||||||
|
<button className="file-entry-dl" onClick={() => download(f.name)}>↓</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
web/src/components/FolderPicker.tsx
Normal file
43
web/src/components/FolderPicker.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { useWaste } from '../store'
|
||||||
|
|
||||||
|
export function FolderPicker() {
|
||||||
|
const { setSharedFiles } = useWaste()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [label, setLabel] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const fileList = e.target.files
|
||||||
|
if (!fileList || fileList.length === 0) return
|
||||||
|
const map = new Map<string, File>()
|
||||||
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
const f = fileList[i]
|
||||||
|
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' : ''})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="folder-picker">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
// @ts-expect-error webkitdirectory is non-standard
|
||||||
|
webkitdirectory=""
|
||||||
|
multiple
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="folder-picker-btn"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
title="Share a folder with peers"
|
||||||
|
>
|
||||||
|
{label ?? '+ Share folder'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useWaste } from '../store'
|
import { useWaste } from '../store'
|
||||||
import type { PeerStatus } from '../store'
|
import type { PeerStatus } from '../store'
|
||||||
|
import { FolderPicker } from './FolderPicker'
|
||||||
|
|
||||||
function makeYawCard(id: string, alias: string): string {
|
function makeYawCard(id: string, alias: string): string {
|
||||||
const nick = encodeURIComponent(alias.trim().slice(0, 40))
|
const nick = encodeURIComponent(alias.trim().slice(0, 40))
|
||||||
@@ -28,7 +29,7 @@ export function Sidebar() {
|
|||||||
localPeer, masterId, masterAlias,
|
localPeer, masterId, masterAlias,
|
||||||
networks, activeNetworkId, activeRoom,
|
networks, activeNetworkId, activeRoom,
|
||||||
connectedPeers, peerStatus,
|
connectedPeers, peerStatus,
|
||||||
setActiveRoom, setActiveNetwork, messages, send,
|
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
|
||||||
} = useWaste()
|
} = useWaste()
|
||||||
|
|
||||||
const rooms = ['general']
|
const rooms = ['general']
|
||||||
@@ -45,7 +46,7 @@ export function Sidebar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requestFiles(peerId: string) {
|
function requestFiles(peerId: string) {
|
||||||
send({ type: 'get_file_list', network_id: activeNetworkId ?? undefined, peer_id: peerId })
|
browseFiles(peerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -86,6 +87,13 @@ export function Sidebar() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{adapterMode === 'browser' && (
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<span className="sidebar-label">Sharing</span>
|
||||||
|
<div style={{ padding: '4px 12px' }}><FolderPicker /></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="sidebar-section sidebar-peers">
|
<div className="sidebar-section sidebar-peers">
|
||||||
<span className="sidebar-label">Peers · {connectedPeers.length + 1}</span>
|
<span className="sidebar-label">Peers · {connectedPeers.length + 1}</span>
|
||||||
|
|
||||||
@@ -116,7 +124,15 @@ export function Sidebar() {
|
|||||||
<span className="peer-row-id">{p.id.slice(0, 8)}</span>
|
<span className="peer-row-id">{p.id.slice(0, 8)}</span>
|
||||||
<span className="peer-row-actions">
|
<span className="peer-row-actions">
|
||||||
<button className="peer-action" onClick={() => openDM(p.id)} title="DM">↩</button>
|
<button className="peer-action" onClick={() => openDM(p.id)} title="DM">↩</button>
|
||||||
<button className="peer-action" onClick={() => requestFiles(p.id)} title="Files">⊞</button>
|
<button className="peer-action" onClick={() => requestFiles(p.id)} title="Browse files">⊞</button>
|
||||||
|
<label className="peer-action" title="Send file" style={{ cursor: 'pointer' }}>
|
||||||
|
📎
|
||||||
|
<input type="file" style={{ display: 'none' }} onChange={e => {
|
||||||
|
const f = e.target.files?.[0]
|
||||||
|
if (f) sendFileTo(p.id, f)
|
||||||
|
e.target.value = ''
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Sidebar } from '../components/Sidebar'
|
import { Sidebar } from '../components/Sidebar'
|
||||||
import { MessagePane } from '../components/MessagePane'
|
import { MessagePane } from '../components/MessagePane'
|
||||||
|
import { FileBrowser } from '../components/FileBrowser'
|
||||||
|
import { useWaste } from '../store'
|
||||||
|
|
||||||
export function Chat() {
|
export function Chat() {
|
||||||
|
const { activeFilePeer } = useWaste()
|
||||||
return (
|
return (
|
||||||
<div className="chat-layout">
|
<div className={`chat-layout${activeFilePeer ? ' has-file-browser' : ''}`}>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<MessagePane />
|
<MessagePane />
|
||||||
|
{activeFilePeer && <FileBrowser />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ interface WasteState {
|
|||||||
// identity backup export result (cleared when consumed)
|
// identity backup export result (cleared when consumed)
|
||||||
exportedBackup: string | null
|
exportedBackup: string | null
|
||||||
|
|
||||||
|
// file browser state
|
||||||
|
activeFilePeer: string | null
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
connect: (url: string) => void
|
connect: (url: string) => void
|
||||||
connectBrowser: () => void
|
connectBrowser: () => void
|
||||||
@@ -50,6 +53,10 @@ interface WasteState {
|
|||||||
send: (msg: IpcMessage) => void
|
send: (msg: IpcMessage) => void
|
||||||
setActiveNetwork: (id: string) => void
|
setActiveNetwork: (id: string) => void
|
||||||
setActiveRoom: (room: string) => void
|
setActiveRoom: (room: string) => void
|
||||||
|
setActiveFilePeer: (peerId: string | null) => void
|
||||||
|
setSharedFiles: (files: Map<string, File>) => void
|
||||||
|
browseFiles: (peerId: string) => void
|
||||||
|
sendFileTo: (peerId: string, file: File) => void
|
||||||
handleEvent: (msg: IpcMessage) => void
|
handleEvent: (msg: IpcMessage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +75,7 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
fileLists: {},
|
fileLists: {},
|
||||||
exportedBackup: null,
|
exportedBackup: null,
|
||||||
peerStatus: {},
|
peerStatus: {},
|
||||||
|
activeFilePeer: null,
|
||||||
|
|
||||||
connect(url: string) {
|
connect(url: string) {
|
||||||
const adapter = new DaemonAdapter(url)
|
const adapter = new DaemonAdapter(url)
|
||||||
@@ -104,6 +112,30 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
set({ activeRoom: room })
|
set({ activeRoom: room })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setActiveFilePeer(peerId) {
|
||||||
|
set({ activeFilePeer: peerId })
|
||||||
|
},
|
||||||
|
|
||||||
|
setSharedFiles(files) {
|
||||||
|
const a = get().adapter
|
||||||
|
if (a instanceof BrowserAdapter) a.setSharedFiles(files)
|
||||||
|
},
|
||||||
|
|
||||||
|
sendFileTo(peerId, file) {
|
||||||
|
const a = get().adapter
|
||||||
|
if (a instanceof BrowserAdapter) a.sendFileTo(peerId, file)
|
||||||
|
},
|
||||||
|
|
||||||
|
browseFiles(peerId) {
|
||||||
|
const a = get().adapter
|
||||||
|
if (a instanceof BrowserAdapter) {
|
||||||
|
a.requestBrowse(peerId)
|
||||||
|
} else {
|
||||||
|
get().send({ type: 'get_file_list', peer_id: peerId as unknown as import('../types').PeerID })
|
||||||
|
}
|
||||||
|
set({ activeFilePeer: peerId })
|
||||||
|
},
|
||||||
|
|
||||||
handleEvent(msg) {
|
handleEvent(msg) {
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'state_snapshot': {
|
case 'state_snapshot': {
|
||||||
@@ -215,6 +247,16 @@ export const useWaste = create<WasteState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'file_complete': {
|
||||||
|
// path holds the blob URL, offer.name holds the filename
|
||||||
|
if (msg.path && msg.offer?.name) {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = msg.path
|
||||||
|
a.download = msg.offer.name
|
||||||
|
a.click()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user