this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 391 lines 11 kB view raw
1import { Component, createRef } from "preact"; 2import { html } from "htm/preact"; 3import { signal } from "@preact/signals"; 4import { getSession, listSessions, logIn, logOut, resolveDID } from "./atsw.js"; 5import metadata from "./client-metadata.json" with { type: "json" }; 6 7import { publishSignal, deleteRecord, deleteExpiredRecords, connectJetstream } from "./signaling.js"; 8import { createPeerConnection, createOffer, createAnswer } from "./rtc.js"; 9 10const config = { 11 clientId: metadata.client_id, 12 redirectUri: metadata.redirect_uris[0], 13 scope: metadata.scope, 14}; 15 16export class App extends Component { 17 loading = signal(true); 18 did = signal(""); 19 session = null; 20 callState = signal("idle"); // idle | calling | incoming | connected 21 incomingOffer = signal(null); // { callerDid, sdp } 22 localStream = signal(null); 23 remoteStream = signal(null); 24 pc = null; 25 jetstream = null; 26 status = signal(""); 27 previewStream = signal(null); 28 signalUri = null; // AT URI of the record we published 29 expiryTimer = null; 30 31 async componentDidMount() { 32 try { 33 let did = localStorage.getItem("webrtc:did"); 34 if (!did) { 35 // check if we just came back from OAuth 36 const sessions = await listSessions(); 37 if (sessions.length > 0) { 38 did = sessions[sessions.length - 1].did; 39 localStorage.setItem("webrtc:did", did); 40 } else { 41 return; 42 } 43 } 44 45 const session = await getSession(did); 46 if (!session) { 47 localStorage.removeItem("webrtc:did"); 48 return; 49 } 50 51 this.session = session; 52 this.did.value = session.did; 53 this.#startJetstream(); 54 this.#startPreview(); 55 deleteExpiredRecords(this.session).catch((e) => 56 console.error("failed to clean up expired records", e), 57 ); 58 } catch (e) { 59 console.error("session restore failed", e); 60 localStorage.removeItem("webrtc:did"); 61 } finally { 62 this.loading.value = false; 63 } 64 } 65 66 componentWillUnmount() { 67 this.jetstream?.close(); 68 this.#hangup(); 69 this.#stopPreview(); 70 } 71 72 #startJetstream() { 73 this.jetstream?.close(); 74 this.jetstream = connectJetstream( 75 this.did.value, 76 (callerDid, record) => { 77 // incoming offer 78 if (this.callState.value !== "idle") return; 79 this.incomingOffer.value = { callerDid, sdp: record.sdp }; 80 this.callState.value = "incoming"; 81 }, 82 async (answererDid, record) => { 83 // incoming answer — we must be in "calling" state 84 if (this.callState.value !== "calling" || !this.pc) return; 85 try { 86 await this.pc.setRemoteDescription( 87 new RTCSessionDescription({ type: "answer", sdp: record.sdp }), 88 ); 89 this.callState.value = "connected"; 90 this.status.value = ""; 91 this.#deleteSignalRecord(); 92 } catch (e) { 93 console.error("failed to set remote answer", e); 94 } 95 }, 96 ); 97 } 98 99 startCall = async (handle) => { 100 try { 101 this.status.value = "Resolving handle..."; 102 const targetDid = await resolveDID(handle); 103 104 this.status.value = "Requesting camera & mic..."; 105 const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); 106 this.localStream.value = stream; 107 108 this.status.value = "Creating offer..."; 109 this.pc = createPeerConnection((e) => { 110 this.remoteStream.value = e.streams[0]; 111 }); 112 this.pc.oniceconnectionstatechange = () => { 113 if (this.pc?.iceConnectionState === "connected") this.#deleteSignalRecord(); 114 if (this.pc?.iceConnectionState === "disconnected" || this.pc?.iceConnectionState === "failed") { 115 this.#hangup(); 116 } 117 }; 118 119 const sdp = await createOffer(this.pc, stream); 120 121 this.status.value = "Sending offer..."; 122 this.signalUri = await publishSignal(this.session, targetDid, "offer", sdp); 123 this.#startExpiryTimer(); 124 125 this.callState.value = "calling"; 126 this.status.value = "Waiting for answer..."; 127 } catch (e) { 128 console.error("call failed", e); 129 this.status.value = "Call failed: " + e.message; 130 this.#hangup(); 131 } 132 }; 133 134 acceptCall = async () => { 135 const offer = this.incomingOffer.value; 136 if (!offer) return; 137 138 try { 139 this.status.value = "Requesting camera & mic..."; 140 const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); 141 this.localStream.value = stream; 142 143 this.status.value = "Creating answer..."; 144 this.pc = createPeerConnection((e) => { 145 this.remoteStream.value = e.streams[0]; 146 }); 147 this.pc.oniceconnectionstatechange = () => { 148 if (this.pc?.iceConnectionState === "connected") this.#deleteSignalRecord(); 149 if (this.pc?.iceConnectionState === "disconnected" || this.pc?.iceConnectionState === "failed") { 150 this.#hangup(); 151 } 152 }; 153 154 const sdp = await createAnswer(this.pc, stream, offer.sdp); 155 156 this.status.value = "Sending answer..."; 157 this.signalUri = await publishSignal(this.session, offer.callerDid, "answer", sdp); 158 this.#startExpiryTimer(); 159 160 this.callState.value = "connected"; 161 this.status.value = ""; 162 } catch (e) { 163 console.error("accept failed", e); 164 this.status.value = "Failed to accept: " + e.message; 165 this.#hangup(); 166 } 167 }; 168 169 declineCall = () => { 170 this.incomingOffer.value = null; 171 this.callState.value = "idle"; 172 }; 173 174 hangup = () => this.#hangup(); 175 176 #hangup() { 177 this.pc?.close(); 178 this.pc = null; 179 // Stop call stream tracks only if they differ from the preview 180 if (this.localStream.value && this.localStream.value !== this.previewStream.value) { 181 this.localStream.value.getTracks().forEach((t) => t.stop()); 182 } 183 this.localStream.value = null; 184 this.remoteStream.value = null; 185 this.incomingOffer.value = null; 186 this.callState.value = "idle"; 187 this.status.value = ""; 188 this.#deleteSignalRecord(); 189 } 190 191 async #startPreview() { 192 try { 193 const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); 194 this.previewStream.value = stream; 195 } catch (e) { 196 console.error("preview camera failed", e); 197 } 198 } 199 200 #stopPreview() { 201 this.previewStream.value?.getTracks().forEach((t) => t.stop()); 202 this.previewStream.value = null; 203 } 204 205 #deleteSignalRecord() { 206 if (!this.signalUri || !this.session) return; 207 const rkey = this.signalUri.split("/").pop(); 208 deleteRecord(this.session, rkey).catch((e) => 209 console.error("failed to delete signal record", e), 210 ); 211 this.signalUri = null; 212 clearTimeout(this.expiryTimer); 213 this.expiryTimer = null; 214 } 215 216 #startExpiryTimer() { 217 clearTimeout(this.expiryTimer); 218 this.expiryTimer = setTimeout(() => { 219 this.#deleteSignalRecord(); 220 // if we're still waiting for an answer, the offer expired 221 if (this.callState.value === "calling") { 222 this.status.value = "Call expired — no answer received."; 223 this.#hangup(); 224 } 225 }, 60_000); 226 } 227 228 logout = async () => { 229 this.jetstream?.close(); 230 this.#hangup(); 231 this.#stopPreview(); 232 const did = this.did.value; 233 localStorage.removeItem("webrtc:did"); 234 this.did.value = ""; 235 this.session = null; 236 await logOut(did); 237 }; 238 239 render() { 240 if (this.loading.value) return html`<div class="center"><p>Loading...</p></div>`; 241 if (!this.did.value) return html`<${Login} />`; 242 243 const state = this.callState.value; 244 245 return html` 246 <div class="app"> 247 <header> 248 <span>Signed in as <strong>${this.did.value}</strong></span> 249 <button onClick=${this.logout}>Sign out</button> 250 </header> 251 252 ${state === "idle" && html` 253 <${Preview} stream=${this.previewStream.value} /> 254 <${CallForm} onCall=${this.startCall} /> 255 `} 256 257 ${state === "incoming" && html` 258 <${Preview} stream=${this.previewStream.value} /> 259 <${IncomingCall} 260 callerDid=${this.incomingOffer.value?.callerDid} 261 onAccept=${this.acceptCall} 262 onDecline=${this.declineCall} 263 /> 264 `} 265 266 ${(state === "calling" || state === "connected") && 267 html`<${VideoCall} 268 localStream=${this.localStream.value} 269 remoteStream=${this.remoteStream.value} 270 onHangup=${this.hangup} 271 />`} 272 273 ${this.status.value && html`<div class="status">${this.status.value}</div>`} 274 </div> 275 `; 276 } 277} 278 279class Login extends Component { 280 async onSubmit(e) { 281 e.preventDefault(); 282 const data = new FormData(e.currentTarget); 283 const identifier = data.get("handle"); 284 if (typeof identifier !== "string") throw new Error("invalid handle"); 285 286 await logIn(config, identifier); 287 } 288 289 render() { 290 return html` 291 <div class="center"> 292 <h1>ATProtoCall</h1> 293 <form class="login-form" onSubmit=${(e) => this.onSubmit(e)}> 294 <input name="handle" placeholder="yourhandle.bsky.social" required /> 295 <button>Log in</button> 296 </form> 297 </div> 298 `; 299 } 300} 301 302class CallForm extends Component { 303 async onSubmit(e) { 304 e.preventDefault(); 305 const data = new FormData(e.currentTarget); 306 const handle = data.get("peer"); 307 if (typeof handle !== "string" || !handle) return; 308 this.props.onCall(handle); 309 } 310 311 render() { 312 return html` 313 <div class="center"> 314 <h2>Start a call</h2> 315 <form class="call-form" onSubmit=${(e) => this.onSubmit(e)}> 316 <input name="peer" placeholder="theirhandle.bsky.social" required /> 317 <button>Call</button> 318 </form> 319 </div> 320 `; 321 } 322} 323 324class IncomingCall extends Component { 325 render() { 326 return html` 327 <div class="toast"> 328 <p>Incoming call from <strong>${this.props.callerDid}</strong></p> 329 <div class="toast-actions"> 330 <button class="accept" onClick=${this.props.onAccept}>Accept</button> 331 <button class="decline" onClick=${this.props.onDecline}>Decline</button> 332 </div> 333 </div> 334 `; 335 } 336} 337 338class Preview extends Component { 339 ref = createRef(); 340 341 componentDidMount() { 342 this.#sync(); 343 } 344 345 componentDidUpdate() { 346 this.#sync(); 347 } 348 349 #sync() { 350 if (this.ref.current) this.ref.current.srcObject = this.props.stream || null; 351 } 352 353 render() { 354 if (!this.props.stream) return null; 355 return html`<video ref=${this.ref} autoplay playsinline muted class="preview-video" />`; 356 } 357} 358 359class VideoCall extends Component { 360 localRef = createRef(); 361 remoteRef = createRef(); 362 363 componentDidMount() { 364 this.#syncVideo(); 365 } 366 367 componentDidUpdate() { 368 this.#syncVideo(); 369 } 370 371 #syncVideo() { 372 if (this.localRef.current && this.props.localStream) { 373 this.localRef.current.srcObject = this.props.localStream; 374 } 375 if (this.remoteRef.current && this.props.remoteStream) { 376 this.remoteRef.current.srcObject = this.props.remoteStream; 377 } 378 } 379 380 render() { 381 return html` 382 <div class="video-call"> 383 <div class="videos"> 384 <video ref=${this.remoteRef} autoplay playsinline class="remote-video"></video> 385 <video ref=${this.localRef} autoplay playsinline muted class="local-video"></video> 386 </div> 387 <button class="hangup" onClick=${this.props.onHangup}>Hang up</button> 388 </div> 389 `; 390 } 391}