relay-companion 0.1.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.
- package/bin/relay.js +262 -0
- package/overlay/inbox.html +1398 -0
- package/overlay/main.cjs +762 -0
- package/overlay/preload.cjs +37 -0
- package/overlay/sounds/tink.wav +0 -0
- package/package.json +25 -0
- package/src/claude-materializer.js +85 -0
- package/src/claude-session-writer.js +629 -0
- package/src/client.js +168 -0
- package/src/codex-app-server.js +120 -0
- package/src/codex-desktop.js +276 -0
- package/src/codex-session-writer.js +170 -0
- package/src/codex-state.js +114 -0
- package/src/config.js +62 -0
- package/src/host-json.js +14 -0
- package/src/host-paths.js +67 -0
- package/src/install.js +142 -0
- package/src/materializer.js +378 -0
- package/src/mcp.js +419 -0
- package/src/notifications.js +412 -0
- package/src/pinning.js +43 -0
- package/src/relay-briefing.js +344 -0
- package/src/runtime.js +1141 -0
- package/src/task-daemon.js +216 -0
|
@@ -0,0 +1,1398 @@
|
|
|
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
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' https://fonts.googleapis.com; img-src 'self' data:;" />
|
|
7
|
+
<title>Relay</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Newsreader:opsz,wght@6..72,400;6..72,500&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
11
|
+
<style>
|
|
12
|
+
:root {
|
|
13
|
+
--bg:#ffffff;
|
|
14
|
+
--ink:#1f1a17; --ink-2:#2c2824;
|
|
15
|
+
--muted:#7c736b; --muted-2:#9a928a; --muted-3:#b7b1a6;
|
|
16
|
+
--accent:#305566; --accent-soft:rgba(48,85,102,.10);
|
|
17
|
+
--hair:rgba(31,26,23,.07); /* between relay/task items */
|
|
18
|
+
--hair-2:rgba(31,26,23,.06); /* between contact rows */
|
|
19
|
+
--serif:'Newsreader',Georgia,serif;
|
|
20
|
+
--sans:'Inter',ui-sans-serif,system-ui,-apple-system,sans-serif;
|
|
21
|
+
--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,monospace;
|
|
22
|
+
--settle:cubic-bezier(.4,0,.2,1);
|
|
23
|
+
--soft:cubic-bezier(.22,1,.28,1);
|
|
24
|
+
}
|
|
25
|
+
* { box-sizing:border-box; }
|
|
26
|
+
html, body { margin:0; height:100%; background:transparent; overflow:hidden; }
|
|
27
|
+
body {
|
|
28
|
+
font-family:var(--sans); color:var(--ink);
|
|
29
|
+
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
|
30
|
+
user-select:none; cursor:default;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* One sheet of warm-white paper. Width/height are written by a JS spring each frame;
|
|
34
|
+
the shadow + radius ride a matching CSS curve so the sheet lightens as it folds.
|
|
35
|
+
The ONLY shadow in the UI is this card's own floating drop shadow. */
|
|
36
|
+
.card {
|
|
37
|
+
position:absolute; top:24px; right:36px;
|
|
38
|
+
width:344px; height:524px;
|
|
39
|
+
background:var(--bg);
|
|
40
|
+
border-radius:16px;
|
|
41
|
+
overflow:hidden;
|
|
42
|
+
display:flex; flex-direction:column;
|
|
43
|
+
box-shadow:
|
|
44
|
+
0 1px 2px rgba(31,26,23,.05),
|
|
45
|
+
0 18px 50px -12px rgba(31,26,23,.16),
|
|
46
|
+
inset 0 1px 0 rgba(255,255,255,.9);
|
|
47
|
+
transition:box-shadow .46s var(--soft), border-radius .42s var(--settle);
|
|
48
|
+
animation:appIn .42s var(--soft) both;
|
|
49
|
+
}
|
|
50
|
+
.card.collapsed {
|
|
51
|
+
border-radius:22px;
|
|
52
|
+
box-shadow:
|
|
53
|
+
0 1px 2px rgba(31,26,23,.05),
|
|
54
|
+
0 6px 18px -6px rgba(31,26,23,.14),
|
|
55
|
+
inset 0 1px 0 rgba(255,255,255,.9);
|
|
56
|
+
}
|
|
57
|
+
@keyframes appIn { from { opacity:0; transform:scale(.97); } to { opacity:1; transform:none; } }
|
|
58
|
+
|
|
59
|
+
/* brand lockup — present in BOTH states; no surface, no rule */
|
|
60
|
+
.lockup {
|
|
61
|
+
flex:0 0 44px; display:flex; align-items:center; gap:9px;
|
|
62
|
+
padding:0 18px; cursor:pointer;
|
|
63
|
+
}
|
|
64
|
+
.mark { display:block; }
|
|
65
|
+
.mark rect { fill:var(--ink); transition:fill .25s var(--settle); }
|
|
66
|
+
.card.has-unread .sq0 { fill:var(--accent); }
|
|
67
|
+
.word { font-family:var(--serif); font-size:19px; font-weight:500; letter-spacing:-.01em; color:var(--ink); line-height:1; }
|
|
68
|
+
.spacer { flex:1 1 auto; }
|
|
69
|
+
.count {
|
|
70
|
+
font-family:var(--mono); font-size:13px; font-weight:500; font-variant-numeric:tabular-nums;
|
|
71
|
+
color:var(--accent); line-height:1; transition:color .3s var(--settle);
|
|
72
|
+
}
|
|
73
|
+
.count.zero { color:var(--muted-3); }
|
|
74
|
+
@keyframes countRoll { from { transform:translateY(-6px); opacity:.3; } to { transform:none; opacity:1; } }
|
|
75
|
+
|
|
76
|
+
/* tab control — individual rounded-rect buttons (the loved control) */
|
|
77
|
+
.tabs {
|
|
78
|
+
flex:0 0 auto;
|
|
79
|
+
display:flex; gap:4px;
|
|
80
|
+
padding:0 14px 6px;
|
|
81
|
+
opacity:1;
|
|
82
|
+
transition:opacity .24s var(--settle);
|
|
83
|
+
}
|
|
84
|
+
.card.collapsed .tabs { opacity:0; pointer-events:none; }
|
|
85
|
+
/* a notification (peek) collapses the tab rail to nothing, so the peek shows just
|
|
86
|
+
the brand lockup and the new relay. */
|
|
87
|
+
.card.peek .tabs {
|
|
88
|
+
height:0;
|
|
89
|
+
padding-top:0;
|
|
90
|
+
padding-bottom:0;
|
|
91
|
+
opacity:0;
|
|
92
|
+
overflow:hidden;
|
|
93
|
+
pointer-events:none;
|
|
94
|
+
}
|
|
95
|
+
.tab {
|
|
96
|
+
appearance:none; border:0; background:transparent; color:var(--muted-2);
|
|
97
|
+
font-family:var(--sans); font-size:11px; font-weight:500;
|
|
98
|
+
height:24px; padding:0 8px; border-radius:7px; cursor:pointer;
|
|
99
|
+
display:inline-flex; align-items:center; gap:6px;
|
|
100
|
+
transition:background-color .15s var(--settle), color .15s var(--settle);
|
|
101
|
+
}
|
|
102
|
+
.tab:hover { background:rgba(31,26,23,.05); color:var(--ink-2); }
|
|
103
|
+
.tab.active { background:var(--accent-soft); color:var(--accent); }
|
|
104
|
+
.tab-badge {
|
|
105
|
+
font-family:var(--mono); font-size:10px; font-weight:500;
|
|
106
|
+
color:var(--muted-3); font-variant-numeric:tabular-nums;
|
|
107
|
+
}
|
|
108
|
+
.tab.active .tab-badge { color:var(--accent); }
|
|
109
|
+
.tab-badge.gone { display:none; }
|
|
110
|
+
|
|
111
|
+
/* scrolling content layer (host for each view) */
|
|
112
|
+
.scroll {
|
|
113
|
+
flex:1 1 auto; min-height:0; width:344px;
|
|
114
|
+
overflow-y:auto; overscroll-behavior:contain;
|
|
115
|
+
padding:0 0 8px;
|
|
116
|
+
transition:opacity .3s var(--settle) .06s;
|
|
117
|
+
}
|
|
118
|
+
.card.collapsed .scroll { opacity:0; pointer-events:none; transition:opacity .14s var(--settle); }
|
|
119
|
+
.card.peek .scroll { opacity:1; pointer-events:auto; }
|
|
120
|
+
.scroll::-webkit-scrollbar { width:10px; }
|
|
121
|
+
.scroll::-webkit-scrollbar-thumb { background:rgba(31,26,23,.12); border-radius:10px; border:3px solid var(--bg); background-clip:padding-box; }
|
|
122
|
+
.scroll::-webkit-scrollbar-thumb:hover { background:rgba(31,26,23,.2); background-clip:padding-box; }
|
|
123
|
+
.view.hidden { display:none; }
|
|
124
|
+
|
|
125
|
+
/* ---- group headers (Tasks) ---- */
|
|
126
|
+
/* Calm, refined section labels: the serif for warmth, sentence case, gentle weight,
|
|
127
|
+
no all-caps / wide tracking / mono. The whole list reads quiet rather than shouty. */
|
|
128
|
+
.group-head {
|
|
129
|
+
font-family:var(--serif); font-size:12.5px; font-weight:500; letter-spacing:0; text-transform:none;
|
|
130
|
+
color:var(--muted-2); padding:14px 16px 6px; display:flex; align-items:baseline; gap:7px;
|
|
131
|
+
}
|
|
132
|
+
.group-head .ghc { font-family:var(--sans); font-size:11px; font-variant-numeric:tabular-nums; color:var(--muted-3); }
|
|
133
|
+
|
|
134
|
+
/* ---- relay items — flat content on the sheet, one hairline between. No box, no hover-lift. ---- */
|
|
135
|
+
.row {
|
|
136
|
+
position:relative; display:block; cursor:pointer;
|
|
137
|
+
padding:13px 16px; margin:0;
|
|
138
|
+
background:transparent;
|
|
139
|
+
border-bottom:1px solid var(--hair);
|
|
140
|
+
transition:background-color .14s var(--settle);
|
|
141
|
+
}
|
|
142
|
+
.row:hover { background:rgba(31,26,23,.025); }
|
|
143
|
+
@keyframes rowEnter { from { opacity:0; transform:translateY(-12px); } to { opacity:1; transform:none; } }
|
|
144
|
+
.row.enter { animation:rowEnter .52s var(--soft) both; }
|
|
145
|
+
|
|
146
|
+
/* meta line: tiny dot · From sender · right-aligned mono time */
|
|
147
|
+
.rk-top { display:flex; align-items:baseline; gap:7px; }
|
|
148
|
+
.rk-dot {
|
|
149
|
+
position:relative;
|
|
150
|
+
flex:0 0 auto; width:7px; height:7px; border-radius:50%;
|
|
151
|
+
background:transparent; border:1.5px solid var(--muted-3); box-sizing:border-box;
|
|
152
|
+
align-self:center;
|
|
153
|
+
transition:background-color .28s var(--settle), border-color .28s var(--settle), transform .42s var(--soft);
|
|
154
|
+
}
|
|
155
|
+
.row.unread .rk-dot { background:var(--accent); border-color:var(--accent); }
|
|
156
|
+
/* opening: the dot's fill dissolves and a tapered accent arc spins in its place, from click
|
|
157
|
+
until the relay has materialized + opened (stopped by the openDone signal from main.cjs). */
|
|
158
|
+
.rk-dot::after {
|
|
159
|
+
content:""; position:absolute; inset:-2.5px; border-radius:50%;
|
|
160
|
+
background:conic-gradient(from 90deg, transparent, var(--accent) 280deg, transparent 318deg);
|
|
161
|
+
-webkit-mask:radial-gradient(farthest-side, #0000 calc(100% - 1.6px), #000 0);
|
|
162
|
+
mask:radial-gradient(farthest-side, #0000 calc(100% - 1.6px), #000 0);
|
|
163
|
+
opacity:0; transition:opacity .22s var(--settle); pointer-events:none;
|
|
164
|
+
}
|
|
165
|
+
.row.opening .rk-dot { background:transparent; border-color:transparent; transform:scale(1.1); }
|
|
166
|
+
.row.opening .rk-dot::after { opacity:1; animation:dotSpin .7s linear infinite; }
|
|
167
|
+
@keyframes dotSpin { to { transform:rotate(1turn); } }
|
|
168
|
+
.rk-from { font-size:11px; color:var(--muted-2); }
|
|
169
|
+
.rk-sender { font-size:12px; font-weight:500; color:var(--ink); letter-spacing:.1px; }
|
|
170
|
+
.rk-time { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; padding-left:6px; }
|
|
171
|
+
|
|
172
|
+
/* subject: single-line serif, right-edge fade mask */
|
|
173
|
+
.rk-subject {
|
|
174
|
+
font-family:var(--serif); font-size:15px; line-height:1.3; color:var(--muted); font-weight:400;
|
|
175
|
+
margin-top:5px;
|
|
176
|
+
white-space:nowrap; overflow:hidden;
|
|
177
|
+
-webkit-mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent);
|
|
178
|
+
mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent);
|
|
179
|
+
transition:color .28s var(--settle);
|
|
180
|
+
}
|
|
181
|
+
.row.unread .rk-subject { color:var(--ink); font-weight:500; }
|
|
182
|
+
/* human_question rows carry an inline quick-reply, so the full question must be readable.
|
|
183
|
+
Let it wrap to ~3 lines (clamp) instead of the single-line fade used by other subjects. */
|
|
184
|
+
.rk-subject.q {
|
|
185
|
+
white-space:normal;
|
|
186
|
+
display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:3;
|
|
187
|
+
-webkit-mask-image:none; mask-image:none;
|
|
188
|
+
}
|
|
189
|
+
/* body preview beneath the subject (plain messages + results) */
|
|
190
|
+
.rk-preview {
|
|
191
|
+
font-size:11.5px; line-height:1.4; color:var(--muted); margin-top:3px;
|
|
192
|
+
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* quick reply (human_question): rounded reply field + prussian round send */
|
|
196
|
+
.qr { display:flex; gap:8px; margin-top:11px; align-items:flex-end; }
|
|
197
|
+
.qr-input {
|
|
198
|
+
flex:1 1 auto; min-height:34px; max-height:86px; resize:none;
|
|
199
|
+
border:1px solid rgba(31,26,23,.14); border-radius:999px; padding:8px 14px;
|
|
200
|
+
font-family:var(--sans); font-size:12.5px; line-height:1.3; color:var(--ink);
|
|
201
|
+
background:#fff; outline:none;
|
|
202
|
+
transition:border-color .15s var(--settle), box-shadow .15s var(--settle);
|
|
203
|
+
}
|
|
204
|
+
.qr-input::placeholder { color:var(--muted-3); }
|
|
205
|
+
.qr-input:focus { border-color:rgba(48,85,102,.42); box-shadow:0 0 0 3px rgba(48,85,102,.08); }
|
|
206
|
+
|
|
207
|
+
/* round Send: 32px prussian circle, white up-arrow */
|
|
208
|
+
.qr-send {
|
|
209
|
+
flex:0 0 auto; appearance:none; border:0; cursor:pointer;
|
|
210
|
+
width:32px; height:32px; border-radius:50%; background:var(--accent);
|
|
211
|
+
display:inline-flex; align-items:center; justify-content:center; padding:0;
|
|
212
|
+
transition:opacity .13s var(--settle);
|
|
213
|
+
}
|
|
214
|
+
.qr-send:hover { opacity:.88; }
|
|
215
|
+
.qr-send:active { opacity:.7; }
|
|
216
|
+
.qr-send:disabled { opacity:.4; cursor:default; }
|
|
217
|
+
|
|
218
|
+
/* inline actions — flat text, prussian accept / muted decline */
|
|
219
|
+
.rk-actions { display:flex; gap:18px; margin-top:12px; align-items:center; flex-wrap:wrap; }
|
|
220
|
+
.act-btn {
|
|
221
|
+
appearance:none; cursor:pointer; border:0; background:transparent; padding:0;
|
|
222
|
+
font-family:var(--sans); font-size:12.5px; font-weight:600; line-height:1;
|
|
223
|
+
transition:opacity .13s var(--settle);
|
|
224
|
+
}
|
|
225
|
+
.act-btn:hover { opacity:.72; }
|
|
226
|
+
.act-btn:disabled { opacity:.4; cursor:default; }
|
|
227
|
+
.act-btn.accept { color:var(--accent); }
|
|
228
|
+
.act-btn.decline { color:var(--muted-2); font-weight:500; }
|
|
229
|
+
.row-note { font-size:11.5px; color:var(--muted-2); margin-top:9px; line-height:1.45; }
|
|
230
|
+
.row-err { font-size:11.5px; color:#b4332a; margin-top:8px; line-height:1.4; }
|
|
231
|
+
.row-err:empty { display:none; }
|
|
232
|
+
|
|
233
|
+
/* ---- task items — also flat, divided by one hairline ---- */
|
|
234
|
+
.task {
|
|
235
|
+
position:relative; display:block; width:100%; text-align:left; appearance:none; cursor:pointer; font-family:var(--sans);
|
|
236
|
+
padding:13px 16px; margin:0;
|
|
237
|
+
background:transparent; border:0; border-bottom:1px solid var(--hair);
|
|
238
|
+
transition:background-color .14s var(--settle);
|
|
239
|
+
}
|
|
240
|
+
.task:hover { background:rgba(31,26,23,.025); }
|
|
241
|
+
.task-top { display:flex; align-items:center; gap:7px; }
|
|
242
|
+
.t-dot { flex:0 0 auto; width:6px; height:6px; border-radius:50%; background:transparent; border:1.5px solid var(--muted-3); box-sizing:border-box; }
|
|
243
|
+
.t-dot.s-active { background:var(--accent); border-color:var(--accent); }
|
|
244
|
+
/* state kicker: calm Inter, sentence case, gentle weight + tracking (no mono / all-caps). */
|
|
245
|
+
.t-state {
|
|
246
|
+
font-family:var(--sans); font-size:11px; font-weight:500; letter-spacing:.01em; text-transform:none; color:var(--muted-2);
|
|
247
|
+
}
|
|
248
|
+
.t-state.s-active { color:var(--accent); }
|
|
249
|
+
.task-time { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; }
|
|
250
|
+
.task-title {
|
|
251
|
+
font-family:var(--serif); font-size:15px; line-height:1.3; color:var(--ink); font-weight:500; margin-top:6px;
|
|
252
|
+
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
253
|
+
}
|
|
254
|
+
.task-obj { font-size:11.5px; color:var(--muted); line-height:1.4; margin-top:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
255
|
+
.task-meta { display:flex; align-items:center; gap:6px; margin-top:7px; font-size:11.5px; color:var(--muted-2); flex-wrap:wrap; }
|
|
256
|
+
.task-meta .sep { color:var(--muted-3); }
|
|
257
|
+
.task-approve { color:var(--accent); font-weight:600; }
|
|
258
|
+
|
|
259
|
+
/* ---- empty states ---- */
|
|
260
|
+
.empty {
|
|
261
|
+
height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
|
262
|
+
gap:11px; padding:34px 28px; text-align:center;
|
|
263
|
+
}
|
|
264
|
+
.empty .mark rect { fill:var(--muted-3); }
|
|
265
|
+
.empty .t1 { font-family:var(--serif); font-size:16px; color:#4f4740; }
|
|
266
|
+
.empty .t2 { font-size:12.5px; color:var(--muted-2); max-width:210px; line-height:1.45; }
|
|
267
|
+
.gone { display:none !important; }
|
|
268
|
+
|
|
269
|
+
@media (prefers-reduced-motion: reduce) {
|
|
270
|
+
.card { animation:none; }
|
|
271
|
+
*, *::before, *::after { transition-duration:.001s !important; }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* ---- Contacts tab ---- */
|
|
275
|
+
.cv-head { display:flex; align-items:center; justify-content:space-between; padding:10px 16px 8px; }
|
|
276
|
+
.cv-title { font-family:var(--serif); font-size:16px; font-weight:500; color:var(--ink); }
|
|
277
|
+
.cv-add {
|
|
278
|
+
appearance:none; border:0; background:var(--accent-soft); color:var(--accent);
|
|
279
|
+
font-family:var(--sans); font-size:12px; font-weight:600; padding:5px 11px; border-radius:8px; cursor:pointer;
|
|
280
|
+
transition:background .2s var(--settle);
|
|
281
|
+
}
|
|
282
|
+
.cv-add:hover { background:rgba(48,85,102,.18); }
|
|
283
|
+
.cv-list { display:flex; flex-direction:column; padding:0; }
|
|
284
|
+
.cv-item {
|
|
285
|
+
display:flex; align-items:center; gap:11px; padding:9px 16px; cursor:pointer;
|
|
286
|
+
text-align:left; appearance:none; border:0; border-bottom:1px solid var(--hair-2);
|
|
287
|
+
background:transparent; width:100%; font-family:var(--sans);
|
|
288
|
+
transition:background-color .14s var(--settle);
|
|
289
|
+
}
|
|
290
|
+
.cv-item:hover { background:rgba(31,26,23,.035); }
|
|
291
|
+
.cv-avatar {
|
|
292
|
+
flex:0 0 auto; width:32px; height:32px; border-radius:50%; background:rgba(31,26,23,.06); color:var(--ink-2);
|
|
293
|
+
font-size:11px; font-weight:600; display:flex; align-items:center; justify-content:center; letter-spacing:.02em;
|
|
294
|
+
}
|
|
295
|
+
.cv-body { display:flex; flex-direction:column; min-width:0; flex:1 1 auto; }
|
|
296
|
+
.cv-name { font-size:13px; font-weight:600; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
297
|
+
.cv-sub { font-size:11.5px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; }
|
|
298
|
+
.cv-chev { flex:0 0 auto; display:flex; align-items:center; }
|
|
299
|
+
.cv-empty { padding:34px 16px; text-align:center; color:var(--muted-2); font-size:12.5px; line-height:1.5; }
|
|
300
|
+
.cv-form { display:flex; flex-direction:column; gap:7px; padding:8px 16px 14px; border-bottom:1px solid var(--hair-2); }
|
|
301
|
+
.cv-form.hidden { display:none; }
|
|
302
|
+
.cv-row2 { display:flex; gap:7px; }
|
|
303
|
+
.cv-row2 .cv-input { flex:1 1 0; min-width:0; }
|
|
304
|
+
.cv-input {
|
|
305
|
+
appearance:none; border:1px solid rgba(31,26,23,.14); border-radius:9px; padding:8px 10px;
|
|
306
|
+
font-family:var(--sans); font-size:13px; color:var(--ink); background:#fff; outline:none;
|
|
307
|
+
transition:border-color .15s var(--settle), box-shadow .15s var(--settle);
|
|
308
|
+
}
|
|
309
|
+
.cv-input::placeholder { color:var(--muted-3); }
|
|
310
|
+
.cv-input:focus { border-color:rgba(48,85,102,.42); box-shadow:0 0 0 3px rgba(48,85,102,.08); }
|
|
311
|
+
.cv-form-actions { display:flex; align-items:center; justify-content:flex-end; gap:14px; margin-top:1px; }
|
|
312
|
+
.cv-cancel { appearance:none; border:0; background:transparent; color:var(--muted); font-family:var(--sans); font-size:12.5px; font-weight:500; cursor:pointer; transition:color .15s var(--settle); }
|
|
313
|
+
.cv-cancel:hover { color:var(--ink-2); }
|
|
314
|
+
.cv-save {
|
|
315
|
+
appearance:none; border:0; background:var(--accent); color:#fff; font-family:var(--sans); font-size:12.5px;
|
|
316
|
+
font-weight:600; padding:7px 15px; border-radius:9px; cursor:pointer; transition:opacity .2s var(--settle);
|
|
317
|
+
}
|
|
318
|
+
.cv-save:hover { opacity:.9; }
|
|
319
|
+
.cv-save:disabled { opacity:.4; cursor:default; }
|
|
320
|
+
.cv-error { color:#b4332a; font-size:11.5px; line-height:1.3; padding:0; }
|
|
321
|
+
.cv-error:empty { display:none; }
|
|
322
|
+
|
|
323
|
+
/* field label above the emails control — quiet, matches the website's "Emails" + hint */
|
|
324
|
+
.cv-flabel { display:flex; align-items:baseline; justify-content:space-between; gap:8px; }
|
|
325
|
+
.cv-flabel .l { font-size:11.5px; font-weight:600; color:var(--ink-2); }
|
|
326
|
+
.cv-flabel .h { font-size:10.5px; color:var(--muted-2); }
|
|
327
|
+
|
|
328
|
+
/* multi-email chip input (ported from the website's EmailsInput) — type an address,
|
|
329
|
+
press Enter / comma / space to set it as a prussian chip; × removes; Backspace on an
|
|
330
|
+
empty field pops the last chip. The whole control is one bordered field that grows. */
|
|
331
|
+
.cv-emails {
|
|
332
|
+
display:flex; flex-wrap:wrap; align-items:center; gap:5px;
|
|
333
|
+
border:1px solid rgba(31,26,23,.14); border-radius:9px; padding:5px 6px;
|
|
334
|
+
background:#fff; cursor:text;
|
|
335
|
+
transition:border-color .15s var(--settle), box-shadow .15s var(--settle);
|
|
336
|
+
}
|
|
337
|
+
.cv-emails.focus { border-color:rgba(48,85,102,.42); box-shadow:0 0 0 3px rgba(48,85,102,.08); }
|
|
338
|
+
.cv-chip {
|
|
339
|
+
display:inline-flex; align-items:center; gap:5px; max-width:100%;
|
|
340
|
+
background:var(--accent-soft); color:var(--accent);
|
|
341
|
+
border-radius:7px; padding:3px 4px 3px 9px;
|
|
342
|
+
font-size:12px; font-weight:500; letter-spacing:.1px;
|
|
343
|
+
}
|
|
344
|
+
.cv-chip .e { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
345
|
+
.cv-chip-x {
|
|
346
|
+
flex:0 0 auto; appearance:none; border:0; background:transparent; cursor:pointer;
|
|
347
|
+
width:16px; height:16px; border-radius:5px; padding:0; line-height:1;
|
|
348
|
+
display:inline-flex; align-items:center; justify-content:center;
|
|
349
|
+
color:var(--accent); opacity:.7; font-size:13px;
|
|
350
|
+
transition:background-color .14s var(--settle), opacity .14s var(--settle);
|
|
351
|
+
}
|
|
352
|
+
.cv-chip-x:hover { background:rgba(48,85,102,.16); opacity:1; }
|
|
353
|
+
.cv-emails-input {
|
|
354
|
+
flex:1 1 110px; min-width:110px; appearance:none; border:0; outline:none; background:transparent;
|
|
355
|
+
padding:4px 4px; font-family:var(--sans); font-size:13px; color:var(--ink);
|
|
356
|
+
}
|
|
357
|
+
.cv-emails-input::placeholder { color:var(--muted-3); }
|
|
358
|
+
/* domain autocomplete dropdown beneath the emails field */
|
|
359
|
+
.cv-suggest {
|
|
360
|
+
margin-top:5px; overflow:hidden; border:1px solid var(--hair); border-radius:8px; background:#fff;
|
|
361
|
+
box-shadow:0 12px 30px -18px rgba(31,26,23,.35);
|
|
362
|
+
}
|
|
363
|
+
.cv-suggest.hidden { display:none; }
|
|
364
|
+
.cv-sugg {
|
|
365
|
+
display:block; width:100%; text-align:left; appearance:none; border:0; background:transparent; cursor:pointer;
|
|
366
|
+
padding:7px 10px; font-family:var(--sans); font-size:12.5px; color:var(--ink-2);
|
|
367
|
+
transition:background-color .12s var(--settle);
|
|
368
|
+
}
|
|
369
|
+
.cv-sugg:hover, .cv-sugg.active { background:rgba(31,26,23,.04); color:var(--ink); }
|
|
370
|
+
.cv-hint { font-size:11px; line-height:1.45; color:var(--muted-2); margin-top:2px; }
|
|
371
|
+
.cv-hint:empty { display:none; }
|
|
372
|
+
</style>
|
|
373
|
+
</head>
|
|
374
|
+
<body>
|
|
375
|
+
<div class="card" id="card">
|
|
376
|
+
<div class="lockup" id="lockup">
|
|
377
|
+
<svg class="mark" width="17" height="17" viewBox="0 0 16 16" aria-hidden="true">
|
|
378
|
+
<rect class="sq0" x="0" y="6" width="4" height="4"/>
|
|
379
|
+
<rect x="6" y="6" width="4" height="4"/>
|
|
380
|
+
<rect x="12" y="6" width="4" height="4"/>
|
|
381
|
+
</svg>
|
|
382
|
+
<span class="word">relay</span>
|
|
383
|
+
<span class="spacer"></span>
|
|
384
|
+
<span class="count zero" id="count">0</span>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
<nav class="tabs" aria-label="Relay views">
|
|
388
|
+
<button class="tab active" type="button" data-view="relays">
|
|
389
|
+
Relays <span class="tab-badge gone" id="relaysBadge">0</span>
|
|
390
|
+
</button>
|
|
391
|
+
<button class="tab" type="button" data-view="tasks">
|
|
392
|
+
Tasks <span class="tab-badge gone" id="tasksBadge">0</span>
|
|
393
|
+
</button>
|
|
394
|
+
<button class="tab" type="button" data-view="contacts">Contacts</button>
|
|
395
|
+
</nav>
|
|
396
|
+
|
|
397
|
+
<div class="scroll" id="scroll">
|
|
398
|
+
<!-- Relays view -->
|
|
399
|
+
<section class="view" id="relaysView">
|
|
400
|
+
<div id="relaysList"></div>
|
|
401
|
+
<div id="relaysEmpty" class="empty gone">
|
|
402
|
+
<svg class="mark" width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
|
|
403
|
+
<rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/>
|
|
404
|
+
</svg>
|
|
405
|
+
<div class="t1">No relays yet</div>
|
|
406
|
+
<div class="t2">Questions, results, and notices will land here.</div>
|
|
407
|
+
</div>
|
|
408
|
+
</section>
|
|
409
|
+
|
|
410
|
+
<!-- Tasks view -->
|
|
411
|
+
<section class="view hidden" id="tasksView">
|
|
412
|
+
<div id="tasksList"></div>
|
|
413
|
+
<div id="tasksEmpty" class="empty gone">
|
|
414
|
+
<svg class="mark" width="22" height="22" viewBox="0 0 16 16" aria-hidden="true">
|
|
415
|
+
<rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/>
|
|
416
|
+
</svg>
|
|
417
|
+
<div class="t1">No tasks yet</div>
|
|
418
|
+
<div class="t2">Tasks you create or join will show their state here.</div>
|
|
419
|
+
</div>
|
|
420
|
+
</section>
|
|
421
|
+
|
|
422
|
+
<!-- Contacts view -->
|
|
423
|
+
<section class="view hidden" id="contactsView">
|
|
424
|
+
<div class="cv-head">
|
|
425
|
+
<span class="cv-title">People you relay with.</span>
|
|
426
|
+
<button class="cv-add" type="button" id="cvAdd">+ Add</button>
|
|
427
|
+
</div>
|
|
428
|
+
<form class="cv-form hidden" id="cvForm">
|
|
429
|
+
<div class="cv-row2">
|
|
430
|
+
<input class="cv-input" id="cvFirst" placeholder="First name" autocomplete="off" spellcheck="false" />
|
|
431
|
+
<input class="cv-input" id="cvLast" placeholder="Surname" autocomplete="off" spellcheck="false" />
|
|
432
|
+
</div>
|
|
433
|
+
<div class="cv-flabel">
|
|
434
|
+
<span class="l">Emails</span>
|
|
435
|
+
<span class="h">One or more — the identity</span>
|
|
436
|
+
</div>
|
|
437
|
+
<div class="cv-emails" id="cvEmails">
|
|
438
|
+
<input class="cv-emails-input" id="cvEmail" type="email" placeholder="name@company.com" autocomplete="off" spellcheck="false" />
|
|
439
|
+
</div>
|
|
440
|
+
<div class="cv-suggest hidden" id="cvSuggest"></div>
|
|
441
|
+
<div class="cv-hint" id="cvHint"></div>
|
|
442
|
+
<div class="cv-error" id="cvError"></div>
|
|
443
|
+
<div class="cv-form-actions">
|
|
444
|
+
<button class="cv-cancel" type="button" id="cvCancel">Cancel</button>
|
|
445
|
+
<button class="cv-save" type="submit" id="cvSave">Save</button>
|
|
446
|
+
</div>
|
|
447
|
+
</form>
|
|
448
|
+
<div class="cv-list" id="cvList"></div>
|
|
449
|
+
<div id="contactsEmpty" class="cv-empty gone">No contacts yet.<br>Add someone, or your agent saves them as you relay.</div>
|
|
450
|
+
</section>
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
|
|
454
|
+
<script>
|
|
455
|
+
const cardEl = document.getElementById("card");
|
|
456
|
+
const lockupEl = document.getElementById("lockup");
|
|
457
|
+
const countEl = document.getElementById("count");
|
|
458
|
+
const tabEls = [...document.querySelectorAll(".tab")];
|
|
459
|
+
const scrollEl = document.getElementById("scroll");
|
|
460
|
+
const relaysViewEl = document.getElementById("relaysView");
|
|
461
|
+
const relaysListEl = document.getElementById("relaysList");
|
|
462
|
+
const relaysEmptyEl = document.getElementById("relaysEmpty");
|
|
463
|
+
const relaysBadgeEl = document.getElementById("relaysBadge");
|
|
464
|
+
const tasksViewEl = document.getElementById("tasksView");
|
|
465
|
+
const tasksListEl = document.getElementById("tasksList");
|
|
466
|
+
const tasksEmptyEl = document.getElementById("tasksEmpty");
|
|
467
|
+
const tasksBadgeEl = document.getElementById("tasksBadge");
|
|
468
|
+
const contactsViewEl = document.getElementById("contactsView");
|
|
469
|
+
const cvAddEl = document.getElementById("cvAdd");
|
|
470
|
+
const cvListEl = document.getElementById("cvList");
|
|
471
|
+
const contactsEmptyEl = document.getElementById("contactsEmpty");
|
|
472
|
+
const cvFormEl = document.getElementById("cvForm");
|
|
473
|
+
const cvFirstEl = document.getElementById("cvFirst");
|
|
474
|
+
const cvLastEl = document.getElementById("cvLast");
|
|
475
|
+
const cvEmailEl = document.getElementById("cvEmail"); // the draft <input> inside the chip field
|
|
476
|
+
const cvEmailsEl = document.getElementById("cvEmails"); // the bordered chip container (click -> focus)
|
|
477
|
+
const cvSuggestEl = document.getElementById("cvSuggest"); // domain autocomplete dropdown
|
|
478
|
+
const cvHintEl = document.getElementById("cvHint"); // "a relay goes to whichever address…" hint
|
|
479
|
+
const cvErrorEl = document.getElementById("cvError");
|
|
480
|
+
const cvSaveEl = document.getElementById("cvSave");
|
|
481
|
+
const cvCancelEl = document.getElementById("cvCancel");
|
|
482
|
+
|
|
483
|
+
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
484
|
+
const EXPANDED = { w: 344, h: 524 };
|
|
485
|
+
const PILL = { w: 168, h: 44 };
|
|
486
|
+
const PEEK = { w: 344, h: 118 };
|
|
487
|
+
|
|
488
|
+
// ---------- zero-latency sound (Web Audio, pre-decoded) ----------
|
|
489
|
+
let actx = null, tinkBuf = null;
|
|
490
|
+
function initAudio() {
|
|
491
|
+
if (actx) return;
|
|
492
|
+
try { actx = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: "interactive" }); } catch { return; }
|
|
493
|
+
Promise.resolve(window.relay.soundBytes("Tink"))
|
|
494
|
+
.then((ab) => (ab ? actx.decodeAudioData(ab) : null))
|
|
495
|
+
.then((b) => { if (b) tinkBuf = b; })
|
|
496
|
+
.catch(() => {});
|
|
497
|
+
}
|
|
498
|
+
function synthTink() {
|
|
499
|
+
if (!actx) return;
|
|
500
|
+
const t = actx.currentTime;
|
|
501
|
+
const o = actx.createOscillator(); o.type = "triangle";
|
|
502
|
+
o.frequency.setValueAtTime(2100, t); o.frequency.exponentialRampToValueAtTime(1300, t + 0.05);
|
|
503
|
+
const g = actx.createGain();
|
|
504
|
+
g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(0.5, t + 0.004); g.gain.exponentialRampToValueAtTime(0.0001, t + 0.12);
|
|
505
|
+
o.connect(g).connect(actx.destination); o.start(t); o.stop(t + 0.13);
|
|
506
|
+
}
|
|
507
|
+
function playTink() {
|
|
508
|
+
if (REDUCED) return;
|
|
509
|
+
if (!actx) initAudio();
|
|
510
|
+
if (!actx) return;
|
|
511
|
+
if (actx.state === "suspended") actx.resume();
|
|
512
|
+
if (tinkBuf) {
|
|
513
|
+
const src = actx.createBufferSource(); src.buffer = tinkBuf;
|
|
514
|
+
const g = actx.createGain(); g.gain.value = 0.6;
|
|
515
|
+
src.connect(g).connect(actx.destination); src.start();
|
|
516
|
+
} else { synthTink(); }
|
|
517
|
+
}
|
|
518
|
+
window.addEventListener("mousedown", () => { initAudio(); if (actx && actx.state === "suspended") actx.resume(); }, { capture: true });
|
|
519
|
+
|
|
520
|
+
// ---------- the spring (stiffness 170, damping 22, mass 1) ----------
|
|
521
|
+
const K = 170, C = 22, M = 1;
|
|
522
|
+
const W = { v: EXPANDED.w, vel: 0, t: EXPANDED.w };
|
|
523
|
+
const H = { v: EXPANDED.h, vel: 0, t: EXPANDED.h };
|
|
524
|
+
let raf = null, lastT = 0;
|
|
525
|
+
function step(s, dt) {
|
|
526
|
+
const a = (-K * (s.v - s.t) - C * s.vel) / M;
|
|
527
|
+
s.vel += a * dt; s.v += s.vel * dt;
|
|
528
|
+
if (Math.abs(s.v - s.t) < 0.15 && Math.abs(s.vel) < 0.15) { s.v = s.t; s.vel = 0; return false; }
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
function frame(now) {
|
|
532
|
+
let dt = (now - lastT) / 1000; lastT = now;
|
|
533
|
+
if (!(dt > 0)) dt = 0.016;
|
|
534
|
+
dt = Math.min(dt, 0.032);
|
|
535
|
+
let moving = false;
|
|
536
|
+
const n = Math.max(1, Math.ceil(dt / 0.008));
|
|
537
|
+
const h = dt / n;
|
|
538
|
+
for (let i = 0; i < n; i++) { const a = step(W, h), b = step(H, h); moving = moving || a || b; }
|
|
539
|
+
cardEl.style.width = W.v.toFixed(2) + "px";
|
|
540
|
+
cardEl.style.height = H.v.toFixed(2) + "px";
|
|
541
|
+
if (moving) raf = requestAnimationFrame(frame);
|
|
542
|
+
else { raf = null; cardEl.style.willChange = ""; }
|
|
543
|
+
}
|
|
544
|
+
function springTo(w, h) {
|
|
545
|
+
W.t = w; H.t = h;
|
|
546
|
+
if (REDUCED) { W.v = w; H.v = h; cardEl.style.width = w + "px"; cardEl.style.height = h + "px"; return; }
|
|
547
|
+
cardEl.style.willChange = "width, height";
|
|
548
|
+
if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(frame); }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---------- collapse / expand / peek ----------
|
|
552
|
+
let collapsed = false;
|
|
553
|
+
let peeking = false, peekTimer = null;
|
|
554
|
+
function clearPeek() {
|
|
555
|
+
if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
|
|
556
|
+
peeking = false;
|
|
557
|
+
cardEl.classList.remove("peek");
|
|
558
|
+
}
|
|
559
|
+
function setCollapsed(v) {
|
|
560
|
+
clearPeek();
|
|
561
|
+
if (v === collapsed) return;
|
|
562
|
+
collapsed = v;
|
|
563
|
+
cardEl.classList.toggle("collapsed", collapsed);
|
|
564
|
+
springTo(collapsed ? PILL.w : EXPANDED.w, collapsed ? PILL.h : EXPANDED.h);
|
|
565
|
+
playTink();
|
|
566
|
+
}
|
|
567
|
+
// A new relay arrived. If folded, peek open like an iOS banner (~7s) then fold back.
|
|
568
|
+
// The peek routes to the tab the item lives in: task_request / share_approval -> Tasks
|
|
569
|
+
// (the clean split), everything else -> Relays.
|
|
570
|
+
function notifyArrival(row) {
|
|
571
|
+
if (!collapsed && !peeking) return;
|
|
572
|
+
if (peekTimer) clearTimeout(peekTimer);
|
|
573
|
+
peeking = true;
|
|
574
|
+
collapsed = false;
|
|
575
|
+
cardEl.classList.remove("collapsed");
|
|
576
|
+
cardEl.classList.add("peek");
|
|
577
|
+
const kind = row && row.relayNotificationKind;
|
|
578
|
+
activeView = (kind === "task_request" || kind === "share_approval") ? "tasks" : "relays";
|
|
579
|
+
syncTabs();
|
|
580
|
+
applyView();
|
|
581
|
+
renderAll();
|
|
582
|
+
scrollEl.scrollTop = 0;
|
|
583
|
+
springTo(PEEK.w, PEEK.h);
|
|
584
|
+
playTink();
|
|
585
|
+
peekTimer = setTimeout(() => {
|
|
586
|
+
peekTimer = null; peeking = false;
|
|
587
|
+
cardEl.classList.remove("peek");
|
|
588
|
+
collapsed = true; cardEl.classList.add("collapsed");
|
|
589
|
+
springTo(PILL.w, PILL.h);
|
|
590
|
+
}, 7000);
|
|
591
|
+
}
|
|
592
|
+
function openFull() {
|
|
593
|
+
clearPeek();
|
|
594
|
+
collapsed = false;
|
|
595
|
+
cardEl.classList.remove("collapsed");
|
|
596
|
+
springTo(EXPANDED.w, EXPANDED.h);
|
|
597
|
+
playTink();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ---------- drag the lockup to move, tap it to fold ----------
|
|
601
|
+
let dragging = false, offX = 0, offY = 0, downSX = 0, downSY = 0, moved = 0;
|
|
602
|
+
lockupEl.addEventListener("mousedown", (e) => {
|
|
603
|
+
if (e.button !== 0) return;
|
|
604
|
+
dragging = true; moved = 0; offX = e.clientX; offY = e.clientY; downSX = e.screenX; downSY = e.screenY;
|
|
605
|
+
e.preventDefault();
|
|
606
|
+
});
|
|
607
|
+
window.addEventListener("mousemove", (e) => {
|
|
608
|
+
if (dragging) {
|
|
609
|
+
moved = Math.max(moved, Math.abs(e.screenX - downSX) + Math.abs(e.screenY - downSY));
|
|
610
|
+
if (moved > 3) window.relay.setPos(e.screenX - offX, e.screenY - offY);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
window.addEventListener("mouseup", () => {
|
|
614
|
+
if (!dragging) return;
|
|
615
|
+
const wasTap = moved <= 3;
|
|
616
|
+
dragging = false;
|
|
617
|
+
if (!wasTap) return;
|
|
618
|
+
if (peeking) { openFull(); return; }
|
|
619
|
+
setCollapsed(!collapsed);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
// ---------- click-through: only the card is interactive ----------
|
|
623
|
+
let interactive = false;
|
|
624
|
+
function setInteractive(v) { if (v !== interactive) { interactive = v; window.relay.interactive(v); } }
|
|
625
|
+
function pointerInsideCard(e) {
|
|
626
|
+
const r = cardEl.getBoundingClientRect();
|
|
627
|
+
return e.clientX >= r.left - 2 && e.clientX <= r.right + 2 && e.clientY >= r.top - 2 && e.clientY <= r.bottom + 2;
|
|
628
|
+
}
|
|
629
|
+
window.addEventListener("mousemove", (e) => { setInteractive(pointerInsideCard(e)); });
|
|
630
|
+
window.addEventListener("mouseout", (e) => {
|
|
631
|
+
if (dragging || e.relatedTarget) return;
|
|
632
|
+
// A re-render (innerHTML swap) fires mouseout with relatedTarget=null while the pointer is
|
|
633
|
+
// still over the card. Only release interactivity when the pointer has truly left the card.
|
|
634
|
+
if (!pointerInsideCard(e)) setInteractive(false);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// ---------- shared helpers ----------
|
|
638
|
+
function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c])); }
|
|
639
|
+
function timeAgo(iso) {
|
|
640
|
+
const t = Date.parse(iso); if (!t) return "";
|
|
641
|
+
const s = Math.max(0, (Date.now() - t) / 1000);
|
|
642
|
+
if (s < 60) return "now";
|
|
643
|
+
if (s < 3600) return Math.floor(s / 60) + "m";
|
|
644
|
+
if (s < 86400) return Math.floor(s / 3600) + "h";
|
|
645
|
+
if (s < 604800) return Math.floor(s / 86400) + "d";
|
|
646
|
+
return Math.floor(s / 604800) + "w";
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Derive a one-line summary from the first meaningful line of markdown body. Strips
|
|
650
|
+
// heading hashes, list bullets, blockquotes, emphasis, and the relay forwarding prefix,
|
|
651
|
+
// then truncates so the single-line serif subject + fade mask stay clean.
|
|
652
|
+
function firstLine(s, max) {
|
|
653
|
+
const cap = max || 90;
|
|
654
|
+
const lines = String(s || "").replace(/\r/g, "").split("\n");
|
|
655
|
+
for (let raw of lines) {
|
|
656
|
+
let line = raw
|
|
657
|
+
.replace(/^\s*🔁\s*From\s+.+?:\s*/i, "")
|
|
658
|
+
.replace(/^\s*🔁\s*/, "")
|
|
659
|
+
.replace(/^\s{0,3}#{1,6}\s+/, "") // headings
|
|
660
|
+
.replace(/^\s{0,3}>\s?/, "") // blockquote
|
|
661
|
+
.replace(/^\s{0,3}[-*+]\s+/, "") // bullet
|
|
662
|
+
.replace(/^\s{0,3}\d+\.\s+/, "") // ordered list
|
|
663
|
+
.replace(/[*_`]+/g, "") // emphasis / code ticks
|
|
664
|
+
.trim();
|
|
665
|
+
if (line) {
|
|
666
|
+
if (line.length > cap) line = line.slice(0, cap - 1).trimEnd() + "…";
|
|
667
|
+
return line;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return "";
|
|
671
|
+
}
|
|
672
|
+
// A short body preview for the line beneath the subject — the next meaningful slice of
|
|
673
|
+
// the body, skipping whatever already became the subject.
|
|
674
|
+
function bodyPreview(body, subject, max) {
|
|
675
|
+
const cap = max || 110;
|
|
676
|
+
const flat = String(body || "")
|
|
677
|
+
.replace(/\r/g, "")
|
|
678
|
+
.replace(/^\s*🔁\s*From\s+.+?:\s*/i, "")
|
|
679
|
+
.replace(/^\s*🔁\s*/, "")
|
|
680
|
+
.replace(/[#>*_`]+/g, "")
|
|
681
|
+
.replace(/\s+/g, " ")
|
|
682
|
+
.trim();
|
|
683
|
+
if (!flat) return "";
|
|
684
|
+
const sub = String(subject || "").replace(/…$/, "").trim();
|
|
685
|
+
let rest = flat;
|
|
686
|
+
if (sub && flat.toLowerCase().startsWith(sub.toLowerCase())) {
|
|
687
|
+
rest = flat.slice(sub.length).replace(/^[\s.:;,—-]+/, "").trim();
|
|
688
|
+
}
|
|
689
|
+
if (!rest) return "";
|
|
690
|
+
if (rest.length > cap) rest = rest.slice(0, cap - 1).trimEnd() + "…";
|
|
691
|
+
return rest;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// map enums -> humane copy (never show a raw enum)
|
|
695
|
+
const RELAY_KIND_LABEL = {
|
|
696
|
+
task_request: "Task request",
|
|
697
|
+
share_approval: "Approval needed",
|
|
698
|
+
human_question: "Waiting on you",
|
|
699
|
+
task_completed: "Task completed",
|
|
700
|
+
task_cancelled: "Task cancelled",
|
|
701
|
+
task_failed: "Task failed",
|
|
702
|
+
connector_reauth: "Reconnect needed",
|
|
703
|
+
};
|
|
704
|
+
function relayKindLabel(kind) { return RELAY_KIND_LABEL[kind] || "Relay"; }
|
|
705
|
+
// The clean split: Relays shows ONLY the urgent quick-reply (human_question) and
|
|
706
|
+
// plain message/result/notice kinds. task_request + share_approval live in Tasks.
|
|
707
|
+
const RELAY_HIDDEN_KINDS = new Set(["task_request", "share_approval"]);
|
|
708
|
+
function isRelayListKind(row) { return !RELAY_HIDDEN_KINDS.has(row && row.relayNotificationKind); }
|
|
709
|
+
|
|
710
|
+
// The serif subject line per kind, using REAL fields:
|
|
711
|
+
// - human_question: the question itself is the subject.
|
|
712
|
+
// - result / task_completed: the one kind with a real title -> title is the subject.
|
|
713
|
+
// - plain message / notice: no real title — derive the subject from the first meaningful
|
|
714
|
+
// line of bodyMarkdown (never promote a fake title and throw the body away).
|
|
715
|
+
function relaySubject(row) {
|
|
716
|
+
const kind = row.relayNotificationKind;
|
|
717
|
+
const body = row.bodyMarkdown || row.body || "";
|
|
718
|
+
const title = String(row.title || row.displayTitle || "").trim();
|
|
719
|
+
if (kind === "human_question") {
|
|
720
|
+
// The question wraps to ~3 lines (.rk-subject.q), so allow a longer cap than the
|
|
721
|
+
// single-line subjects. Source: HumanResponse.question (protocol.ts), the real question.
|
|
722
|
+
return firstLine(body, 240) || title || relayKindLabel(kind);
|
|
723
|
+
}
|
|
724
|
+
if (kind === "task_completed" || kind === "result") {
|
|
725
|
+
return title || firstLine(body, 90) || relayKindLabel(kind);
|
|
726
|
+
}
|
|
727
|
+
// plain message / notice: subject is the first meaningful body line
|
|
728
|
+
return firstLine(body, 90) || title || relayKindLabel(kind);
|
|
729
|
+
}
|
|
730
|
+
// Always the real person, never the literal brand word "Relay". notifications.js stages a
|
|
731
|
+
// real senderName (the message's TaskMessage.senderLabel, the other person, or "Your agent"
|
|
732
|
+
// for the viewer's own agent questions). If an older staged row still carries the bare brand
|
|
733
|
+
// word "Relay" (e.g. main.cjs's senderName||"Relay" fallback), treat it as unknown and show
|
|
734
|
+
// the neutral "Someone" rather than presenting "Relay" as a person.
|
|
735
|
+
function relaySender(row) {
|
|
736
|
+
const name = String(row.senderName || row.fromName || row.personName || "").trim();
|
|
737
|
+
if (!name || name === "Relay") return "Someone";
|
|
738
|
+
return name;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Exhaustive over the TaskState enum (protocol.ts: draft, resolving, inviting, active,
|
|
742
|
+
// completing, completed, cancelled, failed). Every value has a human label so a task row
|
|
743
|
+
// never shows a raw enum.
|
|
744
|
+
const TASK_STATE_LABEL = {
|
|
745
|
+
draft: "Draft", resolving: "Setting up", inviting: "Awaiting replies",
|
|
746
|
+
active: "Active", completing: "Wrapping up", completed: "Completed",
|
|
747
|
+
cancelled: "Cancelled", failed: "Failed",
|
|
748
|
+
};
|
|
749
|
+
function taskStateLabel(state) { return TASK_STATE_LABEL[state] || "Task"; }
|
|
750
|
+
function taskStateClass(state) {
|
|
751
|
+
// The reduced palette only distinguishes "live" (prussian) from everything else (gray).
|
|
752
|
+
if (state === "active" || state === "completing") return "s-active";
|
|
753
|
+
return "";
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Find the viewer's participant row when they have an OUTSTANDING invitation on this
|
|
757
|
+
// task (invitationState "created" or "delivered"). Match on account email, then userId.
|
|
758
|
+
function viewerPendingParticipant(task) {
|
|
759
|
+
const ps = Array.isArray(task.participants) ? task.participants : [];
|
|
760
|
+
if (!ps.length) return null;
|
|
761
|
+
const acct = (payload && payload.account) || {};
|
|
762
|
+
const myEmail = String(acct.email || "").trim().toLowerCase();
|
|
763
|
+
const myUserId = acct.userId || acct.id || null;
|
|
764
|
+
for (const p of ps) {
|
|
765
|
+
const st = p.invitationState;
|
|
766
|
+
if (st !== "created" && st !== "delivered") continue;
|
|
767
|
+
const pEmail = String(p.email || "").trim().toLowerCase();
|
|
768
|
+
if (myEmail && pEmail && pEmail === myEmail) return p;
|
|
769
|
+
if (myUserId && p.userId && p.userId === myUserId) return p;
|
|
770
|
+
}
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
function participantSummary(task) {
|
|
774
|
+
const ps = Array.isArray(task.participants) ? task.participants : [];
|
|
775
|
+
if (!ps.length) return "No participants yet";
|
|
776
|
+
let accepted = 0, waiting = 0, declined = 0;
|
|
777
|
+
for (const p of ps) {
|
|
778
|
+
const st = p.invitationState;
|
|
779
|
+
if (st === "accepted") accepted++;
|
|
780
|
+
else if (st === "rejected" || st === "timed_out" || st === "withdrawn") declined++;
|
|
781
|
+
else waiting++;
|
|
782
|
+
}
|
|
783
|
+
const parts = [];
|
|
784
|
+
if (accepted) parts.push(accepted + " accepted");
|
|
785
|
+
if (waiting) parts.push(waiting + " waiting");
|
|
786
|
+
if (declined) parts.push(declined + " declined");
|
|
787
|
+
return parts.length ? parts.join(" · ") : ps.length + " invited";
|
|
788
|
+
}
|
|
789
|
+
function taskRoleLabel(role) { return role === "creator" ? "You created" : "You joined"; }
|
|
790
|
+
|
|
791
|
+
// ---------- state ----------
|
|
792
|
+
let activeView = "relays";
|
|
793
|
+
let payload = { account: { paired: false }, relays: [], tasks: [], contacts: [] };
|
|
794
|
+
let prevRelayIds = new Set();
|
|
795
|
+
let contactsList = [];
|
|
796
|
+
let cvEditing = null;
|
|
797
|
+
|
|
798
|
+
// rows mid-open: id -> click time (or -1 once a stop is scheduled). Kept across re-renders.
|
|
799
|
+
const openingIds = new Map();
|
|
800
|
+
function openingTargets(id) {
|
|
801
|
+
return [...document.querySelectorAll("[data-opening-id]")].filter((e) => e.getAttribute("data-opening-id") === id);
|
|
802
|
+
}
|
|
803
|
+
function stopOpening(id) {
|
|
804
|
+
const start = openingIds.get(id);
|
|
805
|
+
if (start === undefined || start === -1) return;
|
|
806
|
+
openingIds.set(id, -1);
|
|
807
|
+
const elapsed = performance.now() - start;
|
|
808
|
+
const wait = Math.max(380, 650 - elapsed);
|
|
809
|
+
setTimeout(() => {
|
|
810
|
+
openingIds.delete(id);
|
|
811
|
+
for (const el of openingTargets(id)) el.classList.remove("opening");
|
|
812
|
+
}, wait);
|
|
813
|
+
}
|
|
814
|
+
function openRelayFromUI(id) {
|
|
815
|
+
if (!id || openingIds.has(id)) return;
|
|
816
|
+
openingIds.set(id, performance.now());
|
|
817
|
+
for (const el of openingTargets(id)) el.classList.add("opening");
|
|
818
|
+
setTimeout(() => stopOpening(id), 6000); // safety: never spin forever if openDone is missed
|
|
819
|
+
window.relay.open(id);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function setCount(n) {
|
|
823
|
+
cardEl.classList.toggle("has-unread", n > 0);
|
|
824
|
+
countEl.classList.toggle("zero", n === 0);
|
|
825
|
+
if (countEl.textContent !== String(n)) {
|
|
826
|
+
countEl.textContent = String(n);
|
|
827
|
+
if (!REDUCED) { countEl.style.animation = "none"; void countEl.offsetWidth; countEl.style.animation = "countRoll .2s var(--settle)"; }
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function setBadge(el, n) {
|
|
832
|
+
el.textContent = String(n);
|
|
833
|
+
el.classList.toggle("gone", !(n > 0));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ---------- Relays view ----------
|
|
837
|
+
// A quick-reply (human_question) <textarea class="qr-input"> is "active" while the user is
|
|
838
|
+
// mid-answer: it's focused OR holds typed text. The inbox payload refreshes on a 2.5s safety
|
|
839
|
+
// poll (+ state.json fs.watch), and each refresh used to rebuild relaysListEl.innerHTML —
|
|
840
|
+
// destroying the in-progress textarea and wiping the typed text/focus. We must NOT rebuild the
|
|
841
|
+
// relays list while a reply is being written; the refresh is deferred (pendingRelaysRender)
|
|
842
|
+
// and flushed once the input blurs / the answer is sent.
|
|
843
|
+
let pendingRelaysRender = false;
|
|
844
|
+
function quickReplyIsActive() {
|
|
845
|
+
const active = document.activeElement;
|
|
846
|
+
if (active && active.classList && active.classList.contains("qr-input")) return true;
|
|
847
|
+
for (const ta of relaysListEl.querySelectorAll(".qr-input")) {
|
|
848
|
+
if (ta.value && ta.value.length) return true;
|
|
849
|
+
}
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
function flushPendingRelaysRender() {
|
|
853
|
+
if (!pendingRelaysRender) return;
|
|
854
|
+
pendingRelaysRender = false;
|
|
855
|
+
if (activeView === "relays") renderRelays();
|
|
856
|
+
}
|
|
857
|
+
function renderRelays() {
|
|
858
|
+
// Never rebuild the list out from under an in-progress quick reply — that would destroy the
|
|
859
|
+
// focused textarea and lose what the user is typing. Defer and re-render on blur/submit.
|
|
860
|
+
if (quickReplyIsActive()) { pendingRelaysRender = true; return; }
|
|
861
|
+
pendingRelaysRender = false;
|
|
862
|
+
const rows = (payload.relays || []).filter(isRelayListKind);
|
|
863
|
+
relaysEmptyEl.classList.toggle("gone", rows.length > 0);
|
|
864
|
+
const seen = prevRelayIds;
|
|
865
|
+
relaysListEl.innerHTML = rows.map((r) => {
|
|
866
|
+
const fresh = seen.size > 0 && !seen.has(r.id);
|
|
867
|
+
const kind = r.relayNotificationKind;
|
|
868
|
+
const subject = relaySubject(r);
|
|
869
|
+
const sender = relaySender(r);
|
|
870
|
+
const actions = relayActionsHtml(r);
|
|
871
|
+
// result / task_completed / plain message carry a body preview beneath the subject.
|
|
872
|
+
// (human_question's body IS the subject, so no duplicate preview there.)
|
|
873
|
+
const body = r.bodyMarkdown || r.body || "";
|
|
874
|
+
const preview = kind === "human_question" ? "" : bodyPreview(body, subject, 110);
|
|
875
|
+
const previewHtml = preview ? `<div class="rk-preview">${esc(preview)}</div>` : "";
|
|
876
|
+
return `
|
|
877
|
+
<div class="row ${r.unread ? "unread" : ""}${fresh ? " enter" : ""}" data-id="${esc(r.id)}" data-opening-id="${esc(r.id)}">
|
|
878
|
+
<div class="rk-top">
|
|
879
|
+
<span class="rk-dot"></span>
|
|
880
|
+
<span class="rk-from">From</span>
|
|
881
|
+
<span class="rk-sender">${esc(sender)}</span>
|
|
882
|
+
<span class="rk-time">${timeAgo(r.createdAt)}</span>
|
|
883
|
+
</div>
|
|
884
|
+
<div class="rk-subject${kind === "human_question" ? " q" : ""}">${esc(subject)}</div>
|
|
885
|
+
${previewHtml}
|
|
886
|
+
${actions}
|
|
887
|
+
<div class="row-err" data-err="${esc(r.id)}"></div>
|
|
888
|
+
</div>`;
|
|
889
|
+
}).join("");
|
|
890
|
+
prevRelayIds = new Set(rows.map((r) => r.id));
|
|
891
|
+
wireRelayRows();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// The white up-arrow inside the round prussian Send button.
|
|
895
|
+
const SEND_ARROW_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>';
|
|
896
|
+
|
|
897
|
+
// The inline action affordances for each relay kind. task_request / share_approval
|
|
898
|
+
// never reach the Relays list (filtered out by isRelayListKind) — their accept/decline
|
|
899
|
+
// lives in the Tasks tab now.
|
|
900
|
+
function relayActionsHtml(r) {
|
|
901
|
+
const k = r.relayNotificationKind;
|
|
902
|
+
if (k === "human_question") {
|
|
903
|
+
// relay_answer_human_question requires BOTH taskId and messageId (mcp.js). Only render the
|
|
904
|
+
// inline reply box when both are present; otherwise the box would post a dead answer.
|
|
905
|
+
if (r.quickReply && r.taskId && r.messageId) {
|
|
906
|
+
return `
|
|
907
|
+
<div class="qr" data-stop="1">
|
|
908
|
+
<textarea class="qr-input" data-qr="${esc(r.id)}" rows="1" placeholder="Reply to resume the task…"></textarea>
|
|
909
|
+
<button class="qr-send" type="button" data-answer="${esc(r.id)}" aria-label="Send">${SEND_ARROW_SVG}</button>
|
|
910
|
+
</div>
|
|
911
|
+
<div class="row-note">An agent run is paused until you answer — or open it in Claude or Codex to talk it through.</div>`;
|
|
912
|
+
}
|
|
913
|
+
// Degraded: the message id needed to answer inline is missing. Make the fallback explicit
|
|
914
|
+
// and actionable — an "Open to answer" affordance that uses the existing open path.
|
|
915
|
+
return `
|
|
916
|
+
<div class="rk-actions" data-stop="1">
|
|
917
|
+
<button class="act-btn accept" type="button" data-open="${esc(r.id)}">Open to answer</button>
|
|
918
|
+
</div>
|
|
919
|
+
<div class="row-note">An agent run is paused until you answer. Open it in Claude or Codex to reply.</div>`;
|
|
920
|
+
}
|
|
921
|
+
if (k === "connector_reauth") {
|
|
922
|
+
return `
|
|
923
|
+
<div class="rk-actions" data-stop="1">
|
|
924
|
+
<button class="act-btn accept" type="button" data-review="${esc(r.id)}">Reconnect</button>
|
|
925
|
+
</div>`;
|
|
926
|
+
}
|
|
927
|
+
// task_completed / task_cancelled / task_failed and anything else: plain attention row.
|
|
928
|
+
return "";
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function relayById(id) { return (payload.relays || []).find((r) => r.id === id) || null; }
|
|
932
|
+
function showRowError(id, message) {
|
|
933
|
+
const el = relaysListEl.querySelector('[data-err="' + cssId(id) + '"]');
|
|
934
|
+
if (el) el.textContent = message || "";
|
|
935
|
+
}
|
|
936
|
+
function cssId(id) { return String(id).replace(/"/g, '\\"'); }
|
|
937
|
+
|
|
938
|
+
async function runRowAction(id, btn, fn) {
|
|
939
|
+
if (!btn || btn.disabled) return;
|
|
940
|
+
btn.disabled = true;
|
|
941
|
+
showRowError(id, "");
|
|
942
|
+
try {
|
|
943
|
+
const res = await fn();
|
|
944
|
+
if (res && res.ok === false) {
|
|
945
|
+
showRowError(id, res.conflict ? "This task already moved on. " + (res.error || "") : (res.error || "Could not complete that."));
|
|
946
|
+
} else {
|
|
947
|
+
window.relay.ack(id);
|
|
948
|
+
}
|
|
949
|
+
} catch (err) {
|
|
950
|
+
showRowError(id, (err && err.message) ? err.message : "Could not complete that.");
|
|
951
|
+
} finally {
|
|
952
|
+
btn.disabled = false;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function wireRelayRows() {
|
|
957
|
+
// stop clicks inside action zones from triggering the row open
|
|
958
|
+
for (const z of relaysListEl.querySelectorAll('[data-stop="1"]')) {
|
|
959
|
+
z.addEventListener("click", (e) => e.stopPropagation());
|
|
960
|
+
}
|
|
961
|
+
for (const ta of relaysListEl.querySelectorAll(".qr-input")) {
|
|
962
|
+
// Keep the window focusable the whole time the reply field is focused so no keystroke is
|
|
963
|
+
// ever dropped; only release focusability on blur.
|
|
964
|
+
ta.addEventListener("focus", () => window.relay.setFocusable(true));
|
|
965
|
+
ta.addEventListener("blur", () => {
|
|
966
|
+
if (activeView !== "contacts") window.relay.setFocusable(false);
|
|
967
|
+
// The blur may have cleared the only active quick reply — apply any inbox refresh that
|
|
968
|
+
// was deferred while the user was typing. Defer one tick so a focus moving to the Send
|
|
969
|
+
// button (which we never want to interrupt) settles first.
|
|
970
|
+
setTimeout(flushPendingRelaysRender, 0);
|
|
971
|
+
});
|
|
972
|
+
ta.addEventListener("input", () => { ta.style.height = "auto"; ta.style.height = Math.min(84, ta.scrollHeight) + "px"; });
|
|
973
|
+
ta.addEventListener("mousedown", () => setInteractive(true));
|
|
974
|
+
}
|
|
975
|
+
// accept/reject moved to the Tasks tab (the clean split). Only connector_reauth
|
|
976
|
+
// keeps a [data-review] "Reconnect" affordance here.
|
|
977
|
+
for (const btn of relaysListEl.querySelectorAll("[data-review]")) {
|
|
978
|
+
const id = btn.getAttribute("data-review");
|
|
979
|
+
btn.addEventListener("click", () => {
|
|
980
|
+
const r = relayById(id); if (!r) return;
|
|
981
|
+
window.relay.open(id); // opens the web detail / connectors page + acks
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
// Degraded human_question (no messageId for inline answer) -> explicit "Open to answer"
|
|
985
|
+
// affordance routes through the same open path the row click uses.
|
|
986
|
+
for (const btn of relaysListEl.querySelectorAll("[data-open]")) {
|
|
987
|
+
const id = btn.getAttribute("data-open");
|
|
988
|
+
btn.addEventListener("click", () => {
|
|
989
|
+
const r = relayById(id); if (!r) return;
|
|
990
|
+
openRelayFromUI(id);
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
for (const btn of relaysListEl.querySelectorAll("[data-answer]")) {
|
|
994
|
+
const id = btn.getAttribute("data-answer");
|
|
995
|
+
btn.addEventListener("click", () => {
|
|
996
|
+
const r = relayById(id); if (!r) return;
|
|
997
|
+
const ta = relaysListEl.querySelector('[data-qr="' + cssId(id) + '"]');
|
|
998
|
+
const text = ta ? ta.value.trim() : "";
|
|
999
|
+
if (!text) { showRowError(id, "Write an answer first."); return; }
|
|
1000
|
+
runRowAction(id, btn, async () => {
|
|
1001
|
+
const res = await window.relay.answer(r.taskId, r.messageId, text);
|
|
1002
|
+
if (res && res.ok) { if (ta) ta.value = ""; }
|
|
1003
|
+
return res;
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
for (const el of relaysListEl.querySelectorAll(".row")) {
|
|
1008
|
+
const id = el.getAttribute("data-id");
|
|
1009
|
+
if (openingIds.has(id)) el.classList.add("opening");
|
|
1010
|
+
el.addEventListener("click", () => {
|
|
1011
|
+
el.classList.remove("unread");
|
|
1012
|
+
openRelayFromUI(id);
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ---------- Tasks view ----------
|
|
1018
|
+
const TASK_GROUPS = [
|
|
1019
|
+
{ key: "needs", label: "Needs you", match: (t) => t.requiresViewerAction },
|
|
1020
|
+
{ key: "active", label: "Active", match: (t) => t.state === "active" || t.state === "completing" },
|
|
1021
|
+
{ key: "waiting", label: "Waiting", match: (t) => t.state === "draft" || t.state === "resolving" || t.state === "inviting" },
|
|
1022
|
+
{ key: "completed", label: "Completed", match: (t) => t.state === "completed" },
|
|
1023
|
+
{ key: "stopped", label: "Stopped or failed", match: (t) => t.state === "failed" || t.state === "cancelled" },
|
|
1024
|
+
];
|
|
1025
|
+
function renderTasks() {
|
|
1026
|
+
const tasks = payload.tasks || [];
|
|
1027
|
+
tasksEmptyEl.classList.toggle("gone", tasks.length > 0);
|
|
1028
|
+
if (!tasks.length) { tasksListEl.innerHTML = ""; return; }
|
|
1029
|
+
const assigned = new Set();
|
|
1030
|
+
let html = "";
|
|
1031
|
+
for (const g of TASK_GROUPS) {
|
|
1032
|
+
const inGroup = tasks.filter((t) => !assigned.has(t.id) && g.match(t));
|
|
1033
|
+
for (const t of inGroup) assigned.add(t.id);
|
|
1034
|
+
if (!inGroup.length) continue;
|
|
1035
|
+
inGroup.sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
|
|
1036
|
+
html += `<div class="group-head">${esc(g.label)} <span class="ghc">${inGroup.length}</span></div>`;
|
|
1037
|
+
html += inGroup.map(taskRowHtml).join("");
|
|
1038
|
+
}
|
|
1039
|
+
tasksListEl.innerHTML = html;
|
|
1040
|
+
wireTaskRows();
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
async function runTaskAction(el, fn) {
|
|
1044
|
+
if (!el || el.dataset.busy === "1") return;
|
|
1045
|
+
el.dataset.busy = "1";
|
|
1046
|
+
const prev = el.textContent;
|
|
1047
|
+
el.style.opacity = ".5";
|
|
1048
|
+
try {
|
|
1049
|
+
await fn();
|
|
1050
|
+
await window.relay.refreshTasks().catch(() => {});
|
|
1051
|
+
} catch (err) {
|
|
1052
|
+
el.textContent = prev;
|
|
1053
|
+
} finally {
|
|
1054
|
+
el.dataset.busy = "";
|
|
1055
|
+
el.style.opacity = "";
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function wireTaskRows() {
|
|
1060
|
+
// action zones inside a row must not also open the task web detail
|
|
1061
|
+
for (const z of tasksListEl.querySelectorAll('[data-stop="1"]')) {
|
|
1062
|
+
z.addEventListener("click", (e) => e.stopPropagation());
|
|
1063
|
+
}
|
|
1064
|
+
for (const btn of tasksListEl.querySelectorAll("[data-task-accept]")) {
|
|
1065
|
+
btn.addEventListener("click", (e) => {
|
|
1066
|
+
e.stopPropagation();
|
|
1067
|
+
const taskId = btn.getAttribute("data-task-accept");
|
|
1068
|
+
const pid = btn.getAttribute("data-pid");
|
|
1069
|
+
runTaskAction(btn, () => window.relay.accept(taskId, pid));
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
for (const btn of tasksListEl.querySelectorAll("[data-task-decline]")) {
|
|
1073
|
+
btn.addEventListener("click", (e) => {
|
|
1074
|
+
e.stopPropagation();
|
|
1075
|
+
const taskId = btn.getAttribute("data-task-decline");
|
|
1076
|
+
const pid = btn.getAttribute("data-pid");
|
|
1077
|
+
runTaskAction(btn, () => window.relay.reject(taskId, pid));
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
// "N waiting to approve" -> open the task's web detail (no inline approval)
|
|
1081
|
+
for (const el of tasksListEl.querySelectorAll("[data-approve]")) {
|
|
1082
|
+
el.addEventListener("click", (e) => {
|
|
1083
|
+
e.stopPropagation();
|
|
1084
|
+
window.relay.openTask(el.getAttribute("data-approve"));
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
for (const el of tasksListEl.querySelectorAll(".task")) {
|
|
1088
|
+
el.addEventListener("click", () => window.relay.openTask(el.getAttribute("data-task")));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
function taskRowHtml(t) {
|
|
1092
|
+
const obj = String(t.objective || "").trim();
|
|
1093
|
+
const approvals = Number(t.pendingApprovalCount || 0);
|
|
1094
|
+
const pending = viewerPendingParticipant(t);
|
|
1095
|
+
|
|
1096
|
+
// VIEWER-RELATIVE state label. TaskSummary.requiresViewerAction marks rows that need the
|
|
1097
|
+
// viewer; when there is also an outstanding invitation participant (viewerPendingParticipant,
|
|
1098
|
+
// invitationState created/delivered per protocol.ts InvitationState) the label must reflect
|
|
1099
|
+
// the VIEWER's action, not the global task.state ("Awaiting replies"). The dot/class follow
|
|
1100
|
+
// so a needs-you row visibly reads as needing the viewer (prussian).
|
|
1101
|
+
let stateLabel = taskStateLabel(t.state);
|
|
1102
|
+
let stateCls = taskStateClass(t.state);
|
|
1103
|
+
if (t.requiresViewerAction) {
|
|
1104
|
+
stateCls = "s-active";
|
|
1105
|
+
if (pending) {
|
|
1106
|
+
// Outstanding invitation -> the viewer is being invited to join.
|
|
1107
|
+
stateLabel = "Invited you";
|
|
1108
|
+
} else {
|
|
1109
|
+
// requiresViewerAction without a pending invite means another viewer action is
|
|
1110
|
+
// outstanding (e.g. a pending approval or a question awaiting their reply).
|
|
1111
|
+
stateLabel = "Your reply needed";
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// meta line: role · participant summary · (waiting to approve)
|
|
1116
|
+
const meta = [];
|
|
1117
|
+
meta.push(`<span>${esc(taskRoleLabel(t.viewerRole))}</span>`);
|
|
1118
|
+
meta.push(`<span class="sep">·</span>`);
|
|
1119
|
+
meta.push(`<span>${esc(participantSummary(t))}</span>`);
|
|
1120
|
+
if (approvals > 0) {
|
|
1121
|
+
meta.push(`<span class="sep">·</span>`);
|
|
1122
|
+
meta.push(`<span class="task-approve" data-approve="${esc(t.id)}">${approvals} waiting to approve</span>`);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// outstanding invitation -> inline Accept / Decline (flat text, prussian/muted)
|
|
1126
|
+
const actions = pending
|
|
1127
|
+
? `<div class="rk-actions" data-stop="1">
|
|
1128
|
+
<button class="act-btn accept" type="button" data-task-accept="${esc(t.id)}" data-pid="${esc(pending.id)}">Accept</button>
|
|
1129
|
+
<button class="act-btn decline" type="button" data-task-decline="${esc(t.id)}" data-pid="${esc(pending.id)}">Decline</button>
|
|
1130
|
+
</div>`
|
|
1131
|
+
: "";
|
|
1132
|
+
|
|
1133
|
+
return `
|
|
1134
|
+
<div class="task" role="button" tabindex="0" data-task="${esc(t.id)}">
|
|
1135
|
+
<div class="task-top">
|
|
1136
|
+
<span class="t-dot ${stateCls}"></span>
|
|
1137
|
+
<span class="t-state ${stateCls}">${esc(stateLabel)}</span>
|
|
1138
|
+
<span class="task-time">${timeAgo(t.updatedAt)}</span>
|
|
1139
|
+
</div>
|
|
1140
|
+
<div class="task-title">${esc(t.title || "Untitled task")}</div>
|
|
1141
|
+
${obj ? `<div class="task-obj">${esc(obj)}</div>` : ""}
|
|
1142
|
+
<div class="task-meta">${meta.join("")}</div>
|
|
1143
|
+
${actions}
|
|
1144
|
+
</div>`;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ---------- Contacts view ----------
|
|
1148
|
+
function cvInitials(name, email) {
|
|
1149
|
+
const base = String(name || email || "?").trim();
|
|
1150
|
+
const parts = base.split(/\s+/).filter(Boolean);
|
|
1151
|
+
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
1152
|
+
return base.slice(0, 2).toUpperCase();
|
|
1153
|
+
}
|
|
1154
|
+
const CHEVRON_SVG = '<svg width="7" height="12" viewBox="0 0 8 14" fill="none" stroke="#b7b1a6" stroke-width="1.8" stroke-linecap="round"><path d="M1 1l6 6-6 6"/></svg>';
|
|
1155
|
+
// A contact carries emails[] (primary = emails[0]); older rows may only have a single .email.
|
|
1156
|
+
function contactEmails(c) {
|
|
1157
|
+
if (Array.isArray(c.emails) && c.emails.length) return c.emails.filter(Boolean);
|
|
1158
|
+
const e = String(c.email || "").trim();
|
|
1159
|
+
return e ? [e] : [];
|
|
1160
|
+
}
|
|
1161
|
+
function contactKey(c) { return c.id || (contactEmails(c)[0] || "") || c.name; }
|
|
1162
|
+
function renderContacts() {
|
|
1163
|
+
contactsEmptyEl.classList.toggle("gone", contactsList.length > 0);
|
|
1164
|
+
cvListEl.innerHTML = contactsList.map((c) => {
|
|
1165
|
+
const emails = contactEmails(c);
|
|
1166
|
+
const primary = emails[0] || "";
|
|
1167
|
+
// "name@x.com +2 more · on Relay" — mirrors the website's row sub-line.
|
|
1168
|
+
const more = emails.length > 1 ? ` +${emails.length - 1} more` : "";
|
|
1169
|
+
const sub = (primary || "no email") + more + (c.onRelay ? " · on Relay" : "");
|
|
1170
|
+
return `
|
|
1171
|
+
<button class="cv-item" type="button" data-contact="${esc(contactKey(c))}">
|
|
1172
|
+
<span class="cv-avatar">${esc(cvInitials(c.name, primary))}</span>
|
|
1173
|
+
<span class="cv-body">
|
|
1174
|
+
<span class="cv-name">${esc(c.name || primary)}</span>
|
|
1175
|
+
<span class="cv-sub">${esc(sub)}</span>
|
|
1176
|
+
</span>
|
|
1177
|
+
<span class="cv-chev">${CHEVRON_SVG}</span>
|
|
1178
|
+
</button>`;
|
|
1179
|
+
}).join("");
|
|
1180
|
+
for (const el of cvListEl.querySelectorAll(".cv-item")) {
|
|
1181
|
+
el.addEventListener("click", () => openContactForm(el.getAttribute("data-contact")));
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
async function loadContacts() {
|
|
1185
|
+
try { contactsList = (await window.relay.contacts()) || []; }
|
|
1186
|
+
catch { contactsList = []; }
|
|
1187
|
+
renderContacts();
|
|
1188
|
+
}
|
|
1189
|
+
function cvSplitName(name) {
|
|
1190
|
+
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
|
|
1191
|
+
return { first: parts[0] || "", last: parts.slice(1).join(" ") };
|
|
1192
|
+
}
|
|
1193
|
+
// ---- multi-email chip input (ported from the website's EmailsInput) ----
|
|
1194
|
+
// The form holds the committed addresses here; the draft <input> (#cvEmail) holds whatever
|
|
1195
|
+
// is being typed. Enter / comma / space (or a suggestion click) commits the draft as a chip.
|
|
1196
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
1197
|
+
const COMMON_DOMAINS = ["gmail.com","outlook.com","hotmail.com","yahoo.com","icloud.com","me.com","proton.me","protonmail.com","live.com","aol.com"];
|
|
1198
|
+
let cvEmailsList = []; // committed, lowercased, deduped addresses (the chips)
|
|
1199
|
+
let cvSuggIdx = 0; // highlighted suggestion row
|
|
1200
|
+
function isValidEmail(s) { return EMAIL_RE.test(String(s).trim()); }
|
|
1201
|
+
function cvSuggestions() {
|
|
1202
|
+
const draft = cvEmailEl.value;
|
|
1203
|
+
const at = draft.indexOf("@");
|
|
1204
|
+
if (at < 1) return [];
|
|
1205
|
+
const local = draft.slice(0, at);
|
|
1206
|
+
const domainPart = draft.slice(at + 1).toLowerCase();
|
|
1207
|
+
return COMMON_DOMAINS.filter((d) => d.startsWith(domainPart) && d !== domainPart)
|
|
1208
|
+
.slice(0, 4)
|
|
1209
|
+
.map((d) => `${local}@${d}`);
|
|
1210
|
+
}
|
|
1211
|
+
function renderEmailHint() {
|
|
1212
|
+
// mirror the website: a per-relay routing hint once there's more than one address.
|
|
1213
|
+
cvHintEl.textContent = cvEmailsList.length > 1
|
|
1214
|
+
? "A relay goes to whichever address has a Relay account. If none does, all of them get the email."
|
|
1215
|
+
: "";
|
|
1216
|
+
}
|
|
1217
|
+
function renderEmailChips() {
|
|
1218
|
+
// wipe existing chips (keep the draft <input>), then prepend a chip per address.
|
|
1219
|
+
for (const ch of [...cvEmailsEl.querySelectorAll(".cv-chip")]) ch.remove();
|
|
1220
|
+
for (const e of cvEmailsList) {
|
|
1221
|
+
const chip = document.createElement("span");
|
|
1222
|
+
chip.className = "cv-chip";
|
|
1223
|
+
const label = document.createElement("span");
|
|
1224
|
+
label.className = "e"; label.textContent = e;
|
|
1225
|
+
const x = document.createElement("button");
|
|
1226
|
+
x.type = "button"; x.className = "cv-chip-x"; x.setAttribute("aria-label", "Remove " + e); x.textContent = "×";
|
|
1227
|
+
x.addEventListener("click", (ev) => { ev.stopPropagation(); removeEmail(e); cvEmailEl.focus(); });
|
|
1228
|
+
chip.appendChild(label); chip.appendChild(x);
|
|
1229
|
+
cvEmailsEl.insertBefore(chip, cvEmailEl);
|
|
1230
|
+
}
|
|
1231
|
+
cvEmailEl.placeholder = cvEmailsList.length ? "Add another…" : "name@company.com";
|
|
1232
|
+
renderEmailHint();
|
|
1233
|
+
}
|
|
1234
|
+
function renderSuggestions() {
|
|
1235
|
+
const list = cvSuggestions();
|
|
1236
|
+
if (!list.length) { cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = ""; return; }
|
|
1237
|
+
if (cvSuggIdx >= list.length) cvSuggIdx = 0;
|
|
1238
|
+
cvSuggestEl.innerHTML = list.map((s, i) =>
|
|
1239
|
+
`<button type="button" class="cv-sugg${i === cvSuggIdx ? " active" : ""}" data-sugg="${esc(s)}">${esc(s)}</button>`
|
|
1240
|
+
).join("");
|
|
1241
|
+
cvSuggestEl.classList.remove("hidden");
|
|
1242
|
+
for (const b of cvSuggestEl.querySelectorAll(".cv-sugg")) {
|
|
1243
|
+
b.addEventListener("mousedown", (ev) => { ev.preventDefault(); addEmail(b.getAttribute("data-sugg")); });
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
function addEmail(raw) {
|
|
1247
|
+
const e = String(raw).trim().toLowerCase().replace(/[,;]+$/, "");
|
|
1248
|
+
if (!e) return true;
|
|
1249
|
+
if (!isValidEmail(e)) { cvErrorEl.textContent = `"${e}" doesn't look like a valid email.`; return false; }
|
|
1250
|
+
if (!cvEmailsList.some((v) => v.toLowerCase() === e)) cvEmailsList.push(e);
|
|
1251
|
+
cvEmailEl.value = ""; cvErrorEl.textContent = ""; cvSuggIdx = 0;
|
|
1252
|
+
renderEmailChips(); renderSuggestions();
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
function removeEmail(e) {
|
|
1256
|
+
cvEmailsList = cvEmailsList.filter((x) => x !== e);
|
|
1257
|
+
renderEmailChips();
|
|
1258
|
+
}
|
|
1259
|
+
cvEmailsEl.addEventListener("mousedown", (e) => {
|
|
1260
|
+
if (e.target === cvEmailsEl) { e.preventDefault(); cvEmailEl.focus(); }
|
|
1261
|
+
});
|
|
1262
|
+
cvEmailEl.addEventListener("focus", () => { cvEmailsEl.classList.add("focus"); if (window.relay.setFocusable) window.relay.setFocusable(true); });
|
|
1263
|
+
cvEmailEl.addEventListener("blur", () => {
|
|
1264
|
+
cvEmailsEl.classList.remove("focus");
|
|
1265
|
+
if (cvEmailEl.value.trim()) addEmail(cvEmailEl.value);
|
|
1266
|
+
cvSuggestEl.classList.add("hidden");
|
|
1267
|
+
});
|
|
1268
|
+
cvEmailEl.addEventListener("input", () => { cvErrorEl.textContent = ""; cvSuggIdx = 0; renderSuggestions(); });
|
|
1269
|
+
cvEmailEl.addEventListener("keydown", (ev) => {
|
|
1270
|
+
const list = cvSuggestions();
|
|
1271
|
+
if (ev.key === "ArrowDown" && list.length) { ev.preventDefault(); cvSuggIdx = Math.min(cvSuggIdx + 1, list.length - 1); renderSuggestions(); }
|
|
1272
|
+
else if (ev.key === "ArrowUp" && list.length) { ev.preventDefault(); cvSuggIdx = Math.max(cvSuggIdx - 1, 0); renderSuggestions(); }
|
|
1273
|
+
else if ((ev.key === "Enter" || ev.key === "Tab") && (list.length || cvEmailEl.value)) {
|
|
1274
|
+
if (ev.key === "Tab" && !cvEmailEl.value) return;
|
|
1275
|
+
ev.preventDefault();
|
|
1276
|
+
addEmail(list.length ? list[cvSuggIdx] : cvEmailEl.value);
|
|
1277
|
+
} else if (ev.key === "," || ev.key === " ") {
|
|
1278
|
+
ev.preventDefault(); addEmail(cvEmailEl.value);
|
|
1279
|
+
} else if (ev.key === "Backspace" && !cvEmailEl.value && cvEmailsList.length) {
|
|
1280
|
+
ev.preventDefault(); removeEmail(cvEmailsList[cvEmailsList.length - 1]);
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
|
|
1284
|
+
function openContactForm(key) {
|
|
1285
|
+
const c = key ? contactsList.find((x) => contactKey(x) === key) : null;
|
|
1286
|
+
cvEditing = c || null;
|
|
1287
|
+
const { first, last } = cvSplitName(c ? c.name : "");
|
|
1288
|
+
cvFirstEl.value = first;
|
|
1289
|
+
cvLastEl.value = last;
|
|
1290
|
+
cvEmailsList = c ? contactEmails(c).slice() : [];
|
|
1291
|
+
cvEmailEl.value = "";
|
|
1292
|
+
cvSuggIdx = 0;
|
|
1293
|
+
cvErrorEl.textContent = "";
|
|
1294
|
+
cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = "";
|
|
1295
|
+
renderEmailChips();
|
|
1296
|
+
cvFormEl.classList.remove("hidden");
|
|
1297
|
+
if (window.relay.setFocusable) window.relay.setFocusable(true);
|
|
1298
|
+
setInteractive(true);
|
|
1299
|
+
setTimeout(() => cvFirstEl.focus(), 30);
|
|
1300
|
+
}
|
|
1301
|
+
function closeContactForm() {
|
|
1302
|
+
cvFormEl.classList.add("hidden");
|
|
1303
|
+
cvEditing = null;
|
|
1304
|
+
cvEmailsList = [];
|
|
1305
|
+
cvEmailEl.value = "";
|
|
1306
|
+
cvErrorEl.textContent = "";
|
|
1307
|
+
cvHintEl.textContent = "";
|
|
1308
|
+
cvSuggestEl.classList.add("hidden"); cvSuggestEl.innerHTML = "";
|
|
1309
|
+
if (window.relay.setFocusable) window.relay.setFocusable(false);
|
|
1310
|
+
}
|
|
1311
|
+
cvAddEl.addEventListener("click", () => openContactForm(null));
|
|
1312
|
+
cvCancelEl.addEventListener("click", closeContactForm);
|
|
1313
|
+
cvFormEl.addEventListener("submit", async (e) => {
|
|
1314
|
+
e.preventDefault();
|
|
1315
|
+
const name = [cvFirstEl.value, cvLastEl.value].map((s) => s.trim()).filter(Boolean).join(" ");
|
|
1316
|
+
// fold any half-typed draft into the chips before validating.
|
|
1317
|
+
if (cvEmailEl.value.trim() && !addEmail(cvEmailEl.value)) return;
|
|
1318
|
+
const emails = cvEmailsList.filter(isValidEmail);
|
|
1319
|
+
if (!name) { cvErrorEl.textContent = "A first name is required."; return; }
|
|
1320
|
+
if (!emails.length) { cvErrorEl.textContent = "At least one email is required."; return; }
|
|
1321
|
+
cvSaveEl.disabled = true;
|
|
1322
|
+
cvErrorEl.textContent = "";
|
|
1323
|
+
try {
|
|
1324
|
+
// Match the website's ContactWrite shape: emails[] is the identity, email = primary.
|
|
1325
|
+
const next = await window.relay.contactSave({ name, emails, email: emails[0] });
|
|
1326
|
+
if (Array.isArray(next)) contactsList = next;
|
|
1327
|
+
closeContactForm();
|
|
1328
|
+
renderContacts();
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
cvErrorEl.textContent = (err && err.message) ? err.message : "Could not save.";
|
|
1331
|
+
} finally {
|
|
1332
|
+
cvSaveEl.disabled = false;
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
// ---------- views / tabs ----------
|
|
1337
|
+
function syncTabs() {
|
|
1338
|
+
for (const tab of tabEls) tab.classList.toggle("active", tab.getAttribute("data-view") === activeView);
|
|
1339
|
+
}
|
|
1340
|
+
function applyView() {
|
|
1341
|
+
relaysViewEl.classList.toggle("hidden", activeView !== "relays");
|
|
1342
|
+
tasksViewEl.classList.toggle("hidden", activeView !== "tasks");
|
|
1343
|
+
contactsViewEl.classList.toggle("hidden", activeView !== "contacts");
|
|
1344
|
+
if (activeView === "contacts") {
|
|
1345
|
+
loadContacts();
|
|
1346
|
+
} else {
|
|
1347
|
+
cvFormEl.classList.add("hidden");
|
|
1348
|
+
if (window.relay.setFocusable) window.relay.setFocusable(false);
|
|
1349
|
+
}
|
|
1350
|
+
if (activeView === "tasks") {
|
|
1351
|
+
window.relay.refreshTasks().catch(() => {});
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
for (const tab of tabEls) {
|
|
1355
|
+
tab.addEventListener("click", () => {
|
|
1356
|
+
const view = tab.getAttribute("data-view") || "relays";
|
|
1357
|
+
if (view === activeView) return;
|
|
1358
|
+
activeView = view;
|
|
1359
|
+
syncTabs();
|
|
1360
|
+
applyView();
|
|
1361
|
+
renderAll();
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
function renderAll() {
|
|
1366
|
+
// Relays badge/count reflect only the items that show in the Relays tab
|
|
1367
|
+
// (the clean split keeps task_request / share_approval out of this count).
|
|
1368
|
+
const unread = (payload.relays || []).filter((r) => r.unread && isRelayListKind(r)).length;
|
|
1369
|
+
setCount(unread);
|
|
1370
|
+
setBadge(relaysBadgeEl, unread);
|
|
1371
|
+
const needsTasks = (payload.tasks || []).filter((t) => t.requiresViewerAction).length;
|
|
1372
|
+
setBadge(tasksBadgeEl, needsTasks);
|
|
1373
|
+
if (activeView === "relays") renderRelays();
|
|
1374
|
+
else if (activeView === "tasks") renderTasks();
|
|
1375
|
+
// contacts renders from its own load path
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function onPayload(next) {
|
|
1379
|
+
if (!next || typeof next !== "object") return;
|
|
1380
|
+
payload = {
|
|
1381
|
+
account: next.account || payload.account,
|
|
1382
|
+
relays: Array.isArray(next.relays) ? next.relays : [],
|
|
1383
|
+
tasks: Array.isArray(next.tasks) ? next.tasks : [],
|
|
1384
|
+
contacts: Array.isArray(next.contacts) ? next.contacts : (payload.contacts || []),
|
|
1385
|
+
};
|
|
1386
|
+
renderAll();
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
window.relay.onInbox(onPayload);
|
|
1390
|
+
if (window.relay.onNewRelay) window.relay.onNewRelay((row) => notifyArrival(row));
|
|
1391
|
+
if (window.relay.onOpenDone) window.relay.onOpenDone((id) => stopOpening(id));
|
|
1392
|
+
window.relay.refresh().then((p) => {
|
|
1393
|
+
onPayload(p);
|
|
1394
|
+
console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " tasks=" + (p && p.tasks ? p.tasks.length : 0));
|
|
1395
|
+
}).catch((e) => console.log("[overlay] refresh failed", e && e.message ? e.message : e));
|
|
1396
|
+
</script>
|
|
1397
|
+
</body>
|
|
1398
|
+
</html>
|