1import {useEffect, useRef, useState} from 'react'
2import {useNetwork} from './useNetwork'
4const short = (id: string) => id.slice(0, 8) + '…'
6const btn: React.CSSProperties = {
7 padding: '0.4rem 1rem',
8 borderRadius: 6,
9 border: '1px solid #888',
10 background: '#fff',
11 cursor: 'pointer',
12 fontSize: '1rem'
13}
15const primaryBtn: React.CSSProperties = {
16 ...btn,
17 background: '#1a7f37',
18 borderColor: '#1a7f37',
19 color: '#fff'
20}
22const dangerBtn: React.CSSProperties = {
23 ...btn,
24 background: '#c62828',
25 borderColor: '#c62828',
26 color: '#fff'
27}
29function VideoView({
30 stream,
31 muted,
32 style
33}: {
34 stream: MediaStream | null
35 muted: boolean
36 style: React.CSSProperties
37}) {
38 const ref = useRef<HTMLVideoElement>(null)
39 useEffect(() => {
40 if (ref.current && ref.current.srcObject !== stream) {
41 ref.current.srcObject = stream
42 }
43 }, [stream])
44 return <video ref={ref} autoPlay playsInline muted={muted} style={style} />
45}
47function JoinForm({onJoin, initial}: {onJoin: (name: string) => void; initial: string}) {
48 const [name, setName] = useState(initial)
49 const submit = (e: React.FormEvent) => {
50 e.preventDefault()
51 onJoin(name)
52 }
53 return (
54 <form onSubmit={submit} style={{marginTop: '1rem'}}>
55 <p>Enter an ID so other visitors can see you and call you:</p>
56 <input
57 autoFocus
58 value={name}
59 onChange={e => setName(e.target.value)}
60 placeholder="your id"
61 maxLength={40}
62 style={{padding: '0.4rem', fontSize: '1rem', marginRight: '0.5rem'}}
63 />
64 <button type="submit" style={primaryBtn} disabled={!name.trim()}>
65 Join
66 </button>
67 </form>
68 )
69}
71export default function App() {
72 const {snapshot, network} = useNetwork()
73 const {selfId, name, roster, call, notice} = snapshot
75 const inCall = call?.phase === 'connecting' || call?.phase === 'connected'
77 return (
78 <div
79 style={{
80 fontFamily: 'sans-serif',
81 maxWidth: 720,
82 margin: '2rem auto',
83 padding: '0 1rem'
84 }}
85 >
86 <h1 style={{marginBottom: '0.25rem'}}>CommonCall</h1>
87 <p style={{color: '#666', marginTop: 0}}>
88 Peer-to-peer video calls. No server: presence and call setup ride over
89 public nostr relays; audio/video flows directly over WebRTC.
90 </p>
92 {notice && (
93 <div
94 style={{
95 background: '#fff3cd',
96 border: '1px solid #e0c968',
97 borderRadius: 6,
98 padding: '0.5rem 0.75rem',
99 margin: '0.75rem 0',
100 display: 'flex',
101 justifyContent: 'space-between',
102 alignItems: 'center'
103 }}
104 >
105 <span>{notice}</span>
106 <button style={btn} onClick={() => network.dismissNotice()}>
107 OK
108 </button>
109 </div>
110 )}
112 {!name ? (
113 <JoinForm onJoin={n => network.join(n)} initial={network.savedName} />
114 ) : (
115 <div style={{margin: '0.75rem 0', color: '#444'}}>
116 You are <strong>{name}</strong>{' '}
117 <code style={{color: '#999'}}>{short(selfId)}</code>{' '}
118 <button
119 style={{...btn, fontSize: '0.85rem', padding: '0.2rem 0.6rem'}}
120 onClick={() => network.leave()}
121 disabled={inCall}
122 >
123 Leave
124 </button>
125 </div>
126 )}
128 {call?.phase === 'incoming' && (
129 <section
130 style={{
131 border: '2px solid #1a7f37',
132 borderRadius: 8,
133 padding: '1rem',
134 margin: '1rem 0'
135 }}
136 >
137 <p style={{marginTop: 0}}>
138 <strong>{call.peerName}</strong>{' '}
139 <code style={{color: '#999'}}>{short(call.peerId)}</code> wants to
140 start a video call with you.
141 </p>
142 <button style={primaryBtn} onClick={() => network.accept()}>
143 Accept
144 </button>{' '}
145 <button style={dangerBtn} onClick={() => network.decline()}>
146 Decline
147 </button>
148 </section>
149 )}
151 {call?.phase === 'outgoing' && (
152 <section
153 style={{
154 border: '1px solid #ccc',
155 borderRadius: 8,
156 padding: '1rem',
157 margin: '1rem 0'
158 }}
159 >
160 <p style={{marginTop: 0}}>
161 Calling <strong>{call.peerName}</strong>… waiting for them to
162 accept.
163 </p>
164 <button style={dangerBtn} onClick={() => network.endCall()}>
165 Cancel
166 </button>
167 </section>
168 )}
170 {inCall && call && (
171 <section
172 style={{
173 background: '#111',
174 borderRadius: 8,
175 padding: '0.75rem',
176 margin: '1rem 0',
177 color: '#eee'
178 }}
179 >
180 <div style={{position: 'relative'}}>
181 <VideoView
182 stream={call.remoteStream}
183 muted={false}
184 style={{
185 width: '100%',
186 aspectRatio: '4 / 3',
187 background: '#000',
188 borderRadius: 6,
189 objectFit: 'cover'
190 }}
191 />
192 <VideoView
193 stream={call.localStream}
194 muted
195 style={{
196 position: 'absolute',
197 right: 10,
198 bottom: 10,
199 width: '25%',
200 background: '#000',
201 border: '1px solid #444',
202 borderRadius: 6,
203 transform: 'scaleX(-1)'
204 }}
205 />
206 </div>
207 <div
208 style={{
209 display: 'flex',
210 justifyContent: 'space-between',
211 alignItems: 'center',
212 marginTop: '0.5rem'
213 }}
214 >
215 <span>
216 {call.phase === 'connected'
217 ? `In a call with ${call.peerName}`
218 : `Connecting to ${call.peerName}…`}
219 </span>
220 <button style={dangerBtn} onClick={() => network.endCall()}>
221 Hang up
222 </button>
223 </div>
224 </section>
225 )}
227 <section>
228 <h2>Visitors ({roster.length})</h2>
229 {roster.length === 0 ? (
230 <p style={{color: '#666'}}>
231 Nobody else is here right now. Open this page in another browser or
232 send the link to a friend.
233 </p>
234 ) : (
235 <table style={{borderCollapse: 'collapse', width: '100%'}}>
236 <tbody>
237 {roster.map(p => (
238 <tr key={p.peerId} style={{borderBottom: '1px solid #eee'}}>
239 <td style={{padding: '0.4rem'}}>
240 <strong>{p.name}</strong>{' '}
241 <code style={{color: '#999'}}>{short(p.peerId)}</code>
242 </td>
243 <td style={{padding: '0.4rem', color: '#666'}}>
244 {p.busy ? 'in a call' : 'available'}
245 </td>
246 <td style={{padding: '0.4rem', textAlign: 'right'}}>
247 <button
248 style={primaryBtn}
249 disabled={!name || call !== null || p.busy}
250 onClick={() => network.callPeer(p.peerId)}
251 >
252 Call
253 </button>
254 </td>
255 </tr>
256 ))}
257 </tbody>
258 </table>
259 )}
260 {!name && roster.length > 0 && (
261 <p style={{color: '#666', fontSize: '0.9rem'}}>
262 Enter an ID above to call someone.
263 </p>
264 )}
265 </section>
266 </div>
267 )
268}