import { create } from 'zustand' import type { PeerInfo, NetworkInfo, ChatMessage, FileEntry, IpcMessage, PeerConnState, CandidateType } from '../types' export interface PeerStatus { connState?: PeerConnState candidateType?: CandidateType remoteAddress?: string lastSeen?: number } import { DaemonAdapter } from '../adapter/daemon' import { BrowserAdapter } from '../adapter/browser' type AnyAdapter = DaemonAdapter | BrowserAdapter interface WasteState { // connection adapter: AnyAdapter | null adapterMode: 'daemon' | 'browser' | null daemonStatus: 'disconnected' | 'connecting' | 'connected' // identity masterAlias: string | null masterId: string | null localPeer: PeerInfo | null // networks networks: NetworkInfo[] activeNetworkId: string | null // peers connectedPeers: PeerInfo[] // chat — keyed by room messages: Record // rooms for which we have received history: room → ts of last history message historyCutoff: Record activeRoom: string // user-created rooms, keyed by networkId customRooms: Record // file listings — keyed by peer id fileLists: Record // per-peer connection status (browser mode) and last-seen timestamps peerStatus: Record // identity backup export result (cleared when consumed) exportedBackup: string | null // 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 connectBrowser: () => void disconnect: () => void send: (msg: IpcMessage) => void setActiveNetwork: (id: string) => void setActiveRoom: (room: string) => void setActiveFilePeer: (peerId: string | null) => 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 logout: (clearIdentity: boolean) => void handleEvent: (msg: IpcMessage) => void } export const useWaste = create((set, get) => ({ adapter: null, adapterMode: null, daemonStatus: 'disconnected', masterAlias: null, masterId: null, localPeer: null, networks: [], activeNetworkId: null, connectedPeers: [], messages: {}, historyCutoff: {}, activeRoom: 'general', customRooms: {}, fileLists: {}, exportedBackup: null, peerStatus: {}, activeFilePeer: null, sharedFilesByNetwork: {}, pendingOffers: {}, fileProgress: {}, connect(url: string) { const adapter = new DaemonAdapter(url) adapter.onStatusChange = (s) => set({ daemonStatus: s }) adapter.on((msg) => get().handleEvent(msg)) adapter.connect() set({ adapter, adapterMode: 'daemon' }) }, connectBrowser() { const adapter = new BrowserAdapter() adapter.onStatusChange = (s) => set({ daemonStatus: s }) adapter.on((msg) => get().handleEvent(msg)) adapter.connect() set({ adapter, adapterMode: 'browser' }) }, disconnect() { const a = get().adapter if (a instanceof DaemonAdapter) a.disconnect() else if (a instanceof BrowserAdapter) a.disconnect() set({ adapter: null, adapterMode: null, daemonStatus: 'disconnected' }) }, send(msg) { get().adapter?.send(msg) }, setActiveNetwork(id) { set({ activeNetworkId: id }) }, setActiveRoom(room) { set({ activeRoom: room }) }, setActiveFilePeer(peerId) { set({ activeFilePeer: peerId }) }, 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) }, sendFileTo(peerId, file) { const a = get().adapter 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) }, logout(clearIdentity) { const a = get().adapter if (a instanceof DaemonAdapter) a.disconnect() else if (a instanceof BrowserAdapter) a.disconnect() localStorage.removeItem('waste_last_network') localStorage.removeItem('waste_nick') localStorage.removeItem('waste_anchor_url') if (clearIdentity) localStorage.removeItem('waste_seed') window.location.reload() }, 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) { a.requestBrowse(peerId) } else { get().send({ type: 'get_file_list', peer_id: peerId as unknown as import('../types').PeerID }) } set({ activeFilePeer: peerId }) }, handleEvent(msg) { switch (msg.type) { case 'state_snapshot': { const networks = msg.networks ?? [] set({ masterAlias: msg.master_alias ?? null, masterId: msg.master_id ?? null, localPeer: msg.local_peer ?? null, networks, connectedPeers: msg.connected_peers ?? [], activeNetworkId: networks[0]?.network_id ?? null, }) break } case 'network_joined': { set(s => ({ localPeer: s.localPeer ?? msg.local_peer ?? null, networks: s.networks.some(n => n.network_id === msg.network_id) ? s.networks : [...s.networks, { network_id: msg.network_id!, network_name: msg.network_name!, local_peer: msg.local_peer, share_dir: msg.share_dir, }], activeNetworkId: s.activeNetworkId ?? msg.network_id!, })) break } case 'network_left': { set(s => ({ networks: s.networks.filter(n => n.network_id !== msg.network_id), activeNetworkId: s.activeNetworkId === msg.network_id ? (s.networks.find(n => n.network_id !== msg.network_id)?.network_id ?? null) : s.activeNetworkId, })) break } case 'peer_connected': { if (msg.peer) { set(s => ({ connectedPeers: s.connectedPeers.some(p => p.id === msg.peer!.id) ? s.connectedPeers.map(p => p.id === msg.peer!.id ? { ...p, ...msg.peer } : p) : [...s.connectedPeers, msg.peer!], })) } break } case 'session_ready': { if (msg.peer_id && msg.nick) { set(s => ({ connectedPeers: s.connectedPeers.map(p => p.id === msg.peer_id ? { ...p, alias: msg.nick! } : p ), })) } break } case 'peer_disconnected': { set(s => ({ connectedPeers: s.connectedPeers.filter(p => p.id !== msg.peer_id), })) break } case 'message_received': { if (msg.message) { const m = msg.message const room = m.room const fromId = String(m.from) set(s => { const existing = s.messages[room] ?? [] if (m.mid && existing.some(e => e.mid === m.mid)) return s const prev = s.peerStatus[fromId] ?? {} return { messages: { ...s.messages, [room]: [...existing, m] }, peerStatus: { ...s.peerStatus, [fromId]: { ...prev, lastSeen: m.ts } }, } }) } break } case 'peer_status': { const pid = String(msg.peer_id) set(s => { const prev = s.peerStatus[pid] ?? {} return { peerStatus: { ...s.peerStatus, [pid]: { ...prev, ...(msg.conn_state ? { connState: msg.conn_state } : {}), ...(msg.candidate_type ? { candidateType: msg.candidate_type } : {}), ...(msg.remote_address !== undefined ? { remoteAddress: msg.remote_address } : {}), }, }, } }) break } case 'identity_exported': { 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 => ({ fileLists: { ...s.fileLists, [msg.peer_id!]: msg.files! }, })) } break } case 'file_complete': { 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 a.click() } break } case 'history_loaded': { const room = msg.room const incoming = (msg.messages ?? []) as ChatMessage[] if (!room || incoming.length === 0) break set(s => { const existing = s.messages[room] ?? [] const existingMids = new Set(existing.map(m => m.mid).filter(Boolean)) const fresh = incoming.filter(m => !m.mid || !existingMids.has(m.mid)) if (fresh.length === 0) return s const merged = [...fresh, ...existing].sort((a, b) => a.ts - b.ts) const cutoff = fresh[fresh.length - 1]?.ts ?? 0 return { messages: { ...s.messages, [room]: merged }, historyCutoff: { ...s.historyCutoff, [room]: cutoff }, } }) break } } }, }))