examples/live-notes Public
Show who else is editing on the page
1 check passedA row above the textarea lists everyone with the note open, with their color and (you) on your own entry, and says typing next to a name for a moment when their op arrives. The list clears while the socket is down, since the page can't know who is there.
Changed files
4 files changed56 additions2 deletionsagainst 6e3dc76
Changed.rebasely/kb/concepts/presence.md+8−0
| @@ -7,6 +7,7 @@ | ||
| describes: | ||
| - src/presence.ts | ||
| - src/note-room.ts | ||
| + - client/editor.ts | ||
| --- | ||
| # Presence | ||
| @@ -35,6 +36,13 @@ | ||
| The list of who is here is the list of open sockets, by definition. Writing it to SQLite would | ||
| add a second copy that can disagree with the first after a crash or a deploy. See | ||
| [[0003-presence-lives-on-the-sockets]]. | ||
| + | ||
| +## On the page | ||
| + | ||
| +`client/editor.ts` keeps the latest `here` list and renders it above the textarea, a colored | ||
| +dot and a name per editor, with "(you)" on its own entry. When an `op` arrives, the page | ||
| +looks `by` up in that list and shows "typing" next to the name for a second and a half. While | ||
| +the socket is down the list is cleared, since the page no longer knows. | ||
| ## Who made an edit | ||
ChangedREADME.md+6−1
| @@ -17,6 +17,9 @@ | ||
| does the same transform on its side for edits it has not had acknowledged yet, so its caret | ||
| stays where it was while others type. | ||
| +The page lists who else has the note open, each with an animal name and a color, and shows | ||
| +when one of them is typing. The room works this out from its open sockets and never stores it. | ||
| + | ||
| The room keeps the note and its last 200 operations in the Durable Object's SQLite database | ||
| and accepts sockets with the Hibernation API. A note nobody is typing in is evicted from | ||
| memory without closing anyone's connection, and wakes up on the next keystroke. | ||
| @@ -49,7 +52,8 @@ | ||
| ``` | ||
| and the room answers with `snapshot` (on connect, or when it can't use an op), `ack` (your op | ||
| -is in, at this version) and `op` (someone else's edit, at this version). | ||
| +is in, at this version), `op` (someone else's edit, at this version, with who made it) and | ||
| +`presence` (who is here, whenever someone joins or leaves). | ||
| ## Conflicts in brief | ||
| @@ -74,6 +78,7 @@ | ||
| room.ts the sequencer, with storage behind an interface | ||
| ops.ts insert, delete, transform, compose, diff | ||
| protocol.ts the socket messages and their validation | ||
| + presence.ts names and colors for the editors on a note | ||
| client/ | ||
| session.ts one editor's state: pending ops, remote ops, caret | ||
| editor.ts the page script, compiled to public/js | ||
Changedclient/editor.ts+36−1
| @@ -2,13 +2,14 @@ | ||
| // URL fragment, so /#groceries edits the note "groceries". | ||
| import { Session, moveCaret } from "./session.ts"; | ||
| -import type { ServerMessage } from "../src/protocol.ts"; | ||
| +import type { Editor, ServerMessage } from "../src/protocol.ts"; | ||
| const NOTE_ID = /^[A-Za-z0-9_-]{1,64}$/; | ||
| const area = document.querySelector<HTMLTextAreaElement>("#note")!; | ||
| const status = document.querySelector<HTMLElement>("#status")!; | ||
| const title = document.querySelector<HTMLElement>("#title")!; | ||
| +const people = document.querySelector<HTMLUListElement>("#here")!; | ||
| function noteId(): string { | ||
| const id = decodeURIComponent(location.hash.slice(1)); | ||
| @@ -18,6 +19,31 @@ | ||
| let socket: WebSocket | null = null; | ||
| let retry = 0; | ||
| +// Who is here, from the room's latest presence message, and when each was last seen typing. | ||
| +let here: Editor[] = []; | ||
| +let me: string | null = null; | ||
| +const typing = new Map<string, number>(); | ||
| +const TYPING_MS = 1500; | ||
| + | ||
| +function renderPeople(): void { | ||
| + const now = Date.now(); | ||
| + people.replaceChildren( | ||
| + ...here.map((editor) => { | ||
| + const li = document.createElement("li"); | ||
| + li.style.setProperty("--color", editor.color); | ||
| + li.textContent = editor.id === me ? `${editor.name} (you)` : editor.name; | ||
| + if ((typing.get(editor.id) ?? 0) > now) li.classList.add("typing"); | ||
| + return li; | ||
| + }), | ||
| + ); | ||
| +} | ||
| + | ||
| +function sawTyping(id: string): void { | ||
| + typing.set(id, Date.now() + TYPING_MS); | ||
| + renderPeople(); | ||
| + setTimeout(renderPeople, TYPING_MS + 50); | ||
| +} | ||
| + | ||
| function connect(): void { | ||
| const id = noteId(); | ||
| title.textContent = id; | ||
| @@ -32,6 +58,12 @@ | ||
| ws.addEventListener("message", (event) => { | ||
| const msg = JSON.parse(String(event.data)) as ServerMessage; | ||
| + if (msg.type === "presence") { | ||
| + here = msg.here; | ||
| + if (msg.you) me = msg.you.id; | ||
| + renderPeople(); | ||
| + return; | ||
| + } | ||
| const { selectionStart, selectionEnd } = area; | ||
| const applied = session.receive(msg); | ||
| if (applied === "reset") { | ||
| @@ -43,6 +75,7 @@ | ||
| } else if (applied) { | ||
| area.value = session.text; | ||
| area.setSelectionRange(moveCaret(selectionStart, applied), moveCaret(selectionEnd, applied)); | ||
| + if (msg.type === "op") sawTyping(msg.by); | ||
| } | ||
| if (msg.type !== "snapshot") status.textContent = session.pending ? "Saving" : `Version ${session.version}`; | ||
| }); | ||
| @@ -55,6 +88,8 @@ | ||
| ws.addEventListener("close", () => { | ||
| if (socket !== ws) return; | ||
| area.disabled = true; | ||
| + here = []; | ||
| + renderPeople(); | ||
| // Back off to at most 10 seconds between attempts. | ||
| const wait = Math.min(10_000, 500 * 2 ** retry++); | ||
| status.textContent = "Offline, reconnecting"; | ||
Changedpublic/index.html+6−0
| @@ -12,6 +12,11 @@ | ||
| header { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; } | ||
| h1 { font-size: 20px; font-weight: 600; margin: 0; } | ||
| #status { color: var(--muted); font-size: 13px; } | ||
| + #here { display: flex; flex-wrap: wrap; gap: 6px 14px; list-style: none; margin: 0 0 12px; padding: 0; min-height: 20px; font-size: 13px; } | ||
| + #here li { display: flex; align-items: center; gap: 6px; color: var(--muted); } | ||
| + #here li::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--color); } | ||
| + #here li.typing { color: var(--fg); } | ||
| + #here li.typing::after { content: "typing"; color: var(--color); } | ||
| textarea { box-sizing: border-box; width: 100%; min-height: 70vh; padding: 12px; border: 1px solid var(--line); | ||
| border-radius: 8px; background: transparent; color: inherit; font: 14px/1.6 ui-monospace, monospace; resize: vertical; } | ||
| textarea:disabled { opacity: 0.6; } | ||
| @@ -24,6 +29,7 @@ | ||
| <h1 id="title">scratch</h1> | ||
| <span id="status">Connecting</span> | ||
| </header> | ||
| + <ul id="here" aria-label="Who is here" aria-live="polite"></ul> | ||
| <textarea id="note" spellcheck="false" disabled aria-label="Note text"></textarea> | ||
| <footer>Change the part after # in the address to open another note. Anyone with the same address edits the same text.</footer> | ||
| </main> | ||
Checks
1 check passed| Status | Check | Reported by | Description | Details |
|---|---|---|---|---|
| Success | duragit/ci/test | Rebasely Actions | succeeded | Run |
Runs
| Status | Workflow | Event | Started |
|---|---|---|---|
| Success | ci | Push |