Send a few lines of text alongside files on /Send
- 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 | |
| Blog/Components/Pages/Send.razor.js | +90 -8 | |
| Blog/wwwroot/app.css | +54 -0 | |
| CLAUDE.md | +5 -0 |
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(); |
| 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(); | |
| 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(); | |
| 891 | + align-items: baseline; | |
| 892 | +} | |
| 893 | + | |
| 894 | +.message + .message { | |
| 895 | + border-block-start: var(); | |
| 896 | + margin-block-start: var(); | |
| 897 | + padding-block-start: var(); | |
| 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 |