redesign: 2-col layout, peers in sidebar, IRC-style messages, NAT dot

- Peers moved from right panel into sidebar below Rooms
- Messages left-aligned IRC style: time · alias · text
- Per-peer colored dot: green=direct/NAT-punched, amber=relay/connecting, red=failed
- Hover title on peer row shows full ID, connection type, remote address, last seen
- Removed right PeerList panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-25 13:28:40 +02:00
parent cb80040ddb
commit bb53357bc8
7 changed files with 191 additions and 50 deletions

View File

@@ -257,8 +257,10 @@ class PeerConn {
this._esk = kp.privateKey
this._epk = kp.publicKey
this.pc.ondatachannel = (ev) => this._wire(ev.channel)
this.pc.onconnectionstatechange = () =>
this.pc.onconnectionstatechange = () => {
this.on('status', { peer: this.peerId, state: this.pc.connectionState })
if (this.pc.connectionState === 'connected') this.reportStats()
}
}
private _seal(obj: object, preferEph: boolean): [string, boolean] {
@@ -386,6 +388,28 @@ class PeerConn {
}
}
async reportStats() {
try {
const stats = await this.pc.getStats()
let candidateType: string = 'unknown'
let remoteAddress = ''
stats.forEach((s) => {
if (s.type === 'candidate-pair' && (s as RTCIceCandidatePairStats).state === 'succeeded') {
const pair = s as RTCIceCandidatePairStats & { nominated?: boolean }
if (pair.nominated) {
stats.forEach((c) => {
if (c.type === 'remote-candidate' && c.id === (pair as unknown as Record<string,string>)['remoteCandidateId']) {
candidateType = (c as unknown as Record<string,string>)['candidateType'] || 'unknown'
remoteAddress = (c as unknown as Record<string,string>)['address'] || ''
}
})
}
}
})
this.on('ice_stats', { peer: this.peerId, candidateType, remoteAddress })
} catch { /* ignore */ }
}
sendChat(room: string, text: string) {
this._dc({ type: 'chat', room, text, ts: Date.now() })
}
@@ -608,6 +632,19 @@ export class BrowserAdapter {
ts,
},
})
} else if (event === 'status') {
this.emit({
type: 'peer_status',
peer_id: data['peer'] as unknown as import('../types').PeerID,
conn_state: data['state'] as import('../types').PeerConnState,
})
} else if (event === 'ice_stats') {
this.emit({
type: 'peer_status',
peer_id: data['peer'] as unknown as import('../types').PeerID,
candidate_type: data['candidateType'] as import('../types').CandidateType,
remote_address: data['remoteAddress'] as string,
})
}
}