rogerthat 1.21.2
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.
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/assets/logo.svg +30 -0
- package/assets/og-image.png +0 -0
- package/dist/account-ui.js +895 -0
- package/dist/accounts.js +253 -0
- package/dist/admin.js +303 -0
- package/dist/agentcard.js +76 -0
- package/dist/app.js +1140 -0
- package/dist/channel.js +526 -0
- package/dist/cli.js +158 -0
- package/dist/connect.js +224 -0
- package/dist/discovery.js +569 -0
- package/dist/email.js +67 -0
- package/dist/ids.js +24 -0
- package/dist/landing.js +558 -0
- package/dist/listen-here.js +491 -0
- package/dist/mcp.js +787 -0
- package/dist/policy.js +162 -0
- package/dist/presets.js +113 -0
- package/dist/receive-recipe.js +133 -0
- package/dist/remote-control.js +123 -0
- package/dist/remote-ui.js +850 -0
- package/dist/server.js +13 -0
- package/dist/stats.js +67 -0
- package/dist/store.js +228 -0
- package/dist/transcripts.js +68 -0
- package/dist/webhooks.js +154 -0
- package/package.json +77 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
// Mobile-first chat UI served at /remote/<channel_id>. Designed so a phone can
|
|
2
|
+
// drive an agent that's running elsewhere (e.g. Claude Code on the user's PC,
|
|
3
|
+
// already joined to the same channel and looping on `wait`). Credentials come
|
|
4
|
+
// in via the URL fragment — never hit the server, never end up in logs.
|
|
5
|
+
//
|
|
6
|
+
// URL shape:
|
|
7
|
+
// /remote/<channel_id>#t=<channel_token>&k=<identity_key>&cs=<callsign>&p=<owner_password>&n=<display_name>
|
|
8
|
+
// - t: channel token. Required for non-band channels. Bands use the literal 'public'.
|
|
9
|
+
// - k: identity_key. Optional. If present, callsign is derived from it (k wins over cs).
|
|
10
|
+
// - cs: callsign. Used when the channel doesn't require identity.
|
|
11
|
+
// - p: owner_password. Optional. When set, the phone's join is marked human-authorized
|
|
12
|
+
// and the channel's trusted-mode instructions take effect for it.
|
|
13
|
+
// - n: display name (cosmetic; falls back to callsign).
|
|
14
|
+
//
|
|
15
|
+
// If creds are missing or invalid, the page shows a paste-it form instead of
|
|
16
|
+
// auto-joining. The chat itself polls /api/channels/<id>/wait in a loop.
|
|
17
|
+
function escapeHtml(s) {
|
|
18
|
+
return s.replace(/[&<>"']/g, (ch) => {
|
|
19
|
+
switch (ch) {
|
|
20
|
+
case "&": return "&";
|
|
21
|
+
case "<": return "<";
|
|
22
|
+
case ">": return ">";
|
|
23
|
+
case '"': return """;
|
|
24
|
+
case "'": return "'";
|
|
25
|
+
default: return ch;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function remoteHtml(channelId) {
|
|
30
|
+
const safeId = escapeHtml(channelId);
|
|
31
|
+
return `<!doctype html>
|
|
32
|
+
<html lang="en">
|
|
33
|
+
<head>
|
|
34
|
+
<meta charset="utf-8" />
|
|
35
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
|
36
|
+
<meta name="theme-color" content="#1a1a1a" />
|
|
37
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
38
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
39
|
+
<title>rogerthat / ${safeId}</title>
|
|
40
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231a1a1a'/><path d='M 9 7 Q 16 4 23 7' stroke='%23d6541f' stroke-width='1.5' fill='none' stroke-linecap='round'/><ellipse cx='11' cy='14' rx='2' ry='3' fill='%23f4ede0' transform='rotate(-15 11 14)'/><ellipse cx='21' cy='14' rx='2' ry='3' fill='%23f4ede0' transform='rotate(15 21 14)'/><ellipse cx='16' cy='22' rx='8' ry='6.5' fill='%23f4ede0'/><circle cx='13' cy='21' r='1.2' fill='%231a1a1a'/><circle cx='19' cy='21' r='1.2' fill='%231a1a1a'/><ellipse cx='16' cy='25' rx='1.5' ry='1' fill='%23d6541f'/></svg>" />
|
|
41
|
+
<style>
|
|
42
|
+
:root {
|
|
43
|
+
--bg:#f4ede0; --ink:#1a1a1a; --dim:#7a6f5f; --warn:#d6541f;
|
|
44
|
+
--line:#c9b994; --paper:#fffaef; --ok:#2d8a3e;
|
|
45
|
+
}
|
|
46
|
+
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
|
47
|
+
html, body { height: 100%; margin: 0; }
|
|
48
|
+
body {
|
|
49
|
+
font-family: ui-monospace, Menlo, Consolas, monospace;
|
|
50
|
+
background: var(--bg); color: var(--ink); line-height: 1.45;
|
|
51
|
+
display: flex; flex-direction: column;
|
|
52
|
+
overscroll-behavior: contain;
|
|
53
|
+
-webkit-text-size-adjust: 100%;
|
|
54
|
+
}
|
|
55
|
+
header {
|
|
56
|
+
background: var(--ink); color: var(--bg);
|
|
57
|
+
padding: calc(10px + env(safe-area-inset-top)) 14px 10px;
|
|
58
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
59
|
+
gap: 10px; flex-shrink: 0;
|
|
60
|
+
}
|
|
61
|
+
header .left { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
62
|
+
header svg { width: 22px; height: 22px; flex-shrink: 0; }
|
|
63
|
+
header .title { display: flex; flex-direction: column; min-width: 0; }
|
|
64
|
+
header .title strong { font-size: 13px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
65
|
+
header .title small { font-size: 11px; color: #c9b994; }
|
|
66
|
+
header .status {
|
|
67
|
+
font-size: 11px; padding: 3px 8px; border-radius: 10px;
|
|
68
|
+
background: rgba(255,255,255,0.08); color: #c9b994;
|
|
69
|
+
white-space: nowrap; flex-shrink: 0;
|
|
70
|
+
}
|
|
71
|
+
header .status.ok { background: rgba(45,138,62,0.2); color: #7dd590; }
|
|
72
|
+
header .status.err { background: rgba(214,84,31,0.2); color: #f0a584; }
|
|
73
|
+
|
|
74
|
+
/* Setup view — shown when URL fragment is missing/invalid */
|
|
75
|
+
#setup { padding: 24px 16px; max-width: 480px; margin: 0 auto; width: 100%; }
|
|
76
|
+
#setup h2 { font-size: 18px; margin: 0 0 8px; }
|
|
77
|
+
#setup p { font-size: 13px; color: var(--dim); margin: 0 0 16px; }
|
|
78
|
+
#setup label { display: block; font-size: 12px; color: var(--dim); margin: 12px 0 4px; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
79
|
+
#setup input { width: 100%; padding: 12px 14px; border: 1px solid var(--line); background: white; font-family: inherit; font-size: 15px; }
|
|
80
|
+
#setup .err { color: var(--warn); font-size: 13px; margin: 8px 0; }
|
|
81
|
+
#setup button {
|
|
82
|
+
width: 100%; padding: 14px; margin-top: 16px;
|
|
83
|
+
background: var(--warn); color: white; border: none;
|
|
84
|
+
font-family: inherit; font-size: 15px; font-weight: 700; cursor: pointer;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* Chat view */
|
|
88
|
+
#log {
|
|
89
|
+
flex: 1; overflow-y: auto; overflow-x: hidden;
|
|
90
|
+
padding: 14px 12px; scroll-behavior: smooth;
|
|
91
|
+
-webkit-overflow-scrolling: touch;
|
|
92
|
+
}
|
|
93
|
+
.msg { margin: 8px 0; display: flex; flex-direction: column; gap: 2px; }
|
|
94
|
+
.msg.me { align-items: flex-end; }
|
|
95
|
+
.msg.them { align-items: flex-start; }
|
|
96
|
+
.msg.sys { align-items: center; }
|
|
97
|
+
.msg .who {
|
|
98
|
+
font-size: 10px; color: var(--dim); text-transform: uppercase;
|
|
99
|
+
letter-spacing: 0.06em; padding: 0 6px;
|
|
100
|
+
}
|
|
101
|
+
.msg .bubble {
|
|
102
|
+
max-width: 86%; padding: 9px 13px; border-radius: 14px;
|
|
103
|
+
word-wrap: break-word; overflow-wrap: anywhere;
|
|
104
|
+
background: var(--paper); border: 1px solid var(--line);
|
|
105
|
+
font-size: 15px; line-height: 1.4;
|
|
106
|
+
white-space: pre-wrap;
|
|
107
|
+
}
|
|
108
|
+
.msg.me .bubble { background: var(--warn); color: white; border-color: var(--warn); }
|
|
109
|
+
.msg.them .bubble.broadcast { border-style: dashed; }
|
|
110
|
+
.msg.sys .bubble {
|
|
111
|
+
background: transparent; border: none; padding: 4px 8px;
|
|
112
|
+
color: var(--dim); font-size: 12px; font-style: italic;
|
|
113
|
+
}
|
|
114
|
+
.msg.sys.error .bubble { color: var(--warn); }
|
|
115
|
+
.msg .when { font-size: 10px; color: var(--dim); padding: 0 6px; }
|
|
116
|
+
.msg .chips {
|
|
117
|
+
display: flex; flex-wrap: wrap; gap: 6px;
|
|
118
|
+
margin-top: 4px; padding: 0 4px;
|
|
119
|
+
}
|
|
120
|
+
.msg .chip {
|
|
121
|
+
font-family: inherit; font-size: 13px;
|
|
122
|
+
padding: 6px 12px;
|
|
123
|
+
background: var(--paper); color: var(--ink);
|
|
124
|
+
border: 1px solid var(--warn); border-radius: 14px;
|
|
125
|
+
cursor: pointer; touch-action: manipulation;
|
|
126
|
+
transition: background 0.1s;
|
|
127
|
+
}
|
|
128
|
+
.msg .chip:active { background: var(--warn); color: white; }
|
|
129
|
+
.msg .attachments {
|
|
130
|
+
display: flex; flex-wrap: wrap; gap: 6px;
|
|
131
|
+
margin-top: 4px; padding: 0 4px;
|
|
132
|
+
}
|
|
133
|
+
.msg .attachments img {
|
|
134
|
+
max-width: min(220px, 75vw); max-height: 220px;
|
|
135
|
+
border: 1px solid var(--line); border-radius: 6px;
|
|
136
|
+
cursor: zoom-in; object-fit: contain;
|
|
137
|
+
background: var(--paper);
|
|
138
|
+
}
|
|
139
|
+
.msg .attachments .pdf-link {
|
|
140
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
141
|
+
padding: 8px 12px; font-size: 13px;
|
|
142
|
+
background: var(--paper); color: var(--ink);
|
|
143
|
+
border: 1px solid var(--line); border-radius: 6px;
|
|
144
|
+
text-decoration: none;
|
|
145
|
+
}
|
|
146
|
+
.msg .attachments .pdf-link::before { content: '📄'; font-size: 18px; }
|
|
147
|
+
.msg .attachments .pdf-link:hover { background: var(--bg); }
|
|
148
|
+
|
|
149
|
+
/* Composer paperclip button + file preview chips. */
|
|
150
|
+
.paperclip {
|
|
151
|
+
display: flex; align-items: center; justify-content: center;
|
|
152
|
+
width: 36px; height: 36px; font-size: 20px;
|
|
153
|
+
background: var(--paper); border: 1px solid var(--line); border-radius: 6px;
|
|
154
|
+
cursor: pointer; flex-shrink: 0;
|
|
155
|
+
align-self: flex-end;
|
|
156
|
+
}
|
|
157
|
+
.paperclip:hover { background: var(--bg); }
|
|
158
|
+
#att-preview {
|
|
159
|
+
display: flex; flex-wrap: wrap; gap: 6px;
|
|
160
|
+
padding: 6px 8px 4px;
|
|
161
|
+
}
|
|
162
|
+
#att-preview .att-chip {
|
|
163
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
164
|
+
padding: 4px 8px; font-size: 12px;
|
|
165
|
+
background: var(--paper); border: 1px solid var(--line); border-radius: 12px;
|
|
166
|
+
}
|
|
167
|
+
#att-preview .att-chip .x {
|
|
168
|
+
cursor: pointer; color: var(--dim); font-weight: 700;
|
|
169
|
+
padding: 0 2px;
|
|
170
|
+
}
|
|
171
|
+
#att-preview .att-chip .x:hover { color: var(--warn); }
|
|
172
|
+
|
|
173
|
+
footer {
|
|
174
|
+
flex-shrink: 0; background: var(--paper);
|
|
175
|
+
border-top: 1px solid var(--line);
|
|
176
|
+
padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
|
|
177
|
+
}
|
|
178
|
+
.composer { display: flex; gap: 8px; align-items: flex-end; }
|
|
179
|
+
.target-pill {
|
|
180
|
+
display: flex; align-items: center; gap: 6px;
|
|
181
|
+
padding: 6px 10px; font-size: 12px; color: var(--dim);
|
|
182
|
+
background: var(--bg); border: 1px solid var(--line); border-bottom: none;
|
|
183
|
+
border-radius: 6px 6px 0 0; margin: 0 8px -1px; width: fit-content;
|
|
184
|
+
}
|
|
185
|
+
.target-pill select {
|
|
186
|
+
border: none; background: transparent; font-family: inherit;
|
|
187
|
+
font-size: 12px; color: var(--ink); padding: 0;
|
|
188
|
+
}
|
|
189
|
+
textarea#input {
|
|
190
|
+
flex: 1; min-height: 44px; max-height: 140px;
|
|
191
|
+
padding: 11px 13px; border: 1px solid var(--line); background: white;
|
|
192
|
+
font-family: inherit; font-size: 16px; /* 16px stops iOS zoom-on-focus */
|
|
193
|
+
line-height: 1.4; resize: none; border-radius: 8px;
|
|
194
|
+
}
|
|
195
|
+
textarea#input:focus { outline: 2px solid var(--warn); outline-offset: -1px; }
|
|
196
|
+
button#send {
|
|
197
|
+
flex-shrink: 0; padding: 11px 18px;
|
|
198
|
+
background: var(--warn); color: white; border: none;
|
|
199
|
+
font-family: inherit; font-size: 14px; font-weight: 700; cursor: pointer;
|
|
200
|
+
border-radius: 8px; min-height: 44px;
|
|
201
|
+
}
|
|
202
|
+
button#send:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
203
|
+
</style>
|
|
204
|
+
</head>
|
|
205
|
+
<body>
|
|
206
|
+
|
|
207
|
+
<header>
|
|
208
|
+
<div class="left">
|
|
209
|
+
<svg viewBox="0 0 32 32" aria-hidden="true">
|
|
210
|
+
<rect width="32" height="32" rx="6" fill="#1a1a1a"/>
|
|
211
|
+
<path d="M 9 7 Q 16 4 23 7" stroke="#d6541f" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
|
212
|
+
<ellipse cx="11" cy="14" rx="2" ry="3" fill="#f4ede0" transform="rotate(-15 11 14)"/>
|
|
213
|
+
<ellipse cx="21" cy="14" rx="2" ry="3" fill="#f4ede0" transform="rotate(15 21 14)"/>
|
|
214
|
+
<ellipse cx="16" cy="22" rx="8" ry="6.5" fill="#f4ede0"/>
|
|
215
|
+
<circle cx="13" cy="21" r="1.2" fill="#1a1a1a"/>
|
|
216
|
+
<circle cx="19" cy="21" r="1.2" fill="#1a1a1a"/>
|
|
217
|
+
<ellipse cx="16" cy="25" rx="1.5" ry="1" fill="#d6541f"/>
|
|
218
|
+
</svg>
|
|
219
|
+
<div class="title">
|
|
220
|
+
<strong id="hdr-name">${safeId}</strong>
|
|
221
|
+
<small id="hdr-meta">remote channel</small>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
<div id="status" class="status">connecting…</div>
|
|
225
|
+
</header>
|
|
226
|
+
|
|
227
|
+
<div id="setup" hidden>
|
|
228
|
+
<h2>Connect</h2>
|
|
229
|
+
|
|
230
|
+
<!-- "Quick" panel: shown when t + k/cs came in via the URL fragment.
|
|
231
|
+
The only thing the human has to type is the owner_password (proves they
|
|
232
|
+
were given it out-of-band by the agent, vs just inheriting a leaked URL). -->
|
|
233
|
+
<div id="setup-quick" hidden>
|
|
234
|
+
<p>Auto-filled from the pair link. Enter the password the agent gave you to join as <strong>human-authorized</strong>, or skip to connect as a regular peer.</p>
|
|
235
|
+
<p id="setup-quick-summary" style="font-size:12px;color:var(--dim);margin:0 0 16px"></p>
|
|
236
|
+
<p id="setup-err" class="err" hidden></p>
|
|
237
|
+
<label for="setup-pw">Owner password (from the agent on your PC)</label>
|
|
238
|
+
<input id="setup-pw" type="password" autocomplete="off" placeholder="paste / type the password" />
|
|
239
|
+
<button id="setup-go">Connect — human-authorized</button>
|
|
240
|
+
<button id="setup-skip" style="background:transparent;color:var(--dim);border:1px solid var(--line);margin-top:8px">Skip — connect without password</button>
|
|
241
|
+
<details style="margin-top:18px">
|
|
242
|
+
<summary style="cursor:pointer;font-size:12px;color:var(--dim)">advanced — edit token / identity</summary>
|
|
243
|
+
<div id="setup-advanced-fields" style="margin-top:12px"></div>
|
|
244
|
+
</details>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<!-- "Full" panel: shown when nothing came in via the URL fragment. -->
|
|
248
|
+
<div id="setup-full" hidden>
|
|
249
|
+
<p>Open this page from a QR scan or the pair link and the credentials fill in automatically (the secret lives in the URL fragment, never on the server). Otherwise paste them below.</p>
|
|
250
|
+
<p id="setup-err-full" class="err" hidden></p>
|
|
251
|
+
<label for="setup-token">Channel token</label>
|
|
252
|
+
<input id="setup-token" type="password" autocomplete="off" placeholder="from /api/channels (Bearer …) — or 'public' for bands" />
|
|
253
|
+
<label for="setup-cs">Callsign (or paste an identity_key instead)</label>
|
|
254
|
+
<input id="setup-cs" type="text" autocomplete="off" placeholder="phone" />
|
|
255
|
+
<label for="setup-key">Identity key (optional — required if channel has require_identity)</label>
|
|
256
|
+
<input id="setup-key" type="password" autocomplete="off" placeholder="paste identity_key (callsign comes from this)" />
|
|
257
|
+
<label for="setup-pw-full">Owner password (optional — flips your session to human-authorized on trusted channels)</label>
|
|
258
|
+
<input id="setup-pw-full" type="password" autocomplete="off" placeholder="(optional)" />
|
|
259
|
+
<button id="setup-go-full">Connect</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<main id="log" hidden></main>
|
|
264
|
+
|
|
265
|
+
<footer id="composer-wrap" hidden>
|
|
266
|
+
<div class="target-pill">
|
|
267
|
+
→
|
|
268
|
+
<select id="target">
|
|
269
|
+
<option value="all">all</option>
|
|
270
|
+
</select>
|
|
271
|
+
</div>
|
|
272
|
+
<div id="att-preview" hidden></div>
|
|
273
|
+
<div class="composer">
|
|
274
|
+
<label class="paperclip" for="file-input" title="Attach image or PDF (≤380KB)">📎</label>
|
|
275
|
+
<input id="file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif,application/pdf" multiple hidden />
|
|
276
|
+
<textarea id="input" rows="1" placeholder="Send…" autocomplete="off" autocapitalize="sentences"></textarea>
|
|
277
|
+
<button id="send" type="button">Send</button>
|
|
278
|
+
</div>
|
|
279
|
+
</footer>
|
|
280
|
+
|
|
281
|
+
<script>
|
|
282
|
+
(function(){
|
|
283
|
+
var channelId = ${JSON.stringify(channelId)};
|
|
284
|
+
|
|
285
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
286
|
+
var token = ''; // channel token (Bearer)
|
|
287
|
+
var identityKey = ''; // optional identity_key
|
|
288
|
+
var ownerPassword = ''; // optional owner_password (flips trust posture to authorized)
|
|
289
|
+
var callsign = ''; // resolved after /join
|
|
290
|
+
var displayName = ''; // cosmetic
|
|
291
|
+
var sessionId = ''; // X-Session-Id, from /join
|
|
292
|
+
var lastSeen = 0; // last delivered msg id (for ?since=)
|
|
293
|
+
var rosterCache = []; // last roster snapshot
|
|
294
|
+
var stopped = false; // set on fatal error
|
|
295
|
+
var waiting = false; // a /wait is in-flight
|
|
296
|
+
|
|
297
|
+
// ── Element refs ───────────────────────────────────────────────────────
|
|
298
|
+
var $ = function(id){ return document.getElementById(id); };
|
|
299
|
+
var elSetup = $('setup');
|
|
300
|
+
var elLog = $('log');
|
|
301
|
+
var elComposer = $('composer-wrap');
|
|
302
|
+
var elInput = $('input');
|
|
303
|
+
var elSend = $('send');
|
|
304
|
+
var elTarget = $('target');
|
|
305
|
+
var elStatus = $('status');
|
|
306
|
+
var elHdrName = $('hdr-name');
|
|
307
|
+
var elHdrMeta = $('hdr-meta');
|
|
308
|
+
var elFileInput = $('file-input');
|
|
309
|
+
var elAttPreview = $('att-preview');
|
|
310
|
+
|
|
311
|
+
// Pending attachments waiting to be sent with the next /send.
|
|
312
|
+
// Each: { mime, data_base64, filename, size_b64 }
|
|
313
|
+
var pendingAttachments = [];
|
|
314
|
+
var ATT_MAX_BYTES_B64 = 512 * 1024; // 512KB total (server enforces too)
|
|
315
|
+
var ATT_MAX_COUNT = 4;
|
|
316
|
+
var ATT_ALLOWED = {
|
|
317
|
+
'image/jpeg': 1, 'image/png': 1, 'image/webp': 1,
|
|
318
|
+
'image/gif': 1, 'application/pdf': 1,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// ── Fragment parser — URL hash carries the secret bits ─────────────────
|
|
322
|
+
function parseFragment(){
|
|
323
|
+
var hash = (location.hash || '').replace(/^#/, '');
|
|
324
|
+
if (!hash) return {};
|
|
325
|
+
var out = {};
|
|
326
|
+
var parts = hash.split('&');
|
|
327
|
+
for (var i = 0; i < parts.length; i++){
|
|
328
|
+
var kv = parts[i].split('=');
|
|
329
|
+
if (kv.length === 2){
|
|
330
|
+
try { out[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1]); }
|
|
331
|
+
catch (e) {}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── HTTP helpers ───────────────────────────────────────────────────────
|
|
338
|
+
function authHeaders(){
|
|
339
|
+
var h = { 'Content-Type': 'application/json' };
|
|
340
|
+
if (token) h['Authorization'] = 'Bearer ' + token;
|
|
341
|
+
if (sessionId) h['X-Session-Id'] = sessionId;
|
|
342
|
+
return h;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function api(method, path, body, signal){
|
|
346
|
+
return fetch(path, {
|
|
347
|
+
method: method,
|
|
348
|
+
headers: authHeaders(),
|
|
349
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
350
|
+
signal: signal,
|
|
351
|
+
}).then(function(r){
|
|
352
|
+
return r.text().then(function(text){
|
|
353
|
+
var data = null;
|
|
354
|
+
try { data = text ? JSON.parse(text) : null; } catch (e) {}
|
|
355
|
+
return { ok: r.ok, status: r.status, data: data, text: text };
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── UI ─────────────────────────────────────────────────────────────────
|
|
361
|
+
function setStatus(s, kind){
|
|
362
|
+
elStatus.textContent = s;
|
|
363
|
+
elStatus.className = 'status' + (kind ? ' ' + kind : '');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function appendSys(text, isError){
|
|
367
|
+
var d = document.createElement('div');
|
|
368
|
+
d.className = 'msg sys' + (isError ? ' error' : '');
|
|
369
|
+
var b = document.createElement('div'); b.className = 'bubble'; b.textContent = text;
|
|
370
|
+
d.appendChild(b);
|
|
371
|
+
elLog.appendChild(d);
|
|
372
|
+
scrollDown();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function appendMsg(m){
|
|
376
|
+
var mine = m.from === callsign;
|
|
377
|
+
var d = document.createElement('div');
|
|
378
|
+
d.className = 'msg ' + (mine ? 'me' : 'them');
|
|
379
|
+
var who = document.createElement('div'); who.className = 'who';
|
|
380
|
+
who.textContent = mine
|
|
381
|
+
? 'you' + (m.to === 'all' ? ' → all' : ' → ' + m.to)
|
|
382
|
+
: m.from + (m.to === 'all' ? '' : ' → you');
|
|
383
|
+
var b = document.createElement('div');
|
|
384
|
+
b.className = 'bubble' + (m.to === 'all' && !mine ? ' broadcast' : '');
|
|
385
|
+
// Try to auto-render image URLs in the body; fall back to plain text.
|
|
386
|
+
var imgFrag = m.text ? autoRenderImageUrls(m.text) : null;
|
|
387
|
+
if (imgFrag) b.appendChild(imgFrag);
|
|
388
|
+
else b.textContent = m.text || '';
|
|
389
|
+
var when = document.createElement('div'); when.className = 'when';
|
|
390
|
+
when.textContent = fmtTime(m.at);
|
|
391
|
+
d.appendChild(who); d.appendChild(b); d.appendChild(when);
|
|
392
|
+
// Inline attachments (base64 carried by the message itself).
|
|
393
|
+
if (m.attachments && m.attachments.length){
|
|
394
|
+
var attBar = document.createElement('div');
|
|
395
|
+
attBar.className = 'attachments';
|
|
396
|
+
for (var ai = 0; ai < m.attachments.length; ai++){
|
|
397
|
+
var att = m.attachments[ai];
|
|
398
|
+
if (!att || !att.mime || !att.data_base64) continue;
|
|
399
|
+
var dataUrl = 'data:' + att.mime + ';base64,' + att.data_base64;
|
|
400
|
+
if (att.mime.indexOf('image/') === 0){
|
|
401
|
+
var img = document.createElement('img');
|
|
402
|
+
img.src = dataUrl;
|
|
403
|
+
img.alt = att.filename || att.mime;
|
|
404
|
+
img.addEventListener('click', (function(url){
|
|
405
|
+
return function(){ window.open(url, '_blank', 'noopener'); };
|
|
406
|
+
})(dataUrl));
|
|
407
|
+
attBar.appendChild(img);
|
|
408
|
+
} else if (att.mime === 'application/pdf'){
|
|
409
|
+
var a = document.createElement('a');
|
|
410
|
+
a.className = 'pdf-link';
|
|
411
|
+
a.href = dataUrl;
|
|
412
|
+
a.target = '_blank';
|
|
413
|
+
a.rel = 'noopener';
|
|
414
|
+
a.download = att.filename || 'attachment.pdf';
|
|
415
|
+
a.textContent = att.filename || 'attachment.pdf';
|
|
416
|
+
attBar.appendChild(a);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
d.appendChild(attBar);
|
|
420
|
+
}
|
|
421
|
+
// Suggested replies → render as tappable chips below the bubble (only on
|
|
422
|
+
// incoming msgs; if I sent it, no point clicking my own suggestion).
|
|
423
|
+
if (!mine && m.suggested_replies && m.suggested_replies.length){
|
|
424
|
+
var chipBar = document.createElement('div');
|
|
425
|
+
chipBar.className = 'chips';
|
|
426
|
+
for (var i = 0; i < m.suggested_replies.length; i++){
|
|
427
|
+
var chip = document.createElement('button');
|
|
428
|
+
chip.type = 'button';
|
|
429
|
+
chip.className = 'chip';
|
|
430
|
+
chip.textContent = m.suggested_replies[i];
|
|
431
|
+
// Capture in closure-friendly way for older mobile JS engines
|
|
432
|
+
chip.addEventListener('click', (function(text, fromCallsign){
|
|
433
|
+
return function(){
|
|
434
|
+
elInput.value = text;
|
|
435
|
+
elTarget.value = fromCallsign; // reply to whoever sent the suggestion
|
|
436
|
+
autosize();
|
|
437
|
+
send();
|
|
438
|
+
};
|
|
439
|
+
})(m.suggested_replies[i], m.from));
|
|
440
|
+
chipBar.appendChild(chip);
|
|
441
|
+
}
|
|
442
|
+
d.appendChild(chipBar);
|
|
443
|
+
}
|
|
444
|
+
elLog.appendChild(d);
|
|
445
|
+
scrollDown();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function fmtTime(t){
|
|
449
|
+
if (!t) return '';
|
|
450
|
+
var d = new Date(t);
|
|
451
|
+
var h = String(d.getHours()).padStart(2, '0');
|
|
452
|
+
var m = String(d.getMinutes()).padStart(2, '0');
|
|
453
|
+
return h + ':' + m;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function scrollDown(){
|
|
457
|
+
requestAnimationFrame(function(){ elLog.scrollTop = elLog.scrollHeight; });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function updateRoster(roster){
|
|
461
|
+
rosterCache = roster || [];
|
|
462
|
+
// Build the target dropdown: 'all' + each peer (excluding self)
|
|
463
|
+
var current = elTarget.value;
|
|
464
|
+
elTarget.innerHTML = '<option value="all">all</option>';
|
|
465
|
+
for (var i = 0; i < rosterCache.length; i++){
|
|
466
|
+
var cs = rosterCache[i];
|
|
467
|
+
if (cs === callsign) continue;
|
|
468
|
+
var opt = document.createElement('option');
|
|
469
|
+
opt.value = cs; opt.textContent = '@' + cs;
|
|
470
|
+
elTarget.appendChild(opt);
|
|
471
|
+
}
|
|
472
|
+
// Preserve selection if still valid
|
|
473
|
+
var stillValid = current === 'all' || rosterCache.indexOf(current) !== -1;
|
|
474
|
+
if (stillValid) elTarget.value = current;
|
|
475
|
+
// Update header meta
|
|
476
|
+
var peers = rosterCache.filter(function(c){ return c !== callsign; });
|
|
477
|
+
elHdrMeta.textContent = peers.length
|
|
478
|
+
? peers.length + ' peer' + (peers.length === 1 ? '' : 's') + ': ' + peers.slice(0, 3).join(', ') + (peers.length > 3 ? '…' : '')
|
|
479
|
+
: 'waiting for peers';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Setup view ─────────────────────────────────────────────────────────
|
|
483
|
+
// Two flavours:
|
|
484
|
+
// 1. "Quick" — fragment supplied t + k/cs; only password missing. Tight UI:
|
|
485
|
+
// one input (the password), Connect / Skip buttons. This is the path the
|
|
486
|
+
// bootstrap-MCP flow always lands on.
|
|
487
|
+
// 2. "Full" — nothing supplied; user pastes everything by hand. Rare.
|
|
488
|
+
function showSetup(prefilled){
|
|
489
|
+
elSetup.hidden = false;
|
|
490
|
+
elLog.hidden = true;
|
|
491
|
+
elComposer.hidden = true;
|
|
492
|
+
|
|
493
|
+
var hasQuickCreds = !!(prefilled && prefilled.t && (prefilled.k || prefilled.cs));
|
|
494
|
+
|
|
495
|
+
if (hasQuickCreds){
|
|
496
|
+
// Stash so Connect/Skip can use them.
|
|
497
|
+
token = prefilled.t;
|
|
498
|
+
identityKey = prefilled.k || '';
|
|
499
|
+
callsign = prefilled.cs || '';
|
|
500
|
+
displayName = prefilled.n || prefilled.cs || '';
|
|
501
|
+
$('setup-quick').hidden = false;
|
|
502
|
+
$('setup-full').hidden = true;
|
|
503
|
+
$('setup-quick-summary').textContent =
|
|
504
|
+
'channel ' + channelId + ' · joining as ' + (identityKey ? 'identity ' + (displayName || '(from key)') : '@' + (callsign || 'phone'));
|
|
505
|
+
// Pre-focus the password input so the user can start typing immediately.
|
|
506
|
+
setTimeout(function(){ $('setup-pw').focus(); }, 30);
|
|
507
|
+
$('setup-go').onclick = function(){ submitQuick(true); };
|
|
508
|
+
$('setup-skip').onclick = function(){
|
|
509
|
+
if (!confirm('Connect without a password? Your session will show up to the agent as "trusted-no-password" — useful only if your channel does not have an owner_password set.')) return;
|
|
510
|
+
submitQuick(false);
|
|
511
|
+
};
|
|
512
|
+
$('setup-pw').addEventListener('keydown', function(e){
|
|
513
|
+
if (e.key === 'Enter'){ e.preventDefault(); submitQuick(true); }
|
|
514
|
+
});
|
|
515
|
+
} else {
|
|
516
|
+
$('setup-quick').hidden = true;
|
|
517
|
+
$('setup-full').hidden = false;
|
|
518
|
+
if (prefilled){
|
|
519
|
+
$('setup-token').value = prefilled.t || '';
|
|
520
|
+
$('setup-cs').value = prefilled.cs || '';
|
|
521
|
+
$('setup-key').value = prefilled.k || '';
|
|
522
|
+
$('setup-pw-full').value = prefilled.p || '';
|
|
523
|
+
}
|
|
524
|
+
$('setup-go-full').onclick = function(){
|
|
525
|
+
var t = $('setup-token').value.trim();
|
|
526
|
+
var cs = $('setup-cs').value.trim();
|
|
527
|
+
var k = $('setup-key').value.trim();
|
|
528
|
+
var pw = $('setup-pw-full').value;
|
|
529
|
+
if (!t){
|
|
530
|
+
var err = $('setup-err-full'); err.hidden = false; err.textContent = 'channel token required';
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (!k && !cs){
|
|
534
|
+
var err2 = $('setup-err-full'); err2.hidden = false; err2.textContent = 'either callsign or identity_key required';
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
token = t; callsign = cs; identityKey = k; ownerPassword = pw; displayName = cs || '';
|
|
538
|
+
bootChat();
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function submitQuick(usePassword){
|
|
544
|
+
var pw = $('setup-pw').value;
|
|
545
|
+
if (usePassword && !pw){
|
|
546
|
+
var err = $('setup-err'); err.hidden = false; err.textContent = 'password required (or click "Skip" to connect without)';
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
ownerPassword = usePassword ? pw : '';
|
|
550
|
+
bootChat();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function showChat(){
|
|
554
|
+
elSetup.hidden = true;
|
|
555
|
+
elLog.hidden = false;
|
|
556
|
+
elComposer.hidden = false;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── Join + main loop ──────────────────────────────────────────────────
|
|
560
|
+
var trustPosture = '';
|
|
561
|
+
|
|
562
|
+
function bootChat(){
|
|
563
|
+
showChat();
|
|
564
|
+
setStatus('joining…');
|
|
565
|
+
joinChannel().then(function(){
|
|
566
|
+
setStatus('connected', 'ok');
|
|
567
|
+
var note = trustPosture === 'trusted-authorized'
|
|
568
|
+
? ' (trusted + human-authorized)'
|
|
569
|
+
: trustPosture === 'trusted-no-password'
|
|
570
|
+
? ' (trusted — no password presented)'
|
|
571
|
+
: '';
|
|
572
|
+
appendSys('joined as @' + callsign + note);
|
|
573
|
+
loopWait();
|
|
574
|
+
}).catch(function(e){
|
|
575
|
+
setStatus('error', 'err');
|
|
576
|
+
appendSys('join failed: ' + (e && e.message ? e.message : e), true);
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function joinChannel(){
|
|
581
|
+
var body = {};
|
|
582
|
+
if (identityKey) body.identity_key = identityKey;
|
|
583
|
+
else body.callsign = callsign;
|
|
584
|
+
if (ownerPassword) body.owner_password = ownerPassword;
|
|
585
|
+
return api('POST', '/api/channels/' + encodeURIComponent(channelId) + '/join', body)
|
|
586
|
+
.then(function(r){
|
|
587
|
+
if (!r.ok){
|
|
588
|
+
var msg = (r.data && r.data.error) || ('HTTP ' + r.status);
|
|
589
|
+
throw new Error(msg);
|
|
590
|
+
}
|
|
591
|
+
sessionId = r.data.session_id;
|
|
592
|
+
callsign = r.data.callsign;
|
|
593
|
+
trustPosture = r.data.trust_posture || '';
|
|
594
|
+
displayName = displayName || callsign;
|
|
595
|
+
elHdrName.textContent = '@' + callsign;
|
|
596
|
+
updateRoster(r.data.roster || []);
|
|
597
|
+
// Seed history (so we show what was said before we arrived).
|
|
598
|
+
var hist = r.data.history || [];
|
|
599
|
+
for (var i = 0; i < hist.length; i++){
|
|
600
|
+
appendMsg(hist[i]);
|
|
601
|
+
if (hist[i].id > lastSeen) lastSeen = hist[i].id;
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function loopWait(){
|
|
607
|
+
if (stopped || waiting) return;
|
|
608
|
+
waiting = true;
|
|
609
|
+
// Long-poll: 50s default. /wait gives up to 5min ceiling, but 50s keeps
|
|
610
|
+
// mobile networks happy (LB timeouts ~60s, app-switch resumes faster).
|
|
611
|
+
var url = '/api/channels/' + encodeURIComponent(channelId) + '/wait?timeout=50';
|
|
612
|
+
if (lastSeen > 0) url += '&since=' + encodeURIComponent(lastSeen);
|
|
613
|
+
api('GET', url).then(function(r){
|
|
614
|
+
waiting = false;
|
|
615
|
+
if (!r.ok){
|
|
616
|
+
if (r.status === 410){
|
|
617
|
+
// session expired — rejoin
|
|
618
|
+
setStatus('reconnecting…');
|
|
619
|
+
appendSys('session expired, reconnecting…');
|
|
620
|
+
sessionId = '';
|
|
621
|
+
return joinChannel().then(function(){
|
|
622
|
+
setStatus('connected', 'ok');
|
|
623
|
+
loopWait();
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
setStatus('error', 'err');
|
|
627
|
+
appendSys('wait failed: ' + ((r.data && r.data.error) || ('HTTP ' + r.status)), true);
|
|
628
|
+
// Retry after a small backoff so transient errors don't kill the loop.
|
|
629
|
+
return setTimeout(loopWait, 3000);
|
|
630
|
+
}
|
|
631
|
+
var msgs = (r.data && r.data.messages) || [];
|
|
632
|
+
for (var i = 0; i < msgs.length; i++){
|
|
633
|
+
appendMsg(msgs[i]);
|
|
634
|
+
if (msgs[i].id > lastSeen) lastSeen = msgs[i].id;
|
|
635
|
+
}
|
|
636
|
+
var ro = (r.data && r.data.roster) || [];
|
|
637
|
+
if (ro.length) updateRoster(ro);
|
|
638
|
+
setStatus('connected', 'ok');
|
|
639
|
+
loopWait();
|
|
640
|
+
}).catch(function(e){
|
|
641
|
+
waiting = false;
|
|
642
|
+
if (stopped) return;
|
|
643
|
+
setStatus('reconnecting…', 'err');
|
|
644
|
+
setTimeout(loopWait, 2000);
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ── Attachments ────────────────────────────────────────────────────────
|
|
649
|
+
// Read a File → { mime, data_base64, filename, size_b64 } via FileReader.
|
|
650
|
+
// base64 = the part after 'base64,' in the data URL — we strip the prefix.
|
|
651
|
+
function readFileAsAttachment(file){
|
|
652
|
+
return new Promise(function(resolve, reject){
|
|
653
|
+
var fr = new FileReader();
|
|
654
|
+
fr.onload = function(){
|
|
655
|
+
var url = String(fr.result || '');
|
|
656
|
+
var idx = url.indexOf('base64,');
|
|
657
|
+
if (idx === -1) return reject(new Error('not base64'));
|
|
658
|
+
var b64 = url.substr(idx + 7);
|
|
659
|
+
resolve({ mime: file.type, data_base64: b64, filename: file.name, size_b64: b64.length });
|
|
660
|
+
};
|
|
661
|
+
fr.onerror = function(){ reject(fr.error || new Error('FileReader failed')); };
|
|
662
|
+
fr.readAsDataURL(file);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function renderAttPreview(){
|
|
667
|
+
if (pendingAttachments.length === 0){
|
|
668
|
+
elAttPreview.hidden = true; elAttPreview.innerHTML = '';
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
elAttPreview.hidden = false;
|
|
672
|
+
elAttPreview.innerHTML = '';
|
|
673
|
+
pendingAttachments.forEach(function(att, i){
|
|
674
|
+
var chip = document.createElement('span');
|
|
675
|
+
chip.className = 'att-chip';
|
|
676
|
+
var label = document.createElement('span');
|
|
677
|
+
label.textContent = (att.filename || att.mime) + ' (' + Math.round(att.size_b64 / 1024) + ' KB)';
|
|
678
|
+
var x = document.createElement('span');
|
|
679
|
+
x.className = 'x'; x.textContent = '✕';
|
|
680
|
+
x.title = 'Remove'; x.setAttribute('role', 'button');
|
|
681
|
+
x.addEventListener('click', function(){
|
|
682
|
+
pendingAttachments.splice(i, 1);
|
|
683
|
+
renderAttPreview();
|
|
684
|
+
});
|
|
685
|
+
chip.appendChild(label); chip.appendChild(x);
|
|
686
|
+
elAttPreview.appendChild(chip);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
elFileInput.addEventListener('change', function(){
|
|
691
|
+
var files = Array.from(elFileInput.files || []);
|
|
692
|
+
elFileInput.value = '';
|
|
693
|
+
var promises = files.map(function(f){
|
|
694
|
+
if (!ATT_ALLOWED[f.type]){
|
|
695
|
+
appendSys('skipped "' + f.name + '": type ' + (f.type || 'unknown') + ' not allowed', true);
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
return readFileAsAttachment(f);
|
|
699
|
+
}).filter(function(p){ return p; });
|
|
700
|
+
Promise.all(promises).then(function(atts){
|
|
701
|
+
atts.forEach(function(att){
|
|
702
|
+
// Per-message total cap, not per-file
|
|
703
|
+
var total = pendingAttachments.reduce(function(s,a){return s+a.size_b64;}, 0);
|
|
704
|
+
if (pendingAttachments.length >= ATT_MAX_COUNT){
|
|
705
|
+
appendSys('skipped "' + att.filename + '": max ' + ATT_MAX_COUNT + ' attachments per message. Host bigger files elsewhere and paste the URL.', true);
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (total + att.size_b64 > ATT_MAX_BYTES_B64){
|
|
709
|
+
appendSys('skipped "' + att.filename + '" (' + Math.round(att.size_b64 / 1024) + ' KB): would exceed the ' + Math.round(ATT_MAX_BYTES_B64 / 1024) + ' KB cap. Host it elsewhere (iCloud / Drive / Dropbox) and paste the share link instead.', true);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
pendingAttachments.push(att);
|
|
713
|
+
});
|
|
714
|
+
renderAttPreview();
|
|
715
|
+
}).catch(function(e){
|
|
716
|
+
appendSys('attachment read failed: ' + (e && e.message ? e.message : e), true);
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// Auto-detect image URLs in incoming message text and render as inline img.
|
|
721
|
+
// Returns a DocumentFragment with text + img elements interleaved, or null
|
|
722
|
+
// if no image URLs found (caller falls back to textContent).
|
|
723
|
+
function autoRenderImageUrls(text){
|
|
724
|
+
var re = /https?:\\/\\/\\S+\\.(?:jpg|jpeg|png|webp|gif)(?:\\?\\S*)?/gi;
|
|
725
|
+
if (!re.test(text)) return null;
|
|
726
|
+
re.lastIndex = 0;
|
|
727
|
+
var frag = document.createDocumentFragment();
|
|
728
|
+
var lastIdx = 0;
|
|
729
|
+
var m;
|
|
730
|
+
while ((m = re.exec(text)) !== null){
|
|
731
|
+
if (m.index > lastIdx){
|
|
732
|
+
frag.appendChild(document.createTextNode(text.substring(lastIdx, m.index)));
|
|
733
|
+
}
|
|
734
|
+
var img = document.createElement('img');
|
|
735
|
+
img.src = m[0];
|
|
736
|
+
img.alt = m[0];
|
|
737
|
+
img.referrerPolicy = 'no-referrer';
|
|
738
|
+
img.style.maxWidth = 'min(220px, 75vw)';
|
|
739
|
+
img.style.maxHeight = '220px';
|
|
740
|
+
img.style.display = 'block';
|
|
741
|
+
img.style.marginTop = '4px';
|
|
742
|
+
img.style.border = '1px solid var(--line)';
|
|
743
|
+
img.style.borderRadius = '6px';
|
|
744
|
+
frag.appendChild(img);
|
|
745
|
+
lastIdx = m.index + m[0].length;
|
|
746
|
+
}
|
|
747
|
+
if (lastIdx < text.length){
|
|
748
|
+
frag.appendChild(document.createTextNode(text.substring(lastIdx)));
|
|
749
|
+
}
|
|
750
|
+
return frag;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── Send ───────────────────────────────────────────────────────────────
|
|
754
|
+
function send(){
|
|
755
|
+
var text = elInput.value.replace(/\\s+$/, '');
|
|
756
|
+
var hasAtts = pendingAttachments.length > 0;
|
|
757
|
+
if ((!text && !hasAtts) || !sessionId) return;
|
|
758
|
+
var to = elTarget.value || 'all';
|
|
759
|
+
elSend.disabled = true;
|
|
760
|
+
// Snapshot attachments for the optimistic render + the POST body
|
|
761
|
+
var attsToSend = pendingAttachments.slice();
|
|
762
|
+
pendingAttachments = [];
|
|
763
|
+
renderAttPreview();
|
|
764
|
+
var optimistic = { id: -Date.now(), from: callsign, to: to, text: text, at: Date.now() };
|
|
765
|
+
if (attsToSend.length > 0){
|
|
766
|
+
optimistic.attachments = attsToSend.map(function(a){
|
|
767
|
+
return { mime: a.mime, data_base64: a.data_base64, filename: a.filename };
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
appendMsg(optimistic);
|
|
771
|
+
elInput.value = '';
|
|
772
|
+
autosize();
|
|
773
|
+
var body = { to: to, message: text };
|
|
774
|
+
if (attsToSend.length > 0){
|
|
775
|
+
body.attachments = attsToSend.map(function(a){
|
|
776
|
+
return { mime: a.mime, data_base64: a.data_base64, filename: a.filename };
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
api('POST', '/api/channels/' + encodeURIComponent(channelId) + '/send', body
|
|
780
|
+
).then(function(r){
|
|
781
|
+
elSend.disabled = false;
|
|
782
|
+
if (!r.ok){
|
|
783
|
+
var msg = (r.data && r.data.error) || ('HTTP ' + r.status);
|
|
784
|
+
appendSys('send failed: ' + msg, true);
|
|
785
|
+
}
|
|
786
|
+
}).catch(function(e){
|
|
787
|
+
elSend.disabled = false;
|
|
788
|
+
appendSys('send failed: ' + (e && e.message ? e.message : e), true);
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function autosize(){
|
|
793
|
+
elInput.style.height = 'auto';
|
|
794
|
+
elInput.style.height = Math.min(elInput.scrollHeight, 140) + 'px';
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
elSend.addEventListener('click', send);
|
|
798
|
+
elInput.addEventListener('input', autosize);
|
|
799
|
+
elInput.addEventListener('keydown', function(e){
|
|
800
|
+
// Enter sends (no shift). Shift+Enter inserts newline. iOS soft keyboards
|
|
801
|
+
// send a 'Go' / 'Enter' that maps to plain Enter.
|
|
802
|
+
if (e.key === 'Enter' && !e.shiftKey){
|
|
803
|
+
e.preventDefault();
|
|
804
|
+
send();
|
|
805
|
+
}
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// ── Leave cleanly on unload — best-effort, doesn't block ──────────────
|
|
809
|
+
window.addEventListener('pagehide', function(){
|
|
810
|
+
if (!sessionId) return;
|
|
811
|
+
try {
|
|
812
|
+
navigator.sendBeacon &&
|
|
813
|
+
navigator.sendBeacon(
|
|
814
|
+
'/api/channels/' + encodeURIComponent(channelId) + '/leave',
|
|
815
|
+
new Blob([JSON.stringify({})], { type: 'application/json' })
|
|
816
|
+
);
|
|
817
|
+
} catch (e) {}
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
// ── Boot ──────────────────────────────────────────────────────────────
|
|
821
|
+
var frag = parseFragment();
|
|
822
|
+
// Strip the fragment from the URL bar so the secret doesn't sit there
|
|
823
|
+
// for shoulder-surfers / screenshots. The values are already in memory.
|
|
824
|
+
if (location.hash){
|
|
825
|
+
try { history.replaceState(null, '', location.pathname + location.search); }
|
|
826
|
+
catch (e) {}
|
|
827
|
+
}
|
|
828
|
+
// Auto-boot only when the URL fragment already carries the password (legacy
|
|
829
|
+
// links pre-2026-05-21). Default path: pop the setup screen so the human has
|
|
830
|
+
// to type the password — that's what makes "trusted-authorized" actually mean
|
|
831
|
+
// a human acted, not just "this URL ran in a browser somewhere".
|
|
832
|
+
if (frag.t && (frag.k || frag.cs) && frag.p){
|
|
833
|
+
token = frag.t;
|
|
834
|
+
identityKey = frag.k || '';
|
|
835
|
+
callsign = frag.cs || '';
|
|
836
|
+
ownerPassword = frag.p;
|
|
837
|
+
displayName = frag.n || frag.cs || '';
|
|
838
|
+
elHdrName.textContent = displayName ? ('@' + displayName) : safeChannelLabel();
|
|
839
|
+
bootChat();
|
|
840
|
+
} else {
|
|
841
|
+
setStatus('needs password');
|
|
842
|
+
showSetup(frag);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function safeChannelLabel(){ return ${JSON.stringify(channelId)}; }
|
|
846
|
+
})();
|
|
847
|
+
</script>
|
|
848
|
+
</body>
|
|
849
|
+
</html>`;
|
|
850
|
+
}
|