@crvouga/mockingbird-notifications 0.0.0-stage → 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/dist/ui.js ADDED
@@ -0,0 +1,1923 @@
1
+ import {
2
+ TEST_IDS,
3
+ selectors
4
+ } from "./chunk-LM7R7AR7.js";
5
+ import {
6
+ ROOT_CLASS,
7
+ STYLES
8
+ } from "./chunk-QE3TRUI6.js";
9
+
10
+ // src/ui/icons.ts
11
+ var svg = (w, h, body, extra = "") => `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" fill="none" aria-hidden="true" ${extra}>${body}</svg>`;
12
+ var material = (d, size = 20) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="${d}"/></svg>`;
13
+ var icons = {
14
+ signal: (c = "currentColor") => svg(
15
+ 18,
16
+ 12,
17
+ `<rect x="0" y="8" width="3" height="4" rx="1" fill="${c}"/><rect x="5" y="5.5" width="3" height="6.5" rx="1" fill="${c}"/><rect x="10" y="3" width="3" height="9" rx="1" fill="${c}"/><rect x="15" y="0" width="3" height="12" rx="1" fill="${c}"/>`
18
+ ),
19
+ wifi: (c = "currentColor") => svg(
20
+ 16,
21
+ 12,
22
+ `<path d="M8 2.6c2.3 0 4.4.9 6 2.4l1.1-1.1A10 10 0 0 0 8 1 10 10 0 0 0 .9 3.9L2 5c1.6-1.5 3.7-2.4 6-2.4Z" fill="${c}"/><path d="M8 5.9c1.4 0 2.6.5 3.6 1.4l1.1-1.1A6.7 6.7 0 0 0 8 4.3c-1.8 0-3.5.7-4.7 1.9l1.1 1.1c1-.9 2.2-1.4 3.6-1.4Z" fill="${c}"/><path d="M10.3 8.6 8 11 5.7 8.6A3.3 3.3 0 0 1 8 7.6c.9 0 1.7.4 2.3 1Z" fill="${c}"/>`
23
+ ),
24
+ battery: (c = "currentColor") => svg(
25
+ 27,
26
+ 13,
27
+ `<rect x=".5" y=".5" width="23" height="12" rx="3.8" stroke="${c}" stroke-opacity=".4"/><rect x="2" y="2" width="20" height="9" rx="2.5" fill="${c}"/><path d="M25 4.5v4c.8-.3 1.3-1.1 1.3-2s-.5-1.7-1.3-2Z" fill="${c}" fill-opacity=".45"/>`
28
+ ),
29
+ compose: () => svg(
30
+ 24,
31
+ 24,
32
+ `<path d="M19 13v6.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 3 19.5v-13A1.5 1.5 0 0 1 4.5 5H11" stroke="#007aff" stroke-width="1.8" stroke-linecap="round"/><path d="m9.5 14.5.6-3.1 8.8-8.8a1.5 1.5 0 0 1 2.1 0l.9.9a1.5 1.5 0 0 1 0 2.1l-8.8 8.8-3.6.1Z" stroke="#007aff" stroke-width="1.8" stroke-linejoin="round"/>`
33
+ ),
34
+ search: (c = "currentColor", s = 16) => svg(
35
+ s,
36
+ s,
37
+ `<circle cx="${s * 0.43}" cy="${s * 0.43}" r="${s * 0.3}" stroke="${c}" stroke-width="${s / 9}"/><path d="m${s * 0.65} ${s * 0.65} ${s * 0.27} ${s * 0.27}" stroke="${c}" stroke-width="${s / 9}" stroke-linecap="round"/>`
38
+ ),
39
+ mic: (c = "#8e8e93") => svg(
40
+ 14,
41
+ 20,
42
+ `<rect x="4" y="1" width="6" height="11" rx="3" fill="${c}"/><path d="M1.5 9a5.5 5.5 0 0 0 11 0M7 14.5V18" stroke="${c}" stroke-width="1.6" stroke-linecap="round"/>`
43
+ ),
44
+ chevronRight: (c = "#c4c4c6") => svg(
45
+ 8,
46
+ 13,
47
+ `<path d="m1.5 1.5 5 5-5 5" stroke="${c}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`
48
+ ),
49
+ chevronBack: () => svg(
50
+ 13,
51
+ 21,
52
+ `<path d="M11 2 2.5 10.5 11 19" stroke="#007aff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`
53
+ ),
54
+ plus: () => svg(
55
+ 16,
56
+ 16,
57
+ `<path d="M8 2v12M2 8h12" stroke="#8e8e93" stroke-width="2.2" stroke-linecap="round"/>`
58
+ ),
59
+ person: () => svg(
60
+ 26,
61
+ 26,
62
+ `<circle cx="13" cy="9" r="5" fill="#fff"/><path d="M3 24c0-5.5 4.5-9 10-9s10 3.5 10 9" fill="#fff"/>`
63
+ ),
64
+ padlock: () => svg(
65
+ 16,
66
+ 21,
67
+ `<rect x="1" y="9" width="14" height="11" rx="2.5" fill="#fff"/><path d="M4 9V6a4 4 0 0 1 8 0v3" stroke="#fff" stroke-width="2"/>`
68
+ ),
69
+ flashlight: () => svg(
70
+ 14,
71
+ 22,
72
+ `<path d="M2 1h10v4L9.5 9v11a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1V9L2 5V1Z" fill="#fff"/>`
73
+ ),
74
+ camera: () => svg(
75
+ 22,
76
+ 18,
77
+ `<path d="M2 5.5A1.5 1.5 0 0 1 3.5 4h3l1.6-2.2h5.8L15.5 4h3A1.5 1.5 0 0 1 20 5.5v10a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 2 15.5v-10Z" fill="#fff"/><circle cx="11" cy="10.3" r="3.6" fill="#555"/>`
78
+ ),
79
+ bell: () => material(
80
+ "M12 22a2 2 0 0 0 2-2h-4a2 2 0 0 0 2 2Zm6-6V11a6 6 0 0 0-5-5.9V4a1 1 0 0 0-2 0v1.1A6 6 0 0 0 6 11v5l-2 2v1h16v-1l-2-2Z",
81
+ 18
82
+ ),
83
+ expand: () => material("M7.4 8.6 12 13.2l4.6-4.6L18 10l-6 6-6-6z", 16),
84
+ collapse: () => material("M7.4 15.4 12 10.8l4.6 4.6L18 14l-6-6-6 6z", 16),
85
+ androidWifi: () => material("M12 21 23.6 6.5A17.6 17.6 0 0 0 12 2.5 17.6 17.6 0 0 0 .4 6.5Z", 15),
86
+ androidSignal: () => material("M2 22h20V2Z", 14),
87
+ androidBattery: () => material(
88
+ "M16 4h-2V2h-4v2H8a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1Z",
89
+ 15
90
+ ),
91
+ menu: () => material("M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z", 24),
92
+ tune: () => material(
93
+ "M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z",
94
+ 24
95
+ ),
96
+ help: () => material(
97
+ "M11 18h2v-2h-2v2zm1-16a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm0-14a4 4 0 0 0-4 4h2a2 2 0 1 1 4 0c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5a4 4 0 0 0-4-4z",
98
+ 24
99
+ ),
100
+ gear: () => material(
101
+ "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84a.48.48 0 0 0-.48.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.48.48 0 0 0-.59.22L2.74 8.87a.47.47 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.47.47 0 0 0-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2z",
102
+ 24
103
+ ),
104
+ apps: () => material(
105
+ "M6 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm6 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm-6 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0-6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm6 0a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm4-8a2 2 0 1 0 4 0 2 2 0 0 0-4 0zm-4 2a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm6 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4z",
106
+ 24
107
+ ),
108
+ pencil: () => material(
109
+ "M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z",
110
+ 24
111
+ ),
112
+ inbox: () => material(
113
+ "M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 12h-4a3 3 0 0 1-6 0H5V5h14v10z",
114
+ 20
115
+ ),
116
+ star: () => material(
117
+ "M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z",
118
+ 20
119
+ ),
120
+ clock: () => material(
121
+ "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 18a8 8 0 1 1 0-16 8 8 0 0 1 0 16zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z",
122
+ 20
123
+ ),
124
+ send: () => material(
125
+ "M3.4 20.4l17.45-7.48a1 1 0 0 0 0-1.84L3.4 3.6a.99.99 0 0 0-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z",
126
+ 20
127
+ ),
128
+ draft: () => material(
129
+ "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z",
130
+ 20
131
+ ),
132
+ more: () => material(
133
+ "M12 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm0 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4z",
134
+ 20
135
+ ),
136
+ refresh: () => material(
137
+ "M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 1 0 7.73 10h-2.08A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z",
138
+ 20
139
+ ),
140
+ checkbox: () => material(
141
+ "M19 5v14H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z",
142
+ 18
143
+ ),
144
+ back: () => material("M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z", 20),
145
+ archive: () => material(
146
+ "M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5 6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z",
147
+ 20
148
+ ),
149
+ report: () => material(
150
+ "M15.73 3H8.27L3 8.27v7.46L8.27 21h7.46L21 15.73V8.27L15.73 3zM19 14.9 14.9 19H9.1L5 14.9V9.1L9.1 5h5.8L19 9.1v5.8zM11 7h2v6h-2zm0 8h2v2h-2z",
151
+ 20
152
+ ),
153
+ trash: () => material(
154
+ "M15 4V3H9v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5zm2 15H7V6h10v13zM9 8h2v9H9zm4 0h2v9h-2z",
155
+ 20
156
+ ),
157
+ reply: () => material("M10 9V5l-7 7 7 7v-4.1c5 0 8.5 1.6 11 5.1-1-5-4-10-11-11z", 20),
158
+ forward: () => material("M14 9V5l7 7-7 7v-4.1c-5 0-8.5 1.6-11 5.1 1-5 4-10 11-11z", 20),
159
+ clip: () => material(
160
+ "M16.5 6v11.5a4 4 0 0 1-8 0V5a2.5 2.5 0 0 1 5 0v10.5a1 1 0 0 1-2 0V6H10v9.5a2.5 2.5 0 0 0 5 0V5a4 4 0 0 0-8 0v12.5a5.5 5.5 0 0 0 11 0V6h-1.5z",
161
+ 16
162
+ ),
163
+ file: () => material(
164
+ "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zm4 18H6V4h7v5h5v11z",
165
+ 28
166
+ ),
167
+ dropdown: () => material("M7 10l5 5 5-5z", 18),
168
+ lock: () => material(
169
+ "M18 8h-1V6A5 5 0 0 0 7 6v2H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V10a2 2 0 0 0-2-2zM9 6a3 3 0 0 1 6 0v2H9V6z",
170
+ 14
171
+ ),
172
+ envelope: () => svg(
173
+ 30,
174
+ 24,
175
+ `<path d="M3 4h24v16a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4Z" fill="#e8eaed"/><path d="M3 4l12 9 12-9" stroke="#5f6368" stroke-width="2.2" stroke-linejoin="round"/><path d="M3 4h24" stroke="#5f6368" stroke-width="2.2"/>`
176
+ ),
177
+ pin: (size = 16) => material(
178
+ "M16 9V4h1c.55 0 1-.45 1-1s-.45-1-1-1H7c-.55 0-1 .45-1 1s.45 1 1 1h1v5c0 1.66-1.34 3-3 3v2h5.97v7l1 1 1-1v-7H19v-2c-1.66 0-3-1.34-3-3z",
179
+ size
180
+ ),
181
+ bellSlash: (size = 16) => material(
182
+ "M20 18.69 7.84 6.14 5.27 3.49 4 4.76l2.8 2.8v.01c-.52.99-.8 2.16-.8 3.42v5l-2 2v1h13.73l2 2L21 19.72l-1-1.03zM12 22c1.11 0 2-.89 2-2h-4c0 1.11.89 2 2 2zm6-7.32V11c0-3.08-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68c-.15.03-.29.08-.42.12-.1.03-.2.07-.3.11-.23.09-.46.2-.68.31L18 14.68z",
183
+ size
184
+ ),
185
+ volumeOn: (size = 16) => material(
186
+ "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z",
187
+ size
188
+ ),
189
+ volumeOff: (size = 16) => material(
190
+ "M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z",
191
+ size
192
+ ),
193
+ markUnread: (size = 20) => material(
194
+ "M22 8.98V18c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2h10.1c-.06.32-.1.66-.1 1 0 1.48.65 2.79 1.67 3.71L12 11 4 6v2l8 5 5.3-3.32c.54.2 1.1.32 1.7.32 1.13 0 2.16-.39 3-1.02zM16 5c0 1.66 1.34 3 3 3s3-1.34 3-3-1.34-3-3-3-3 1.34-3 3z",
195
+ size
196
+ ),
197
+ markRead: (size = 20) => material(
198
+ "M21.99 8c0-.72-.37-1.35-.94-1.7L12 1 2.95 6.3C2.38 6.65 2 7.28 2 8v10c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2l-.01-10zM12 13 3.74 7.84 12 3l8.26 4.84L12 13z",
199
+ size
200
+ ),
201
+ close: (size = 16) => material(
202
+ "M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z",
203
+ size
204
+ ),
205
+ starFilled: (size = 20) => material(
206
+ "M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z",
207
+ size
208
+ )
209
+ };
210
+
211
+ // src/ui/sounds.ts
212
+ var bell = (ctx, out, at, freq, length, level = 1) => {
213
+ for (const [ratio, gain] of [
214
+ [1, 1],
215
+ [2.76, 0.18],
216
+ [5.4, 0.06]
217
+ ]) {
218
+ const osc = ctx.createOscillator();
219
+ const env = ctx.createGain();
220
+ osc.type = "sine";
221
+ osc.frequency.value = freq * ratio;
222
+ env.gain.setValueAtTime(0, at);
223
+ env.gain.linearRampToValueAtTime(0.32 * gain * level, at + 6e-3);
224
+ env.gain.exponentialRampToValueAtTime(1e-4, at + length / ratio ** 0.35);
225
+ osc.connect(env).connect(out);
226
+ osc.start(at);
227
+ osc.stop(at + length + 0.05);
228
+ }
229
+ };
230
+ var mallet = (ctx, out, at, freq, length) => {
231
+ const osc = ctx.createOscillator();
232
+ const filter = ctx.createBiquadFilter();
233
+ const env = ctx.createGain();
234
+ osc.type = "triangle";
235
+ osc.frequency.value = freq;
236
+ filter.type = "lowpass";
237
+ filter.frequency.setValueAtTime(freq * 6, at);
238
+ filter.frequency.exponentialRampToValueAtTime(freq * 1.5, at + length);
239
+ env.gain.setValueAtTime(0, at);
240
+ env.gain.linearRampToValueAtTime(0.42, at + 4e-3);
241
+ env.gain.exponentialRampToValueAtTime(1e-4, at + length);
242
+ osc.connect(filter).connect(env).connect(out);
243
+ osc.start(at);
244
+ osc.stop(at + length + 0.05);
245
+ };
246
+ var VOICES = {
247
+ sms: (ctx, out, t) => {
248
+ bell(ctx, out, t, 1318.5, 0.5);
249
+ bell(ctx, out, t + 0.12, 1661.2, 0.5);
250
+ bell(ctx, out, t + 0.24, 1975.5, 0.8);
251
+ },
252
+ "push-ios": (ctx, out, t) => {
253
+ bell(ctx, out, t, 1760, 0.35, 0.8);
254
+ bell(ctx, out, t + 0.09, 2349.3, 0.9, 0.9);
255
+ },
256
+ "push-android": (ctx, out, t) => {
257
+ mallet(ctx, out, t, 1046.5, 0.22);
258
+ mallet(ctx, out, t + 0.1, 1568, 0.22);
259
+ mallet(ctx, out, t + 0.2, 2093, 0.45);
260
+ },
261
+ email: (ctx, out, t) => {
262
+ bell(ctx, out, t, 987.8, 0.45, 0.7);
263
+ bell(ctx, out, t + 0.16, 1318.5, 0.9, 0.7);
264
+ }
265
+ };
266
+ var VIBRATION = {
267
+ sms: [180, 90, 180],
268
+ "push-ios": [120],
269
+ "push-android": [60, 80, 60]
270
+ };
271
+ var createSoundPlayer = (initial = false) => {
272
+ let ctx = null;
273
+ let master = null;
274
+ const lastPlayed = /* @__PURE__ */ new Map();
275
+ const ensure = () => {
276
+ if (ctx) return ctx;
277
+ const Ctor = globalThis.AudioContext ?? globalThis.webkitAudioContext;
278
+ if (!Ctor) return null;
279
+ try {
280
+ ctx = new Ctor();
281
+ master = ctx.createGain();
282
+ master.gain.value = 0.6;
283
+ master.connect(ctx.destination);
284
+ } catch {
285
+ ctx = null;
286
+ }
287
+ return ctx;
288
+ };
289
+ const player = {
290
+ enabled: initial,
291
+ unlock() {
292
+ if (!player.enabled) return;
293
+ const c = ensure();
294
+ if (c?.state === "suspended") void c.resume().catch(() => {
295
+ });
296
+ },
297
+ play(kind) {
298
+ if (!player.enabled) return;
299
+ const now = Date.now();
300
+ if (now - (lastPlayed.get(kind) ?? 0) < 600) return;
301
+ lastPlayed.set(kind, now);
302
+ const pattern = VIBRATION[kind];
303
+ if (pattern) {
304
+ try {
305
+ navigator.vibrate?.(pattern);
306
+ } catch {
307
+ }
308
+ }
309
+ const c = ensure();
310
+ if (!c || !master) return;
311
+ if (c.state === "suspended") void c.resume().catch(() => {
312
+ });
313
+ VOICES[kind](c, master, c.currentTime + 0.02);
314
+ },
315
+ close() {
316
+ void ctx?.close().catch(() => {
317
+ });
318
+ ctx = null;
319
+ }
320
+ };
321
+ return player;
322
+ };
323
+
324
+ // src/ui/app.ts
325
+ var TAPBACKS = [
326
+ { id: "heart", glyph: "\u2665", label: "Heart" },
327
+ { id: "like", glyph: "\u{1F44D}", label: "Like" },
328
+ { id: "dislike", glyph: "\u{1F44E}", label: "Dislike" },
329
+ { id: "laugh", glyph: "HA<br>HA", label: "Laugh" },
330
+ { id: "emphasize", glyph: "\u203C", label: "Emphasize" },
331
+ { id: "question", glyph: "?", label: "Question" }
332
+ ];
333
+ var CHANNELS = ["sms", "push", "email"];
334
+ function createApp(root, options = {}) {
335
+ const live = options.live ?? true;
336
+ const syncUrl = options.syncUrl ?? (live && !options.fetch && !options.baseUrl);
337
+ const storage = options.storage ?? live;
338
+ const fixedNow = options.now;
339
+ const nowMs = () => fixedNow ?? Date.now();
340
+ const base = options.baseUrl ?? (options.fetch || typeof location === "undefined" ? "http://harness.local/" : location.href);
341
+ const send = (path, init2) => {
342
+ const request = new Request(new URL(path, base), init2);
343
+ return options.fetch ? options.fetch(request) : fetch(request);
344
+ };
345
+ const listeners = new AbortController();
346
+ const on = (type, handler, capture = false) => root.addEventListener(type, handler, { signal: listeners.signal, capture });
347
+ const timers = /* @__PURE__ */ new Set();
348
+ const later = (ms, fn) => {
349
+ const t = setTimeout(() => {
350
+ timers.delete(t);
351
+ fn();
352
+ }, ms);
353
+ timers.add(t);
354
+ };
355
+ let destroyed = false;
356
+ const params = new URLSearchParams(
357
+ options.search ?? (syncUrl && typeof location !== "undefined" ? location.search : "")
358
+ );
359
+ const initialView = params.get("view");
360
+ const init = options.initial ?? {};
361
+ const state = {
362
+ namespace: params.get("namespace") ?? options.namespace ?? "default",
363
+ namespaces: [],
364
+ items: [],
365
+ sources: [],
366
+ discoveryError: null,
367
+ version: 0,
368
+ view: initialView === "timeline" ? "timeline" : "devices",
369
+ only: initialView === "sms" || initialView === "email" || initialView === "push" ? initialView : null,
370
+ search: "",
371
+ follow: params.get("follow") !== "0",
372
+ connected: false,
373
+ sms: {
374
+ to: null,
375
+ thread: null,
376
+ menu: null,
377
+ transition: null
378
+ },
379
+ push: {
380
+ to: null,
381
+ skin: "auto",
382
+ expanded: null,
383
+ preview: null,
384
+ settings: null,
385
+ wake: false
386
+ },
387
+ email: { to: null, open: null, transition: false },
388
+ inspect: null,
389
+ toast: null,
390
+ arrived: new Set(options.arrived ?? [])
391
+ };
392
+ Object.assign(state, {
393
+ ...init,
394
+ sms: { ...state.sms, ...init.sms },
395
+ push: { ...state.push, ...init.push },
396
+ email: { ...state.email, ...init.email },
397
+ toast: init.toast ? { text: init.toast } : null
398
+ });
399
+ if (initialView === "timeline") state.view = "timeline";
400
+ delete state.local;
401
+ delete state.push.openGroups;
402
+ const local = {
403
+ dismissed: new Set(init.local?.dismissed ?? []),
404
+ pinned: new Set(init.local?.pinned ?? []),
405
+ muted: new Set(init.local?.muted ?? []),
406
+ starred: new Set(init.local?.starred ?? []),
407
+ silent: new Set(init.local?.silent ?? []),
408
+ checked: /* @__PURE__ */ new Set(),
409
+ tapbacks: new Map(Object.entries(init.local?.tapbacks ?? {})),
410
+ openGroups: new Set(init.push?.openGroups ?? [])
411
+ };
412
+ let mailDetailsOpen = init.mailDetailsOpen ?? false;
413
+ const pinnedTo = params.get("to");
414
+ const pinnedFrom = params.get("from");
415
+ const pinnedId = params.get("id");
416
+ const pinned = pinnedTo && state.only ? { channel: state.only, to: pinnedTo } : null;
417
+ if (params.get("follow") === null && pinned) state.follow = false;
418
+ const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
419
+ const motion = () => (options.motion ?? "auto") === "auto" ? reducedMotion() ? "reduce" : "full" : options.motion;
420
+ const animate = () => motion() === "full";
421
+ const soundKey = "mockingbird-notifications:sound";
422
+ const storedSound = () => {
423
+ if (!storage) return null;
424
+ try {
425
+ const v = localStorage.getItem(soundKey);
426
+ return v === null ? null : v === "1";
427
+ } catch {
428
+ return null;
429
+ }
430
+ };
431
+ const sound = createSoundPlayer(
432
+ params.get("sound") !== null ? params.get("sound") === "1" : storedSound() ?? options.sound ?? false
433
+ );
434
+ const seenKey = () => `mockingbird-notifications:seen:${state.namespace}`;
435
+ let seen = new Set(options.seen ?? []);
436
+ const loadSeen = () => {
437
+ if (!storage) return;
438
+ try {
439
+ seen = new Set(JSON.parse(localStorage.getItem(seenKey()) ?? "[]"));
440
+ } catch {
441
+ seen = /* @__PURE__ */ new Set();
442
+ }
443
+ };
444
+ const saveSeen = () => {
445
+ if (!storage) return;
446
+ try {
447
+ localStorage.setItem(seenKey(), JSON.stringify([...seen].slice(-5e3)));
448
+ } catch {
449
+ }
450
+ };
451
+ const markSeen = (ids) => {
452
+ let changed = false;
453
+ for (const id of ids)
454
+ if (!seen.has(id)) {
455
+ seen.add(id);
456
+ changed = true;
457
+ }
458
+ if (changed) saveSeen();
459
+ };
460
+ const markUnseen = (ids) => {
461
+ for (const id of ids) seen.delete(id);
462
+ saveSeen();
463
+ };
464
+ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
465
+ const tid = (id) => `data-testid="${id}"`;
466
+ const nAttrs = (n) => `data-notification-id="${esc(n.id)}" data-channel="${n.channel}" data-service="${esc(n.service)}" data-source="${esc(n.source)}" data-to="${esc(n.to)}" data-from="${esc(n.from.address)}" data-status="${esc(n.status ?? "")}" data-created-at="${esc(n.createdAt)}" data-unread="${!seen.has(n.id)}"`;
467
+ const linkify = (escaped) => escaped.replace(
468
+ /\bhttps?:\/\/[^\s<]+[^\s<.,;:!?)'"]/g,
469
+ (url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`
470
+ );
471
+ const formatPhone = (value) => {
472
+ const us = /^\+1(\d{3})(\d{3})(\d{4})$/.exec(value);
473
+ if (us) return `+1 (${us[1]}) ${us[2]}-${us[3]}`;
474
+ return value;
475
+ };
476
+ const isPhone = (value) => /^\+?\d[\d\s()-]{5,}$/.test(value);
477
+ const displaySender = (n) => n.from.name ?? (isPhone(n.from.address) ? formatPhone(n.from.address) : n.from.address);
478
+ const initials = (value) => {
479
+ const words = value.replace(/[^\p{L}\p{N}\s]/gu, " ").trim().split(/\s+/).filter(Boolean);
480
+ if (!words.length) return "?";
481
+ return ((words[0]?.[0] ?? "") + (words[1]?.[0] ?? "")).toUpperCase();
482
+ };
483
+ const AVATAR = [
484
+ "#1a73e8",
485
+ "#e8710a",
486
+ "#188038",
487
+ "#d93025",
488
+ "#9334e6",
489
+ "#12b5cb",
490
+ "#e52592",
491
+ "#f9ab00",
492
+ "#5f6368",
493
+ "#a142f4"
494
+ ];
495
+ const APP = [
496
+ "#ff9500",
497
+ "#34c759",
498
+ "#5856d6",
499
+ "#ff2d55",
500
+ "#007aff",
501
+ "#af52de",
502
+ "#ff3b30",
503
+ "#30b0c7"
504
+ ];
505
+ const hash = (value) => [...value].reduce((h, c) => h * 31 + c.charCodeAt(0) >>> 0, 7);
506
+ const colorFor = (value, palette = AVATAR) => palette[hash(value) % palette.length] ?? "#5f6368";
507
+ const sameDay = (a, b) => a.toDateString() === b.toDateString();
508
+ const clock = (d) => d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
509
+ const iosListTime = (iso) => {
510
+ const d = new Date(iso);
511
+ const now = new Date(nowMs());
512
+ if (sameDay(d, now)) return clock(d);
513
+ const y = new Date(now);
514
+ y.setDate(now.getDate() - 1);
515
+ if (sameDay(d, y)) return "Yesterday";
516
+ if (now.getTime() - d.getTime() < 6 * 864e5)
517
+ return d.toLocaleDateString([], { weekday: "long" });
518
+ return d.toLocaleDateString([], { year: "2-digit", month: "numeric", day: "numeric" });
519
+ };
520
+ const iosStamp = (iso) => {
521
+ const d = new Date(iso);
522
+ const now = new Date(nowMs());
523
+ if (sameDay(d, now)) return `<b>Today</b> ${clock(d)}`;
524
+ return `<b>${esc(d.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }))}</b> at ${clock(d)}`;
525
+ };
526
+ const ago = (iso) => {
527
+ const s = Math.max(0, (nowMs() - Date.parse(iso)) / 1e3);
528
+ if (s < 60) return "now";
529
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
530
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
531
+ return `${Math.floor(s / 86400)}d ago`;
532
+ };
533
+ const androidAgo = (iso) => {
534
+ const s = Math.max(0, (nowMs() - Date.parse(iso)) / 1e3);
535
+ if (s < 60) return "now";
536
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
537
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
538
+ return `${Math.floor(s / 86400)}d`;
539
+ };
540
+ const timelineTime = (iso) => {
541
+ const d = new Date(iso);
542
+ if (sameDay(d, new Date(nowMs()))) return d.toLocaleTimeString();
543
+ return d.toLocaleString([], {
544
+ month: "short",
545
+ day: "numeric",
546
+ hour: "numeric",
547
+ minute: "2-digit",
548
+ ...d.getFullYear() === new Date(nowMs()).getFullYear() ? {} : { year: "numeric" }
549
+ });
550
+ };
551
+ const mailDate = (iso) => {
552
+ const d = new Date(iso);
553
+ const now = new Date(nowMs());
554
+ if (sameDay(d, now)) return clock(d);
555
+ if (d.getFullYear() === now.getFullYear())
556
+ return d.toLocaleDateString([], { month: "short", day: "numeric" });
557
+ return d.toLocaleDateString([], { year: "2-digit", month: "numeric", day: "numeric" });
558
+ };
559
+ const mailLongDate = (iso) => {
560
+ const d = new Date(iso);
561
+ const full = d.toLocaleString([], {
562
+ weekday: "short",
563
+ month: "short",
564
+ day: "numeric",
565
+ year: "numeric",
566
+ hour: "numeric",
567
+ minute: "2-digit"
568
+ });
569
+ return `${full} (${ago(iso) === "now" ? "0 minutes ago" : ago(iso)})`;
570
+ };
571
+ const SERVICE_LABEL = {
572
+ twilio: "Twilio",
573
+ resend: "Resend",
574
+ fcm: "FCM",
575
+ ses: "SES",
576
+ sns: "SNS",
577
+ customerio: "Customer.io",
578
+ mailosaur: "Mailosaur"
579
+ };
580
+ const matchesSearch = (n) => {
581
+ const q = state.search.trim().toLowerCase();
582
+ if (!q) return true;
583
+ return [
584
+ n.to,
585
+ n.from.address,
586
+ n.from.name ?? "",
587
+ n.preview,
588
+ n.email?.subject ?? "",
589
+ n.push?.title ?? "",
590
+ n.source,
591
+ n.service
592
+ ].join("\n").toLowerCase().includes(q);
593
+ };
594
+ const visible = () => state.items.filter(matchesSearch);
595
+ const byChannel = (channel) => visible().filter((n) => n.channel === channel && !local.dismissed.has(n.id));
596
+ const recipients = (channel) => {
597
+ const map = /* @__PURE__ */ new Map();
598
+ for (const n of byChannel(channel)) {
599
+ const entry = map.get(n.to) ?? { to: n.to, count: 0, unread: 0, latest: n.createdAt };
600
+ entry.count++;
601
+ if (!seen.has(n.id)) entry.unread++;
602
+ if (n.createdAt > entry.latest) entry.latest = n.createdAt;
603
+ if (n.push) entry.platform = n.push.platform;
604
+ map.set(n.to, entry);
605
+ }
606
+ return [...map.values()].sort((a, b) => a.latest < b.latest ? 1 : -1);
607
+ };
608
+ const ensureSelection = () => {
609
+ for (const channel of CHANNELS) {
610
+ const list = recipients(channel);
611
+ const slot = state[channel];
612
+ if (pinned?.channel === channel) {
613
+ slot.to = pinned.to;
614
+ continue;
615
+ }
616
+ if (!slot.to || !list.some((r) => r.to === slot.to)) slot.to = list[0]?.to ?? null;
617
+ }
618
+ };
619
+ const api = async (path) => {
620
+ const response = await send(path);
621
+ if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
622
+ return await response.json();
623
+ };
624
+ const refresh = async () => {
625
+ const q = new URLSearchParams({ namespace: state.namespace, order: "newest" });
626
+ const body = await api(
627
+ `./api/notifications?${q}`
628
+ );
629
+ if (destroyed) return;
630
+ state.items = body.notifications;
631
+ state.version = body.version ?? state.version;
632
+ ensureSelection();
633
+ render();
634
+ };
635
+ const refreshSources = async () => {
636
+ try {
637
+ const body = await api(
638
+ "./api/sources"
639
+ );
640
+ state.sources = body.sources;
641
+ state.discoveryError = body.discoveryError;
642
+ const ns = await api("./api/namespaces");
643
+ state.namespaces = ns.namespaces;
644
+ if (!destroyed) renderTopbar();
645
+ } catch {
646
+ }
647
+ };
648
+ const threadKey = (to, from) => `${to}|${from}`;
649
+ const groupKey = (to, app) => `${to}|${app}`;
650
+ const alertFor = (n) => {
651
+ if (n.channel === "sms") return local.muted.has(threadKey(n.to, n.from.address)) ? null : "sms";
652
+ if (n.channel === "email") return "email";
653
+ const platform = state.push.skin === "auto" ? n.push?.platform === "android" ? "android" : "ios" : state.push.skin;
654
+ if (platform === "android" && local.silent.has(n.push?.app ?? "App")) return null;
655
+ return platform === "android" ? "push-android" : "push-ios";
656
+ };
657
+ const onAdded = (added) => {
658
+ for (const n of added) state.arrived.add(n.id);
659
+ const newest = [...added].sort((a, b) => a.createdAt < b.createdAt ? 1 : -1);
660
+ for (const channel of CHANNELS) {
661
+ const mine = newest.filter((n) => n.channel === channel);
662
+ const latest = pinned?.channel === channel ? mine.find((n) => n.to === pinned.to) : mine[0];
663
+ if (!latest) continue;
664
+ if (pinned?.channel === channel) {
665
+ if (channel === "sms" && !state.sms.thread) state.sms.thread = latest.from.address;
666
+ } else if (state.follow) {
667
+ state[channel].to = latest.to;
668
+ if (channel === "sms" && state.sms.thread !== null) state.sms.thread = latest.from.address;
669
+ }
670
+ if (channel === "push" && state.push.to === latest.to) state.push.wake = true;
671
+ }
672
+ const kinds = new Set(added.map(alertFor).filter((k) => k !== null));
673
+ for (const kind of kinds) sound.play(kind);
674
+ options.onArrive?.(added);
675
+ later(1800, () => {
676
+ for (const n of added) state.arrived.delete(n.id);
677
+ });
678
+ };
679
+ let stream = null;
680
+ const connect = () => {
681
+ stream?.abort();
682
+ const controller = new AbortController();
683
+ stream = controller;
684
+ const namespace = state.namespace;
685
+ const dispatch = (event, data) => {
686
+ if (controller.signal.aborted) return;
687
+ if (event === "sources") {
688
+ state.sources = JSON.parse(data);
689
+ renderTopbar();
690
+ } else if (event === "change") {
691
+ const { added, version } = JSON.parse(data);
692
+ state.version = version;
693
+ onAdded(added);
694
+ void refresh().catch(() => {
695
+ });
696
+ }
697
+ };
698
+ const run = async () => {
699
+ while (!controller.signal.aborted) {
700
+ try {
701
+ const response = await send(`./api/events?namespace=${encodeURIComponent(namespace)}`, {
702
+ signal: controller.signal,
703
+ headers: { accept: "text/event-stream" }
704
+ });
705
+ if (!response.ok || !response.body) throw new Error(`events: HTTP ${response.status}`);
706
+ state.connected = true;
707
+ renderTopbar();
708
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
709
+ let buffer = "";
710
+ for (; ; ) {
711
+ const { value, done } = await reader.read();
712
+ if (done) break;
713
+ buffer += value.replace(/\r\n/g, "\n");
714
+ let end = buffer.indexOf("\n\n");
715
+ while (end >= 0) {
716
+ const chunk = buffer.slice(0, end);
717
+ buffer = buffer.slice(end + 2);
718
+ let event = "message";
719
+ const data = [];
720
+ for (const line of chunk.split("\n")) {
721
+ if (line.startsWith("event:")) event = line.slice(6).trim();
722
+ else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
723
+ }
724
+ if (data.length) dispatch(event, data.join("\n"));
725
+ end = buffer.indexOf("\n\n");
726
+ }
727
+ }
728
+ } catch {
729
+ }
730
+ if (controller.signal.aborted) return;
731
+ state.connected = false;
732
+ renderTopbar();
733
+ await new Promise((resolve) => later(1e3, () => resolve(null)));
734
+ }
735
+ };
736
+ void run();
737
+ };
738
+ root.classList.add(ROOT_CLASS);
739
+ if (options.embedded) root.classList.add("embedded");
740
+ root.dataset.testid = TEST_IDS.app;
741
+ root.dataset.ready = "false";
742
+ root.innerHTML = `
743
+ <header class="topbar" data-region="topbar" ${tid(TEST_IDS.topbar)}></header>
744
+ <div data-region="notice"></div>
745
+ <div class="mbn-scroll" data-region="scroll">
746
+ <section class="devices" data-region="devices">
747
+ <div class="device-col" data-region="col-sms"></div>
748
+ <div class="device-col" data-region="col-push"></div>
749
+ <div class="device-col wide" data-region="col-email"></div>
750
+ </section>
751
+ <section class="timeline" data-region="timeline" hidden></section>
752
+ </div>
753
+ <aside class="inspector" data-region="inspector" ${tid(TEST_IDS.inspector)} hidden></aside>`;
754
+ const $ = (region) => root.querySelector(`[data-region="${region}"]`);
755
+ const byTestId = (id) => root.querySelector(`[data-testid="${id}"]`);
756
+ const title = () => options.title ?? root.dataset.title ?? "Notifications";
757
+ const updateRootAttrs = () => {
758
+ Object.assign(root.dataset, {
759
+ view: state.view,
760
+ namespace: state.namespace,
761
+ count: String(state.items.length),
762
+ version: String(state.version),
763
+ connected: String(state.connected),
764
+ sound: String(sound.enabled),
765
+ motion: motion()
766
+ });
767
+ };
768
+ const renderTopbar = () => {
769
+ updateRootAttrs();
770
+ const bar = $("topbar");
771
+ bar.hidden = options.chrome === false;
772
+ if (options.chrome === false) return;
773
+ const namespaces = [.../* @__PURE__ */ new Set([...state.namespaces, state.namespace])].sort();
774
+ const searchFocused = document.activeElement === byTestId(TEST_IDS.search);
775
+ bar.innerHTML = `
776
+ <div class="brand"><span class="brand-mark">${icons.bell()}</span><span>${esc(title())} <small>Mockingbird</small></span></div>
777
+ <label class="field">Namespace <select data-action="namespace" ${tid(TEST_IDS.namespace)} aria-label="Namespace">${namespaces.map((ns) => `<option ${ns === state.namespace ? "selected" : ""}>${esc(ns)}</option>`).join("")}</select></label>
778
+ <div class="field search">${icons.search("#9aa1b1", 14)}<input type="search" data-action="search" ${tid(TEST_IDS.search)} aria-label="Filter notifications" placeholder="Filter by recipient, sender, text\u2026" value="${esc(state.search)}"></div>
779
+ <div class="tabs" role="group" aria-label="View">
780
+ <button data-action="view" data-value="devices" ${tid(TEST_IDS.viewDevices)} aria-pressed="${state.view === "devices"}">Devices</button>
781
+ <button data-action="view" data-value="timeline" ${tid(TEST_IDS.viewTimeline)} aria-pressed="${state.view === "timeline"}">Timeline <span style="opacity:.6">${state.items.length}</span></button>
782
+ </div>
783
+ <label class="toggle" title="Jump each device to the newest arrival"><input type="checkbox" data-action="follow" ${tid(TEST_IDS.follow)} ${state.follow ? "checked" : ""}> Follow new</label>
784
+ <button class="icon-toggle" data-action="sound" ${tid(TEST_IDS.sound)} aria-pressed="${sound.enabled}" title="Play alert sounds on arrivals">${sound.enabled ? icons.volumeOn() : icons.volumeOff()}<span>Sound</span></button>
785
+ <span class="live" ${tid(TEST_IDS.connection)} data-state="${state.connected ? "live" : "reconnecting"}" role="status"><span class="dot ${state.connected ? "ok" : "bad"}"></span>${state.connected ? "Live" : "Reconnecting"}</span>
786
+ <div class="sources">${state.sources.map(
787
+ (s) => `<span class="pill" title="${esc(s.error ?? s.url)}" ${tid(TEST_IDS.source)} data-name="${esc(s.name)}" data-service="${esc(s.service ?? "")}" data-ok="${s.ok}" data-count="${s.count}"><span class="dot ${s.ok ? "ok" : s.error ? "bad" : ""}"></span>${esc(
788
+ s.service && s.name !== s.service ? `${s.name} \xB7 ${SERVICE_LABEL[s.service] ?? s.service}` : SERVICE_LABEL[s.name] ?? s.name
789
+ )} <b>${s.count}</b></span>`
790
+ ).join("")}</div>`;
791
+ if (searchFocused) {
792
+ const input = byTestId(TEST_IDS.search);
793
+ input?.focus();
794
+ input?.setSelectionRange(input.value.length, input.value.length);
795
+ }
796
+ const failing = state.sources.filter((s) => s.error);
797
+ $("notice").innerHTML = state.discoveryError || failing.length ? `<div class="notice" ${tid(TEST_IDS.notice)} data-kind="error" role="alert">${state.discoveryError ? `Fleet discovery failed: ${esc(state.discoveryError)}. ` : ""}${failing.map((s) => `<b>${esc(s.name)}</b>: ${esc(s.error)}`).join(" \xB7 ")}</div>` : !state.sources.length ? `<div class="notice" ${tid(TEST_IDS.notice)} data-kind="empty" style="border-color:#3a3f4b;background:#171a21;color:#9aa1b1">No messaging emulators found yet. Start the fleet with twilio, resend, fcm, ses, sns, customerio or mailosaur.</div>` : "";
798
+ };
799
+ const recipientSelect = (channel, label, testId, format) => {
800
+ const list = recipients(channel);
801
+ const current = state[channel].to;
802
+ return `<label class="field">${label}<select data-action="recipient" data-channel="${channel}" ${tid(testId)} aria-label="${label}" ${list.length ? "" : "disabled"}>${list.length ? list.map(
803
+ (r) => `<option value="${esc(r.to)}" ${r.to === current ? "selected" : ""}>${r.unread ? "\u25CF " : ""}${esc(format(r.to))} \xB7 ${r.count}</option>`
804
+ ).join("") : "<option>No recipients yet</option>"}</select></label>`;
805
+ };
806
+ const countBadge = (n, testId) => n ? `<span class="count" ${tid(testId)} data-count="${n}">${n}</span>` : "";
807
+ const statusBar = (light = false) => `
808
+ <div class="statusbar ${light ? "light" : ""}"><span>${clock(new Date(nowMs())).replace(/\s?[AP]M$/i, "")}</span>
809
+ <span class="icons">${icons.signal()}${icons.wifi()}${icons.battery()}</span></div>`;
810
+ const flip = (container, mutate) => {
811
+ if (!animate() || typeof container.animate !== "function") return mutate();
812
+ const before = /* @__PURE__ */ new Map();
813
+ for (const el of container.querySelectorAll("[data-flip]"))
814
+ before.set(el.dataset.flip ?? "", el.getBoundingClientRect().top);
815
+ mutate();
816
+ for (const el of container.querySelectorAll("[data-flip]")) {
817
+ const top = before.get(el.dataset.flip ?? "");
818
+ if (top === void 0) continue;
819
+ const dy = top - el.getBoundingClientRect().top;
820
+ if (Math.abs(dy) < 1) continue;
821
+ el.animate([{ transform: `translateY(${dy}px)` }, { transform: "none" }], {
822
+ duration: 380,
823
+ easing: "cubic-bezier(.2,.9,.25,1)"
824
+ });
825
+ }
826
+ };
827
+ const smsThreads = (to) => {
828
+ const map = /* @__PURE__ */ new Map();
829
+ for (const n of byChannel("sms").filter((n2) => n2.to === to)) {
830
+ const key = n.from.address;
831
+ map.set(key, [...map.get(key) ?? [], n]);
832
+ }
833
+ return [...map.entries()].map(([from, items]) => ({
834
+ from,
835
+ items: items.sort((a, b) => a.createdAt < b.createdAt ? -1 : 1),
836
+ pinned: local.pinned.has(threadKey(to, from))
837
+ })).sort(
838
+ (a, b) => a.pinned !== b.pinned ? a.pinned ? -1 : 1 : (a.items.at(-1)?.createdAt ?? "") < (b.items.at(-1)?.createdAt ?? "") ? 1 : -1
839
+ );
840
+ };
841
+ const avatarFor = (n) => {
842
+ const name = n ? displaySender(n) : "";
843
+ return isPhone(n?.from.address ?? "") && !n?.from.name ? icons.person() : esc(initials(name));
844
+ };
845
+ const otpOf = (n) => n.sms?.code ?? (/\b(code|otp|passcode|pin|verif)/i.test(n.sms?.body ?? "") ? /\b(\d{4,8})\b/.exec(n.sms?.body ?? "")?.[1] ?? null : null);
846
+ const highlightCode = (escaped, code) => code ? escaped.replace(new RegExp(`\\b${code}\\b`), `<span class="code">${code}</span>`) : escaped;
847
+ const tapbackBadge = (id) => {
848
+ const t = local.tapbacks.get(id);
849
+ const def = TAPBACKS.find((x) => x.id === t);
850
+ return def ? `<span class="tapback" ${tid(TEST_IDS.smsTapback)} data-tapback="${def.id}" aria-label="${def.label}">${def.glyph}</span>` : "";
851
+ };
852
+ const bubbleBody = (n) => {
853
+ const body = highlightCode(linkify(esc(n.sms?.body ?? "")), otpOf(n));
854
+ const media = (n.sms?.media ?? []).map((src) => `<img class="media" src="${esc(src)}" alt="" ${tid(TEST_IDS.smsBubbleMedia)}>`).join("");
855
+ return media + body;
856
+ };
857
+ const renderSms = () => {
858
+ const to = state.sms.to;
859
+ const threads = to ? smsThreads(to) : [];
860
+ const unreadTotal = byChannel("sms").filter((n) => !seen.has(n.id)).length;
861
+ let screen;
862
+ const thread = threads.find((t) => t.from === state.sms.thread);
863
+ const transition = state.sms.transition;
864
+ state.sms.transition = null;
865
+ if (thread && to) {
866
+ markSeen(thread.items.map((n) => n.id));
867
+ const first = thread.items[0];
868
+ const otherUnread = threads.filter((t) => t !== thread).reduce((s, t) => s + t.items.filter((n) => !seen.has(n.id)).length, 0);
869
+ let last = 0;
870
+ let prevTail = true;
871
+ const rows = thread.items.map((n, i) => {
872
+ const at = Date.parse(n.createdAt);
873
+ const stamp = i === 0 || at - last > 15 * 6e4 ? `<div class="stamp" ${tid(TEST_IDS.smsTimestamp)}>${i === 0 ? "Text Message<br>" : ""}${iosStamp(n.createdAt)}</div>` : "";
874
+ const next = thread.items[i + 1];
875
+ const tail = !next || Date.parse(next.createdAt) - at > 6e4;
876
+ const gap = !stamp && prevTail && i > 0;
877
+ last = at;
878
+ prevTail = tail;
879
+ const code2 = otpOf(n);
880
+ return `${stamp}<div class="bubble-row ${gap ? "gap" : ""}"><div class="bubble ${tail ? "tail" : ""} ${state.arrived.has(n.id) ? "arrive" : ""}" ${nAttrs(n)} ${tid(TEST_IDS.smsBubble)} ${code2 ? `data-code="${esc(code2)}"` : ""} data-longpress="sms-bubble" tabindex="0" aria-haspopup="menu">${bubbleBody(n)}${tapbackBadge(n.id)}</div><span class="bubble-time" ${tid(TEST_IDS.smsBubbleTime)} aria-hidden="true">${esc(clock(new Date(at)))}</span></div>`;
881
+ }).join("");
882
+ const latest = thread.items.at(-1);
883
+ const code = latest ? otpOf(latest) : null;
884
+ const menuFor = state.sms.menu ? thread.items.find((n) => n.id === state.sms.menu) : null;
885
+ const muted = local.muted.has(threadKey(to, thread.from));
886
+ screen = `<div class="convo ${transition === "push" && animate() ? "enter" : ""}" ${tid(TEST_IDS.smsConversation)} data-from="${esc(thread.from)}">
887
+ <div class="convo-nav"><div class="bar">
888
+ <button class="back" data-action="sms-back" ${tid(TEST_IDS.smsBack)} aria-label="Back to Messages">${icons.chevronBack()}${otherUnread ? `<span class="badge">${otherUnread}</span>` : ""}</button>
889
+ <div class="avatar">${avatarFor(first)}</div>
890
+ <div class="who" ${tid(TEST_IDS.smsConversationTitle)}>${esc(first ? displaySender(first) : thread.from)}${muted ? ` <span class="flag">${icons.bellSlash(12)}</span>` : ""} ${icons.chevronRight("#8e8e93")}</div>
891
+ </div></div>
892
+ <div class="convo-body" data-swipe="sms-convo">${rows}</div>
893
+ ${code && latest && nowMs() - Date.parse(latest.createdAt) < 10 * 6e4 ? `<button class="qt" data-action="sms-copy-code" data-code="${esc(code)}" ${tid(TEST_IDS.smsCode)}><small>From Messages</small>${esc(code)}</button>` : ""}
894
+ <div class="convo-input"><span class="plus">${icons.plus()}</span><span class="field-ios">Text Message<span>${icons.mic()}</span></span></div>
895
+ ${menuFor ? smsMenu(menuFor) : ""}
896
+ </div>`;
897
+ } else {
898
+ state.sms.thread = null;
899
+ state.sms.menu = null;
900
+ screen = `<div class="msgs ${transition === "back" && animate() ? "enter" : ""}">
901
+ <div class="msgs-nav"><div class="row"><span>Edit</span>${icons.compose()}</div><h1>Messages</h1>
902
+ <div class="ios-search">${icons.search("rgba(60,60,67,.6)", 15)}<span>Search</span>${icons.mic("rgba(60,60,67,.6)")}</div></div>
903
+ ${threads.length && to ? `<div class="threads" role="list">${threads.map((t) => {
904
+ const latest = t.items.at(-1);
905
+ const unread = t.items.some((n) => !seen.has(n.id));
906
+ const key = threadKey(to, t.from);
907
+ const muted = local.muted.has(key);
908
+ const name = latest ? displaySender(latest) : t.from;
909
+ return `<div class="swipe thread-swipe" data-swipe="sms-thread" data-from="${esc(t.from)}" data-flip="thread:${esc(t.from)}" role="listitem">
910
+ <div class="swipe-actions">
911
+ <div class="side"><button class="act-unread" data-action="thread-unread" data-from="${esc(t.from)}" ${tid(TEST_IDS.smsThreadToggleUnread)}>${unread ? icons.markRead(22) : icons.markUnread(22)}${unread ? "Read" : "Unread"}</button><button class="act-pin" data-action="thread-pin" data-from="${esc(t.from)}" ${tid(TEST_IDS.smsThreadPin)}>${icons.pin(22)}${t.pinned ? "Unpin" : "Pin"}</button></div>
912
+ <div class="side"><button class="act-mute" data-action="thread-mute" data-from="${esc(t.from)}" ${tid(TEST_IDS.smsThreadMute)}>${icons.bellSlash(22)}${muted ? "Show Alerts" : "Hide Alerts"}</button><button class="act-delete" data-action="thread-delete" data-from="${esc(t.from)}" ${tid(TEST_IDS.smsThreadDelete)}>${icons.trash()}Delete</button></div>
913
+ </div>
914
+ <div class="thread swipe-card" data-action="sms-open" data-from="${esc(t.from)}" ${tid(TEST_IDS.smsThread)} ${latest ? nAttrs(latest) : ""} data-unread="${unread}" data-pinned="${t.pinned}" data-muted="${muted}" role="button" tabindex="0" aria-label="${esc(name)}${unread ? ", unread" : ""}">
915
+ ${unread ? `<span class="unread" ${tid(TEST_IDS.smsThreadUnread)}></span>` : ""}<div class="avatar">${avatarFor(latest)}</div>
916
+ <div class="text"><div class="top"><span class="name-line"><span class="name" ${tid(TEST_IDS.smsThreadName)}>${esc(name)}</span>${t.pinned ? `<span class="flag">${icons.pin(13)}</span>` : ""}${muted ? `<span class="flag">${icons.bellSlash(13)}</span>` : ""}</span><span class="when" ${tid(TEST_IDS.smsThreadTime)}>${esc(iosListTime(latest?.createdAt ?? ""))} ${icons.chevronRight()}</span></div>
917
+ <div class="preview" ${tid(TEST_IDS.smsThreadPreview)}>${esc(latest?.sms?.body ?? "")}</div></div></div></div>`;
918
+ }).join("")}</div>` : `<div class="empty-ios" ${tid(TEST_IDS.smsEmpty)}>${to ? "No Messages" : "No text messages yet.<br><br>SMS sent through Twilio, SNS, Customer.io or Mailosaur shows up here."}</div>`}
919
+ </div>`;
920
+ }
921
+ const col = $("col-sms");
922
+ flip(col, () => {
923
+ col.innerHTML = `
924
+ <div class="device-head"><h2>Text messages ${countBadge(unreadTotal, TEST_IDS.smsUnreadCount)}</h2>${recipientSelect("sms", "Phone", TEST_IDS.smsRecipient, formatPhone)}</div>
925
+ <div class="iphone" ${tid(TEST_IDS.smsDevice)} data-screen="${thread ? "conversation" : "threads"}" data-recipient="${esc(to ?? "")}" data-thread="${esc(thread?.from ?? "")}"><div class="screen"><div class="island"></div>${statusBar()}${screen}<div class="home-indicator"></div></div></div>`;
926
+ });
927
+ const body = col.querySelector(".convo-body");
928
+ const toBottom = () => {
929
+ if (body) body.scrollTop = body.scrollHeight;
930
+ };
931
+ toBottom();
932
+ requestAnimationFrame(toBottom);
933
+ };
934
+ const smsMenu = (n) => {
935
+ const current = local.tapbacks.get(n.id);
936
+ return `<div class="ios-overlay" data-action="close-overlay" ${tid(TEST_IDS.smsMenu)} data-notification-id="${esc(n.id)}" role="dialog" aria-label="Message actions">
937
+ <div class="tapback-bar" role="toolbar" aria-label="Tapback">${TAPBACKS.map(
938
+ (t) => `<button data-action="tapback" data-id="${esc(n.id)}" data-value="${t.id}" ${tid(TEST_IDS.smsMenuTapback)} data-tapback="${t.id}" aria-label="${t.label}" aria-pressed="${current === t.id}">${t.glyph}</button>`
939
+ ).join("")}</div>
940
+ <div class="bubble tail">${bubbleBody(n)}</div>
941
+ <div class="ios-menu" role="menu">
942
+ <button role="menuitem" data-action="close-overlay">Reply</button>
943
+ <button role="menuitem" data-action="sms-copy" data-id="${esc(n.id)}" ${tid(TEST_IDS.smsMenuCopy)}>Copy</button>
944
+ <button role="menuitem" data-action="close-overlay">Translate</button>
945
+ <button role="menuitem" data-action="close-overlay">More\u2026</button>
946
+ </div></div>`;
947
+ };
948
+ const pushData = (n) => {
949
+ const data = n.push?.data ?? {};
950
+ const keys = Object.keys(data);
951
+ return keys.length ? `<div class="note-data" ${tid(TEST_IDS.pushNotificationData)}>${esc(JSON.stringify(data, null, 2))}</div>` : "";
952
+ };
953
+ const pushMeta = (n) => `<div class="note-meta" ${tid(TEST_IDS.pushNotificationMeta)}>${esc(SERVICE_LABEL[n.service] ?? n.service)} \xB7 ${esc(n.status ?? "accepted")}${n.push?.badge !== null && n.push?.badge !== void 0 ? ` \xB7 badge ${n.push.badge}` : ""}${n.push?.sound ? ` \xB7 sound ${esc(n.push.sound)}` : ""}</div>`;
954
+ const pushAttrs = (n, extra = {}) => `${nAttrs(n)} ${tid(TEST_IDS.pushNotification)} data-platform="${esc(n.push?.platform ?? "unknown")}" data-app="${esc(n.push?.app ?? "App")}" ${Object.entries(
955
+ extra
956
+ ).map(([k, v]) => `data-${k}="${esc(v)}"`).join(" ")}`;
957
+ const iosCard = (n, opts) => {
958
+ const p = n.push;
959
+ const app = p?.app ?? "App";
960
+ const expanded = opts.expanded;
961
+ return `<div class="ios-note swipe-card ${expanded ? "expanded" : ""} ${state.arrived.has(n.id) ? "arrive" : ""}" data-action="push-toggle" data-id="${esc(n.id)}" ${pushAttrs(n, { expanded, stacked: opts.stacked ?? false })} ${opts.extra ?? ""} role="button" tabindex="0" aria-expanded="${expanded}" data-longpress="ios-note">
962
+ <span class="app-icon" style="background:${colorFor(app, APP)}" ${tid(TEST_IDS.pushNotificationApp)} aria-label="${esc(app)}">${esc(initials(app).slice(0, 1))}</span>
963
+ <div class="content">
964
+ <div class="line1"><span class="title" ${tid(TEST_IDS.pushNotificationTitle)}>${esc(p?.title ?? app)}</span><span class="time" ${tid(TEST_IDS.pushNotificationTime)}>${esc(ago(n.createdAt))}</span></div>
965
+ <div class="body" ${tid(TEST_IDS.pushNotificationBody)}>${esc(p?.body ?? "")}</div>
966
+ ${expanded && p?.imageUrl ? `<img class="hero" src="${esc(p.imageUrl)}" alt="" ${tid(TEST_IDS.pushNotificationImage)}>` : ""}
967
+ ${expanded ? pushData(n) + pushMeta(n) : ""}
968
+ </div>
969
+ ${!expanded && p?.imageUrl ? `<img class="thumb" src="${esc(p.imageUrl)}" alt="" ${tid(TEST_IDS.pushNotificationImage)}>` : ""}
970
+ </div>`;
971
+ };
972
+ const iosNote = (n, opts = {}) => `<div class="swipe ios-swipe" data-swipe="ios-note" data-id="${esc(n.id)}" data-flip="note:${esc(n.id)}" ${opts.hidden ? "hidden" : ""}>
973
+ <div class="swipe-actions"><button data-action="push-options" data-id="${esc(n.id)}" ${tid(TEST_IDS.pushOptions)}>Options</button><button data-action="push-clear" data-id="${esc(n.id)}" ${tid(TEST_IDS.pushClear)}>Clear</button></div>
974
+ ${iosCard(n, { expanded: state.push.expanded === n.id, stacked: opts.stacked ?? false, extra: opts.top ? 'data-group-top="true"' : "" })}
975
+ </div>`;
976
+ const androidNote = (n) => {
977
+ const p = n.push;
978
+ const expanded = state.push.expanded === n.id;
979
+ const app = p?.app ?? "App";
980
+ const settings = state.push.settings === n.id;
981
+ const silent = local.silent.has(app);
982
+ return `<div class="a-note ${expanded ? "expanded" : ""} ${state.arrived.has(n.id) ? "arrive" : ""}" data-action="push-toggle" data-id="${esc(n.id)}" ${pushAttrs(n, { expanded })} data-swipe="a-note" data-longpress="a-note" data-flip="note:${esc(n.id)}" role="button" tabindex="0" aria-expanded="${expanded}">
983
+ <span class="a-icon" style="background:${colorFor(app, APP)}" aria-hidden="true">${esc(initials(app).slice(0, 1))}</span>
984
+ <div class="a-main">
985
+ <div class="a-head"><span ${tid(TEST_IDS.pushNotificationApp)}>${esc(app)}</span><span>\u2022</span><span ${tid(TEST_IDS.pushNotificationTime)}>${esc(androidAgo(n.createdAt))}</span>${silent ? `<span>${icons.bellSlash(12)}</span>` : ""}<span class="chev">${expanded ? icons.collapse() : icons.expand()}</span></div>
986
+ ${p?.title ? `<div class="a-title" ${tid(TEST_IDS.pushNotificationTitle)}>${esc(p.title)}</div>` : ""}
987
+ <div class="a-body" ${tid(TEST_IDS.pushNotificationBody)}>${esc(p?.body ?? "")}</div>
988
+ ${expanded && p?.imageUrl ? `<img class="big-picture" src="${esc(p.imageUrl)}" alt="" ${tid(TEST_IDS.pushNotificationImage)}>` : ""}
989
+ ${expanded ? pushData(n) + pushMeta(n) : ""}
990
+ ${settings ? `<div class="a-settings" ${tid(TEST_IDS.pushSettings)} data-app="${esc(app)}">
991
+ <button class="opt" data-action="push-alerting" data-app="${esc(app)}" data-value="alerting" aria-pressed="${!silent}">${icons.bell()}<span>Default<small>May ring or vibrate based on device settings</small></span></button>
992
+ <button class="opt" data-action="push-alerting" data-app="${esc(app)}" data-value="silent" aria-pressed="${silent}">${icons.bellSlash(18)}<span>Silent<small>No sound or vibration</small></span></button>
993
+ <div class="row"><button data-action="close-overlay">Turn off notifications</button><button data-action="close-overlay">Done</button></div></div>` : ""}
994
+ </div>
995
+ ${!expanded && p?.imageUrl ? `<img class="large-icon" src="${esc(p.imageUrl)}" alt="" ${tid(TEST_IDS.pushNotificationImage)}>` : ""}
996
+ </div>`;
997
+ };
998
+ const iosGroups = (to, items) => {
999
+ const order = [];
1000
+ const groups = /* @__PURE__ */ new Map();
1001
+ for (const n of items) {
1002
+ const app = n.push?.app ?? "App";
1003
+ if (!groups.has(app)) order.push(app);
1004
+ groups.set(app, [...groups.get(app) ?? [], n]);
1005
+ }
1006
+ return order.map((app) => {
1007
+ const list = groups.get(app) ?? [];
1008
+ const first = list[0];
1009
+ if (!first) return "";
1010
+ if (list.length === 1) return iosNote(first);
1011
+ const key = groupKey(to, app);
1012
+ const open = local.openGroups.has(key);
1013
+ const rest = list.length - 1;
1014
+ if (open)
1015
+ return `<div class="push-group" ${tid(TEST_IDS.pushGroup)} data-app="${esc(app)}" data-count="${list.length}" data-open="true" data-flip="group:${esc(app)}">
1016
+ <div class="group-head"><h3>${esc(app)}</h3><button data-action="group-collapse" data-key="${esc(key)}" ${tid(TEST_IDS.pushGroupCollapse)}>Show less</button><button class="x" data-action="group-clear" data-key="${esc(key)}" data-app="${esc(app)}" aria-label="Clear ${esc(app)} notifications">${icons.close(14)}</button></div>
1017
+ ${list.map((n) => iosNote(n)).join("")}</div>`;
1018
+ return `<div class="push-group" ${tid(TEST_IDS.pushGroup)} data-app="${esc(app)}" data-count="${list.length}" data-open="false" data-flip="group:${esc(app)}">
1019
+ ${iosNote(first, { top: true })}
1020
+ <button class="stack-layers" data-action="group-open" data-key="${esc(key)}" ${tid(TEST_IDS.pushGroupMore)} data-count="${rest}" aria-label="Show ${rest} more notification${rest === 1 ? "" : "s"}"><div class="ios-stack-more"></div>${rest > 1 ? '<div class="ios-stack-more"></div>' : ""}<div class="center-label" style="font-size:13px;margin-top:6px">${rest} more notification${rest === 1 ? "" : "s"}</div></button>
1021
+ ${list.slice(1).map((n) => iosNote(n, { stacked: true, hidden: true })).join("")}</div>`;
1022
+ }).join("");
1023
+ };
1024
+ const renderPush = () => {
1025
+ const to = state.push.to;
1026
+ const items = to ? byChannel("push").filter((n) => n.to === to) : [];
1027
+ markSeen(items.map((n) => n.id));
1028
+ const platform = items[0]?.push?.platform ?? "unknown";
1029
+ const skin = state.push.skin === "auto" ? platform === "android" ? "android" : "ios" : state.push.skin;
1030
+ const now = new Date(nowMs());
1031
+ const unread = byChannel("push").filter((n) => !seen.has(n.id)).length;
1032
+ const wake = state.push.wake && animate();
1033
+ state.push.wake = false;
1034
+ let device;
1035
+ const deviceAttrs = `${tid(TEST_IDS.pushDevice)} data-skin="${skin}" data-recipient="${esc(to ?? "")}" data-platform="${esc(platform)}"`;
1036
+ if (skin === "android") {
1037
+ device = `<div class="pixel" ${deviceAttrs}><div class="screen"><div class="punch"></div><div class="shade">
1038
+ <div class="sb"><span>${clock(now).replace(/\s?[AP]M$/i, "")}</span><span class="icons">${icons.androidWifi()}${icons.androidSignal()}${icons.androidBattery()}</span></div>
1039
+ <div class="qs-head"><span>${esc(now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }))}</span>${icons.gear()}</div>
1040
+ <div class="qs"><div class="tile on">${icons.androidWifi()}<div>Internet<small>Mockingbird</small></div></div><div class="tile">${icons.bell()}<div>Do Not Disturb<small>Off</small></div></div>
1041
+ <div class="tile">${icons.androidBattery()}<div>Battery Saver<small>Off</small></div></div><div class="tile">${icons.lock()}<div>Screen lock<small>On</small></div></div></div>
1042
+ <div class="qs-bright"></div>
1043
+ <div class="shade-list" role="list">${items.length ? items.map(androidNote).join("") : `<div class="shade-empty" ${tid(TEST_IDS.pushEmpty)}>No notifications</div>`}</div>
1044
+ ${items.length ? `<div class="shade-actions"><button data-action="close-overlay">Manage</button><button data-action="push-clear-all" ${tid(TEST_IDS.pushClearAll)}>Clear all</button></div>` : ""}
1045
+ <div class="gesture"></div></div></div></div>`;
1046
+ } else {
1047
+ const preview = state.push.preview ? items.find((n) => n.id === state.push.preview) : null;
1048
+ device = `<div class="iphone" ${deviceAttrs}><div class="screen"><div class="island"></div>${statusBar(true)}<div class="lock ${wake ? "wake" : ""}">
1049
+ <div class="padlock">${icons.padlock()}</div>
1050
+ <div class="date">${esc(now.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric" }))}</div>
1051
+ <div class="clock">${esc(clock(now).replace(/\s?[AP]M$/i, ""))}</div>
1052
+ <div class="lock-stack" role="list">${items.length && to ? `<div class="center-label">Notification Center</div>${iosGroups(to, items)}` : `<div class="lock-empty" ${tid(TEST_IDS.pushEmpty)}>${to ? "No Older Notifications" : "No push notifications yet"}</div>`}</div>
1053
+ <div class="lock-buttons"><span>${icons.flashlight()}</span><span>${icons.camera()}</span></div>
1054
+ ${preview ? `<div class="ios-overlay dark" data-action="close-overlay" ${tid(TEST_IDS.pushPreview)} data-notification-id="${esc(preview.id)}" role="dialog" aria-label="Notification preview">${iosCard(preview, { expanded: true })}
1055
+ <div class="ios-menu" role="menu"><button role="menuitem" data-action="push-clear" data-id="${esc(preview.id)}">Clear</button><button role="menuitem" data-action="close-overlay">Options</button></div></div>` : ""}
1056
+ </div><div class="home-indicator light"></div></div></div>`;
1057
+ }
1058
+ const tokenLabel = (t) => {
1059
+ const r = recipients("push").find((x) => x.to === t);
1060
+ const short = t.length > 22 ? `${t.slice(0, 10)}\u2026${t.slice(-8)}` : t;
1061
+ return `${short}${r?.platform && r.platform !== "unknown" ? ` \xB7 ${r.platform}` : ""}`;
1062
+ };
1063
+ const col = $("col-push");
1064
+ flip(col, () => {
1065
+ col.innerHTML = `
1066
+ <div class="device-head"><h2>Push ${countBadge(unread, TEST_IDS.pushUnreadCount)}</h2>
1067
+ <div class="controls">${recipientSelect("push", "Device", TEST_IDS.pushRecipient, tokenLabel)}
1068
+ <div class="seg" role="group" aria-label="Phone">${["auto", "ios", "android"].map(
1069
+ (s) => `<button data-action="skin" data-value="${s}" ${tid(TEST_IDS.pushSkin)} aria-pressed="${state.push.skin === s}">${s === "auto" ? "Auto" : s === "ios" ? "iOS" : "Android"}</button>`
1070
+ ).join("")}</div></div></div>${device}`;
1071
+ });
1072
+ };
1073
+ const mailHtml = (n) => {
1074
+ const html = n.email?.html ?? "";
1075
+ return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>body{margin:0;font:14px/1.5 Arial,Helvetica,sans-serif;color:#222;overflow-wrap:anywhere}img{max-width:100%;height:auto}</style></head><body>${html}</body></html>`;
1076
+ };
1077
+ const toast = (text, undo) => {
1078
+ state.toast = undo ? { text, undo } : { text };
1079
+ renderEmail();
1080
+ const current = state.toast;
1081
+ later(6e3, () => {
1082
+ if (state.toast === current) {
1083
+ state.toast = null;
1084
+ renderEmail();
1085
+ }
1086
+ });
1087
+ };
1088
+ const archiveEmail = (id, verb) => {
1089
+ local.dismissed.add(id);
1090
+ if (state.email.open === id) {
1091
+ state.email.open = null;
1092
+ state.email.transition = true;
1093
+ }
1094
+ toast(verb === "archived" ? "Conversation archived." : "Conversation moved to Trash.", () => {
1095
+ local.dismissed.delete(id);
1096
+ state.toast = null;
1097
+ renderEmail();
1098
+ });
1099
+ };
1100
+ const renderEmail = () => {
1101
+ const to = state.email.to;
1102
+ const items = to ? byChannel("email").filter((n) => n.to === to) : [];
1103
+ const unread = items.filter((n) => !seen.has(n.id)).length;
1104
+ const unreadAll = byChannel("email").filter((n) => !seen.has(n.id)).length;
1105
+ const open = items.find((n) => n.id === state.email.open);
1106
+ if (!open) state.email.open = null;
1107
+ const transition = state.email.transition && animate();
1108
+ state.email.transition = false;
1109
+ const me = to ?? "you@example.test";
1110
+ let main;
1111
+ if (open) {
1112
+ markSeen([open.id]);
1113
+ const e = open.email;
1114
+ const sender = open.from.name ?? open.from.address;
1115
+ const toLine = open.to.toLowerCase() === me.toLowerCase() ? "me" : open.to;
1116
+ const detail = (field, label, value) => `<span>${label}:</span><span ${tid(TEST_IDS.emailDetail)} data-field="${field}">${esc(value)}</span>`;
1117
+ main = `<div class="mail-toolbar">
1118
+ <button class="icon-btn" data-action="mail-back" ${tid(TEST_IDS.emailBack)} title="Back to Inbox" aria-label="Back to Inbox">${icons.back()}</button>
1119
+ <button class="icon-btn" data-action="mail-archive" data-id="${esc(open.id)}" ${tid(TEST_IDS.emailArchive)} title="Archive" aria-label="Archive">${icons.archive()}</button><span class="icon-btn">${icons.report()}</span><button class="icon-btn" data-action="mail-delete" data-id="${esc(open.id)}" ${tid(TEST_IDS.emailDelete)} title="Delete" aria-label="Delete">${icons.trash()}</button>
1120
+ <button class="icon-btn" data-action="mail-unread" data-id="${esc(open.id)}" ${tid(TEST_IDS.emailMarkUnread)} title="Mark as unread" aria-label="Mark as unread">${icons.markUnread()}</button><span class="icon-btn">${icons.clock()}</span><span class="icon-btn">${icons.more()}</span>
1121
+ <span class="range">${items.indexOf(open) + 1} of ${items.length}</span></div>
1122
+ <div class="reader ${transition ? "enter" : ""}" ${nAttrs(open)} ${tid(TEST_IDS.emailOpen)}>
1123
+ <div class="subject-row"><h1 ${tid(TEST_IDS.emailSubject)}>${esc(e?.subject || "(no subject)")}</h1><span class="label-chip">Inbox</span></div>
1124
+ <div class="sender-row">
1125
+ <span class="mail-avatar" style="background:${colorFor(open.from.address)}">${esc(initials(sender).slice(0, 1))}</span>
1126
+ <div class="who"><b ${tid(TEST_IDS.emailFromName)}>${esc(sender)}</b> <span class="addr">&lt;<span ${tid(TEST_IDS.emailFromAddress)}>${esc(open.from.address)}</span>&gt;</span>
1127
+ <div class="to" data-action="mail-details" ${tid(TEST_IDS.emailTo)} role="button" tabindex="0" aria-expanded="${mailDetailsOpen}">to ${esc(toLine)}${e?.cc.length ? `, ${esc(e.cc.join(", "))}` : ""} ${icons.dropdown()}</div></div>
1128
+ <div class="meta"><span ${tid(TEST_IDS.emailDate)}>${esc(mailLongDate(open.createdAt))}</span> <span class="icon-btn">${icons.star()}</span><span class="icon-btn">${icons.reply()}</span><span class="icon-btn">${icons.more()}</span></div>
1129
+ </div>
1130
+ ${mailDetailsOpen ? `<div class="details" ${tid(TEST_IDS.emailDetails)}>
1131
+ ${detail("from", "from", open.from.name ? `${open.from.name} <${open.from.address}>` : open.from.address)}
1132
+ ${detail("to", "to", open.to)}
1133
+ ${e?.cc.length ? detail("cc", "cc", e.cc.join(", ")) : ""}
1134
+ ${e?.bcc.length ? detail("bcc", "bcc", e.bcc.join(", ")) : ""}
1135
+ ${e?.replyTo.length ? detail("reply-to", "reply-to", e.replyTo.join(", ")) : ""}
1136
+ ${detail("date", "date", new Date(open.createdAt).toLocaleString())}
1137
+ ${detail("subject", "subject", e?.subject ?? "")}
1138
+ ${detail("mailed-by", "mailed-by", `${SERVICE_LABEL[open.service] ?? open.service} (${open.source})`)}
1139
+ ${detail("message-id", "message id", open.providerId)}
1140
+ </div>` : ""}
1141
+ ${e?.html ? `<iframe class="mail-frame" name="email-body" ${tid(TEST_IDS.emailBody)} data-format="html" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" title="Email body"></iframe>` : `<div class="mail-text" ${tid(TEST_IDS.emailText)} data-format="text">${linkify(esc(e?.text ?? ""))}</div>`}
1142
+ ${e?.attachments.length ? `<div class="attachments" ${tid(TEST_IDS.emailAttachments)} data-count="${e.attachments.length}"><h3>${e.attachments.length} attachment${e.attachments.length === 1 ? "" : "s"}</h3><div class="att-grid">${e.attachments.map(
1143
+ (a) => `<div class="att" ${tid(TEST_IDS.emailAttachment)} data-filename="${esc(a.filename)}" data-content-type="${esc(a.contentType ?? "")}" data-size="${a.size ?? ""}"><div class="att-top">${icons.file()}</div><div class="att-name">${icons.clip()} ${esc(a.filename)}${a.size ? ` <span style="color:#5f6368;font-weight:400">${Math.max(1, Math.round(a.size / 1024))} KB</span>` : ""}</div></div>`
1144
+ ).join("")}</div></div>` : ""}
1145
+ <div class="reply-row"><span>${icons.reply()} Reply</span><span>${icons.forward()} Forward</span></div>
1146
+ </div>`;
1147
+ } else {
1148
+ main = `<div class="mail-toolbar"><span class="icon-btn">${icons.checkbox()}</span><button class="icon-btn" data-action="refresh" ${tid(TEST_IDS.emailRefresh)} title="Refresh" aria-label="Refresh">${icons.refresh()}</button><span class="icon-btn">${icons.more()}</span>
1149
+ <span class="range">${items.length ? `1\u2013${items.length} of ${items.length}` : ""}</span></div>
1150
+ <div class="cat-tabs"><div class="on">${icons.inbox()} Primary</div><div>${icons.star()} Promotions</div><div>${icons.send()} Updates</div></div>
1151
+ <div class="mail-list ${transition ? "enter" : ""}" role="list">${items.length ? items.map((n) => {
1152
+ const isUnread = !seen.has(n.id);
1153
+ const sender = n.from.name ?? n.from.address;
1154
+ const starred = local.starred.has(n.id);
1155
+ return `<div class="swipe mrow-swipe" data-swipe="mrow" data-id="${esc(n.id)}" data-flip="row:${esc(n.id)}" role="listitem">
1156
+ <div class="swipe-actions">${icons.archive()}${icons.archive()}</div>
1157
+ <div class="mrow swipe-card ${isUnread ? "unread" : ""} ${state.arrived.has(n.id) ? "arrive" : ""}" data-action="mail-open" data-id="${esc(n.id)}" ${nAttrs(n)} ${tid(TEST_IDS.emailRow)} data-starred="${starred}" role="button" tabindex="0">
1158
+ <span class="chk" data-action="mail-check" data-id="${esc(n.id)}" role="checkbox" aria-checked="${local.checked.has(n.id)}" aria-label="Select">${icons.checkbox()}</span><span class="star" data-action="mail-star" data-id="${esc(n.id)}" ${tid(TEST_IDS.emailRowStar)} role="button" aria-pressed="${starred}" aria-label="${starred ? "Starred" : "Not starred"}">${starred ? icons.starFilled() : icons.star()}</span>
1159
+ <span class="from" ${tid(TEST_IDS.emailRowFrom)}>${esc(sender)}</span>
1160
+ <span class="subj"><span ${tid(TEST_IDS.emailRowSubject)}>${esc(n.email?.subject || "(no subject)")}</span> <span class="snippet">- <span ${tid(TEST_IDS.emailRowSnippet)}>${esc(n.preview)}</span></span></span>
1161
+ ${n.email?.attachments.length ? `<span class="clip" ${tid(TEST_IDS.emailRowAttachment)}>${icons.clip()}</span>` : ""}<span class="svc" ${tid(TEST_IDS.emailRowService)}>${esc(SERVICE_LABEL[n.service] ?? n.service)}</span>
1162
+ <span class="date" ${tid(TEST_IDS.emailRowDate)}>${esc(mailDate(n.createdAt))}</span>
1163
+ <span class="hover-actions"><button class="icon-btn" data-action="mail-archive" data-id="${esc(n.id)}" ${tid(TEST_IDS.emailRowArchive)} title="Archive" aria-label="Archive">${icons.archive()}</button><button class="icon-btn" data-action="mail-delete" data-id="${esc(n.id)}" ${tid(TEST_IDS.emailRowDelete)} title="Delete" aria-label="Delete">${icons.trash()}</button><button class="icon-btn" data-action="mail-toggle-read" data-id="${esc(n.id)}" ${tid(TEST_IDS.emailRowToggleRead)} title="${isUnread ? "Mark as read" : "Mark as unread"}" aria-label="${isUnread ? "Mark as read" : "Mark as unread"}">${isUnread ? icons.markRead() : icons.markUnread()}</button><span class="icon-btn">${icons.clock()}</span></span></div></div>`;
1164
+ }).join("") : `<div class="mail-empty" ${tid(TEST_IDS.emailEmpty)}>${icons.envelope()}<b>${to ? "Your Primary tab is empty." : "No email yet"}</b>${to ? "" : "Email sent through Resend, SES, SNS, Customer.io or Mailosaur lands here."}</div>`}</div>`;
1165
+ }
1166
+ const col = $("col-email");
1167
+ flip(col, () => {
1168
+ col.innerHTML = `
1169
+ <div class="device-head"><h2>Email ${countBadge(unreadAll, TEST_IDS.emailUnreadCount)}</h2>${recipientSelect("email", "Mailbox", TEST_IDS.emailRecipient, (t) => t)}</div>
1170
+ <div class="window" ${tid(TEST_IDS.emailDevice)} data-screen="${open ? "message" : "inbox"}" data-recipient="${esc(to ?? "")}">
1171
+ <div class="chrome"><span class="lights"><i></i><i></i><i></i></span><span class="omnibox">${icons.lock()} mail.example.test/#inbox${open ? `/${esc(open.providerId)}` : ""}</span></div>
1172
+ <div class="mail-top"><span class="icon-btn">${icons.menu()}</span><span class="mail-logo">${icons.envelope()}<span class="lbl">Mail</span></span>
1173
+ <div class="mail-search"><span class="icon-btn">${icons.search("#444746", 20)}</span><span class="placeholder">Search mail</span><span class="icon-btn">${icons.tune()}</span></div>
1174
+ <div class="right"><span class="icon-btn">${icons.help()}</span><span class="icon-btn">${icons.gear()}</span><span class="icon-btn">${icons.apps()}</span>
1175
+ <span class="mail-avatar" title="${esc(me)}" style="background:${colorFor(me)}">${esc(me.slice(0, 1).toUpperCase())}</span></div></div>
1176
+ <div class="mail-body">
1177
+ <nav class="mail-nav"><button class="compose">${icons.pencil()}<span class="lbl">Compose</span></button>
1178
+ <div class="nav-item active">${icons.inbox()}<span class="lbl">Inbox</span><span class="n">${unread || ""}</span></div>
1179
+ <div class="nav-item">${icons.star()}<span class="lbl">Starred</span></div>
1180
+ <div class="nav-item">${icons.clock()}<span class="lbl">Snoozed</span></div>
1181
+ <div class="nav-item">${icons.send()}<span class="lbl">Sent</span></div>
1182
+ <div class="nav-item">${icons.draft()}<span class="lbl">Drafts</span></div>
1183
+ <div class="nav-item">${icons.expand()}<span class="lbl">More</span></div></nav>
1184
+ <main class="mail-main">${main}</main>
1185
+ </div>
1186
+ ${state.toast ? `<div class="toast" ${tid(TEST_IDS.toast)} role="status"><span>${esc(state.toast.text)}</span>${state.toast.undo ? `<button data-action="toast-undo" ${tid(TEST_IDS.toastUndo)}>Undo</button>` : ""}<button data-action="toast-close" aria-label="Dismiss">${icons.close(16)}</button></div>` : ""}
1187
+ </div>`;
1188
+ });
1189
+ const frame = col.querySelector(".mail-frame");
1190
+ if (frame && open) {
1191
+ frame.addEventListener("load", () => {
1192
+ const doc = frame.contentDocument;
1193
+ if (doc) frame.style.height = `${Math.max(120, doc.documentElement.scrollHeight + 8)}px`;
1194
+ });
1195
+ frame.srcdoc = mailHtml(open);
1196
+ }
1197
+ if (syncUrl && live) {
1198
+ const total = CHANNELS.reduce(
1199
+ (sum, c) => sum + byChannel(c).filter((n) => !seen.has(n.id)).length,
1200
+ 0
1201
+ );
1202
+ document.title = `${total ? `(${total}) ` : ""}${title()} \xB7 Mockingbird`;
1203
+ }
1204
+ };
1205
+ const renderTimeline = () => {
1206
+ const items = visible();
1207
+ $("timeline").innerHTML = items.length ? `<table class="tl" ${tid(TEST_IDS.timeline)}><thead><tr><th>Time</th><th>Channel</th><th>Source</th><th>To</th><th>From</th><th>Content</th><th>Status</th></tr></thead><tbody>${items.map(
1208
+ (n) => `<tr data-action="inspect" data-id="${esc(n.id)}" ${nAttrs(n)} ${tid(TEST_IDS.timelineRow)} tabindex="0">
1209
+ <td class="mono" data-col="time">${esc(timelineTime(n.createdAt))}</td>
1210
+ <td data-col="channel"><span class="chan ${n.channel}">${n.channel === "sms" ? "SMS" : n.channel === "email" ? "Email" : "Push"}</span></td>
1211
+ <td data-col="source"><span class="svc-tag">${esc(SERVICE_LABEL[n.service] ?? n.service)}${n.source !== n.service ? ` \xB7 ${esc(n.source)}` : ""}</span></td>
1212
+ <td class="mono" data-col="to">${esc(n.channel === "sms" ? formatPhone(n.to) : n.to.length > 28 ? `${n.to.slice(0, 26)}\u2026` : n.to)}</td>
1213
+ <td data-col="from">${esc(displaySender(n))}</td>
1214
+ <td class="content" data-col="content">${n.email ? `<b>${esc(n.email.subject)}</b> \u2014 ` : n.push?.title ? `<b>${esc(n.push.title)}</b> \u2014 ` : ""}${esc(n.channel === "email" ? n.preview.slice(0, 120) : n.sms?.body ?? n.push?.body ?? n.preview)}</td>
1215
+ <td class="svc-tag" data-col="status">${esc(n.status ?? "")}</td></tr>`
1216
+ ).join("")}</tbody></table>` : `<div class="empty-tl" ${tid(TEST_IDS.timelineEmpty)}>Nothing sent in namespace <b>${esc(state.namespace)}</b> yet.</div>`;
1217
+ };
1218
+ const renderInspector = () => {
1219
+ const n = state.items.find((x) => x.id === state.inspect);
1220
+ const el = $("inspector");
1221
+ if (!n) {
1222
+ el.hidden = true;
1223
+ el.innerHTML = "";
1224
+ for (const k of Object.keys(el.dataset))
1225
+ if (k !== "region" && k !== "testid") delete el.dataset[k];
1226
+ return;
1227
+ }
1228
+ el.hidden = false;
1229
+ Object.assign(el.dataset, {
1230
+ notificationId: n.id,
1231
+ channel: n.channel,
1232
+ service: n.service,
1233
+ source: n.source,
1234
+ to: n.to,
1235
+ from: n.from.address,
1236
+ status: n.status ?? "",
1237
+ createdAt: n.createdAt
1238
+ });
1239
+ const { raw, ...normalized } = n;
1240
+ const field = (key, label, value) => `<span>${label}</span><span ${tid(TEST_IDS.inspectorField)} data-field="${key}">${esc(value)}</span>`;
1241
+ el.innerHTML = `<header><h3 ${tid(TEST_IDS.inspectorTitle)}>${esc(n.channel.toUpperCase())} \xB7 ${esc(n.email?.subject ?? n.push?.title ?? n.preview.slice(0, 60))}</h3>
1242
+ <button class="btn" data-action="show-on-device" data-id="${esc(n.id)}" ${tid(TEST_IDS.inspectorShowOnDevice)}>Show on device</button><button class="btn" data-action="close-inspector" ${tid(TEST_IDS.inspectorClose)}>Close</button></header>
1243
+ <div class="kv">${field("id", "Id", n.id)}${field("providerId", "Provider id", n.providerId)}${field("service", "Service", `${n.service} (${n.source})`)}
1244
+ ${field("namespace", "Namespace", n.namespace)}${field("to", "To", n.to)}${field("from", "From", n.from.name ? `${n.from.name} <${n.from.address}>` : n.from.address)}
1245
+ ${field("createdAt", "Created", n.createdAt)}${field("status", "Status", n.status ?? "\u2014")}</div>
1246
+ <pre ${tid(TEST_IDS.inspectorJson)}>${esc(JSON.stringify({ ...normalized, raw }, null, 2))}</pre>`;
1247
+ };
1248
+ const shown = (channel) => (state.only === null || state.only === channel) && (options.channels === void 0 || options.channels.includes(channel));
1249
+ const render = () => {
1250
+ if (destroyed) return;
1251
+ renderTopbar();
1252
+ $("devices").hidden = state.view !== "devices";
1253
+ $("timeline").hidden = state.view !== "timeline";
1254
+ for (const channel of CHANNELS) $(`col-${channel}`).hidden = !shown(channel);
1255
+ $("devices").classList.toggle("solo", CHANNELS.filter(shown).length === 1);
1256
+ if (state.view === "devices") {
1257
+ if (shown("sms")) renderSms();
1258
+ if (shown("push")) renderPush();
1259
+ if (shown("email")) renderEmail();
1260
+ } else renderTimeline();
1261
+ renderInspector();
1262
+ };
1263
+ const showOnDevice = (n) => {
1264
+ state.view = "devices";
1265
+ state[n.channel].to = n.to;
1266
+ if (n.channel === "sms") state.sms.thread = n.from.address;
1267
+ if (n.channel === "email") state.email.open = n.id;
1268
+ if (n.channel === "push") {
1269
+ state.push.expanded = n.id;
1270
+ local.openGroups.add(groupKey(n.to, n.push?.app ?? "App"));
1271
+ }
1272
+ local.dismissed.delete(n.id);
1273
+ state.inspect = null;
1274
+ render();
1275
+ root.querySelector(`.devices [data-notification-id="${CSS.escape(n.id)}"]`)?.scrollIntoView({ block: "nearest" });
1276
+ };
1277
+ const collapse = (el, then) => {
1278
+ if (!animate()) return then();
1279
+ el.style.height = `${el.offsetHeight}px`;
1280
+ el.classList.add("collapsing");
1281
+ void el.offsetHeight;
1282
+ Object.assign(el.style, {
1283
+ height: "0px",
1284
+ marginTop: "0px",
1285
+ marginBottom: "0px",
1286
+ paddingTop: "0px",
1287
+ paddingBottom: "0px",
1288
+ opacity: "0"
1289
+ });
1290
+ later(270, then);
1291
+ };
1292
+ const flyOut = (row, card, direction, then) => {
1293
+ if (!animate()) return then();
1294
+ row.classList.add("settling");
1295
+ card.classList.add("settling");
1296
+ card.style.transform = `translateX(${direction * (row.offsetWidth + 40)}px)`;
1297
+ card.style.opacity = "0";
1298
+ later(230, () => collapse(row, then));
1299
+ };
1300
+ const rowFor = (selector) => root.querySelector(selector);
1301
+ const cardIn = (row) => row.querySelector(".swipe-card") ?? row;
1302
+ const dismissPush = (id, direction = -1) => {
1303
+ const row = rowFor(`.ios-swipe[data-id="${CSS.escape(id)}"]`) ?? rowFor(`.a-note[data-id="${CSS.escape(id)}"]`);
1304
+ const done = () => {
1305
+ local.dismissed.add(id);
1306
+ if (state.push.preview === id) state.push.preview = null;
1307
+ renderPush();
1308
+ };
1309
+ if (!row) return done();
1310
+ flyOut(row, cardIn(row), direction, done);
1311
+ };
1312
+ const deleteThread = (from) => {
1313
+ const to = state.sms.to;
1314
+ if (!to) return;
1315
+ const ids = byChannel("sms").filter((n) => n.to === to && n.from.address === from).map((n) => n.id);
1316
+ const row = rowFor(`.thread-swipe[data-from="${CSS.escape(from)}"]`);
1317
+ const done = () => {
1318
+ for (const id of ids) local.dismissed.add(id);
1319
+ renderSms();
1320
+ };
1321
+ if (!row) return done();
1322
+ flyOut(row, cardIn(row), -1, done);
1323
+ };
1324
+ const removeEmail = (id, verb, direction = -1) => {
1325
+ const row = rowFor(`.mrow-swipe[data-id="${CSS.escape(id)}"]`);
1326
+ if (!row) return archiveEmail(id, verb);
1327
+ flyOut(row, cardIn(row), direction, () => archiveEmail(id, verb));
1328
+ };
1329
+ let drag = null;
1330
+ let suppressClick = false;
1331
+ const IOS_OPEN = 160;
1332
+ const THREAD_OPEN = 148;
1333
+ const setOffset = (row, x) => {
1334
+ const card = cardIn(row);
1335
+ card.style.transform = x ? `translateX(${x}px)` : "";
1336
+ if (row.dataset.swipe === "a-note")
1337
+ card.style.opacity = String(1 - Math.min(0.9, Math.abs(x) / row.offsetWidth));
1338
+ };
1339
+ const settle = (row, x, open) => {
1340
+ row.classList.add("settling");
1341
+ row.classList.toggle("open", open);
1342
+ if (row.dataset.swipe === "a-note") row.classList.add("settling");
1343
+ setOffset(row, x);
1344
+ later(340, () => {
1345
+ row.classList.remove("settling", "swiping");
1346
+ row.dataset.offset = String(x);
1347
+ });
1348
+ };
1349
+ const longPress = (el) => {
1350
+ const kind = el.dataset.longpress;
1351
+ const id = el.dataset.notificationId ?? el.dataset.id ?? "";
1352
+ if (kind === "sms-bubble") {
1353
+ state.sms.menu = id;
1354
+ renderSms();
1355
+ try {
1356
+ navigator.vibrate?.(10);
1357
+ } catch {
1358
+ }
1359
+ } else if (kind === "ios-note") {
1360
+ state.push.preview = id;
1361
+ renderPush();
1362
+ } else if (kind === "a-note") {
1363
+ state.push.settings = state.push.settings === id ? null : id;
1364
+ renderPush();
1365
+ }
1366
+ };
1367
+ on("pointerdown", (event) => {
1368
+ sound.unlock();
1369
+ suppressClick = false;
1370
+ if (event.button !== 0 || !event.isPrimary) return;
1371
+ const target = event.target;
1372
+ if (target.closest("select, input, .swipe-actions button, .ios-overlay")) return;
1373
+ const swipe = target.closest("[data-swipe]");
1374
+ const press = target.closest("[data-longpress]");
1375
+ if (!swipe && !press) return;
1376
+ drag = {
1377
+ pointerId: event.pointerId,
1378
+ x0: event.clientX,
1379
+ y0: event.clientY,
1380
+ t0: performance.now(),
1381
+ dx: 0,
1382
+ axis: null,
1383
+ swipe,
1384
+ press,
1385
+ base: Number(swipe?.dataset.offset ?? 0),
1386
+ timer: null
1387
+ };
1388
+ if (press) {
1389
+ const current = drag;
1390
+ current.timer = setTimeout(() => {
1391
+ if (drag !== current || current.axis) return;
1392
+ suppressClick = true;
1393
+ drag = null;
1394
+ longPress(press);
1395
+ }, 480);
1396
+ }
1397
+ });
1398
+ on("pointermove", (event) => {
1399
+ if (!drag || event.pointerId !== drag.pointerId) return;
1400
+ const dx = event.clientX - drag.x0;
1401
+ const dy = event.clientY - drag.y0;
1402
+ if (!drag.axis) {
1403
+ if (Math.hypot(dx, dy) < 8) return;
1404
+ drag.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
1405
+ if (drag.timer) clearTimeout(drag.timer);
1406
+ if (drag.axis === "x" && drag.swipe) {
1407
+ drag.swipe.classList.add("swiping");
1408
+ drag.swipe.classList.remove("settling");
1409
+ cardIn(drag.swipe).classList.remove("settling");
1410
+ try {
1411
+ drag.swipe.setPointerCapture(event.pointerId);
1412
+ } catch {
1413
+ }
1414
+ }
1415
+ }
1416
+ if (drag.axis !== "x" || !drag.swipe) return;
1417
+ event.preventDefault();
1418
+ drag.dx = dx;
1419
+ const kind = drag.swipe.dataset.swipe;
1420
+ let x = drag.base + dx;
1421
+ if (kind === "sms-convo") {
1422
+ const reveal = Math.max(0, Math.min(72, -dx));
1423
+ drag.swipe.style.setProperty("--reveal", `${reveal}px`);
1424
+ return;
1425
+ }
1426
+ if (kind === "ios-note" && x > 0) x = x * 0.35;
1427
+ if (kind === "mrow" || kind === "a-note") x = dx;
1428
+ setOffset(drag.swipe, x);
1429
+ });
1430
+ const endDrag = (event) => {
1431
+ if (!drag || event.pointerId !== drag.pointerId) return;
1432
+ const d = drag;
1433
+ drag = null;
1434
+ if (d.timer) clearTimeout(d.timer);
1435
+ if (d.axis !== "x" || !d.swipe) return;
1436
+ suppressClick = true;
1437
+ const row = d.swipe;
1438
+ const kind = row.dataset.swipe;
1439
+ const width = row.offsetWidth || 1;
1440
+ const velocity = d.dx / Math.max(1, performance.now() - d.t0);
1441
+ const x = d.base + d.dx;
1442
+ if (kind === "sms-convo") {
1443
+ row.classList.add("settling");
1444
+ row.style.setProperty("--reveal", "0px");
1445
+ later(320, () => row.classList.remove("settling"));
1446
+ } else if (kind === "ios-note") {
1447
+ const id = row.dataset.id ?? "";
1448
+ if (x < -width * 0.7 || velocity < -1.1 && x < -60) dismissPush(id);
1449
+ else if (x < -60) settle(row, -IOS_OPEN, true);
1450
+ else if (x > 70) {
1451
+ settle(row, 0, false);
1452
+ state.push.expanded = id;
1453
+ renderPush();
1454
+ } else settle(row, 0, false);
1455
+ } else if (kind === "a-note") {
1456
+ if (Math.abs(x) > width * 0.4 || Math.abs(velocity) > 0.7)
1457
+ dismissPush(row.dataset.id ?? "", Math.sign(x) || 1);
1458
+ else settle(row, 0, false);
1459
+ } else if (kind === "sms-thread") {
1460
+ const from = row.dataset.from ?? "";
1461
+ if (x < -width * 0.6) deleteThread(from);
1462
+ else if (x < -50) settle(row, -THREAD_OPEN, true);
1463
+ else if (x > 50) settle(row, THREAD_OPEN, true);
1464
+ else settle(row, 0, false);
1465
+ } else if (kind === "mrow") {
1466
+ if (Math.abs(x) > width * 0.35 || Math.abs(velocity) > 0.8)
1467
+ removeEmail(row.dataset.id ?? "", "archived", Math.sign(x) || 1);
1468
+ else settle(row, 0, false);
1469
+ }
1470
+ };
1471
+ on("pointerup", endDrag);
1472
+ on("pointercancel", endDrag);
1473
+ on("contextmenu", (event) => {
1474
+ const press = event.target.closest("[data-longpress]");
1475
+ if (!press) return;
1476
+ event.preventDefault();
1477
+ longPress(press);
1478
+ });
1479
+ on(
1480
+ "click",
1481
+ (event) => {
1482
+ if (!suppressClick) return;
1483
+ suppressClick = false;
1484
+ event.stopPropagation();
1485
+ event.preventDefault();
1486
+ },
1487
+ true
1488
+ );
1489
+ const closeOverlays = () => {
1490
+ const had = state.sms.menu || state.push.preview || state.push.settings;
1491
+ state.sms.menu = null;
1492
+ state.push.preview = null;
1493
+ state.push.settings = null;
1494
+ if (had) {
1495
+ renderSms();
1496
+ renderPush();
1497
+ }
1498
+ return Boolean(had);
1499
+ };
1500
+ const copy = (text) => {
1501
+ try {
1502
+ void navigator.clipboard?.writeText(text).catch(() => {
1503
+ });
1504
+ } catch {
1505
+ }
1506
+ };
1507
+ on("click", (event) => {
1508
+ const target = event.target.closest("[data-action]");
1509
+ if (!target) return;
1510
+ const openRow = target.closest(".swipe.open");
1511
+ if (openRow && target.classList.contains("swipe-card")) {
1512
+ settle(openRow, 0, false);
1513
+ return;
1514
+ }
1515
+ const action = target.dataset.action;
1516
+ const id = target.dataset.id ?? "";
1517
+ const from = target.dataset.from ?? "";
1518
+ const smsTo = state.sms.to ?? "";
1519
+ switch (action) {
1520
+ case "view":
1521
+ state.view = target.dataset.value === "timeline" ? "timeline" : "devices";
1522
+ render();
1523
+ break;
1524
+ case "sound":
1525
+ handle.setSound(!sound.enabled);
1526
+ break;
1527
+ case "sms-open":
1528
+ state.sms.thread = from || null;
1529
+ state.sms.transition = "push";
1530
+ renderSms();
1531
+ break;
1532
+ case "sms-back":
1533
+ state.sms.thread = null;
1534
+ state.sms.transition = "back";
1535
+ renderSms();
1536
+ break;
1537
+ case "thread-delete":
1538
+ deleteThread(from);
1539
+ break;
1540
+ case "thread-pin": {
1541
+ const key = threadKey(smsTo, from);
1542
+ if (!local.pinned.delete(key)) local.pinned.add(key);
1543
+ renderSms();
1544
+ break;
1545
+ }
1546
+ case "thread-mute": {
1547
+ const key = threadKey(smsTo, from);
1548
+ if (!local.muted.delete(key)) local.muted.add(key);
1549
+ renderSms();
1550
+ break;
1551
+ }
1552
+ case "thread-unread": {
1553
+ const items = byChannel("sms").filter((n) => n.to === smsTo && n.from.address === from);
1554
+ if (items.some((n) => !seen.has(n.id))) markSeen(items.map((n) => n.id));
1555
+ else {
1556
+ const latest = items.at(-1);
1557
+ if (latest) markUnseen([latest.id]);
1558
+ }
1559
+ renderSms();
1560
+ break;
1561
+ }
1562
+ case "tapback": {
1563
+ const value = target.dataset.value;
1564
+ if (local.tapbacks.get(id) === value) local.tapbacks.delete(id);
1565
+ else local.tapbacks.set(id, value);
1566
+ state.sms.menu = null;
1567
+ renderSms();
1568
+ break;
1569
+ }
1570
+ case "sms-copy": {
1571
+ const n = state.items.find((x) => x.id === id);
1572
+ if (n) copy(n.sms?.body ?? "");
1573
+ state.sms.menu = null;
1574
+ renderSms();
1575
+ break;
1576
+ }
1577
+ case "sms-copy-code":
1578
+ copy(target.dataset.code ?? "");
1579
+ break;
1580
+ case "close-overlay":
1581
+ closeOverlays();
1582
+ break;
1583
+ case "push-toggle": {
1584
+ if (target.dataset.groupTop === "true") {
1585
+ const n = state.items.find((x) => x.id === id);
1586
+ if (n) {
1587
+ local.openGroups.add(groupKey(n.to, n.push?.app ?? "App"));
1588
+ renderPush();
1589
+ }
1590
+ break;
1591
+ }
1592
+ state.push.expanded = state.push.expanded === id ? null : id;
1593
+ renderPush();
1594
+ break;
1595
+ }
1596
+ case "push-clear":
1597
+ state.push.preview = null;
1598
+ dismissPush(id);
1599
+ break;
1600
+ case "push-options":
1601
+ state.push.preview = id;
1602
+ renderPush();
1603
+ break;
1604
+ case "push-alerting": {
1605
+ const app = target.dataset.app ?? "App";
1606
+ if (target.dataset.value === "silent") local.silent.add(app);
1607
+ else local.silent.delete(app);
1608
+ renderPush();
1609
+ break;
1610
+ }
1611
+ case "push-clear-all": {
1612
+ const rows = [...root.querySelectorAll(".shade-list .a-note")];
1613
+ const ids = rows.map((r) => r.dataset.id ?? "");
1614
+ const done = () => {
1615
+ for (const x of ids) local.dismissed.add(x);
1616
+ renderPush();
1617
+ };
1618
+ if (!animate()) return done();
1619
+ rows.forEach((row, i) => {
1620
+ later(i * 60, () => {
1621
+ row.classList.add("settling");
1622
+ row.style.transform = `translateX(${row.offsetWidth + 40}px)`;
1623
+ row.style.opacity = "0";
1624
+ });
1625
+ });
1626
+ later(rows.length * 60 + 300, done);
1627
+ break;
1628
+ }
1629
+ case "group-open":
1630
+ local.openGroups.add(target.dataset.key ?? "");
1631
+ renderPush();
1632
+ break;
1633
+ case "group-collapse":
1634
+ local.openGroups.delete(target.dataset.key ?? "");
1635
+ renderPush();
1636
+ break;
1637
+ case "group-clear": {
1638
+ const app = target.dataset.app ?? "App";
1639
+ for (const n of byChannel("push"))
1640
+ if (n.to === state.push.to && (n.push?.app ?? "App") === app) local.dismissed.add(n.id);
1641
+ local.openGroups.delete(target.dataset.key ?? "");
1642
+ renderPush();
1643
+ break;
1644
+ }
1645
+ case "skin":
1646
+ state.push.skin = target.dataset.value ?? "auto";
1647
+ renderPush();
1648
+ break;
1649
+ case "mail-open":
1650
+ state.email.open = id;
1651
+ state.email.transition = true;
1652
+ mailDetailsOpen = false;
1653
+ renderEmail();
1654
+ break;
1655
+ case "mail-back":
1656
+ state.email.open = null;
1657
+ state.email.transition = true;
1658
+ renderEmail();
1659
+ break;
1660
+ case "mail-details":
1661
+ mailDetailsOpen = !mailDetailsOpen;
1662
+ renderEmail();
1663
+ break;
1664
+ case "mail-archive":
1665
+ removeEmail(id, "archived");
1666
+ break;
1667
+ case "mail-delete":
1668
+ removeEmail(id, "deleted");
1669
+ break;
1670
+ case "mail-unread":
1671
+ markUnseen([id]);
1672
+ state.email.open = null;
1673
+ state.email.transition = true;
1674
+ renderEmail();
1675
+ break;
1676
+ case "mail-toggle-read":
1677
+ if (seen.has(id)) markUnseen([id]);
1678
+ else markSeen([id]);
1679
+ renderEmail();
1680
+ break;
1681
+ case "mail-star":
1682
+ if (!local.starred.delete(id)) local.starred.add(id);
1683
+ renderEmail();
1684
+ break;
1685
+ case "mail-check":
1686
+ if (!local.checked.delete(id)) local.checked.add(id);
1687
+ renderEmail();
1688
+ break;
1689
+ case "toast-undo":
1690
+ state.toast?.undo?.();
1691
+ break;
1692
+ case "toast-close":
1693
+ state.toast = null;
1694
+ renderEmail();
1695
+ break;
1696
+ case "refresh":
1697
+ void refresh().catch(() => {
1698
+ });
1699
+ break;
1700
+ case "inspect":
1701
+ state.inspect = id;
1702
+ renderInspector();
1703
+ break;
1704
+ case "close-inspector":
1705
+ state.inspect = null;
1706
+ renderInspector();
1707
+ break;
1708
+ case "show-on-device": {
1709
+ const n = state.items.find((x) => x.id === id);
1710
+ if (n) showOnDevice(n);
1711
+ break;
1712
+ }
1713
+ }
1714
+ });
1715
+ on("dblclick", (event) => {
1716
+ const target = event.target.closest("[data-notification-id]");
1717
+ if (!target || target.closest("[data-region=inspector]")) return;
1718
+ state.inspect = target.dataset.notificationId ?? null;
1719
+ renderInspector();
1720
+ });
1721
+ on("change", (event) => {
1722
+ const target = event.target;
1723
+ const action = target.dataset.action;
1724
+ if (action === "namespace") handle.setNamespace(target.value);
1725
+ else if (action === "follow") state.follow = target.checked;
1726
+ else if (action === "recipient") {
1727
+ const channel = target.dataset.channel;
1728
+ state[channel].to = target.value;
1729
+ if (channel === "sms") state.sms.thread = null;
1730
+ if (channel === "email") state.email.open = null;
1731
+ render();
1732
+ }
1733
+ });
1734
+ on("input", (event) => {
1735
+ const target = event.target;
1736
+ if (target.dataset.action !== "search") return;
1737
+ state.search = target.value;
1738
+ ensureSelection();
1739
+ render();
1740
+ });
1741
+ on("keydown", (event) => {
1742
+ sound.unlock();
1743
+ const target = event.target;
1744
+ if (event.key === "Escape") {
1745
+ if (closeOverlays()) return;
1746
+ if (state.inspect) {
1747
+ state.inspect = null;
1748
+ renderInspector();
1749
+ }
1750
+ return;
1751
+ }
1752
+ if (target.matches("input, select, textarea")) return;
1753
+ if (event.key === "ContextMenu" || event.shiftKey && event.key === "F10") {
1754
+ const press = target.closest("[data-longpress]");
1755
+ if (press) {
1756
+ event.preventDefault();
1757
+ longPress(press);
1758
+ }
1759
+ return;
1760
+ }
1761
+ if (event.key === "Delete" || event.key === "Backspace" || event.key === "#" || event.key === "e") {
1762
+ const id = target.dataset.id ?? "";
1763
+ if (target.matches("[data-testid=push-notification]") && event.key !== "e") {
1764
+ event.preventDefault();
1765
+ dismissPush(id);
1766
+ } else if (target.matches("[data-testid=sms-thread]") && event.key !== "e") {
1767
+ event.preventDefault();
1768
+ deleteThread(target.dataset.from ?? "");
1769
+ } else if (target.matches("[data-testid=email-row]")) {
1770
+ event.preventDefault();
1771
+ removeEmail(id, event.key === "e" ? "archived" : "deleted");
1772
+ }
1773
+ return;
1774
+ }
1775
+ if ((event.key === "Enter" || event.key === " ") && target.matches("[role=button]:not(button)")) {
1776
+ event.preventDefault();
1777
+ target.click();
1778
+ }
1779
+ });
1780
+ const applyPins = () => {
1781
+ if (pinned) {
1782
+ state[pinned.channel].to = pinned.to;
1783
+ if (pinned.channel === "sms")
1784
+ state.sms.thread = pinnedFrom ?? smsThreads(pinned.to)[0]?.from ?? null;
1785
+ }
1786
+ if (pinnedId) {
1787
+ const n = state.items.find((x) => x.id === pinnedId);
1788
+ if (n) return showOnDevice(n);
1789
+ }
1790
+ render();
1791
+ };
1792
+ let markReady = () => {
1793
+ };
1794
+ const ready = new Promise((resolve) => {
1795
+ markReady = () => {
1796
+ root.dataset.ready = "true";
1797
+ resolve();
1798
+ };
1799
+ });
1800
+ const handle = {
1801
+ render,
1802
+ state,
1803
+ ready,
1804
+ show(target) {
1805
+ const n = target.id ? state.items.find((x) => x.id === target.id) : void 0;
1806
+ if (n) return showOnDevice(n);
1807
+ if (target.channel) {
1808
+ state.view = "devices";
1809
+ if (target.to) state[target.channel].to = target.to;
1810
+ if (target.channel === "sms" && target.to)
1811
+ state.sms.thread = smsThreads(target.to)[0]?.from ?? null;
1812
+ render();
1813
+ }
1814
+ },
1815
+ setNamespace(namespace) {
1816
+ if (namespace === state.namespace) return;
1817
+ state.namespace = namespace;
1818
+ state.items = [];
1819
+ state.sms = { ...state.sms, to: null, thread: null, menu: null };
1820
+ state.email = { ...state.email, to: null, open: null };
1821
+ state.push.to = null;
1822
+ loadSeen();
1823
+ if (syncUrl) {
1824
+ const url = new URL(location.href);
1825
+ url.searchParams.set("namespace", namespace);
1826
+ history.replaceState(null, "", url);
1827
+ }
1828
+ if (live) {
1829
+ connect();
1830
+ void refresh().catch(() => {
1831
+ });
1832
+ } else render();
1833
+ },
1834
+ setSound(enabled) {
1835
+ sound.enabled = enabled;
1836
+ if (enabled) sound.unlock();
1837
+ if (storage)
1838
+ try {
1839
+ localStorage.setItem(soundKey, enabled ? "1" : "0");
1840
+ } catch {
1841
+ }
1842
+ renderTopbar();
1843
+ },
1844
+ destroy() {
1845
+ destroyed = true;
1846
+ listeners.abort();
1847
+ stream?.abort();
1848
+ for (const t of timers) clearTimeout(t);
1849
+ timers.clear();
1850
+ for (const t of intervals) clearInterval(t);
1851
+ sound.close();
1852
+ root.innerHTML = "";
1853
+ root.classList.remove(ROOT_CLASS, "embedded");
1854
+ }
1855
+ };
1856
+ const intervals = [];
1857
+ if (!live) {
1858
+ state.items = [...options.items ?? []].sort((a, b) => a.createdAt < b.createdAt ? 1 : -1);
1859
+ state.sources = options.sources ?? [];
1860
+ state.namespaces = options.namespaces ?? [];
1861
+ state.discoveryError = options.discoveryError ?? null;
1862
+ state.connected = options.connected ?? true;
1863
+ ensureSelection();
1864
+ applyPins();
1865
+ markReady();
1866
+ return handle;
1867
+ }
1868
+ intervals.push(
1869
+ setInterval(() => {
1870
+ if (drag || state.sms.menu || state.push.preview || state.push.settings) return;
1871
+ if (state.view === "devices" && !root.querySelector(".mail-frame")) render();
1872
+ else renderTopbar();
1873
+ }, 3e4),
1874
+ setInterval(() => void refreshSources(), 5e3)
1875
+ );
1876
+ loadSeen();
1877
+ render();
1878
+ connect();
1879
+ void refreshSources();
1880
+ const boot = async () => {
1881
+ for (let attempt = 0; !destroyed; attempt++) {
1882
+ try {
1883
+ await refresh();
1884
+ applyPins();
1885
+ markReady();
1886
+ return;
1887
+ } catch {
1888
+ await new Promise(
1889
+ (resolve) => later(Math.min(5e3, 500 * 2 ** attempt), () => resolve(null))
1890
+ );
1891
+ }
1892
+ }
1893
+ };
1894
+ void boot();
1895
+ return handle;
1896
+ }
1897
+
1898
+ // src/ui.ts
1899
+ var STYLE_ID = "mockingbird-notifications-styles";
1900
+ var installStyles = (host) => {
1901
+ const rootNode = host.getRootNode();
1902
+ const target = rootNode instanceof ShadowRoot ? rootNode : document.head;
1903
+ if (target.querySelector(`#${STYLE_ID}`)) return;
1904
+ const style = document.createElement("style");
1905
+ style.id = STYLE_ID;
1906
+ style.textContent = STYLES;
1907
+ target.append(style);
1908
+ };
1909
+ var mountNotifications = (host, options = {}) => {
1910
+ const { harness, injectStyles = true, ...rest } = options;
1911
+ if (injectStyles) installStyles(host);
1912
+ if (harness) rest.fetch = (request) => harness.fetch(request);
1913
+ return createApp(host, { embedded: true, syncUrl: false, ...rest });
1914
+ };
1915
+ export {
1916
+ ROOT_CLASS,
1917
+ STYLES,
1918
+ TEST_IDS,
1919
+ createApp,
1920
+ mountNotifications,
1921
+ selectors
1922
+ };
1923
+ //# sourceMappingURL=ui.js.map