this repo has no description
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}