7 Commits

Author SHA1 Message Date
Fredrik Johansson
fcbd84f873 fix: compact timestamps, historical alias resolution, wider ts column
- Timestamps now show "Jun 28 10:58" for older messages (dropped
  "Yesterday" which overflowed the fixed-width column)
- Timestamp column widened from 52px to 72px to fit date+time
- state_snapshot now includes known_peers (historically seen, not
  currently connected) so the UI can resolve aliases in history
- MessagePane aliasFor falls back to knownPeers map

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 13:43:29 +02:00
Fredrik Johansson
cef9374416 fix: show date in timestamps; fix history divider appearing immediately
- MessagePane: show date in timestamps (Yesterday/MMM D) for messages
  not from today; divider now appears at the top of history on load
  rather than waiting for a live message to create the boundary
- TUI: same date-aware timestamp formatting (Yesterday / Jan 2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:45:51 +02:00
Fredrik Johansson
9ad3c96d43 fix: send stored history to IPC client on connect
history_loaded events were fired when peers exchanged history gossip,
but the browser UI often connects to the daemon after that handshake
has already happened. Now the daemon pushes recent messages for all
known rooms immediately after the state_snapshot on each new IPC
connection, so the browser always gets history regardless of timing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:30:33 +02:00
Fredrik Johansson
48400440dd fix: history gossip not triggering for pre-feature messages
Two bugs:
1. RecentMessagesSince had msg_id IS NOT NULL filter — messages sent
   before EXT-007 deployment all have msg_id=NULL so nothing was returned.
   Removed the filter; mid-based INSERT OR IGNORE dedup is sufficient.
2. queryMessages didn't SELECT room, so gossipped messages had empty room
   field. Added room to SELECT and Scan in queryMessages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:14:43 +02:00
Fredrik Johansson
0e812a2479 fix: clear file progress on completion in daemon mode
file_complete in daemon mode carries transfer_id but no offer field.
The old condition required msg.offer, so the progress/cancel row was
never removed. Now clears using transfer_id first, offer.xid as fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:55:00 +02:00
Fredrik Johansson
f319721e01 feat: resumable transfer UX — surface partial downloads to UI on reconnect
Daemon scans the download directory for .tmp.meta sidecars on network join
and emits resumable_transfers IPC event. Web UI shows them in the Transfers
panel with a dimmed progress bar and "will resume on reconnect" note.

- proto: ResumableFile type, EvtResumableTransfers, resumable_files IpcMessage field
- mesh: ScanResumable() scans download dir and emits the event
- netmgr: call ScanResumable() after join (both Join and JoinByHash paths)
- web: resumableFiles store state, resumable_transfers handler, Transfers UI section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:02:49 +02:00
Fredrik Johansson
9de625d617 feat: render history_loaded in web UI with earlier messages divider
- store: handle history_loaded event — prepend gossipped messages,
  dedup by mid, sort by ts, record cutoff timestamp per room
- MessagePane: show "earlier messages" divider between history and
  live messages based on the cutoff timestamp
- types: add history_loaded, room_created, create_room to IpcMsgType;
  add messages field to IpcMessage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:50:48 +02:00
11 changed files with 246 additions and 22 deletions

View File

@@ -383,7 +383,7 @@ func (m model) refreshViewport() model {
w := m.vpContentWidth() w := m.vpContentWidth()
var sb strings.Builder var sb strings.Builder
for _, e := range m.messages[room] { for _, e := range m.messages[room] {
ts := styleMsgTime.Render(e.at.Format("15:04")) ts := styleMsgTime.Render(formatMsgTime(e.at))
var from string var from string
if e.fromMe { if e.fromMe {
from = styleMsgMe.Render(e.from) from = styleMsgMe.Render(e.from)
@@ -614,6 +614,17 @@ func filterIDs(ids []proto.PeerID, remove proto.PeerID) []proto.PeerID {
return out return out
} }
func formatMsgTime(t time.Time) string {
now := time.Now()
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04")
}
if t.Year() == now.Year() && t.YearDay() == now.YearDay()-1 {
return "Yesterday " + t.Format("15:04")
}
return t.Format("Jan 2 15:04")
}
func min(a, b int) int { func min(a, b int) int {
if a < b { if a < b {
return a return a

View File

@@ -127,6 +127,8 @@ func handleClient(conn net.Conn, mgr *netmgr.Manager) {
// Send initial state snapshot. // Send initial state snapshot.
send(stateSnapshot(mgr)) send(stateSnapshot(mgr))
// Send stored history for each room so the UI is populated on connect.
sendStoredHistory(mgr, send)
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
for scanner.Scan() { for scanner.Scan() {
@@ -457,11 +459,55 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
msg.Rooms = append(msg.Rooms, r) msg.Rooms = append(msg.Rooms, r)
} }
} }
// Include all historically-known peers so the UI can resolve aliases in history.
if known, err := all[0].Store.KnownPeers(); err == nil {
connected := map[proto.PeerID]bool{}
for _, p := range msg.ConnectedPeers {
connected[p.ID] = true
}
for id, alias := range known {
if connected[id] {
continue // already in ConnectedPeers
}
msg.KnownPeers = append(msg.KnownPeers, proto.PeerInfo{
ID: id,
Alias: alias,
})
}
}
} }
return msg return msg
} }
// sendStoredHistory pushes recent messages for all known rooms to a newly-connected IPC client.
func sendStoredHistory(mgr *netmgr.Manager, send func(proto.IpcMessage)) {
all := mgr.All()
if len(all) == 0 {
return
}
n := all[0] // use first network; multi-network history follows same pattern
if n.Store == nil {
return
}
rooms := []string{"general"}
if extra, err := n.Store.Rooms(); err == nil {
rooms = append(rooms, extra...)
}
for _, room := range rooms {
msgs, err := n.Store.RecentMessagesSince(room, 0, 200)
if err != nil || len(msgs) == 0 {
continue
}
send(proto.IpcMessage{
Type: proto.EvtHistoryLoaded,
NetworkID: n.ID,
Room: room,
Messages: msgs,
})
}
}
func errMsg(s string) proto.IpcMessage { func errMsg(s string) proto.IpcMessage {
return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s} return proto.IpcMessage{Type: proto.EvtError, ErrorMessage: s}
} }

View File

@@ -91,6 +91,47 @@ func writePartialMeta(path string, t *inboundTransfer) {
os.WriteFile(path, data, 0o644) //nolint:errcheck os.WriteFile(path, data, 0o644) //nolint:errcheck
} }
// ScanResumable scans the download directory for .tmp.meta sidecars left by
// interrupted transfers and emits a resumable_transfers IPC event listing them.
// Called once after a network is joined so the UI can show pending transfers.
func (m *Mesh) ScanResumable() {
if m.DownloadDir == "" {
return
}
metas, _ := filepath.Glob(filepath.Join(m.DownloadDir, "*.tmp.meta"))
var files []proto.ResumableFile
for _, mp := range metas {
data, err := os.ReadFile(mp)
if err != nil {
continue
}
var meta partialMeta
if err := json.Unmarshal(data, &meta); err != nil {
continue
}
tp := strings.TrimSuffix(mp, ".meta")
info, err := os.Stat(tp)
if err != nil {
continue
}
files = append(files, proto.ResumableFile{
Name: meta.Name,
SHA256: meta.SHA256,
From: meta.From,
Size: meta.Size,
Offset: info.Size(),
})
}
if len(files) == 0 {
return
}
m.emit(proto.IpcMessage{
Type: proto.EvtResumableTransfers,
ResumableFiles: files,
})
log.Printf("transfer: %d resumable transfer(s) found in %s", len(files), m.DownloadDir)
}
// OfferFile reads filename from ShareDir, computes its SHA-256, and sends a // OfferFile reads filename from ShareDir, computes its SHA-256, and sends a
// file-offer to peerID over the existing "yaw" DataChannel. // file-offer to peerID over the existing "yaw" DataChannel.
func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error { func (m *Mesh) OfferFile(peerID proto.PeerID, filename string) error {

View File

@@ -162,6 +162,8 @@ func (mgr *Manager) Join(name, shareDir string) (string, error) {
}() }()
} }
go m.ScanResumable()
mgr.emit(proto.IpcMessage{ mgr.emit(proto.IpcMessage{
Type: proto.EvtNetworkJoined, Type: proto.EvtNetworkJoined,
NetworkID: netID, NetworkID: netID,
@@ -249,6 +251,8 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
}() }()
} }
go m.ScanResumable()
mgr.emit(proto.IpcMessage{ mgr.emit(proto.IpcMessage{
Type: proto.EvtNetworkJoined, Type: proto.EvtNetworkJoined,
NetworkID: netID, NetworkID: netID,

View File

@@ -99,6 +99,15 @@ type PeerMessage struct {
HistoryDone bool `json:"history_done,omitempty"` HistoryDone bool `json:"history_done,omitempty"`
} }
// ResumableFile describes a partially-downloaded file found on daemon startup.
type ResumableFile struct {
Name string `json:"name"`
SHA256 string `json:"sha256"`
From string `json:"from"` // peer ID hex
Size int64 `json:"size"`
Offset int64 `json:"offset"` // bytes already received
}
// HistoryEntry is one message in a history_chunk response. // HistoryEntry is one message in a history_chunk response.
type HistoryEntry struct { type HistoryEntry struct {
Mid string `json:"mid"` Mid string `json:"mid"`
@@ -291,7 +300,8 @@ const (
EvtIdentityImported IpcMsgType = "identity_imported" EvtIdentityImported IpcMsgType = "identity_imported"
EvtSharesList IpcMsgType = "shares_list" EvtSharesList IpcMsgType = "shares_list"
EvtRoomCreated IpcMsgType = "room_created" // field: room (name) EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages
EvtResumableTransfers IpcMsgType = "resumable_transfers" // field: resumable_files
) )
// NetworkInfo summarises one joined network for state_snapshot and network_joined events. // NetworkInfo summarises one joined network for state_snapshot and network_joined events.
@@ -340,13 +350,15 @@ type IpcMessage struct {
MasterID string `json:"master_id,omitempty"` // daemon's master public key hex MasterID string `json:"master_id,omitempty"` // daemon's master public key hex
LocalPeer *PeerInfo `json:"local_peer,omitempty"` LocalPeer *PeerInfo `json:"local_peer,omitempty"`
ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"` ConnectedPeers []PeerInfo `json:"connected_peers,omitempty"`
KnownPeers []PeerInfo `json:"known_peers,omitempty"` // historically seen, not currently connected
Rooms []string `json:"rooms,omitempty"` Rooms []string `json:"rooms,omitempty"`
// multi-network: all joined networks (additive) // multi-network: all joined networks (additive)
Networks []NetworkInfo `json:"networks,omitempty"` Networks []NetworkInfo `json:"networks,omitempty"`
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
InviteGenerated string `json:"invite,omitempty"` InviteGenerated string `json:"invite,omitempty"`
Files []FileEntry `json:"files,omitempty"` Files []FileEntry `json:"files,omitempty"`
Messages []ChatMessage `json:"messages,omitempty"` // history_loaded Messages []ChatMessage `json:"messages,omitempty"` // history_loaded
ResumableFiles []ResumableFile `json:"resumable_files,omitempty"` // resumable_transfers
Shares []ShareEntry `json:"shares,omitempty"` Shares []ShareEntry `json:"shares,omitempty"`
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
// export_identity / import_identity // export_identity / import_identity

View File

@@ -107,7 +107,7 @@ func (s *Store) PeerAlias(peerID proto.PeerID) string {
// RecentMessages returns up to limit messages for a room, oldest first. // RecentMessages returns up to limit messages for a room, oldest first.
func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) { func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) {
return s.queryMessages( return s.queryMessages(
`SELECT mid, from_peer, body, sent_at FROM messages `SELECT mid, from_peer, room, body, sent_at FROM messages
WHERE room = ? WHERE room = ?
ORDER BY sent_at DESC LIMIT ?`, ORDER BY sent_at DESC LIMIT ?`,
room, limit, room, limit,
@@ -115,15 +115,23 @@ func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, err
} }
// RecentMessagesSince returns up to limit messages for a room with ts > sinceMs, oldest first. // RecentMessagesSince returns up to limit messages for a room with ts > sinceMs, oldest first.
// Only messages that have a msg_id (i.e. gossip-safe) are returned. // sinceMs == 0 returns the most recent messages regardless of timestamp.
func (s *Store) RecentMessagesSince(room string, sinceMs int64, limit int) ([]proto.ChatMessage, error) { func (s *Store) RecentMessagesSince(room string, sinceMs int64, limit int) ([]proto.ChatMessage, error) {
if limit <= 0 || limit > 500 { if limit <= 0 || limit > 500 {
limit = 500 limit = 500
} }
if sinceMs == 0 {
return s.queryMessages(
`SELECT mid, from_peer, room, body, sent_at FROM messages
WHERE room = ?
ORDER BY sent_at DESC LIMIT ?`,
room, limit,
)
}
since := time.UnixMilli(sinceMs).UTC() since := time.UnixMilli(sinceMs).UTC()
return s.queryMessages( return s.queryMessages(
`SELECT mid, from_peer, body, sent_at FROM messages `SELECT mid, from_peer, room, body, sent_at FROM messages
WHERE room = ? AND sent_at > ? AND msg_id IS NOT NULL WHERE room = ? AND sent_at > ?
ORDER BY sent_at DESC LIMIT ?`, ORDER BY sent_at DESC LIMIT ?`,
room, since, limit, room, since, limit,
) )
@@ -141,7 +149,7 @@ func (s *Store) queryMessages(q string, args ...any) ([]proto.ChatMessage, error
var m proto.ChatMessage var m proto.ChatMessage
var from string var from string
var sentAt time.Time var sentAt time.Time
if err := rows.Scan(&m.Mid, &from, &m.Text, &sentAt); err != nil { if err := rows.Scan(&m.Mid, &from, &m.Room, &m.Text, &sentAt); err != nil {
return nil, err return nil, err
} }
m.From = proto.PeerID(from) m.From = proto.PeerID(from)

View File

@@ -88,7 +88,7 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; } .messages { flex: 1; overflow-y: auto; padding: 8px 0; display: flex; flex-direction: column; }
.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: 72px; }
.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-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); }
@@ -158,3 +158,5 @@ details summary { color: var(--muted); font-size: 12px; cursor: pointer; }
.file-entry-dir { cursor: pointer; } .file-entry-dir { cursor: pointer; }
.file-entry-dir:hover { background: rgba(255,255,255,0.04); } .file-entry-dir:hover { background: rgba(255,255,255,0.04); }
.file-entry-icon { font-size: 12px; flex-shrink: 0; } .file-entry-icon { font-size: 12px; flex-shrink: 0; }
.history-divider { display: flex; align-items: center; gap: 8px; margin: 10px 0 6px; color: var(--muted); font-size: 11px; }
.history-divider::before, .history-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }

View File

@@ -1,11 +1,33 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useWaste } from '../store' import { useWaste } from '../store'
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayMs = today.getTime()
function formatTs(ts: number): string {
const d = new Date(ts)
const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
if (ts >= todayMs) return time
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + time
}
export function MessagePane() { export function MessagePane() {
const { messages, activeRoom, activeNetworkId, localPeer, connectedPeers, send } = useWaste() const { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, knownPeers, send } = useWaste()
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const roomMessages = messages[activeRoom] ?? [] const roomMessages = messages[activeRoom] ?? []
const cutoff = historyCutoff[activeRoom] ?? 0
// Find the index of the first live message (ts > cutoff).
// The divider appears just before this index, or at the top if all are history.
const firstLiveIdx = cutoff > 0
? roomMessages.findIndex(m => m.ts > cutoff)
: -1
// If all messages are history (no live yet), put divider at the start.
const dividerIdx = cutoff > 0
? (firstLiveIdx === -1 ? 0 : firstLiveIdx)
: -1
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -30,7 +52,9 @@ export function MessagePane() {
function aliasFor(fromId: string) { function aliasFor(fromId: string) {
if (fromId === localPeer?.id) return localPeer.alias if (fromId === localPeer?.id) return localPeer.alias
return connectedPeers.find(p => p.id === fromId)?.alias ?? fromId.slice(0, 8) return connectedPeers.find(p => p.id === fromId)?.alias
?? knownPeers[fromId]
?? fromId.slice(0, 8)
} }
const roomLabel = activeRoom.startsWith('dm:') const roomLabel = activeRoom.startsWith('dm:')
@@ -42,15 +66,23 @@ export function MessagePane() {
<div className="message-pane-header">{roomLabel}</div> <div className="message-pane-header">{roomLabel}</div>
<div className="messages"> <div className="messages">
{dividerIdx === 0 && (
<div className="history-divider"><span>earlier messages</span></div>
)}
{roomMessages.map((msg, i) => { {roomMessages.map((msg, i) => {
const mine = msg.from === localPeer?.id const mine = msg.from === localPeer?.id
const alias = aliasFor(msg.from) const alias = aliasFor(String(msg.from))
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) const ts = formatTs(msg.ts)
return ( return (
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}> <div key={msg.mid ?? i}>
<span className="message-ts">{time}</span> {i === dividerIdx && dividerIdx > 0 && (
<span className="message-alias">{alias}</span> <div className="history-divider"><span>earlier messages</span></div>
<span className="message-text">{msg.text}</span> )}
<div className={`message ${mine ? 'mine' : ''}`}>
<span className="message-ts">{ts}</span>
<span className="message-alias">{alias}</span>
<span className="message-text">{msg.text}</span>
</div>
</div> </div>
) )
})} })}

View File

@@ -7,12 +7,13 @@ function fmt(bytes: number): string {
} }
export function Transfers() { export function Transfers() {
const { pendingOffers, fileProgress, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste() const { pendingOffers, fileProgress, resumableFiles, acceptOffer, rejectOffer, cancelTransfer, connectedPeers } = useWaste()
const hasPending = Object.keys(pendingOffers).length > 0 const hasPending = Object.keys(pendingOffers).length > 0
const hasActive = Object.keys(fileProgress).length > 0 const hasActive = Object.keys(fileProgress).length > 0
const hasResumable = Object.keys(resumableFiles).length > 0
if (!hasPending && !hasActive) return null if (!hasPending && !hasActive && !hasResumable) return null
function alias(peerId: string) { function alias(peerId: string) {
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8) return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
@@ -22,6 +23,24 @@ export function Transfers() {
<div className="sidebar-section"> <div className="sidebar-section">
<span className="sidebar-label">Transfers</span> <span className="sidebar-label">Transfers</span>
{hasResumable && (
<>
<span className="sidebar-label" style={{ fontSize: 10, opacity: 0.6 }}>resumable</span>
{Object.entries(resumableFiles).map(([sha256, f]) => {
const pct = f.size > 0 ? Math.round((f.offset / f.size) * 100) : 0
return (
<div key={sha256} className="transfer-row">
<span className="transfer-name" title={f.name}>{f.name}</span>
<span className="transfer-meta">{fmt(f.offset)} / {fmt(f.size)} · {alias(f.from)} · will resume on reconnect</span>
<div className="transfer-progress">
<div className="transfer-progress-bar" style={{ width: `${pct}%`, opacity: 0.5 }} />
</div>
</div>
)
})}
</>
)}
{Object.entries(pendingOffers).map(([xid, offer]) => ( {Object.entries(pendingOffers).map(([xid, offer]) => (
<div key={xid} className="transfer-row"> <div key={xid} className="transfer-row">
<span className="transfer-name" title={offer.name}>{offer.name}</span> <span className="transfer-name" title={offer.name}>{offer.name}</span>

View File

@@ -29,9 +29,12 @@ interface WasteState {
// peers // peers
connectedPeers: PeerInfo[] connectedPeers: PeerInfo[]
knownPeers: Record<string, string> // id → alias for historical peers
// chat — keyed by room // chat — keyed by room
messages: Record<string, ChatMessage[]> messages: Record<string, ChatMessage[]>
// rooms for which we have received history: room → ts of last history message
historyCutoff: Record<string, number>
activeRoom: string activeRoom: string
// user-created rooms, keyed by networkId // user-created rooms, keyed by networkId
customRooms: Record<string, string[]> customRooms: Record<string, string[]>
@@ -53,6 +56,8 @@ interface WasteState {
pendingOffers: Record<string, { peerId: string; name: string; size: number }> pendingOffers: Record<string, { peerId: string; name: string; size: number }>
// active in-progress transfers: xid → progress // active in-progress transfers: xid → progress
fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }> fileProgress: Record<string, { peerId: string; name: string; received: number; total: number }>
// partial downloads found on daemon startup: sha256 → info
resumableFiles: Record<string, { name: string; from: string; size: number; offset: number }>
// actions // actions
connect: (url: string) => void connect: (url: string) => void
@@ -83,7 +88,9 @@ export const useWaste = create<WasteState>((set, get) => ({
networks: [], networks: [],
activeNetworkId: null, activeNetworkId: null,
connectedPeers: [], connectedPeers: [],
knownPeers: {},
messages: {}, messages: {},
historyCutoff: {},
activeRoom: 'general', activeRoom: 'general',
customRooms: {}, customRooms: {},
fileLists: {}, fileLists: {},
@@ -93,6 +100,7 @@ export const useWaste = create<WasteState>((set, get) => ({
sharedFilesByNetwork: {}, sharedFilesByNetwork: {},
pendingOffers: {}, pendingOffers: {},
fileProgress: {}, fileProgress: {},
resumableFiles: {},
connect(url: string) { connect(url: string) {
const adapter = new DaemonAdapter(url) const adapter = new DaemonAdapter(url)
@@ -209,6 +217,8 @@ export const useWaste = create<WasteState>((set, get) => ({
switch (msg.type) { switch (msg.type) {
case 'state_snapshot': { case 'state_snapshot': {
const networks = msg.networks ?? [] const networks = msg.networks ?? []
const knownPeers: Record<string, string> = {}
for (const p of msg.known_peers ?? []) knownPeers[p.id] = p.alias
set({ set({
masterAlias: msg.master_alias ?? null, masterAlias: msg.master_alias ?? null,
masterId: msg.master_id ?? null, masterId: msg.master_id ?? null,
@@ -216,6 +226,7 @@ export const useWaste = create<WasteState>((set, get) => ({
networks, networks,
connectedPeers: msg.connected_peers ?? [], connectedPeers: msg.connected_peers ?? [],
activeNetworkId: networks[0]?.network_id ?? null, activeNetworkId: networks[0]?.network_id ?? null,
knownPeers,
}) })
break break
} }
@@ -341,10 +352,13 @@ export const useWaste = create<WasteState>((set, get) => ({
break break
} }
case 'file_complete': { case 'file_complete': {
if (msg.path && msg.offer?.name) { // Always clear progress — transfer_id is the xid in daemon mode; offer.xid in browser mode.
// clear progress entry const xid = msg.transfer_id ?? msg.offer?.xid
const xid = msg.offer.xid if (xid) {
set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } }) set(s => { const fp = { ...s.fileProgress }; delete fp[xid]; return { fileProgress: fp } })
}
// Browser mode: trigger download via anchor click.
if (msg.path && msg.offer?.name) {
const a = document.createElement('a') const a = document.createElement('a')
a.href = msg.path a.href = msg.path
a.download = msg.offer.name a.download = msg.offer.name
@@ -352,6 +366,32 @@ export const useWaste = create<WasteState>((set, get) => ({
} }
break break
} }
case 'resumable_transfers': {
const files = (msg.resumable_files ?? []) as Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
if (files.length === 0) break
const byHash: Record<string, { name: string; from: string; size: number; offset: number }> = {}
for (const f of files) byHash[f.sha256] = { name: f.name, from: f.from, size: f.size, offset: f.offset }
set(s => ({ resumableFiles: { ...s.resumableFiles, ...byHash } }))
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
}
} }
}, },
})) }))

View File

@@ -82,6 +82,10 @@ export type IpcMsgType =
| 'shares_list' | 'shares_list'
| 'peer_status' | 'peer_status'
| 'error' | 'error'
| 'history_loaded'
| 'room_created'
| 'create_room'
| 'resumable_transfers'
export interface IpcMessage { export interface IpcMessage {
type: IpcMsgType type: IpcMsgType
@@ -113,6 +117,7 @@ export interface IpcMessage {
master_id?: string master_id?: string
local_peer?: PeerInfo local_peer?: PeerInfo
connected_peers?: PeerInfo[] connected_peers?: PeerInfo[]
known_peers?: PeerInfo[]
rooms?: string[] rooms?: string[]
networks?: NetworkInfo[] networks?: NetworkInfo[]
error_message?: string error_message?: string
@@ -124,4 +129,8 @@ export interface IpcMessage {
conn_state?: PeerConnState conn_state?: PeerConnState
candidate_type?: CandidateType candidate_type?: CandidateType
remote_address?: string remote_address?: string
// history_loaded
messages?: ChatMessage[]
// resumable_transfers
resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
} }