Skip to content

Commit 5d8c907

Browse files
authored
Merge pull request #20 from lklynet/feature/ephemeral-chat
feat: ephemeral p2p chat
2 parents 836b5c2 + 6fe004d commit 5d8c907

11 files changed

Lines changed: 669 additions & 15 deletions

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,22 @@ See detailed [instructions](https://gethomepage.dev/configs/services/#icons).
126126
| --- | --- | --- |
127127
| `PORT` | `3000` | The port the web dashboard listens on. Since `--network host` is used, this port opens directly on the host. |
128128
| `MAX_PEERS` | `1000000` | Maximum number of peers to track in the swarm. Unless you're expecting the entire internet to join, the default is probably fine. |
129+
| `ENABLE_CHAT` | `false` | Set to `true` to enable the ephemeral P2P chat terminal. |
130+
131+
## » Features
132+
133+
### 1. The Counter
134+
It counts. That's the main thing.
135+
136+
### 2. Ephemeral Chat
137+
**New:** A completely decentralized, ephemeral chat system built directly on top of the swarm topology.
138+
139+
* **Ephemeral:** No database. No history. If you refresh, it's gone.
140+
* **Restricted:** You can only talk to your ~32 direct connections.
141+
* **Chaotic:** Every 30 seconds, the network rotates your connections. You might be mid-sentence and—*poof*—your audience changes.
142+
* **Anonymous:** You are identified only by the last 4 characters of your node ID.
143+
144+
To enable this feature, set `ENABLE_CHAT=true`.
129145

130146
## » Usage
131147

public/app.js

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,275 @@ document.getElementById('diagnosticsModal').addEventListener('click', (e) => {
9696
document.addEventListener('keydown', (e) => {
9797
if (e.key === 'Escape') {
9898
closeDiagnostics();
99+
closeMap();
100+
}
101+
});
102+
103+
// Map Logic
104+
let map = null;
105+
let mapInitialized = false;
106+
let peerMarkers = {}; // id -> marker
107+
let ipCache = {}; // ip -> { lat, lon }
108+
let lastPeerData = [];
109+
let myLocation = null;
110+
111+
const fetchMyLocation = async () => {
112+
if (myLocation) return;
113+
try {
114+
const res = await fetch('https://ipwho.is/');
115+
const data = await res.json();
116+
if (data.success) {
117+
myLocation = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country };
118+
updateMap(lastPeerData);
119+
}
120+
} catch (e) {
121+
console.error('My location fetch failed', e);
122+
}
123+
}
124+
125+
const openMap = () => {
126+
document.getElementById('mapModal').classList.add('active');
127+
if (!mapInitialized) {
128+
initMap();
129+
} else {
130+
setTimeout(() => {
131+
map.invalidateSize();
132+
}, 100);
133+
}
134+
135+
fetchMyLocation();
136+
137+
if (lastPeerData.length > 0) {
138+
updateMap(lastPeerData);
139+
}
140+
}
141+
142+
const closeMap = () => {
143+
document.getElementById('mapModal').classList.remove('active');
144+
}
145+
146+
document.getElementById('mapModal').addEventListener('click', (e) => {
147+
if (e.target.id === 'mapModal') {
148+
closeMap();
149+
}
150+
});
151+
152+
const initMap = () => {
153+
if (mapInitialized) return;
154+
155+
map = L.map('map').setView([20, 0], 2);
156+
157+
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
158+
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
159+
subdomains: 'abcd',
160+
maxZoom: 19
161+
}).addTo(map);
162+
163+
mapInitialized = true;
164+
165+
setTimeout(() => {
166+
map.invalidateSize();
167+
}, 100);
168+
}
169+
170+
const fetchLocation = async (ip) => {
171+
if (ipCache[ip]) return ipCache[ip];
172+
173+
// Skip local IPs
174+
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.') || ip.startsWith('172.16.')) {
175+
return null;
176+
}
177+
178+
try {
179+
const res = await fetch(`https://ipwho.is/${ip}`);
180+
const data = await res.json();
181+
if (data.success) {
182+
const loc = { lat: data.latitude, lon: data.longitude, city: data.city, country: data.country };
183+
ipCache[ip] = loc;
184+
return loc;
185+
}
186+
} catch (e) {
187+
console.error('Geo fetch failed', e);
188+
}
189+
return null;
190+
}
191+
192+
const updateMap = async (peers) => {
193+
if (!mapInitialized) return;
194+
if (!peers) peers = [];
195+
196+
const currentIds = new Set(peers.map(p => p.id));
197+
198+
// Remove old markers
199+
for (const id in peerMarkers) {
200+
if (id !== 'me' && !currentIds.has(id)) {
201+
map.removeLayer(peerMarkers[id]);
202+
delete peerMarkers[id];
203+
}
204+
}
205+
206+
// Add/Update markers
207+
for (const peer of peers) {
208+
if (!peer.ip) continue;
209+
210+
if (!peerMarkers[peer.id]) {
211+
const loc = await fetchLocation(peer.ip);
212+
if (loc) {
213+
const marker = L.circleMarker([loc.lat, loc.lon], {
214+
radius: 10,
215+
fillColor: "#4ade80",
216+
color: "transparent",
217+
weight: 0,
218+
opacity: 0,
219+
fillOpacity: 0.15
220+
}).addTo(map);
221+
222+
marker.bindPopup(`<b>Node</b> ${peer.id.slice(-8)}<br>${loc.city}, ${loc.country}`);
223+
peerMarkers[peer.id] = marker;
224+
}
225+
}
226+
}
227+
228+
// Add My Location
229+
if (myLocation && !peerMarkers['me']) {
230+
const marker = L.circleMarker([myLocation.lat, myLocation.lon], {
231+
radius: 6,
232+
fillColor: "#ffffff",
233+
color: "#4ade80",
234+
weight: 2,
235+
opacity: 1,
236+
fillOpacity: 1
237+
}).addTo(map);
238+
239+
marker.bindPopup(`<b>This Node</b><br>${myLocation.city}, ${myLocation.country}`);
240+
peerMarkers['me'] = marker;
241+
}
242+
}
243+
244+
const terminal = document.getElementById('terminal');
245+
const terminalOutput = document.getElementById('terminal-output');
246+
const terminalInput = document.getElementById('terminal-input');
247+
const terminalToggle = document.getElementById('terminal-toggle');
248+
const promptEl = document.querySelector('.prompt');
249+
let myId = null;
250+
let myChatHistory = [];
251+
252+
terminalToggle.addEventListener('click', (e) => {
253+
e.stopPropagation();
254+
toggleChat();
255+
});
256+
257+
// Initialize chat state from localStorage
258+
const initChatState = () => {
259+
const isCollapsed = localStorage.getItem('chatCollapsed') === 'true';
260+
if (isCollapsed) {
261+
terminal.classList.add('collapsed');
262+
terminalToggle.innerText = '▲';
263+
document.body.classList.remove('chat-active');
264+
document.body.classList.add('chat-collapsed');
265+
} else {
266+
terminal.classList.remove('collapsed');
267+
terminalToggle.innerText = '▼';
268+
document.body.classList.add('chat-active');
269+
document.body.classList.remove('chat-collapsed');
270+
}
271+
};
272+
273+
const toggleChat = () => {
274+
terminal.classList.toggle('collapsed');
275+
const isCollapsed = terminal.classList.contains('collapsed');
276+
terminalToggle.innerText = isCollapsed ? '▲' : '▼';
277+
278+
localStorage.setItem('chatCollapsed', isCollapsed);
279+
280+
if (isCollapsed) {
281+
document.body.classList.remove('chat-active');
282+
document.body.classList.add('chat-collapsed');
283+
} else {
284+
document.body.classList.add('chat-active');
285+
document.body.classList.remove('chat-collapsed');
286+
terminalOutput.scrollTop = terminalOutput.scrollHeight;
287+
}
288+
}
289+
290+
const updatePromptStatus = () => {
291+
const now = Date.now();
292+
myChatHistory = myChatHistory.filter(t => now - t < 10000);
293+
294+
if (myChatHistory.length >= 5) {
295+
promptEl.style.color = 'orange';
296+
} else {
297+
promptEl.style.color = '#4ade80';
298+
}
299+
};
300+
301+
setInterval(updatePromptStatus, 500);
302+
303+
const getColorFromId = (id) => {
304+
if (!id) return '#666';
305+
let hash = 0;
306+
for (let i = 0; i < id.length; i++) {
307+
hash = id.charCodeAt(i) + ((hash << 5) - hash);
308+
}
309+
const c = (hash & 0x00FFFFFF).toString(16).toUpperCase();
310+
return '#' + "00000".substring(0, 6 - c.length) + c;
311+
}
312+
313+
const appendMessage = (msg) => {
314+
const div = document.createElement('div');
315+
316+
if (msg.type === 'SYSTEM') {
317+
div.className = 'msg-system';
318+
div.innerText = `[SYSTEM] ${msg.content}`;
319+
} else if (msg.type === 'CHAT') {
320+
const senderColor = getColorFromId(msg.sender);
321+
const senderName = msg.sender === myId ? 'You' : msg.sender.slice(-4);
322+
323+
const senderSpan = document.createElement('span');
324+
senderSpan.className = 'msg-sender';
325+
senderSpan.style.color = senderColor;
326+
senderSpan.innerText = `[${senderName}]`;
327+
328+
const contentSpan = document.createElement('span');
329+
contentSpan.className = 'msg-content';
330+
contentSpan.innerText = ` > ${msg.content}`;
331+
332+
div.appendChild(senderSpan);
333+
div.appendChild(contentSpan);
334+
}
335+
336+
terminalOutput.appendChild(div);
337+
terminalOutput.scrollTop = terminalOutput.scrollHeight;
338+
}
339+
340+
terminalInput.addEventListener('keypress', async (e) => {
341+
if (e.key === 'Enter') {
342+
const content = terminalInput.value.trim();
343+
if (!content) return;
344+
345+
terminalInput.value = '';
346+
347+
try {
348+
const res = await fetch('/api/chat', {
349+
method: 'POST',
350+
headers: { 'Content-Type': 'application/json' },
351+
body: JSON.stringify({ content })
352+
});
353+
354+
if (res.ok) {
355+
myChatHistory.push(Date.now());
356+
updatePromptStatus();
357+
} else if (res.status === 429) {
358+
// Force update if we hit the limit unexpectedly
359+
// Add a dummy timestamp to force the limit state if not already there
360+
if (myChatHistory.length < 5) {
361+
myChatHistory.push(Date.now());
362+
}
363+
updatePromptStatus();
364+
}
365+
} catch (err) {
366+
console.error('Failed to send message', err);
367+
}
99368
}
100369
});
101370

@@ -104,6 +373,34 @@ const evtSource = new EventSource("/events");
104373
evtSource.onmessage = (event) => {
105374
const data = JSON.parse(event.data);
106375

376+
if (data.type === 'CHAT' || data.type === 'SYSTEM') {
377+
appendMessage(data);
378+
return;
379+
}
380+
381+
if (data.chatEnabled) {
382+
terminal.classList.remove('hidden');
383+
384+
// Only initialize state once when chat becomes enabled
385+
if (!terminal.dataset.initialized) {
386+
initChatState();
387+
terminal.dataset.initialized = 'true';
388+
}
389+
} else {
390+
terminal.classList.add('hidden');
391+
document.body.classList.remove('chat-active');
392+
document.body.classList.remove('chat-collapsed');
393+
}
394+
395+
if (data.id) myId = data.id;
396+
397+
if (data.peers) {
398+
lastPeerData = data.peers;
399+
if (mapInitialized && document.getElementById('mapModal').classList.contains('active')) {
400+
updateMap(data.peers);
401+
}
402+
}
403+
107404
updateParticles(data.count);
108405

109406
if (countEl.innerText != data.count) {

public/index.html

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1">
66
<link rel="icon" href="/favicon.ico">
77
<link rel="stylesheet" href="/style.css">
8+
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
9+
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
810
</head>
911
<body>
1012
<canvas id="network"></canvas>
@@ -17,7 +19,15 @@
1719
<div class="debug">
1820
ID: {{ID}}<br>
1921
Direct Connections: <span id="direct">{{DIRECT}}</span><br>
20-
<span class="debug-link" onclick="openDiagnostics()">diagnostics</span>
22+
<span class="debug-link" onclick="openDiagnostics()">diagnostics</span> |
23+
<span class="debug-link" onclick="openMap()">map</span>
24+
</div>
25+
</div>
26+
27+
<div id="mapModal" class="modal">
28+
<div class="modal-content map-content">
29+
<button class="close-btn" onclick="closeMap()">×</button>
30+
<div id="map"></div>
2131
</div>
2232
</div>
2333

@@ -65,6 +75,14 @@
6575
</div>
6676
</div>
6777

78+
<div id="terminal" class="terminal hidden">
79+
<button id="terminal-toggle" class="terminal-toggle" title="Toggle Chat"></button>
80+
<div id="terminal-output" class="terminal-output"></div>
81+
<div class="terminal-input-line">
82+
<span class="prompt">&gt;</span>
83+
<input type="text" id="terminal-input" maxlength="140" placeholder="Broadcast to direct peers..." autocomplete="off">
84+
</div>
85+
</div>
6886

6987
<script src="/app.js"></script>
7088
</body>

0 commit comments

Comments
 (0)