1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189 | /**
* Local room directory + lobby. Rooms are capability links (#secret); the list
* of rooms you know lives ONLY in this browser's localStorage — the relay
* never learns which rooms exist or what they're called.
*
* No native prompt()/confirm() anywhere: naming is an inline input, renaming
* is in-place, and forgetting is a two-tap confirmation.
*/
export interface RoomEntry {
s: string; // secret (43-char base64url)
label: string;
ts: number; // last visited
}
const KEY = "sueta:rooms";
export function loadRooms(): RoomEntry[] {
try {
const raw = JSON.parse(localStorage.getItem(KEY) ?? "[]");
if (Array.isArray(raw)) return raw.filter((r) => typeof r?.s === "string" && typeof r?.label === "string");
} catch {}
return [];
}
function persist(rooms: RoomEntry[]): void {
try {
localStorage.setItem(KEY, JSON.stringify(rooms.slice(0, 50)));
} catch {}
}
/** Record a visit; creates the entry with a default label on first sight. */
export function touchRoom(secret: string): RoomEntry {
const rooms = loadRooms();
let r = rooms.find((x) => x.s === secret);
if (!r) {
r = { s: secret, label: defaultLabel(), ts: Date.now() };
rooms.push(r);
}
r.ts = Date.now();
rooms.sort((a, b) => b.ts - a.ts);
persist(rooms);
return r;
}
export function renameRoom(secret: string, label: string): void {
const rooms = loadRooms();
const r = rooms.find((x) => x.s === secret);
if (r) {
r.label = label.slice(0, 40) || r.label;
persist(rooms);
}
}
export function forgetRoom(secret: string): void {
persist(loadRooms().filter((x) => x.s !== secret));
}
function defaultLabel(): string {
return "room · " + new Date().toLocaleDateString([], { month: "short", day: "numeric" });
}
const fmtAgo = (ts: number): string => {
const m = Math.round((Date.now() - ts) / 60000);
if (m < 1) return "now";
if (m < 60) return `${m}m ago`;
if (m < 60 * 24) return `${Math.round(m / 60)}h ago`;
return `${Math.round(m / 1440)}d ago`;
};
/** Full-screen lobby; resolves with the chosen or newly created room secret. */
export function lobby(root: HTMLElement, makeSecret: () => string): Promise<string> {
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.className = "overlay lobby";
let renaming: string | null = null; // secret of the row being renamed
const done = (secret: string) => {
overlay.remove();
resolve(secret);
};
const render = () => {
const rooms = loadRooms();
overlay.innerHTML = `
<div class="modal lobby-box">
<h2>sueta</h2>
<p>p2p · end-to-end encrypted · rooms are private links,<br>saved only on this device</p>
<div class="room-list">${rooms.length === 0 ? '<p class="hint">no rooms yet — start one below</p>' : ""}</div>
<form id="new-room-form" class="new-room-form">
<input id="new-room-name" type="text" maxlength="40" placeholder="new room name…" enterkeyhint="go" autocomplete="off">
<button type="submit" class="primary" id="new-room">➕ start</button>
</form>
<form id="join-form" class="new-room-form">
<input id="join-link" type="text" placeholder="…or paste an invite link" enterkeyhint="go" autocomplete="off" spellcheck="false">
<button type="submit" class="primary ghosty" id="join-room">join</button>
</form>
<p class="hint">links open the room directly in a browser — in the installed app, paste the invite here</p>
<p class="hint ver">version ${__APP_VERSION__}</p>
</div>`;
const list = overlay.querySelector(".room-list")!;
for (const r of rooms) {
const row = document.createElement("div");
row.className = "room-row";
if (renaming === r.s) {
// in-place rename
row.innerHTML = `<input class="rename-in" type="text" maxlength="40" value="" enterkeyhint="done">`;
const input = row.querySelector<HTMLInputElement>(".rename-in")!;
input.value = r.label;
const commit = () => {
renaming = null;
if (input.value.trim()) renameRoom(r.s, input.value.trim());
render();
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") {
renaming = null;
render();
}
});
input.addEventListener("blur", commit);
list.appendChild(row);
setTimeout(() => {
input.focus();
input.select();
}, 30);
continue;
}
row.innerHTML = `
<button class="room-open"><span class="room-label"></span><span class="room-ago">${fmtAgo(r.ts)}</span></button>
<button class="icon-btn small" title="rename">✎</button>
<button class="icon-btn small forget" title="forget">🗑</button>`;
row.querySelector<HTMLElement>(".room-label")!.textContent = r.label;
row.querySelector<HTMLElement>(".room-open")!.addEventListener("click", () => done(r.s));
row.querySelectorAll<HTMLElement>(".icon-btn")[0].addEventListener("click", () => {
renaming = r.s;
render();
});
// two-tap forget: first tap arms the button, second within 3.5s deletes
const forgetBtn = row.querySelector<HTMLButtonElement>(".forget")!;
forgetBtn.addEventListener("click", () => {
if (forgetBtn.classList.contains("armed")) {
forgetRoom(r.s);
render();
} else {
forgetBtn.classList.add("armed");
forgetBtn.textContent = "sure?";
setTimeout(() => {
forgetBtn.classList.remove("armed");
forgetBtn.textContent = "🗑";
}, 3500);
}
});
list.appendChild(row);
}
// PWA fix: invite links open the browser, not the installed app — so the
// lobby accepts a pasted link (or bare secret) and joins directly.
overlay.querySelector<HTMLFormElement>("#join-form")!.addEventListener("submit", (e) => {
e.preventDefault();
const input = overlay.querySelector<HTMLInputElement>("#join-link")!;
const m = input.value.match(/[A-Za-z0-9_-]{43}/);
if (!m) {
input.value = "";
input.placeholder = "that's not an invite link — paste the full link";
return;
}
touchRoom(m[0]);
done(m[0]);
});
overlay.querySelector<HTMLFormElement>("#new-room-form")!.addEventListener("submit", (e) => {
e.preventDefault();
const label = overlay.querySelector<HTMLInputElement>("#new-room-name")!.value.trim();
const secret = makeSecret();
touchRoom(secret);
if (label) renameRoom(secret, label);
done(secret);
});
};
render();
root.appendChild(overlay);
});
}
|