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(stateSnapshot(mgr))
// Send stored history for each room so the UI is populated on connect.
sendStoredHistory(mgr, send)
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
@@ -462,6 +464,34 @@ func stateSnapshot(mgr *netmgr.Manager) proto.IpcMessage {
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 {
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
}
// 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
// file-offer to peerID over the existing "yaw" DataChannel.
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{
Type: proto.EvtNetworkJoined,
NetworkID: netID,
@@ -249,6 +251,8 @@ func (mgr *Manager) JoinByHash(netHash64, shareDir string) (string, error) {
}()
}
go m.ScanResumable()
mgr.emit(proto.IpcMessage{
Type: proto.EvtNetworkJoined,
NetworkID: netID,

View File

@@ -99,6 +99,15 @@ type PeerMessage struct {
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.
type HistoryEntry struct {
Mid string `json:"mid"`
@@ -291,7 +300,8 @@ const (
EvtIdentityImported IpcMsgType = "identity_imported"
EvtSharesList IpcMsgType = "shares_list"
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.
@@ -346,7 +356,8 @@ type IpcMessage struct {
ErrorMessage string `json:"error_message,omitempty"`
InviteGenerated string `json:"invite,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"`
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
// 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.
func (s *Store) RecentMessages(room string, limit int) ([]proto.ChatMessage, error) {
return s.queryMessages(
`SELECT mid, from_peer, body, sent_at FROM messages
`SELECT mid, from_peer, room, body, sent_at FROM messages
WHERE room = ?
ORDER BY sent_at DESC 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.
// 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) {
if limit <= 0 || 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()
return s.queryMessages(
`SELECT mid, from_peer, body, sent_at FROM messages
WHERE room = ? AND sent_at > ? AND msg_id IS NOT NULL
`SELECT mid, from_peer, room, body, sent_at FROM messages
WHERE room = ? AND sent_at > ?
ORDER BY sent_at DESC LIMIT ?`,
room, since, limit,
)
@@ -141,7 +149,7 @@ func (s *Store) queryMessages(q string, args ...any) ([]proto.ChatMessage, error
var m proto.ChatMessage
var from string
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
}
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:hover { background: rgba(255,255,255,0.04); }
.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'
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 bottomRef = useRef<HTMLDivElement>(null)
const roomMessages = messages[activeRoom] ?? []
const cutoff = historyCutoff[activeRoom] ?? 0
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -46,11 +47,19 @@ export function MessagePane() {
const mine = msg.from === localPeer?.id
const alias = aliasFor(msg.from)
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 (
<div key={msg.mid ?? i} className={`message ${mine ? 'mine' : ''}`}>
<span className="message-ts">{time}</span>
<span className="message-alias">{alias}</span>
<span className="message-text">{msg.text}</span>
<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-alias">{alias}</span>
<span className="message-text">{msg.text}</span>
</div>
</div>
)
})}

View File

@@ -7,12 +7,13 @@ function fmt(bytes: number): string {
}
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 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) {
return connectedPeers.find(p => p.id === peerId)?.alias ?? peerId.slice(0, 8)
@@ -22,6 +23,24 @@ export function Transfers() {
<div className="sidebar-section">
<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]) => (
<div key={xid} className="transfer-row">
<span className="transfer-name" title={offer.name}>{offer.name}</span>

View File

@@ -32,6 +32,8 @@ interface WasteState {
// chat — keyed by room
messages: Record<string, ChatMessage[]>
// rooms for which we have received history: room → ts of last history message
historyCutoff: Record<string, number>
activeRoom: string
// user-created rooms, keyed by networkId
customRooms: Record<string, string[]>
@@ -53,6 +55,8 @@ interface WasteState {
pendingOffers: Record<string, { peerId: string; name: string; size: number }>
// active in-progress transfers: xid → progress
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
connect: (url: string) => void
@@ -84,6 +88,7 @@ export const useWaste = create<WasteState>((set, get) => ({
activeNetworkId: null,
connectedPeers: [],
messages: {},
historyCutoff: {},
activeRoom: 'general',
customRooms: {},
fileLists: {},
@@ -93,6 +98,7 @@ export const useWaste = create<WasteState>((set, get) => ({
sharedFilesByNetwork: {},
pendingOffers: {},
fileProgress: {},
resumableFiles: {},
connect(url: string) {
const adapter = new DaemonAdapter(url)
@@ -341,10 +347,13 @@ export const useWaste = create<WasteState>((set, get) => ({
break
}
case 'file_complete': {
if (msg.path && msg.offer?.name) {
// clear progress entry
const xid = msg.offer.xid
// Always clear progress — transfer_id is the xid in daemon mode; offer.xid in browser mode.
const xid = msg.transfer_id ?? msg.offer?.xid
if (xid) {
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')
a.href = msg.path
a.download = msg.offer.name
@@ -352,6 +361,32 @@ export const useWaste = create<WasteState>((set, get) => ({
}
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'
| 'peer_status'
| 'error'
| 'history_loaded'
| 'room_created'
| 'create_room'
| 'resumable_transfers'
export interface IpcMessage {
type: IpcMsgType
@@ -124,4 +128,8 @@ export interface IpcMessage {
conn_state?: PeerConnState
candidate_type?: CandidateType
remote_address?: string
// history_loaded
messages?: ChatMessage[]
// resumable_transfers
resumable_files?: Array<{ name: string; sha256: string; from: string; size: number; offset: number }>
}