Rebasely Beta: data may be purged

examples/live-notes Public

Show who else is editing on the page

1 check passed
A 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.
99dd857456135566c3ddea862d6704b401b97ddd
Parent 6e3dc76 Browse filesHistory

Changed files

4 files changed56 additions2 deletionsagainst 6e3dc76

  1. Changed.rebasely/kb/concepts/presence.md+8−0
  2. ChangedREADME.md+6−1
  3. Changedclient/editor.ts+36−1
  4. Changedpublic/index.html+6−0
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
View file
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
View file
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";
View file
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>
View file

Checks

1 check passed
StatusCheckReported byDescriptionDetails
Successduragit/ci/testRebasely ActionssucceededRun

Runs

StatusWorkflowEventStarted
SuccessciPush