@@ -54,11 +54,14 @@ export default function App() {
5454 const [ connected , setConnected ] = useState ( false )
5555 const [ streamActive , setStreamActive ] = useState ( false )
5656 const [ showQR , setShowQR ] = useState ( false )
57+ const [ messages , setMessages ] = useState ( [ ] )
58+ const [ msgInput , setMsgInput ] = useState ( '' )
5759
5860 const peerRef = useRef ( null )
5961 const connRef = useRef ( null )
6062 const mediaRef = useRef ( null )
6163 const audioRef = useRef ( null )
64+ const chatEndRef = useRef ( null )
6265
6366 const peerOptions = useMemo ( ( ) => ( {
6467 host : cfg . host , port : Number ( cfg . port ) , secure : ! ! cfg . secure , path : cfg . path || '/' ,
@@ -110,6 +113,10 @@ export default function App() {
110113 }
111114 } , [ peerOptions ] )
112115
116+ useEffect ( ( ) => {
117+ chatEndRef . current ?. scrollIntoView ( { behavior : 'smooth' } )
118+ } , [ messages ] )
119+
113120 function pushLog ( x ) { setLog ( ( l ) => [ x , ...l ] . slice ( 0 , 200 ) ) }
114121
115122 async function getMic ( ) {
@@ -138,6 +145,14 @@ export default function App() {
138145 conn . on ( 'data' , ( data ) => {
139146 if ( data ?. type === 'presence' ) {
140147 setPeers ( ( p ) => mergePeers ( p , data . payload ) )
148+ } else if ( data ?. type === 'message' ) {
149+ setMessages ( ( msgs ) => [ ...msgs , {
150+ text : data . text ,
151+ sender : data . sender ,
152+ timestamp : data . timestamp ,
153+ isMe : false
154+ } ] )
155+ pushLog ( `Message from ${ data . sender } : ${ data . text } ` )
141156 } else if ( data ?. type === 'signal' ) {
142157 // Place for extra messages if needed
143158 }
@@ -195,12 +210,245 @@ export default function App() {
195210 }
196211 }
197212
213+ function sendMessage ( ) {
214+ if ( ! msgInput . trim ( ) || ! connRef . current ?. open ) return
215+
216+ const message = {
217+ type : 'message' ,
218+ text : msgInput ,
219+ sender : label || 'Anonymous' ,
220+ timestamp : Date . now ( )
221+ }
222+
223+ connRef . current . send ( message )
224+
225+ setMessages ( ( msgs ) => [ ...msgs , {
226+ text : msgInput ,
227+ sender : label || 'Anonymous' ,
228+ timestamp : Date . now ( ) ,
229+ isMe : true
230+ } ] )
231+
232+ pushLog ( `You sent: ${ msgInput } ` )
233+ setMsgInput ( '' )
234+ }
235+
236+ function handleKeyPress ( e ) {
237+ if ( e . key === 'Enter' && ! e . shiftKey ) {
238+ e . preventDefault ( )
239+ sendMessage ( )
240+ }
241+ }
242+
198243 const connectDisabled = ! peerIdInput || status !== 'ready'
199244 const shareableUrl = myId ? `${ window . location . origin } ${ window . location . pathname } ?peer=${ myId } ` : ''
200245 const qrUrl = myId ? generateQR ( shareableUrl ) : ''
201246
202247 return (
203248 < div className = "app" >
249+ < style > { `
250+ .app {
251+ min-height: 100vh;
252+ background: #0f172a;
253+ color: #e2e8f0;
254+ padding: 20px;
255+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
256+ }
257+ .card {
258+ background: #1e293b;
259+ border-radius: 12px;
260+ padding: 20px;
261+ max-width: 1200px;
262+ margin: 0 auto;
263+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
264+ }
265+ .header {
266+ display: flex;
267+ align-items: center;
268+ gap: 12px;
269+ flex-wrap: wrap;
270+ }
271+ .h {
272+ margin: 0;
273+ font-size: 24px;
274+ font-weight: 600;
275+ color: #f1f5f9;
276+ }
277+ .badge {
278+ background: #334155;
279+ padding: 6px 12px;
280+ border-radius: 6px;
281+ font-size: 13px;
282+ font-weight: 500;
283+ }
284+ .badge.small {
285+ font-size: 11px;
286+ padding: 4px 8px;
287+ }
288+ .row {
289+ display: flex;
290+ gap: 10px;
291+ align-items: center;
292+ }
293+ .grow {
294+ flex: 1;
295+ }
296+ .small {
297+ font-size: 13px;
298+ color: #94a3b8;
299+ margin-bottom: 4px;
300+ }
301+ .mono {
302+ font-family: 'Courier New', monospace;
303+ font-size: 13px;
304+ background: #0f172a;
305+ padding: 8px;
306+ border-radius: 6px;
307+ margin-top: 4px;
308+ }
309+ button {
310+ background: #3b82f6;
311+ color: white;
312+ border: none;
313+ padding: 10px 16px;
314+ border-radius: 6px;
315+ cursor: pointer;
316+ font-size: 14px;
317+ font-weight: 500;
318+ transition: all 0.2s;
319+ }
320+ button:hover:not(:disabled) {
321+ background: #2563eb;
322+ transform: translateY(-1px);
323+ }
324+ button:disabled {
325+ opacity: 0.5;
326+ cursor: not-allowed;
327+ }
328+ button.secondary {
329+ background: #475569;
330+ }
331+ button.secondary:hover:not(:disabled) {
332+ background: #334155;
333+ }
334+ button.primary {
335+ background: #10b981;
336+ }
337+ button.primary:hover:not(:disabled) {
338+ background: #059669;
339+ }
340+ button.danger {
341+ background: #ef4444;
342+ }
343+ button.danger:hover:not(:disabled) {
344+ background: #dc2626;
345+ }
346+ input {
347+ background: #0f172a;
348+ border: 1px solid #334155;
349+ color: #e2e8f0;
350+ padding: 10px;
351+ border-radius: 6px;
352+ font-size: 14px;
353+ width: 100%;
354+ }
355+ input:focus {
356+ outline: none;
357+ border-color: #3b82f6;
358+ }
359+ .grid {
360+ display: grid;
361+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
362+ gap: 16px;
363+ }
364+ .list {
365+ max-height: 200px;
366+ overflow-y: auto;
367+ background: #0f172a;
368+ padding: 12px;
369+ border-radius: 6px;
370+ }
371+ .list::-webkit-scrollbar {
372+ width: 6px;
373+ }
374+ .list::-webkit-scrollbar-track {
375+ background: #1e293b;
376+ }
377+ .list::-webkit-scrollbar-thumb {
378+ background: #475569;
379+ border-radius: 3px;
380+ }
381+ ul {
382+ margin: 8px 0;
383+ padding-left: 20px;
384+ }
385+ li {
386+ margin: 4px 0;
387+ }
388+ .chat-container {
389+ background: #0f172a;
390+ border-radius: 8px;
391+ padding: 16px;
392+ height: 400px;
393+ display: flex;
394+ flex-direction: column;
395+ }
396+ .chat-messages {
397+ flex: 1;
398+ overflow-y: auto;
399+ margin-bottom: 12px;
400+ display: flex;
401+ flex-direction: column;
402+ gap: 8px;
403+ }
404+ .chat-messages::-webkit-scrollbar {
405+ width: 6px;
406+ }
407+ .chat-messages::-webkit-scrollbar-track {
408+ background: #1e293b;
409+ }
410+ .chat-messages::-webkit-scrollbar-thumb {
411+ background: #475569;
412+ border-radius: 3px;
413+ }
414+ .message {
415+ padding: 8px 12px;
416+ border-radius: 8px;
417+ max-width: 70%;
418+ word-wrap: break-word;
419+ }
420+ .message.me {
421+ background: #3b82f6;
422+ align-self: flex-end;
423+ margin-left: auto;
424+ }
425+ .message.other {
426+ background: #334155;
427+ align-self: flex-start;
428+ }
429+ .message-sender {
430+ font-size: 11px;
431+ font-weight: 600;
432+ margin-bottom: 2px;
433+ opacity: 0.8;
434+ }
435+ .message-text {
436+ font-size: 14px;
437+ }
438+ .message-time {
439+ font-size: 10px;
440+ opacity: 0.6;
441+ margin-top: 2px;
442+ }
443+ .chat-input-container {
444+ display: flex;
445+ gap: 8px;
446+ }
447+ .chat-input {
448+ flex: 1;
449+ }
450+ ` } </ style >
451+
204452 < div className = "card" >
205453 < div className = "header" >
206454 < h2 className = "h" > Local P2P Voice Chat</ h2 >
@@ -261,6 +509,43 @@ export default function App() {
261509 </ div >
262510 </ div >
263511
512+ < div className = "card" style = { { marginTop : 16 , padding : 16 } } >
513+ < div className = "small" style = { { marginBottom : 8 } } > Text Chat</ div >
514+ < div className = "chat-container" >
515+ < div className = "chat-messages" >
516+ { messages . length === 0 ? (
517+ < div className = "small" style = { { textAlign : 'center' , opacity : 0.5 , marginTop : 'auto' , marginBottom : 'auto' } } >
518+ No messages yet. Connect with a peer to start chatting!
519+ </ div >
520+ ) : (
521+ messages . map ( ( msg , i ) => (
522+ < div key = { i } className = { `message ${ msg . isMe ? 'me' : 'other' } ` } >
523+ < div className = "message-sender" > { msg . sender } </ div >
524+ < div className = "message-text" > { msg . text } </ div >
525+ < div className = "message-time" >
526+ { new Date ( msg . timestamp ) . toLocaleTimeString ( ) }
527+ </ div >
528+ </ div >
529+ ) )
530+ ) }
531+ < div ref = { chatEndRef } />
532+ </ div >
533+ < div className = "chat-input-container" >
534+ < input
535+ className = "chat-input"
536+ placeholder = { connected ? "Type a message..." : "Connect to a peer first" }
537+ value = { msgInput }
538+ onChange = { ( e ) => setMsgInput ( e . target . value ) }
539+ onKeyPress = { handleKeyPress }
540+ disabled = { ! connected }
541+ />
542+ < button onClick = { sendMessage } disabled = { ! connected || ! msgInput . trim ( ) } className = "primary" >
543+ Send
544+ </ button >
545+ </ div >
546+ </ div >
547+ </ div >
548+
264549 < div className = "card" style = { { marginTop : 16 , padding : 16 } } >
265550 < audio ref = { audioRef } autoPlay playsInline />
266551 </ div >
@@ -281,6 +566,7 @@ export default function App() {
281566 < li > Audio is sent end-to-end via WebRTC. Without your own TURN, very strict NATs may block audio.</ li >
282567 < li > Over GitHub Pages, only static hosting is available; we use PeerJS public broker for signalling.</ li >
283568 < li > Use the QR code to quickly share your Peer ID with others on the same Wi-Fi.</ li >
569+ < li > Text chat works independently of voice calls - you can chat without calling.</ li >
284570 </ ul >
285571 </ div >
286572 </ div >
0 commit comments