Send a few lines of text alongside files on /Send

A Messages panel beside Sending and Received, for the link or note that is not worth making a file of. Enter sends and shift-Enter is a new line; the button is there for a phone keyboard. Both sides share one list, named rather than laid out left and right, since a bubble layout needs a colour this page does not have. Messages go down the same data channel as the files, as one more JSON control message. Chunks are binary and messages are strings, so the receiver never puts one into the other, and a peer on the old page just ignores the new kind. The channel is ordered, so a message sent during a transfer waits behind what of the file is already buffered - at most the 8 MB high-water mark - rather than overtaking it. A message is one channel message, and send() throws on one larger than the peer's maxMessageSize, so that is checked first and becomes a line in the log suggesting a file instead. Received text is only ever a text node. Verified between two tabs: a two-line message with markup in it arrives as those characters in both directions, and a 300 kB paste is refused with the channel still open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-09-24 17:25 UTC
committer
Marijn Besseling <njirambem@gmail.com> · 2026-09-24 17:25 UTC
commit
3959191b4814d0193d2ef40db211fada5209cd1f
parent
dbdcaffffe
tree
browse at this commit

4 files changed +165 -11

Blog/Components/Pages/Send.razor +16 -3

@@ -11,8 +11,8 @@
11 11 <h1>Send</h1>
12 12
13 13 <p>
14 - Send a file to another browser. Open this page in both, pair them with the link or the
15 - code, and the file goes straight from one to the other.
14 + Send a file, or a few lines of text, to another browser. Open this page in both, pair them
15 + with the link or the code, and it goes straight from one to the other.
16 16 </p>
17 17
18 18 @* The ICE servers are configuration, so the page carries them rather than the script
@@ -57,6 +57,19 @@
57 57 <p id="nothingYet">Nothing yet.</p>
58 58 </Panel>
59 59
60 + <Panel Legend="Messages">
61 + <ol id="messages" class="messages"></ol>
62 +
63 + @* Enter sends and shift-Enter is a new line, the way every chat box works; the button is
64 + there for a phone keyboard, which has no shift-Enter to speak of. *@
65 + <form id="messageForm">
66 + <label for="messageText">Text</label>
67 + <textarea id="messageText" rows="2" disabled
68 + placeholder="A link, a note, a snippet — Enter sends it."></textarea>
69 + <button type="submit" id="messageSend" disabled>Send</button>
70 + </form>
71 + </Panel>
72 +
60 73 <Panel Legend="How this works">
61 74 <p>
62 75 <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API"
@@ -64,7 +77,7 @@
64 77 connection straight to another browser. The two ends swap what they know about how
65 78 to reach each other, settle on a route, and from then on talk directly — on a shared
66 79 network, over the local one. The file is read off disk in chunks and written down
67 - that connection.
80 + that connection. Messages take the same way, so they are never seen by this site either.
68 81 </p>
69 82 </Panel>
70 83

Blog/Components/Pages/Send.razor.js +90 -8

@@ -1,4 +1,4 @@
1 -// /Send — a file from one browser to another, over a WebRTC data channel.
1 +// /Send — a file, or some text, from one browser to another, over a WebRTC data channel.
2 2 //
3 3 // The site's part is the introduction only: a WebSocket at /api/send/{code} carries the offer,
4 4 // the answer and the ICE candidates between the two browsers holding the same code. Once the data
@@ -35,6 +35,10 @@ const dropZone = getById("drop");
35 35 const outgoing = getById("outgoing");
36 36 const incoming = getById("incoming");
37 37 const nothingYet = getById("nothingYet");
38 +const messages = getById("messages");
39 +const messageForm = getById("messageForm");
40 +const messageText = getById("messageText");
41 +const messageSend = getById("messageSend");
38 42
39 43 /** @type {RTCPeerConnection|null} */
40 44 let connection = null;
@@ -283,8 +287,15 @@ function teardown() {
283 287 earlyCandidates = [];
284 288 arriving = null;
285 289 everConnected = false;
286 - fileInput.disabled = true;
287 - pairing.hidden = false;
290 + setOpen(false);
291 +}
292 +
293 +/** Everything that can only be used with a channel to use it on, and the pairing that can't. */
294 +function setOpen(open) {
295 + fileInput.disabled = !open;
296 + messageText.disabled = !open;
297 + messageSend.disabled = !open;
298 + pairing.hidden = open;
288 299 }
289 300
290 301 function attach(dataChannel) {
@@ -293,15 +304,13 @@ function attach(dataChannel) {
293 304 channel.bufferedAmountLowThreshold = LOW_WATER;
294 305
295 306 channel.addEventListener("open", () => {
296 - fileInput.disabled = false;
297 - pairing.hidden = true;
298 - setState("Connected. Pick a file.");
307 + setOpen(true);
308 + setState("Connected. Pick a file, or write something.");
299 309 describeRoute();
300 310 });
301 311
302 312 channel.addEventListener("close", () => {
303 - fileInput.disabled = true;
304 - pairing.hidden = false;
313 + setOpen(false);
305 314 setState("The connection closed.");
306 315 });
307 316
@@ -416,6 +425,74 @@ function drain() {
416 425 }
417 426
418 427
428 +/* -- Messages -------------------------------------------------------------- */
429 +
430 +// Text goes down the same channel as the files, as one more control message. The channel is
431 +// ordered, so a message sent while a file is going out waits behind whatever of that file is
432 +// already buffered — at most HIGH_WATER — rather than overtaking it. The receiver can tell the two
433 +// apart because chunks are binary and messages are strings, so one never lands in the other.
434 +
435 +messageForm.addEventListener("submit", event => {
436 + event.preventDefault();
437 + sendMessage();
438 +});
439 +
440 +messageText.addEventListener("keydown", event => {
441 + // isComposing: Enter also confirms a word in an input method, and that one is not a send.
442 + if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
443 + event.preventDefault();
444 + sendMessage();
445 +});
446 +
447 +function sendMessage() {
448 + const text = messageText.value;
449 + if (text.trim() === "") return;
450 +
451 + if (channel?.readyState !== "open") {
452 + writeError("Not connected — the message was not sent.");
453 + return;
454 + }
455 +
456 + // A message goes as one data channel message, and send() throws on one larger than the peer
457 + // will take. Checked first so that is a sentence in the log rather than an uncaught TypeError.
458 + // Measured as the UTF-8 the channel actually sends, not in UTF-16 code units.
459 + const payload = JSON.stringify({kind: "text", text});
460 + const limit = connection.sctp?.maxMessageSize || CHUNK;
461 + if (new TextEncoder().encode(payload).byteLength > limit) {
462 + writeError(`That is too long for a message (over ${formatSize(limit)}) — send it as a file.`);
463 + return;
464 + }
465 +
466 + channel.send(payload);
467 + addMessage("you", text);
468 + messageText.value = "";
469 +}
470 +
471 +function addMessage(from, text) {
472 + const copy = h("button", {type: "button", class: "message-copy"}, "Copy");
473 + copy.addEventListener("click", async () => {
474 + try {
475 + await navigator.clipboard.writeText(text);
476 + writeInfo("Message copied.");
477 + } catch {
478 + writeError("Could not copy — select the text instead.");
479 + }
480 + });
481 +
482 + const time = new Date();
483 + messages.appendChild(h("li", {class: `message from-${from}`}, [
484 + h("span", {class: "message-from"}, from),
485 + h("time", {datetime: time.toISOString()},
486 + time.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"})),
487 + copy,
488 + // A text node through h(), never markup: this is whatever the other side typed.
489 + h("p", {class: "message-text"}, text),
490 + ]));
491 +
492 + messages.lastElementChild.scrollIntoView({block: "nearest"});
493 +}
494 +
495 +
419 496 /* -- Receiving ------------------------------------------------------------- */
420 497
421 498 function onData(event) {
@@ -435,6 +512,11 @@ function onData(event) {
435 512 }
436 513
437 514 function onControl(message) {
515 + if (message.kind === "text") {
516 + if (typeof message.text === "string") addMessage("them", message.text);
517 + return;
518 + }
519 +
438 520 if (message.kind === "start") {
439 521 nothingYet.hidden = true;
440 522 arriving = {

Blog/wwwroot/app.css +54 -0

@@ -868,6 +868,60 @@ progress {
868 868 color: var(--error);
869 869 }
870 870
871 +/* Both sides in one column, oldest first, told apart by the name on each rather than by which
872 + side of the panel they sit on: a bubble layout needs a colour or a width to spend, and this
873 + page has an 80ch measure and no colour. */
874 +.messages {
875 + list-style: none;
876 + margin: 0 0 var(--s0);
877 + padding: 0;
878 + max-block-size: 60vh;
879 + overflow-y: auto;
880 +}
881 +
882 +.messages:empty {
883 + display: none;
884 +}
885 +
886 +/* Who, when and a copy button on one line, the text under them across the whole row. */
887 +.message {
888 + display: grid;
889 + grid-template-columns: auto 1fr auto;
890 + column-gap: var(--s0);
891 + align-items: baseline;
892 +}
893 +
894 +.message + .message {
895 + border-block-start: var(--rule);
896 + margin-block-start: var(--s-1);
897 + padding-block-start: var(--s-1);
898 +}
899 +
900 +.message-from {
901 + font-weight: bold;
902 +}
903 +
904 +.message.from-you .message-from {
905 + font-weight: normal;
906 +}
907 +
908 +.message time {
909 + font-variant-numeric: tabular-nums;
910 +}
911 +
912 +.message-copy {
913 + font-size: 0.8em;
914 +}
915 +
916 +/* What was typed, as it was typed: its own line breaks, and a pasted URL or token with no spaces
917 + still wrapping instead of pushing the panel wide. */
918 +.message-text {
919 + grid-column: 1 / -1;
920 + margin: 0;
921 + white-space: pre-wrap;
922 + overflow-wrap: anywhere;
923 +}
924 +
871 925
872 926 /* -- Scroll shortcuts ------------------------------------------------------ */
873 927

CLAUDE.md +5 -0

@@ -195,6 +195,11 @@ Sending is chunked with backpressure (`bufferedAmountLowThreshold`, a high-water
195 195 file at a time, because the receiver's side of the protocol assumes it; a received file is held in
196 196 memory as `Blob` parts until it is saved. Both ends can send — the page is symmetric.
197 197
198 +Text messages go down the same channel as one more JSON control message (`{kind: "text"}`);
199 +chunks are binary and control messages are strings, which is how the receiver keeps them apart.
200 +A message is one channel message, so anything over `sctp.maxMessageSize` is refused up front
201 +rather than handed to `send()`, which would throw.
202 +
198 203 ### Git browser (`/git`)
199 204
200 205 `Components/Pages/Git/*.razor` replace the cgit that used to run at git.bes.is: an index, a