- Browser mode auto-rejoins saved network on reload (waste_last_network) - Logout button (⏻) in sidebar clears session; optionally clears identity keypair - TURN credentials now use HMAC-SHA1 of username (coturn use-auth-secret compatible) - README: deploy scripts documented, session persistence section, serve-web.sh noted - FUTURE.md: mark shipped items, add remaining work for daemon TURN + TUI rooms Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
180 lines
6.6 KiB
TypeScript
180 lines
6.6 KiB
TypeScript
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))
|
|
return nick ? `yaw:${id}?n=${nick}` : `yaw:${id}`
|
|
}
|
|
|
|
function connDot(status: PeerStatus | undefined): { color: string; label: string } {
|
|
const ct = status?.candidateType
|
|
const cs = status?.connState
|
|
if (cs === 'failed' || cs === 'closed') return { color: '#e06060', label: 'failed' }
|
|
if (cs === 'connecting' || cs === 'new') return { color: '#c8a020', label: 'connecting…' }
|
|
if (ct === 'relay') return { color: '#c8a020', label: 'relayed (TURN)' }
|
|
if (ct === 'srflx') return { color: '#60c060', label: 'NAT punched' }
|
|
if (ct === 'host') return { color: '#60c060', label: 'direct (LAN)' }
|
|
if (cs === 'connected') return { color: '#60c060', label: 'connected' }
|
|
return { color: 'var(--muted)', label: 'unknown' }
|
|
}
|
|
|
|
function formatTs(ts: number | undefined): string {
|
|
if (!ts) return '—'
|
|
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
}
|
|
|
|
export function Sidebar() {
|
|
const {
|
|
localPeer, masterId, masterAlias,
|
|
networks, activeNetworkId, activeRoom,
|
|
connectedPeers, peerStatus,
|
|
setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode,
|
|
customRooms, createRoom, logout,
|
|
} = useWaste()
|
|
const [addingRoom, setAddingRoom] = useState(false)
|
|
const [newRoomName, setNewRoomName] = useState('')
|
|
|
|
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
|
|
|
|
function handleLogout() {
|
|
const clearId = window.confirm('Also clear your identity keypair? (Cannot be undone — export a backup first if you want to keep it.)')
|
|
logout(clearId)
|
|
}
|
|
|
|
function openDM(peerId: string) {
|
|
setActiveRoom(`dm:${peerId}`)
|
|
}
|
|
|
|
function requestFiles(peerId: string) {
|
|
browseFiles(peerId)
|
|
}
|
|
|
|
return (
|
|
<nav className="sidebar">
|
|
<div className="sidebar-identity">
|
|
<div
|
|
style={{ flex: 1, cursor: card ? 'pointer' : undefined }}
|
|
title={card ?? undefined}
|
|
onClick={() => card && navigator.clipboard?.writeText(card)}
|
|
>
|
|
<span className="alias">{displayAlias || '…'}</span>
|
|
<span className="peer-id">{displayId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim()}</span>
|
|
</div>
|
|
<button className="sidebar-logout" onClick={handleLogout} title="Leave network">⏻</button>
|
|
</div>
|
|
|
|
<div className="sidebar-section">
|
|
<span className="sidebar-label">Networks</span>
|
|
{networks.map(n => (
|
|
<button
|
|
key={n.network_id}
|
|
className={`sidebar-item ${n.network_id === activeNetworkId ? 'active' : ''}`}
|
|
onClick={() => setActiveNetwork(n.network_id)}
|
|
>
|
|
{n.network_name}
|
|
</button>
|
|
))}
|
|
</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}
|
|
className={`sidebar-item ${r === activeRoom ? 'active' : ''}`}
|
|
onClick={() => setActiveRoom(r)}
|
|
>
|
|
{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' && (
|
|
<div className="sidebar-section">
|
|
<span className="sidebar-label">Sharing</span>
|
|
<div style={{ padding: '4px 12px' }}><FolderPicker /></div>
|
|
</div>
|
|
)}
|
|
|
|
<Transfers />
|
|
|
|
<div className="sidebar-section sidebar-peers">
|
|
<span className="sidebar-label">Peers · {connectedPeers.length + 1}</span>
|
|
|
|
{/* Self */}
|
|
<div className="peer-row peer-row-self">
|
|
<span className="peer-dot" style={{ background: 'var(--accent)' }} />
|
|
<span className="peer-row-alias">{displayAlias || '…'}</span>
|
|
<span className="peer-row-you">you</span>
|
|
</div>
|
|
|
|
{connectedPeers.length === 0 && (
|
|
<span className="sidebar-empty">no peers connected</span>
|
|
)}
|
|
{connectedPeers.map(p => {
|
|
const st = peerStatus[p.id]
|
|
const dot = connDot(st)
|
|
const tooltip = [
|
|
p.alias,
|
|
p.id,
|
|
`connection: ${dot.label}`,
|
|
st?.remoteAddress ? `remote: ${st.remoteAddress}` : null,
|
|
st?.lastSeen ? `last seen: ${formatTs(st.lastSeen)}` : null,
|
|
].filter(Boolean).join('\n')
|
|
return (
|
|
<div key={p.id} className="peer-row" title={tooltip}>
|
|
<span className="peer-dot" style={{ background: dot.color }} />
|
|
<span className="peer-row-alias">{p.alias}</span>
|
|
<span className="peer-row-id">{p.id.slice(0, 8)}</span>
|
|
<span className="peer-row-actions">
|
|
<button className="peer-action" onClick={() => openDM(p.id)} title="DM">↩</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>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</nav>
|
|
)
|
|
}
|