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