room-kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ import { io } from "socket.io-client";
2
+
3
+ import { createRoomClient, type JoinedRoom } from "../../src/index";
4
+ import { chatRoomType, type ChatMessage } from "../common";
5
+
6
+ const socket = io();
7
+ const chatClient = createRoomClient(socket, chatRoomType);
8
+
9
+ const joinPanel = document.getElementById("join-panel") as HTMLElement;
10
+ const workspace = document.getElementById("workspace") as HTMLElement;
11
+ const joinForm = document.getElementById("join-form") as HTMLFormElement;
12
+ const messageForm = document.getElementById("message-form") as HTMLFormElement;
13
+ const leaveButton = document.getElementById("leave-button") as HTMLButtonElement;
14
+ const presencePrevButton = document.getElementById("presence-prev") as HTMLButtonElement;
15
+ const presenceNextButton = document.getElementById("presence-next") as HTMLButtonElement;
16
+ const status = document.getElementById("status") as HTMLParagraphElement;
17
+ const roomTitle = document.getElementById("room-title") as HTMLHeadingElement;
18
+ const roomSubtitle = document.getElementById("room-subtitle") as HTMLParagraphElement;
19
+ const messages = document.getElementById("messages") as HTMLElement;
20
+ const presenceList = document.getElementById("presence-list") as HTMLUListElement;
21
+ const presenceCount = document.getElementById("presence-count") as HTMLSpanElement;
22
+ const presencePage = document.getElementById("presence-page") as HTMLParagraphElement;
23
+ const nameInput = document.getElementById("name-input") as HTMLInputElement;
24
+ const roomInput = document.getElementById("room-input") as HTMLInputElement;
25
+ const keyInput = document.getElementById("key-input") as HTMLInputElement;
26
+ const messageInput = document.getElementById("message-input") as HTMLInputElement;
27
+
28
+ let joinedRoom: JoinedRoom<typeof chatRoomType> | null = null;
29
+ let stopMessageListener: (() => void) | null = null;
30
+ let stopSystemListener: (() => void) | null = null;
31
+ let stopPresenceListener: (() => void) | null = null;
32
+ let presenceOffset = 0;
33
+ const presencePageSize = 4;
34
+
35
+ function setStatus(message: string): void {
36
+ status.textContent = message;
37
+ }
38
+
39
+ function setWorkspaceVisible(visible: boolean): void {
40
+ workspace.classList.toggle("hidden", !visible);
41
+ joinPanel.classList.toggle("hidden", visible);
42
+ }
43
+
44
+ function clearMessages(): void {
45
+ messages.innerHTML = "";
46
+ }
47
+
48
+ function renderMessage(message: ChatMessage): void {
49
+ const item = document.createElement("article");
50
+ item.className = "message";
51
+ item.innerHTML = `
52
+ <div class="message-header">
53
+ <span class="message-name">${escapeHtml(message.name)}</span>
54
+ <span class="message-time">${formatTime(message.sentAt)}</span>
55
+ </div>
56
+ <div class="message-text">${escapeHtml(message.text)}</div>
57
+ `;
58
+ messages.appendChild(item);
59
+ messages.scrollTop = messages.scrollHeight;
60
+ }
61
+
62
+ function renderSystemNotice(text: string, sentAt: string): void {
63
+ const item = document.createElement("article");
64
+ item.className = "message";
65
+ item.innerHTML = `
66
+ <div class="message-header">
67
+ <span class="message-name">System</span>
68
+ <span class="message-time">${formatTime(sentAt)}</span>
69
+ </div>
70
+ <div class="message-text">${escapeHtml(text)}</div>
71
+ `;
72
+ messages.appendChild(item);
73
+ messages.scrollTop = messages.scrollHeight;
74
+ }
75
+
76
+ function renderHistory(history: ChatMessage[]): void {
77
+ clearMessages();
78
+ for (const message of history) {
79
+ renderMessage(message);
80
+ }
81
+ }
82
+
83
+ function renderPresence(): void {
84
+ if (!joinedRoom) {
85
+ presenceCount.textContent = "0 online";
86
+ presencePage.textContent = "Showing 0 members.";
87
+ presenceList.innerHTML = "";
88
+ return;
89
+ }
90
+
91
+ const presence = joinedRoom.presence.current;
92
+ presenceCount.textContent = `${presence.count} online`;
93
+ presencePage.textContent = `Showing ${Math.min(presenceOffset + 1, presence.count)}-${Math.min(
94
+ presenceOffset + presence.members.length,
95
+ presence.count,
96
+ )} of ${presence.count} members.`;
97
+ presenceList.innerHTML = "";
98
+
99
+ if (presence.members.length === 0) {
100
+ const empty = document.createElement("li");
101
+ empty.className = "presence-empty";
102
+ empty.textContent = "Nobody is here yet.";
103
+ presenceList.appendChild(empty);
104
+ return;
105
+ }
106
+
107
+ for (const entry of presence.members) {
108
+ const item = document.createElement("li");
109
+ item.className = "presence-item";
110
+ item.innerHTML = `
111
+ <span>${escapeHtml(entry.memberProfile.userName)}</span>
112
+ <span class="presence-pill" aria-hidden="true"></span>
113
+ `;
114
+ presenceList.appendChild(item);
115
+ }
116
+ }
117
+
118
+ async function refreshPresence(): Promise<void> {
119
+ if (!joinedRoom) {
120
+ return;
121
+ }
122
+
123
+ const [count, page] = await Promise.all([
124
+ joinedRoom.presence.count(),
125
+ joinedRoom.presence.list({ offset: presenceOffset, limit: presencePageSize }),
126
+ ]);
127
+
128
+ presenceCount.textContent = `${count} online`;
129
+ presencePage.textContent = page.members.length === 0
130
+ ? `Showing 0 of ${count} members.`
131
+ : `Showing ${page.offset + 1}-${page.offset + page.members.length} of ${count} members.`;
132
+
133
+ presenceList.innerHTML = "";
134
+ if (page.members.length === 0) {
135
+ const empty = document.createElement("li");
136
+ empty.className = "presence-empty";
137
+ empty.textContent = "Nobody is here yet.";
138
+ presenceList.appendChild(empty);
139
+ return;
140
+ }
141
+
142
+ for (const entry of page.members) {
143
+ const item = document.createElement("li");
144
+ item.className = "presence-item";
145
+ item.innerHTML = `
146
+ <span>${escapeHtml(entry.memberProfile.userName)}</span>
147
+ <span class="presence-pill" aria-hidden="true"></span>
148
+ `;
149
+ presenceList.appendChild(item);
150
+ }
151
+ }
152
+
153
+ function formatTime(value: string): string {
154
+ const date = new Date(value);
155
+ return Number.isNaN(date.getTime())
156
+ ? ""
157
+ : new Intl.DateTimeFormat(undefined, {
158
+ hour: "2-digit",
159
+ minute: "2-digit",
160
+ }).format(date);
161
+ }
162
+
163
+ function escapeHtml(value: string): string {
164
+ return String(value)
165
+ .replaceAll("&", "&amp;")
166
+ .replaceAll("<", "&lt;")
167
+ .replaceAll(">", "&gt;")
168
+ .replaceAll('"', "&quot;")
169
+ .replaceAll("'", "&#39;");
170
+ }
171
+
172
+ async function connectRoom(): Promise<void> {
173
+ const payload = {
174
+ roomId: roomInput.value.trim().toLowerCase(),
175
+ roomKey: keyInput.value,
176
+ userName: nameInput.value,
177
+ };
178
+
179
+ setStatus("Joining room...");
180
+
181
+ try {
182
+ presenceOffset = 0;
183
+ joinedRoom = await chatClient.join(payload);
184
+ renderHistory(joinedRoom.roomProfile.history);
185
+ await refreshPresence();
186
+
187
+ stopMessageListener = joinedRoom.on.message((message) => {
188
+ renderMessage(message);
189
+ });
190
+
191
+ stopSystemListener = joinedRoom.on.systemNotice((notice) => {
192
+ renderSystemNotice(notice.text, notice.sentAt);
193
+ });
194
+
195
+ stopPresenceListener = joinedRoom.presence.onChange(() => {
196
+ void refreshPresence();
197
+ });
198
+
199
+ roomTitle.textContent = joinedRoom.roomId;
200
+ roomSubtitle.textContent = `Signed in as ${payload.userName}. The room stays private behind the shared key.`;
201
+ setWorkspaceVisible(true);
202
+ setStatus(`Connected to ${joinedRoom.roomId}.`);
203
+ messageInput.focus();
204
+ } catch (error) {
205
+ joinedRoom = null;
206
+ setStatus(error instanceof Error ? error.message : String(error));
207
+ }
208
+ }
209
+
210
+ async function leaveRoom(): Promise<void> {
211
+ if (!joinedRoom) {
212
+ return;
213
+ }
214
+
215
+ const roomId = joinedRoom.roomId;
216
+
217
+ try {
218
+ await joinedRoom.leave();
219
+ } finally {
220
+ stopMessageListener?.();
221
+ stopSystemListener?.();
222
+ stopPresenceListener?.();
223
+ stopMessageListener = null;
224
+ stopSystemListener = null;
225
+ stopPresenceListener = null;
226
+ joinedRoom = null;
227
+
228
+ setWorkspaceVisible(false);
229
+ joinForm.reset();
230
+ clearMessages();
231
+ presenceList.innerHTML = "";
232
+ presenceCount.textContent = "0 online";
233
+ presencePage.textContent = "Showing 0 members.";
234
+ roomTitle.textContent = "-";
235
+ roomSubtitle.textContent = "-";
236
+ setStatus(`Left ${roomId}.`);
237
+ }
238
+ }
239
+
240
+ joinForm.addEventListener("submit", async (event) => {
241
+ event.preventDefault();
242
+ await connectRoom();
243
+ });
244
+
245
+ messageForm.addEventListener("submit", async (event) => {
246
+ event.preventDefault();
247
+
248
+ if (!joinedRoom) {
249
+ setStatus("Join a room before sending messages.");
250
+ return;
251
+ }
252
+
253
+ const text = messageInput.value.trim();
254
+ if (!text) {
255
+ return;
256
+ }
257
+
258
+ try {
259
+ await joinedRoom.rpc.sendMessage({ text });
260
+ messageInput.value = "";
261
+ } catch (error) {
262
+ setStatus(error instanceof Error ? error.message : String(error));
263
+ }
264
+ });
265
+
266
+ leaveButton.addEventListener("click", async () => {
267
+ await leaveRoom();
268
+ });
269
+
270
+ presencePrevButton.addEventListener("click", async () => {
271
+ if (!joinedRoom || presenceOffset === 0) {
272
+ return;
273
+ }
274
+
275
+ presenceOffset = Math.max(0, presenceOffset - presencePageSize);
276
+ await refreshPresence();
277
+ });
278
+
279
+ presenceNextButton.addEventListener("click", async () => {
280
+ if (!joinedRoom) {
281
+ return;
282
+ }
283
+
284
+ presenceOffset += presencePageSize;
285
+ await refreshPresence();
286
+ });
287
+
288
+ socket.on("connect", () => {
289
+ setStatus("Connected. Join a room to start chatting.");
290
+ });
291
+
292
+ socket.on("disconnect", () => {
293
+ setStatus("Disconnected from the server.");
294
+ });
295
+
296
+ setWorkspaceVisible(false);
297
+ setStatus("Connecting to server...");
298
+ presencePage.textContent = "Showing 0 members.";
@@ -0,0 +1,86 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Private Room Chat</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ </head>
9
+ <body>
10
+ <main class="shell">
11
+ <section class="hero">
12
+ <p class="eyebrow">Example app</p>
13
+ <h1>Private room chat with live presence</h1>
14
+ <p class="lede">
15
+ Create a room, share the room key with the people you want inside, and watch the member list update in real time.
16
+ </p>
17
+ </section>
18
+
19
+ <section class="panel" id="join-panel">
20
+ <h2>Join a room</h2>
21
+ <form id="join-form" class="form-grid">
22
+ <label>
23
+ Display name
24
+ <input id="name-input" name="name" type="text" maxlength="32" placeholder="Ada" autocomplete="nickname" required />
25
+ </label>
26
+ <label>
27
+ Room id
28
+ <input id="room-input" name="roomId" type="text" maxlength="64" placeholder="launch-team" autocomplete="off" required />
29
+ </label>
30
+ <label>
31
+ Room key
32
+ <input id="key-input" name="roomKey" type="password" maxlength="64" placeholder="secret phrase" autocomplete="off" required />
33
+ </label>
34
+ <div class="actions">
35
+ <button type="submit">Enter room</button>
36
+ </div>
37
+ </form>
38
+ <p class="hint">
39
+ The first person to join a room sets the key. Everyone else needs the same key to enter.
40
+ </p>
41
+ <p id="status" class="status">Disconnected.</p>
42
+ </section>
43
+
44
+ <section class="workspace hidden" id="workspace">
45
+ <div class="panel room-header">
46
+ <div>
47
+ <p class="eyebrow">Active room</p>
48
+ <h2 id="room-title">-</h2>
49
+ <p id="room-subtitle" class="hint">-</p>
50
+ </div>
51
+ <button id="leave-button" class="ghost-button" type="button">Leave room</button>
52
+ </div>
53
+
54
+ <div class="columns">
55
+ <section class="panel feed-panel">
56
+ <div class="panel-title-row">
57
+ <h3>Messages</h3>
58
+ </div>
59
+ <div id="messages" class="feed"></div>
60
+ <form id="message-form" class="message-form">
61
+ <input id="message-input" type="text" maxlength="500" placeholder="Type a message..." autocomplete="off" required />
62
+ <button type="submit">Send</button>
63
+ </form>
64
+ </section>
65
+
66
+ <aside class="panel presence-panel">
67
+ <div class="panel-title-row">
68
+ <div>
69
+ <h3>Presence</h3>
70
+ <span id="presence-count" class="count">0 online</span>
71
+ </div>
72
+ <div class="presence-controls">
73
+ <button id="presence-prev" class="ghost-button" type="button">Prev</button>
74
+ <button id="presence-next" class="ghost-button" type="button">Next</button>
75
+ </div>
76
+ </div>
77
+ <p id="presence-page" class="meta">Showing 0 members.</p>
78
+ <ul id="presence-list" class="presence-list"></ul>
79
+ </aside>
80
+ </div>
81
+ </section>
82
+ </main>
83
+
84
+ <script src="/app.js"></script>
85
+ </body>
86
+ </html>
@@ -0,0 +1,317 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #0b1020;
4
+ --bg-alt: #111936;
5
+ --panel: rgba(15, 22, 47, 0.86);
6
+ --panel-border: rgba(148, 163, 184, 0.18);
7
+ --text: #e5eefc;
8
+ --muted: #9db0d0;
9
+ --accent: #7dd3fc;
10
+ --accent-strong: #38bdf8;
11
+ --danger: #fb7185;
12
+ --shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
13
+ }
14
+
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ html,
20
+ body {
21
+ min-height: 100%;
22
+ }
23
+
24
+ body {
25
+ margin: 0;
26
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
27
+ color: var(--text);
28
+ background:
29
+ radial-gradient(circle at top left, rgba(56, 189, 248, 0.28), transparent 36%),
30
+ radial-gradient(circle at 80% 0%, rgba(59, 130, 246, 0.16), transparent 26%),
31
+ linear-gradient(160deg, var(--bg), var(--bg-alt));
32
+ }
33
+
34
+ body::before {
35
+ content: "";
36
+ position: fixed;
37
+ inset: 0;
38
+ pointer-events: none;
39
+ background-image:
40
+ linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px),
41
+ linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px);
42
+ background-size: 42px 42px;
43
+ mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), transparent 90%);
44
+ }
45
+
46
+ .shell {
47
+ width: min(1120px, calc(100% - 32px));
48
+ margin: 0 auto;
49
+ padding: 40px 0 56px;
50
+ position: relative;
51
+ z-index: 1;
52
+ }
53
+
54
+ .hero {
55
+ padding: 24px 0 32px;
56
+ max-width: 760px;
57
+ }
58
+
59
+ .eyebrow {
60
+ margin: 0 0 10px;
61
+ text-transform: uppercase;
62
+ letter-spacing: 0.14em;
63
+ font-size: 0.78rem;
64
+ color: var(--accent);
65
+ }
66
+
67
+ h1,
68
+ h2,
69
+ h3,
70
+ p {
71
+ margin-top: 0;
72
+ }
73
+
74
+ h1 {
75
+ font-size: clamp(2.4rem, 5vw, 4.6rem);
76
+ line-height: 0.95;
77
+ letter-spacing: -0.05em;
78
+ margin-bottom: 18px;
79
+ }
80
+
81
+ .lede {
82
+ max-width: 60ch;
83
+ font-size: 1.06rem;
84
+ line-height: 1.65;
85
+ color: var(--muted);
86
+ }
87
+
88
+ .panel {
89
+ background: var(--panel);
90
+ border: 1px solid var(--panel-border);
91
+ border-radius: 24px;
92
+ box-shadow: var(--shadow);
93
+ backdrop-filter: blur(18px);
94
+ -webkit-backdrop-filter: blur(18px);
95
+ }
96
+
97
+ #join-panel,
98
+ .room-header,
99
+ .feed-panel,
100
+ .presence-panel {
101
+ padding: 22px;
102
+ }
103
+
104
+ .form-grid {
105
+ display: grid;
106
+ grid-template-columns: repeat(3, minmax(0, 1fr));
107
+ gap: 14px;
108
+ align-items: end;
109
+ }
110
+
111
+ label {
112
+ display: grid;
113
+ gap: 8px;
114
+ color: var(--muted);
115
+ font-size: 0.92rem;
116
+ }
117
+
118
+ input {
119
+ width: 100%;
120
+ border: 1px solid rgba(125, 211, 252, 0.2);
121
+ background: rgba(15, 23, 42, 0.9);
122
+ color: var(--text);
123
+ border-radius: 14px;
124
+ padding: 14px 16px;
125
+ font: inherit;
126
+ outline: none;
127
+ }
128
+
129
+ input:focus {
130
+ border-color: rgba(125, 211, 252, 0.7);
131
+ box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.14);
132
+ }
133
+
134
+ .actions {
135
+ display: flex;
136
+ align-items: end;
137
+ }
138
+
139
+ button {
140
+ border: 0;
141
+ border-radius: 14px;
142
+ padding: 14px 18px;
143
+ font: inherit;
144
+ font-weight: 700;
145
+ color: #042f49;
146
+ background: linear-gradient(180deg, #bae6fd, #38bdf8);
147
+ cursor: pointer;
148
+ }
149
+
150
+ button:hover {
151
+ filter: brightness(1.04);
152
+ }
153
+
154
+ button:disabled {
155
+ opacity: 0.55;
156
+ cursor: not-allowed;
157
+ }
158
+
159
+ .ghost-button {
160
+ color: var(--text);
161
+ background: rgba(148, 163, 184, 0.12);
162
+ border: 1px solid rgba(148, 163, 184, 0.2);
163
+ }
164
+
165
+ .hint,
166
+ .status,
167
+ .count,
168
+ .meta {
169
+ color: var(--muted);
170
+ }
171
+
172
+ .status {
173
+ margin-bottom: 0;
174
+ }
175
+
176
+ .hidden {
177
+ display: none;
178
+ }
179
+
180
+ .workspace {
181
+ display: grid;
182
+ gap: 18px;
183
+ margin-top: 18px;
184
+ }
185
+
186
+ .room-header {
187
+ display: flex;
188
+ align-items: start;
189
+ justify-content: space-between;
190
+ gap: 16px;
191
+ }
192
+
193
+ .columns {
194
+ display: grid;
195
+ grid-template-columns: minmax(0, 1.5fr) minmax(280px, 0.9fr);
196
+ gap: 18px;
197
+ }
198
+
199
+ .panel-title-row {
200
+ display: flex;
201
+ align-items: center;
202
+ justify-content: space-between;
203
+ gap: 12px;
204
+ margin-bottom: 14px;
205
+ }
206
+
207
+ .presence-controls {
208
+ display: flex;
209
+ gap: 8px;
210
+ flex-wrap: wrap;
211
+ justify-content: flex-end;
212
+ }
213
+
214
+ .feed-panel,
215
+ .presence-panel {
216
+ min-height: 480px;
217
+ }
218
+
219
+ .feed {
220
+ display: grid;
221
+ gap: 12px;
222
+ align-content: start;
223
+ height: 360px;
224
+ overflow: auto;
225
+ padding-right: 6px;
226
+ }
227
+
228
+ .message {
229
+ border: 1px solid rgba(148, 163, 184, 0.16);
230
+ background: rgba(8, 15, 31, 0.62);
231
+ border-radius: 18px;
232
+ padding: 14px 16px;
233
+ }
234
+
235
+ .message-header {
236
+ display: flex;
237
+ align-items: baseline;
238
+ justify-content: space-between;
239
+ gap: 12px;
240
+ margin-bottom: 8px;
241
+ }
242
+
243
+ .message-name {
244
+ font-weight: 700;
245
+ }
246
+
247
+ .message-time,
248
+ .meta {
249
+ font-size: 0.82rem;
250
+ }
251
+
252
+ .message-text {
253
+ line-height: 1.6;
254
+ white-space: pre-wrap;
255
+ word-break: break-word;
256
+ }
257
+
258
+ .message-form {
259
+ display: grid;
260
+ grid-template-columns: minmax(0, 1fr) auto;
261
+ gap: 12px;
262
+ margin-top: 16px;
263
+ }
264
+
265
+ .presence-list {
266
+ list-style: none;
267
+ padding: 0;
268
+ margin: 0;
269
+ display: grid;
270
+ gap: 10px;
271
+ }
272
+
273
+ .presence-item {
274
+ display: flex;
275
+ align-items: center;
276
+ justify-content: space-between;
277
+ gap: 12px;
278
+ border: 1px solid rgba(148, 163, 184, 0.16);
279
+ background: rgba(8, 15, 31, 0.45);
280
+ border-radius: 16px;
281
+ padding: 12px 14px;
282
+ }
283
+
284
+ .presence-pill {
285
+ width: 10px;
286
+ height: 10px;
287
+ border-radius: 999px;
288
+ background: #34d399;
289
+ box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.14);
290
+ }
291
+
292
+ .presence-empty {
293
+ color: var(--muted);
294
+ border: 1px dashed rgba(148, 163, 184, 0.18);
295
+ border-radius: 16px;
296
+ padding: 14px;
297
+ }
298
+
299
+ .presence-controls button {
300
+ padding: 10px 14px;
301
+ }
302
+
303
+ @media (max-width: 900px) {
304
+ .form-grid,
305
+ .columns,
306
+ .message-form {
307
+ grid-template-columns: 1fr;
308
+ }
309
+
310
+ .room-header {
311
+ flex-direction: column;
312
+ }
313
+
314
+ .presence-controls {
315
+ justify-content: flex-start;
316
+ }
317
+ }