5 Commits

Author SHA1 Message Date
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
10 changed files with 184 additions and 17 deletions

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() {
@@ -462,6 +464,34 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
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"`
@@ -292,6 +301,7 @@ const (
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.
@@ -347,6 +357,7 @@ type IpcMessage struct {
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

@@ -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

@@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from 'react'
import { useWaste } from '../store' import { useWaste } from '../store'
export function MessagePane() { export function MessagePane() {
const { messages, activeRoom, activeNetworkId, localPeer, connectedPeers, send } = useWaste() const { messages, historyCutoff, activeRoom, activeNetworkId, localPeer, connectedPeers, 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
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -46,12 +47,20 @@ export function MessagePane() {
const mine = msg.from === localPeer?.id const mine = msg.from === localPeer?.id
const alias = aliasFor(msg.from) const alias = aliasFor(msg.from)
const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) const time = new Date(msg.ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
const showDivider = cutoff > 0 && i > 0 && roomMessages[i - 1].ts <= cutoff && msg.ts > cutoff
return ( return (
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}> <div key={msg.mid ?? i}>
{showDivider && (
<div className="history-divider">
<span>earlier messages</span>
</div>
)}
<div className={`message ${mine ? 'mine' : ''}`}>
<span className="message-ts">{time}</span> <span className="message-ts">{time}</span>
<span className="message-alias">{alias}</span> <span className="message-alias">{alias}</span>
<span className="message-text">{msg.text}</span> <span className="message-text">{msg.text}</span>
</div> </div>
</div>
) )
})} })}
<div ref={bottomRef} /> <div ref={bottomRef} />

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

@@ -32,6 +32,8 @@ interface WasteState {
// 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 +55,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
@@ -84,6 +88,7 @@ export const useWaste = create<WasteState>((set, get) => ({
activeNetworkId: null, activeNetworkId: null,
connectedPeers: [], connectedPeers: [],
messages: {}, messages: {},
historyCutoff: {},
activeRoom: 'general', activeRoom: 'general',
customRooms: {}, customRooms: {},
fileLists: {}, fileLists: {},
@@ -93,6 +98,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)
@@ -341,10 +347,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 +361,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
@@ -124,4 +128,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 }>
} }