dsh-scheduled-message 0.1.1

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/client.js ADDED
@@ -0,0 +1,1739 @@
1
+ /**
2
+ * dsh-scheduled-message — browser bundle. GENERATED FILE, do not edit.
3
+ *
4
+ * Built from client.src.js + lib/core.js + locale/en.json + locale/zh.json by
5
+ * scripts/build-client.mjs. Rebuild with `node scripts/build-client.mjs`;
6
+ * `node --test test/` fails when this file is stale.
7
+ */
8
+ window.__ModuleLoader__.load({
9
+ id: "dsh-scheduled-message",
10
+ factory: (require) => {
11
+ const core = (() => {
12
+ /**
13
+ * Pure shared logic for dsh-scheduled-message.
14
+ *
15
+ * Everything here is dependency-free and deterministic so the Host half, the
16
+ * browser half and the unit tests agree on one implementation: 12-hour clock
17
+ * conversion, wall-clock to absolute-instant resolution in an explicit IANA
18
+ * zone, validation of a create request, the RRULE text handed to
19
+ * `ctx.schedule.create`, timer chunking and retention pruning.
20
+ *
21
+ * @module dsh-scheduled-message/lib/core
22
+ */
23
+
24
+ /** Plugin id, locale namespace and browser entry name. */
25
+ const NS = 'dsh-scheduled-message'
26
+
27
+ /**
28
+ * Durable storage domain name.
29
+ *
30
+ * Storage unit names are narrower than package names: `@deepseek-ai/dsh-storage`
31
+ * matches them against `/^[a-z][a-z0-9_]*$/`, so the package name (which has
32
+ * hyphens) would be refused when the domain is defined.
33
+ */
34
+ const DOMAIN_NAME = 'dsh_scheduled_message'
35
+
36
+ /** A scheduled instant must be at least this far ahead of the accepted request. */
37
+ const MIN_LEAD_MS = 30_000
38
+
39
+ /** Longest accepted message text. */
40
+ const MAX_TEXT_CHARS = 200_000
41
+
42
+ /** Most images accepted in one scheduled message. */
43
+ const MAX_IMAGES = 10
44
+
45
+ /** Most `pending` records the Host keeps. */
46
+ const MAX_PENDING = 500
47
+
48
+ /** Longest title accepted by `ctx.schedule.create`. */
49
+ const MAX_TITLE_CHARS = 120
50
+
51
+ /** `setTimeout` stores its delay in a signed 32-bit integer. */
52
+ const MAX_TIMER_DELAY_MS = 2_147_483_647
53
+
54
+ /** A delivery later than this after its due instant is reported as sent late. */
55
+ const LATE_THRESHOLD_MS = 60_000
56
+
57
+ /** Finished records older than this are pruned. */
58
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000
59
+
60
+ /** Repeat frequencies the composer offers. */
61
+ const REPEAT_FREQUENCIES = Object.freeze(['daily', 'weekdays', 'weekly', 'monthly'])
62
+
63
+ const ISO_WEEKDAY_CODES = Object.freeze(['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'])
64
+
65
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
66
+
67
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/
68
+
69
+ /** Clamp and zero-pad one clock field. */
70
+ function pad2(value) {
71
+ return String(Math.trunc(value)).padStart(2, '0')
72
+ }
73
+
74
+ /**
75
+ * Convert a 24-hour clock hour to the 12-hour face shown in the picker.
76
+ * @param hour24 - hour in 0..23.
77
+ * @returns the 1..12 hour and its meridiem; an out-of-range hour throws.
78
+ */
79
+ function toMeridiem(hour24) {
80
+ if (!Number.isInteger(hour24) || hour24 < 0 || hour24 > 23) throw new RangeError(`hour out of range: ${hour24}`)
81
+ if (hour24 === 0) return { hour12: 12, meridiem: 'AM' }
82
+ if (hour24 < 12) return { hour12: hour24, meridiem: 'AM' }
83
+ if (hour24 === 12) return { hour12: 12, meridiem: 'PM' }
84
+ return { hour12: hour24 - 12, meridiem: 'PM' }
85
+ }
86
+
87
+ /**
88
+ * Convert the picker's 12-hour face back to a 24-hour clock hour.
89
+ * @param hour12 - hour in 1..12.
90
+ * @param meridiem - 'AM' or 'PM'.
91
+ * @returns the 0..23 hour; a malformed input throws.
92
+ */
93
+ function to24Hour(hour12, meridiem) {
94
+ if (!Number.isInteger(hour12) || hour12 < 1 || hour12 > 12) throw new RangeError(`hour out of range: ${hour12}`)
95
+ if (meridiem !== 'AM' && meridiem !== 'PM') throw new RangeError(`meridiem out of range: ${String(meridiem)}`)
96
+ const base = hour12 % 12
97
+ return meridiem === 'PM' ? base + 12 : base
98
+ }
99
+
100
+ /**
101
+ * Render the picker's clock face for a copy line.
102
+ * @param hour12 - hour in 1..12.
103
+ * @param minute - minute in 0..59.
104
+ * @param meridiem - 'AM' or 'PM'.
105
+ * @returns text such as `9:00 AM`.
106
+ */
107
+ function formatClock(hour12, minute, meridiem) {
108
+ return `${hour12}:${pad2(minute)} ${meridiem}`
109
+ }
110
+
111
+ /**
112
+ * Read a strict `YYYY-MM-DD` calendar date without consulting a process zone.
113
+ * @param text - candidate date.
114
+ * @returns the three fields, or null when the text is not a real calendar date.
115
+ */
116
+ function parseLocalDate(text) {
117
+ if (typeof text !== 'string') return null
118
+ const match = ISO_DATE.exec(text)
119
+ if (match === null) return null
120
+ const year = Number(match[1])
121
+ const month = Number(match[2])
122
+ const day = Number(match[3])
123
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null
124
+ const probe = new Date(Date.UTC(year, month - 1, day))
125
+ if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null
126
+ return { year, month, day }
127
+ }
128
+
129
+ /**
130
+ * Whether a name is usable as an IANA zone for `Intl`.
131
+ * @param timeZone - candidate zone name.
132
+ * @returns whether `Intl.DateTimeFormat` accepts it; `UTC` is accepted.
133
+ */
134
+ function isValidTimeZone(timeZone) {
135
+ if (typeof timeZone !== 'string' || timeZone.length === 0 || timeZone.length > 64) return false
136
+ try {
137
+ new Intl.DateTimeFormat('en-US', { timeZone })
138
+ return true
139
+ } catch {
140
+ return false
141
+ }
142
+ }
143
+
144
+ const offsetFormatters = new Map()
145
+
146
+ function offsetFormatter(timeZone) {
147
+ let formatter = offsetFormatters.get(timeZone)
148
+ if (formatter === undefined) {
149
+ formatter = new Intl.DateTimeFormat('en-US', {
150
+ timeZone,
151
+ hourCycle: 'h23',
152
+ year: 'numeric',
153
+ month: '2-digit',
154
+ day: '2-digit',
155
+ hour: '2-digit',
156
+ minute: '2-digit',
157
+ second: '2-digit',
158
+ })
159
+ offsetFormatters.set(timeZone, formatter)
160
+ }
161
+ return formatter
162
+ }
163
+
164
+ /**
165
+ * Read the wall clock of one instant in one zone.
166
+ * @param instantMs - epoch milliseconds.
167
+ * @param timeZone - explicit IANA zone.
168
+ * @returns local calendar fields, including the ISO weekday 1..7 (Monday first).
169
+ */
170
+ function localParts(instantMs, timeZone) {
171
+ const parts = offsetFormatter(timeZone).formatToParts(new Date(instantMs))
172
+ const field = (type) => Number(parts.find(part => part.type === type)?.value)
173
+ const year = field('year')
174
+ const month = field('month')
175
+ const day = field('day')
176
+ const hour24 = field('hour')
177
+ const minute = field('minute')
178
+ const second = field('second')
179
+ const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
180
+ return {
181
+ year,
182
+ month,
183
+ day,
184
+ date: `${year}-${pad2(month)}-${pad2(day)}`,
185
+ hour24,
186
+ minute,
187
+ second,
188
+ isoWeekday: weekday === 0 ? 7 : weekday,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Offset of one zone at one instant, in milliseconds (local minus UTC).
194
+ * @param instantMs - epoch milliseconds.
195
+ * @param timeZone - explicit IANA zone.
196
+ * @returns the offset, positive east of Greenwich.
197
+ */
198
+ function zoneOffsetMs(instantMs, timeZone) {
199
+ const local = localParts(instantMs, timeZone)
200
+ const asUtc = Date.UTC(local.year, local.month - 1, local.day, local.hour24, local.minute, local.second)
201
+ return asUtc - Math.floor(instantMs / 1000) * 1000
202
+ }
203
+
204
+ /**
205
+ * Resolve a wall-clock selection in an explicit zone to an absolute instant.
206
+ *
207
+ * The selection is the one the picker shows, so it is interpreted in the
208
+ * browser's zone; the Host only ever sees the resulting absolute instant.
209
+ * A wall clock that a daylight-saving gap removed is refused instead of being
210
+ * shifted silently.
211
+ * @param selection - `date` (`YYYY-MM-DD`), `hour24` (0..23) and `minute` (0..59).
212
+ * @param timeZone - explicit IANA zone.
213
+ * @returns the resolved instant, or a rejection naming the reason.
214
+ */
215
+ function wallClockToInstant(selection, timeZone) {
216
+ if (!isValidTimeZone(timeZone)) return { ok: false, reason: 'invalid-time-zone' }
217
+ const date = parseLocalDate(selection?.date)
218
+ const hour24 = selection?.hour24
219
+ const minute = selection?.minute
220
+ if (date === null) return { ok: false, reason: 'invalid-date' }
221
+ if (!Number.isInteger(hour24) || hour24 < 0 || hour24 > 23) return { ok: false, reason: 'invalid-time' }
222
+ if (!Number.isInteger(minute) || minute < 0 || minute > 59) return { ok: false, reason: 'invalid-time' }
223
+ const target = Date.UTC(date.year, date.month - 1, date.day, hour24, minute, 0, 0)
224
+ // Two passes settle zones whose offset at UTC midnight differs from the
225
+ // offset at the resolved instant; a third pass changes nothing.
226
+ let instantMs = target - zoneOffsetMs(target, timeZone)
227
+ instantMs = target - zoneOffsetMs(instantMs, timeZone)
228
+ const check = localParts(instantMs, timeZone)
229
+ if (check.date !== `${date.year}-${pad2(date.month)}-${pad2(date.day)}`
230
+ || check.hour24 !== hour24 || check.minute !== minute) {
231
+ return { ok: false, reason: 'nonexistent-local-time' }
232
+ }
233
+ return { ok: true, instantMs, iso: new Date(instantMs).toISOString() }
234
+ }
235
+
236
+ /**
237
+ * Build the two-line `DTSTART` + `RRULE` text `ctx.schedule.create` accepts.
238
+ *
239
+ * `DTSTART` carries the zone, so the wall clock the user chose is kept across
240
+ * daylight-saving changes and Host zone changes; `BYDAY`/`BYMONTHDAY` project
241
+ * that same wall clock onto the requested cadence.
242
+ * @param options - repeat frequency, first occurrence instant and explicit zone.
243
+ * @returns normalized recurrence text; an unsupported frequency throws.
244
+ */
245
+ function buildRrule({ repeat, instantMs, timeZone }) {
246
+ if (!REPEAT_FREQUENCIES.includes(repeat)) throw new RangeError(`unsupported repeat: ${String(repeat)}`)
247
+ if (!isValidTimeZone(timeZone)) throw new RangeError(`invalid time zone: ${String(timeZone)}`)
248
+ const parts = localParts(instantMs, timeZone)
249
+ const stamp = `${parts.date.replaceAll('-', '')}T${pad2(parts.hour24)}${pad2(parts.minute)}00`
250
+ const start = `DTSTART;TZID=${timeZone}:${stamp}`
251
+ const day = ISO_WEEKDAY_CODES[parts.isoWeekday - 1]
252
+ switch (repeat) {
253
+ case 'daily':
254
+ return `${start}\nRRULE:FREQ=DAILY`
255
+ case 'weekdays':
256
+ return `${start}\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;WKST=MO`
257
+ case 'weekly':
258
+ return `${start}\nRRULE:FREQ=WEEKLY;BYDAY=${day};WKST=MO`
259
+ default:
260
+ return `${start}\nRRULE:FREQ=MONTHLY;BYMONTHDAY=${parts.day}`
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Whether a monthly rule on this day of month skips shorter months.
266
+ * @param dayOfMonth - day 1..31.
267
+ * @returns whether the rule cannot fire in every month.
268
+ */
269
+ function skipsShortMonths(dayOfMonth) {
270
+ return Number.isInteger(dayOfMonth) && dayOfMonth >= 29 && dayOfMonth <= 31
271
+ }
272
+
273
+ /**
274
+ * Compress a message into the one-line title `ctx.schedule.create` requires.
275
+ * @param text - message text.
276
+ * @param max - longest accepted title; 120 by default.
277
+ * @returns a trimmed single line no longer than `max` characters.
278
+ */
279
+ function summariseTitle(text, max = MAX_TITLE_CHARS) {
280
+ const collapsed = String(text ?? '').replace(/\s+/gu, ' ').trim()
281
+ if (collapsed.length === 0) return ''
282
+ if (collapsed.length <= max) return collapsed
283
+ if (max <= 1) return collapsed.slice(0, Math.max(max, 0))
284
+ return `${collapsed.slice(0, max - 1).trimEnd()}…`
285
+ }
286
+
287
+ /**
288
+ * Validate one create request before any durable work happens.
289
+ *
290
+ * Pure: the caller supplies the clock, so the same request validates
291
+ * identically in the browser and in the Host.
292
+ * @param payload - untrusted request body.
293
+ * @param nowMs - the accepting clock.
294
+ * @returns normalized content, or a stable rejection code and message.
295
+ */
296
+ function validateCreateRequest(payload, nowMs) {
297
+ const fail = (code, message) => ({ ok: false, code, message })
298
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
299
+ return fail('invalid-request', 'The request body must be an object.')
300
+ }
301
+ const { sessionId, dueAt, timeZone } = payload
302
+ if (typeof sessionId !== 'string' || sessionId.length === 0) return fail('invalid-session', 'A session id is required.')
303
+ if (typeof dueAt !== 'string' || !ISO_INSTANT.test(dueAt)) {
304
+ return fail('invalid-due-at', 'dueAt must be an absolute ISO instant with an offset or Z.')
305
+ }
306
+ const dueMs = Date.parse(dueAt)
307
+ if (!Number.isFinite(dueMs)) return fail('invalid-due-at', 'dueAt is not a real instant.')
308
+ if (dueMs < nowMs + MIN_LEAD_MS) {
309
+ return fail('too-soon', `Choose a time at least ${Math.round(MIN_LEAD_MS / 1000)} seconds from now.`)
310
+ }
311
+ if (!isValidTimeZone(timeZone)) return fail('invalid-time-zone', 'timeZone must be an IANA zone name or UTC.')
312
+
313
+ const text = typeof payload.text === 'string' ? payload.text : ''
314
+ if (text.length > MAX_TEXT_CHARS) return fail('text-too-long', `A message may carry at most ${MAX_TEXT_CHARS} characters.`)
315
+ const images = Array.isArray(payload.images) ? payload.images : payload.images === undefined ? [] : null
316
+ if (images === null) return fail('invalid-images', 'images must be an array.')
317
+ if (images.length > MAX_IMAGES) return fail('too-many-images', `A message may carry at most ${MAX_IMAGES} images.`)
318
+ for (const image of images) {
319
+ if (image === null || typeof image !== 'object'
320
+ || typeof image.mediaType !== 'string' || image.mediaType.length === 0
321
+ || typeof image.data !== 'string' || image.data.length === 0) {
322
+ return fail('invalid-images', 'Every image needs a mediaType and base64 data.')
323
+ }
324
+ }
325
+ const rawReceipts = payload.fileReceipts ?? []
326
+ if (!Array.isArray(rawReceipts) || rawReceipts.some(id => typeof id !== 'string' || id.length === 0)) {
327
+ return fail('invalid-files', 'fileReceipts must be a list of receipt ids.')
328
+ }
329
+ const fileReceipts = [...new Set(rawReceipts)]
330
+
331
+ const trimmed = text.trim()
332
+ if (trimmed.length === 0 && images.length === 0 && fileReceipts.length === 0) {
333
+ return fail('empty', 'Type a message or attach something first.')
334
+ }
335
+
336
+ const repeat = payload.repeat ?? null
337
+ if (repeat !== null && !REPEAT_FREQUENCIES.includes(repeat)) {
338
+ return fail('invalid-repeat', 'repeat must be daily, weekdays, weekly or monthly.')
339
+ }
340
+ if (repeat !== null && (images.length > 0 || fileReceipts.length > 0)) {
341
+ return fail('repeat-with-attachments', "Repeating messages can't include images or files.")
342
+ }
343
+ if (repeat !== null && trimmed.length === 0) {
344
+ return fail('empty', 'A repeating message needs text.')
345
+ }
346
+
347
+ return {
348
+ ok: true,
349
+ value: {
350
+ sessionId,
351
+ dueAt: new Date(dueMs).toISOString(),
352
+ dueMs,
353
+ timeZone,
354
+ text: trimmed,
355
+ images: images.map(image => ({
356
+ mediaType: image.mediaType,
357
+ data: image.data,
358
+ ...(typeof image.name === 'string' && image.name.length > 0 ? { name: image.name } : {}),
359
+ })),
360
+ fileReceipts,
361
+ repeat,
362
+ },
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Pick the record that must be delivered first.
368
+ * @param records - every stored record.
369
+ * @returns the earliest `pending` (or crash-left `sending`) record, if any.
370
+ */
371
+ function earliestPending(records) {
372
+ let best
373
+ let bestMs = Number.POSITIVE_INFINITY
374
+ for (const record of records) {
375
+ if (record.status !== 'pending' && record.status !== 'sending') continue
376
+ const dueMs = Date.parse(record.dueAt)
377
+ if (!Number.isFinite(dueMs)) continue
378
+ if (dueMs < bestMs) {
379
+ best = record
380
+ bestMs = dueMs
381
+ }
382
+ }
383
+ return best
384
+ }
385
+
386
+ /**
387
+ * Clamp the wait for one due instant to what `setTimeout` can hold.
388
+ * @param dueMs - target instant.
389
+ * @param nowMs - current instant.
390
+ * @returns a non-negative delay no larger than {@link MAX_TIMER_DELAY_MS}.
391
+ */
392
+ function timerDelay(dueMs, nowMs) {
393
+ const remaining = dueMs - nowMs
394
+ if (!Number.isFinite(remaining)) return MAX_TIMER_DELAY_MS
395
+ return Math.min(Math.max(Math.round(remaining), 0), MAX_TIMER_DELAY_MS)
396
+ }
397
+
398
+ /**
399
+ * Whether a delivery happened long enough after its due instant to be reported late.
400
+ * @param dueMs - the instant the message was due.
401
+ * @param deliveredMs - the instant delivery settled.
402
+ * @returns whether the delivery is late by more than {@link LATE_THRESHOLD_MS}.
403
+ */
404
+ function isLate(dueMs, deliveredMs) {
405
+ return Number.isFinite(dueMs) && deliveredMs - dueMs > LATE_THRESHOLD_MS
406
+ }
407
+
408
+ /**
409
+ * Choose the finished records that retention should drop.
410
+ * @param records - every stored record.
411
+ * @param nowMs - current instant.
412
+ * @returns ids of finished records older than {@link RETENTION_MS}.
413
+ */
414
+ function pruneTargets(records, nowMs) {
415
+ const ids = []
416
+ for (const record of records) {
417
+ if (record.status !== 'sent' && record.status !== 'cancelled') continue
418
+ const settledMs = Date.parse(record.sentAt ?? record.updatedAt ?? record.createdAt)
419
+ if (!Number.isFinite(settledMs)) continue
420
+ if (nowMs - settledMs > RETENTION_MS) ids.push(record.id)
421
+ }
422
+ return ids
423
+ }
424
+
425
+ /**
426
+ * Count the records a session's composer strip should show.
427
+ * @param records - records for one session.
428
+ * @param nowMs - current instant.
429
+ * @returns how many are still scheduled and when the next one fires.
430
+ */
431
+ function pendingSummary(records, nowMs) {
432
+ let pending = 0
433
+ let nextMs = Number.POSITIVE_INFINITY
434
+ for (const record of records) {
435
+ if (record.status !== 'pending') continue
436
+ pending += 1
437
+ const dueMs = Date.parse(record.dueAt)
438
+ if (Number.isFinite(dueMs) && dueMs < nextMs) nextMs = dueMs
439
+ }
440
+ return { pending, nextDueMs: Number.isFinite(nextMs) ? nextMs : null, nowMs }
441
+ }
442
+
443
+ /**
444
+ * Format one due instant for the summary line and the strip.
445
+ * @param instantMs - the instant to render.
446
+ * @param locale - BCP 47 locale tag.
447
+ * @param timeZone - explicit zone used for display.
448
+ * @returns text such as `Fri, 14 Nov, 9:00 am`; a formatter failure falls back to ISO.
449
+ */
450
+ function describeDue(instantMs, locale, timeZone) {
451
+ try {
452
+ return new Intl.DateTimeFormat(locale, {
453
+ dateStyle: 'medium',
454
+ timeStyle: 'short',
455
+ timeZone,
456
+ }).format(new Date(instantMs))
457
+ } catch {
458
+ return new Date(instantMs).toISOString()
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Format one zone's label for a copy line.
464
+ * @param instantMs - the instant whose offset should be labelled.
465
+ * @param locale - BCP 47 locale tag.
466
+ * @param timeZone - explicit zone.
467
+ * @returns the zone's long name when available, else its IANA name.
468
+ */
469
+ function describeZone(instantMs, locale, timeZone) {
470
+ try {
471
+ const parts = new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: 'long' }).formatToParts(new Date(instantMs))
472
+ const name = parts.find(part => part.type === 'timeZoneName')?.value
473
+ return typeof name === 'string' && name.length > 0 ? name : timeZone
474
+ } catch {
475
+ return timeZone
476
+ }
477
+ }
478
+
479
+ return { NS, DOMAIN_NAME, MIN_LEAD_MS, MAX_TEXT_CHARS, MAX_IMAGES, MAX_PENDING, MAX_TITLE_CHARS, MAX_TIMER_DELAY_MS, LATE_THRESHOLD_MS, RETENTION_MS, REPEAT_FREQUENCIES, pad2, toMeridiem, to24Hour, formatClock, parseLocalDate, isValidTimeZone, localParts, zoneOffsetMs, wallClockToInstant, buildRrule, skipsShortMonths, summariseTitle, validateCreateRequest, earliestPending, timerDelay, isLate, pruneTargets, pendingSummary, describeDue, describeZone }
480
+ })()
481
+
482
+ const EN = {
483
+ "button.label": "Schedule",
484
+ "button.shortLabel": "Later",
485
+ "button.emptyHint": "Type a message first",
486
+ "button.busyHint": "Wait for the message to finish sending",
487
+ "shortcut.label": "Schedule message",
488
+ "attachments.image": "{count} image",
489
+ "attachments.images": "{count} images",
490
+ "attachments.file": "{count} file",
491
+ "attachments.files": "{count} files",
492
+ "attachments.any": "{count} attachment",
493
+ "attachments.anyMany": "{count} attachments",
494
+ "dialog.title": "Schedule message",
495
+ "dialog.rescheduleTitle": "Change send time",
496
+ "dialog.close": "Close",
497
+ "dialog.date": "Date",
498
+ "dialog.hour": "Hour",
499
+ "dialog.minute": "Minute",
500
+ "dialog.meridiem": "AM or PM",
501
+ "dialog.summary": "Sends {time} ({zone})",
502
+ "dialog.pastWarning": "That time has already passed, or is less than 30 seconds away.",
503
+ "dialog.repeat": "Repeat",
504
+ "dialog.repeatHint": "Create an automation task that sends this text again on a rule.",
505
+ "dialog.repeatLocked": "Repeating messages can't include images or files.",
506
+ "dialog.repeatShortMonths": "Shorter months are skipped.",
507
+ "dialog.submit": "Schedule",
508
+ "dialog.createAutomation": "Create automation",
509
+ "dialog.saveTime": "Save time",
510
+ "dialog.cancel": "Cancel",
511
+ "dialog.noText": "Only attachments",
512
+ "repeat.daily": "Daily",
513
+ "repeat.weekdays": "Weekdays",
514
+ "repeat.weekly": "Weekly",
515
+ "repeat.monthly": "Monthly",
516
+ "repeat.detail.daily": "Every day at this time.",
517
+ "repeat.detail.weekdays": "Monday to Friday at this time.",
518
+ "repeat.detail.weekly": "Every {weekday} at this time.",
519
+ "repeat.detail.monthly": "Every month on this date at this time.",
520
+ "weekday.mon": "Monday",
521
+ "weekday.tue": "Tuesday",
522
+ "weekday.wed": "Wednesday",
523
+ "weekday.thu": "Thursday",
524
+ "weekday.fri": "Friday",
525
+ "weekday.sat": "Saturday",
526
+ "weekday.sun": "Sunday",
527
+ "quick.inHour": "In 1 hour",
528
+ "quick.thisEvening": "This evening, 8:00 PM",
529
+ "quick.tomorrow": "Tomorrow, 9:00 AM",
530
+ "quick.nextMonday": "Next Monday, 9:00 AM",
531
+ "dock.collapsed": "{count} scheduled · next {next}",
532
+ "dock.collapsedEmpty": "Scheduled messages",
533
+ "dock.noText": "Only attachments",
534
+ "dock.sent": "sent {time}",
535
+ "dock.sentLate": "sent late, {time}",
536
+ "dock.failed": "failed, was due {time}",
537
+ "dock.sending": "sending…",
538
+ "dock.sendNow": "Send now",
539
+ "dock.changeTime": "Change time",
540
+ "dock.cancel": "Cancel",
541
+ "dock.confirmCancel": "Cancel it",
542
+ "dock.keep": "Keep",
543
+ "dock.retry": "Retry",
544
+ "notice.scheduled": "Scheduled for {time}",
545
+ "notice.automationCreated": "Automation created, first send {time}",
546
+ "notice.rescheduled": "Send time changed to {time}",
547
+ "notice.noComposer": "Open a conversation to schedule a message",
548
+ "notice.busy": "Wait for the message to finish sending",
549
+ "notice.typeFirst": "Type a message first",
550
+ "error.generic": "That did not work. Try again.",
551
+ "error.tooSoon": "Choose a time at least 30 seconds from now.",
552
+ "error.empty": "Type a message or attach something first.",
553
+ "error.repeatWithAttachments": "Repeating messages can't include images or files.",
554
+ "error.repeatUnavailable": "Repeating messages need the Automation service in this profile.",
555
+ "error.unknownReceipt": "One of the attached files is no longer available; attach it again.",
556
+ "error.unknownSession": "That conversation is no longer available; reopen it and try again.",
557
+ "error.notPending": "That message is no longer waiting to be sent.",
558
+ "error.notFound": "That scheduled message no longer exists.",
559
+ "error.tooManyPending": "At most 500 messages may wait to be sent.",
560
+ "error.invalidImages": "One of the images could not be stored. Try attaching it again.",
561
+ "error.attachmentsUnavailable": "This conversation cannot hand over its attachments.",
562
+ "error.uploading": "Wait for uploads to finish, then try again."
563
+ }
564
+
565
+ const ZH = {
566
+ "button.label": "定时发送",
567
+ "button.shortLabel": "稍后",
568
+ "button.emptyHint": "先输入内容",
569
+ "button.busyHint": "请等这条消息发送完成",
570
+ "shortcut.label": "定时发送消息",
571
+ "attachments.image": "{count} 张图片",
572
+ "attachments.images": "{count} 张图片",
573
+ "attachments.file": "{count} 个文件",
574
+ "attachments.files": "{count} 个文件",
575
+ "attachments.any": "{count} 个附件",
576
+ "attachments.anyMany": "{count} 个附件",
577
+ "dialog.title": "定时发送消息",
578
+ "dialog.rescheduleTitle": "修改发送时间",
579
+ "dialog.close": "关闭",
580
+ "dialog.date": "日期",
581
+ "dialog.hour": "小时",
582
+ "dialog.minute": "分钟",
583
+ "dialog.meridiem": "上午或下午",
584
+ "dialog.summary": "将于 {time}({zone})发送",
585
+ "dialog.pastWarning": "这个时间已经过去,或距现在不足 30 秒。",
586
+ "dialog.repeat": "重复",
587
+ "dialog.repeatHint": "创建自动化任务,按规则重复发送这段文字。",
588
+ "dialog.repeatLocked": "重复消息不能包含图片或文件。",
589
+ "dialog.repeatShortMonths": "没有这一天的小月会被跳过。",
590
+ "dialog.submit": "定时发送",
591
+ "dialog.createAutomation": "创建自动化",
592
+ "dialog.saveTime": "保存时间",
593
+ "dialog.cancel": "取消",
594
+ "dialog.noText": "仅附件",
595
+ "repeat.daily": "每天",
596
+ "repeat.weekdays": "工作日",
597
+ "repeat.weekly": "每周",
598
+ "repeat.monthly": "每月",
599
+ "repeat.detail.daily": "每天这个时间发送。",
600
+ "repeat.detail.weekdays": "周一至周五这个时间发送。",
601
+ "repeat.detail.weekly": "每周{weekday}这个时间发送。",
602
+ "repeat.detail.monthly": "每月这一天这个时间发送。",
603
+ "weekday.mon": "一",
604
+ "weekday.tue": "二",
605
+ "weekday.wed": "三",
606
+ "weekday.thu": "四",
607
+ "weekday.fri": "五",
608
+ "weekday.sat": "六",
609
+ "weekday.sun": "日",
610
+ "quick.inHour": "1 小时后",
611
+ "quick.thisEvening": "今晚 20:00",
612
+ "quick.tomorrow": "明天 09:00",
613
+ "quick.nextMonday": "下周一 09:00",
614
+ "dock.collapsed": "{count} 条待发送 · 最近 {next}",
615
+ "dock.collapsedEmpty": "定时消息",
616
+ "dock.noText": "仅附件",
617
+ "dock.sent": "已发送 {time}",
618
+ "dock.sentLate": "已补发 {time}",
619
+ "dock.failed": "发送失败,原定 {time}",
620
+ "dock.sending": "正在发送…",
621
+ "dock.sendNow": "立即发送",
622
+ "dock.changeTime": "改时间",
623
+ "dock.cancel": "取消",
624
+ "dock.confirmCancel": "确认取消",
625
+ "dock.keep": "保留",
626
+ "dock.retry": "重试",
627
+ "notice.scheduled": "已定时,将于 {time} 发送",
628
+ "notice.automationCreated": "已创建自动化,首次发送 {time}",
629
+ "notice.rescheduled": "发送时间改为 {time}",
630
+ "notice.noComposer": "请先打开一个会话再定时",
631
+ "notice.busy": "请等这条消息发送完成",
632
+ "notice.typeFirst": "先输入内容",
633
+ "error.generic": "没有成功,请再试一次。",
634
+ "error.tooSoon": "请选择至少 30 秒之后的时间。",
635
+ "error.empty": "请先输入内容或添加附件。",
636
+ "error.repeatWithAttachments": "重复消息不能包含图片或文件。",
637
+ "error.repeatUnavailable": "重复消息需要此配置里的自动化服务。",
638
+ "error.unknownReceipt": "其中一个附件已经不可用,请重新添加。",
639
+ "error.unknownSession": "该会话已不可用,请重新打开后重试。",
640
+ "error.notPending": "这条消息已经不在待发送状态。",
641
+ "error.notFound": "这条定时消息已经不存在。",
642
+ "error.tooManyPending": "最多只能有 500 条待发送消息。",
643
+ "error.invalidImages": "其中一张图片保存失败,请重新添加。",
644
+ "error.attachmentsUnavailable": "此会话无法交出草稿附件。",
645
+ "error.uploading": "请等附件上传完成后再试。"
646
+ }
647
+
648
+ /**
649
+ * Browser half of dsh-scheduled-message — source template.
650
+ *
651
+ * This file is not loaded by the browser. `scripts/build-client.mjs` inlines
652
+ * `lib/core.js` and `locale/*.json` into it and writes the module-loader
653
+ * bundle `client.js`, which is what the package exports as `./client`.
654
+ * Run `node scripts/build-client.mjs` after editing either half; the test
655
+ * suite fails when `client.js` is stale.
656
+ *
657
+ * The bundle must not import another DSH package: it may only require the
658
+ * platform table (`react`, `react-dom`).
659
+ *
660
+ * @module dsh-scheduled-message/client
661
+ */
662
+
663
+ /**
664
+ * Build one client plugin instance.
665
+ * @param require - the module-loader require handed to the bundle factory.
666
+ * @returns the cordis plugin object.
667
+ */
668
+ function createClientPlugin(require) {
669
+ 'use strict'
670
+
671
+ const React = require('react')
672
+ const ReactDOM = require('react-dom')
673
+ const h = React.createElement
674
+ const { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } = React
675
+
676
+ const NS = core.NS
677
+ const API = '/api/dsh-scheduled-message'
678
+ const SHORTCUT_ID = 'dsh-scheduled-message.open'
679
+ const SHORTCUT_PROFILES = ['desktop:macos', 'desktop:windows', 'desktop:linux', 'web:macos', 'web:windows', 'web:linux']
680
+ const TASKS_PANEL = 'schedules'
681
+ const ORANGE = '#F97316'
682
+ const ORANGE_HOVER = '#EA6A0C'
683
+ const STYLE_ID = 'dsm-styles'
684
+ const MINUTE_STEPS = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]
685
+ const HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
686
+ const WEEKDAY_KEYS = ['weekday.mon', 'weekday.tue', 'weekday.wed', 'weekday.thu', 'weekday.fri', 'weekday.sat', 'weekday.sun']
687
+
688
+ /** Stable machine codes the Host answers with, mapped onto localized copy. */
689
+ const ERROR_KEYS = {
690
+ 'too-soon': 'error.tooSoon',
691
+ empty: 'error.empty',
692
+ 'repeat-with-attachments': 'error.repeatWithAttachments',
693
+ 'repeat-unavailable': 'error.repeatUnavailable',
694
+ 'unknown-receipt': 'error.unknownReceipt',
695
+ 'unknown-session': 'error.unknownSession',
696
+ 'not-pending': 'error.notPending',
697
+ 'not-found': 'error.notFound',
698
+ 'too-many-pending': 'error.tooManyPending',
699
+ 'invalid-images': 'error.invalidImages',
700
+ 'invalid-request': 'error.generic',
701
+ internal: 'error.generic',
702
+ }
703
+
704
+ // ---------------------------------------------------------------- stores
705
+
706
+ const openRequests = new Map()
707
+ const openListeners = new Set()
708
+ let refreshSeq = 0
709
+ const refreshListeners = new Set()
710
+ let toast = null
711
+
712
+ function bumpOpen(sessionId) {
713
+ openRequests.set(sessionId, (openRequests.get(sessionId) ?? 0) + 1)
714
+ for (const listener of [...openListeners]) listener()
715
+ }
716
+
717
+ function bumpRefresh() {
718
+ refreshSeq += 1
719
+ for (const listener of [...refreshListeners]) listener()
720
+ }
721
+
722
+ function setToast(text) {
723
+ toast = text
724
+ bumpRefresh()
725
+ const captured = text
726
+ setTimeout(() => {
727
+ if (toast === captured) {
728
+ toast = null
729
+ bumpRefresh()
730
+ }
731
+ }, 7000)
732
+ }
733
+
734
+ function subscribeOpen(listener) {
735
+ openListeners.add(listener)
736
+ return () => openListeners.delete(listener)
737
+ }
738
+
739
+ function subscribeRefresh(listener) {
740
+ refreshListeners.add(listener)
741
+ return () => refreshListeners.delete(listener)
742
+ }
743
+
744
+ function useOpenRequest(sessionId) {
745
+ return useSyncExternalStore(subscribeOpen, () => openRequests.get(sessionId) ?? 0, () => 0)
746
+ }
747
+
748
+ function useRefreshSignal() {
749
+ return useSyncExternalStore(subscribeRefresh, () => refreshSeq, () => 0)
750
+ }
751
+
752
+ /** Mounted composers, used by the shortcut to find the focused conversation. */
753
+ const composers = new Map()
754
+ let lastTouchedSession = null
755
+
756
+ function preferComposer(sessionId) {
757
+ lastTouchedSession = sessionId
758
+ }
759
+
760
+ function pickComposer() {
761
+ if (composers.size === 0) return null
762
+ const preferred = lastTouchedSession === null ? undefined : composers.get(lastTouchedSession)
763
+ if (preferred !== undefined) return preferred
764
+ return composers.values().next().value ?? null
765
+ }
766
+
767
+ /** Inert snapshot for a seat rendered outside the composer tree. */
768
+ const EMPTY_INPUT = Object.freeze({ draft: '', attachmentIds: Object.freeze([]), phase: 'plain' })
769
+
770
+ /** Stand-in for `props.useInput`: registered in the same hook position, so hook order stays fixed. */
771
+ function useNoopInput() {
772
+ return EMPTY_INPUT
773
+ }
774
+
775
+ /** Identity selector; `InputState` is immutable, so a seat re-renders only when it changes. */
776
+ function selectInput(state) {
777
+ return state ?? EMPTY_INPUT
778
+ }
779
+
780
+ // ---------------------------------------------------------------- helpers
781
+
782
+ function currentZone() {
783
+ try {
784
+ const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone
785
+ return typeof zone === 'string' && zone.length > 0 ? zone : 'UTC'
786
+ } catch {
787
+ return 'UTC'
788
+ }
789
+ }
790
+
791
+ function currentLocale() {
792
+ try {
793
+ const declared = document?.documentElement?.getAttribute('lang')
794
+ if (typeof declared === 'string' && declared.length > 0) return declared
795
+ } catch {
796
+ // A missing document is not an error here: fall through to the navigator.
797
+ }
798
+ try {
799
+ return navigator.language || 'en'
800
+ } catch {
801
+ return 'en'
802
+ }
803
+ }
804
+
805
+ function selectionOf(instantMs, zone) {
806
+ const parts = core.localParts(instantMs, zone)
807
+ const clock = core.toMeridiem(parts.hour24)
808
+ return { date: parts.date, hour12: clock.hour12, minute: parts.minute, meridiem: clock.meridiem }
809
+ }
810
+
811
+ function addDays(date, days) {
812
+ const [year, month, day] = date.split('-').map(Number)
813
+ const shifted = new Date(Date.UTC(year, month - 1, day + days))
814
+ return `${shifted.getUTCFullYear()}-${core.pad2(shifted.getUTCMonth() + 1)}-${core.pad2(shifted.getUTCDate())}`
815
+ }
816
+
817
+ function defaultSelection(zone) {
818
+ const rounded = Math.ceil((Date.now() + 30 * 60_000) / 300_000) * 300_000
819
+ return selectionOf(rounded, zone)
820
+ }
821
+
822
+ function resolveSelection(selection, zone) {
823
+ return core.wallClockToInstant({
824
+ date: selection.date,
825
+ hour24: core.to24Hour(selection.hour12, selection.meridiem),
826
+ minute: selection.minute,
827
+ }, zone)
828
+ }
829
+
830
+ function firstLine(text, lines) {
831
+ return String(text ?? '').split('\n').slice(0, lines).join('\n').trim()
832
+ }
833
+
834
+ function attachmentSummary(t, imageCount, fileCount) {
835
+ const parts = []
836
+ if (imageCount > 0) parts.push(t(imageCount === 1 ? 'attachments.image' : 'attachments.images', { count: imageCount }))
837
+ if (fileCount > 0) parts.push(t(fileCount === 1 ? 'attachments.file' : 'attachments.files', { count: fileCount }))
838
+ return parts.join(' · ')
839
+ }
840
+
841
+ function attachmentCountText(t, count) {
842
+ if (count <= 0) return ''
843
+ return t(count === 1 ? 'attachments.any' : 'attachments.anyMany', { count })
844
+ }
845
+
846
+ /**
847
+ * Optional services, filled by nested `ctx.inject` scopes.
848
+ *
849
+ * Reading a service the plugin did not declare throws in cordis, so each
850
+ * optional service is taken from its own scope; when the profile has no such
851
+ * service the scope never runs and the feature that needs it degrades to a
852
+ * no-op.
853
+ */
854
+ const services = { sessions: undefined, layout: undefined }
855
+
856
+ function errorText(t, error) {
857
+ const code = error?.code
858
+ const key = typeof code === 'string' ? ERROR_KEYS[code] : undefined
859
+ if (key !== undefined) return t(key)
860
+ const message = error?.message
861
+ if (typeof message === 'string' && message.length > 0) return message
862
+ return t('error.generic')
863
+ }
864
+
865
+ async function requestJson(path, init) {
866
+ const response = await fetch(path, { credentials: 'include', ...init })
867
+ let body
868
+ try {
869
+ body = await response.json()
870
+ } catch {
871
+ throw Object.assign(new Error(`HTTP ${response.status}`), { code: 'internal' })
872
+ }
873
+ if (body?.ok !== true) {
874
+ const failure = body?.error
875
+ throw Object.assign(new Error(failure?.message ?? `HTTP ${response.status}`), {
876
+ code: typeof failure?.code === 'string' ? failure.code : 'internal',
877
+ })
878
+ }
879
+ return body.value
880
+ }
881
+
882
+ function postJson(path, payload) {
883
+ return requestJson(path, {
884
+ method: 'POST',
885
+ headers: { 'content-type': 'application/json' },
886
+ body: JSON.stringify(payload),
887
+ })
888
+ }
889
+
890
+ function listScheduled(sessionId) {
891
+ return requestJson(`${API}/list?sessionId=${encodeURIComponent(sessionId)}`, { method: 'GET' })
892
+ }
893
+
894
+ // ---------------------------------------------------------------- icons
895
+
896
+ function ClockSendIcon() {
897
+ return h('svg', { viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': 'true', focusable: 'false' },
898
+ h('circle', { cx: 6.5, cy: 8, r: 4.75, fill: 'none', stroke: 'currentColor', strokeWidth: 1.4 }),
899
+ h('path', { d: 'M6.5 5.4V8l1.9 1.2', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }),
900
+ h('path', { d: 'M11.6 9.1 15 11l-3.4 1.9', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }))
901
+ }
902
+
903
+ function BellIcon() {
904
+ return h('svg', { viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': 'true', focusable: 'false' },
905
+ h('path', {
906
+ d: 'M4.2 11.1V7.3a3.8 3.8 0 0 1 7.6 0v3.8l1 1.1H3.2l1-1.1Z',
907
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round',
908
+ }),
909
+ h('path', { d: 'M6.6 14a1.5 1.5 0 0 0 2.8 0', fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' }))
910
+ }
911
+
912
+ function CloseIcon() {
913
+ return h('svg', { viewBox: '0 0 16 16', width: 14, height: 14, 'aria-hidden': 'true', focusable: 'false' },
914
+ h('path', { d: 'm4.5 4.5 7 7m0-7-7 7', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round' }))
915
+ }
916
+
917
+ // ---------------------------------------------------------------- dialog
918
+
919
+ /**
920
+ * Date, 12-hour clock, AM/PM and Repeat editor.
921
+ *
922
+ * @param props - localized translator, current zone, mode, initial selection,
923
+ * whether Repeat is available, and the submit callback (which throws to
924
+ * keep the dialog open with an inline error).
925
+ */
926
+ function ScheduleDialog(props) {
927
+ const { t, zone, mode, initial, repeatLocked, previewText, attachmentCount, onSubmit, onClose } = props
928
+ const locale = currentLocale()
929
+ const titleId = useId()
930
+ const panel = useRef(null)
931
+ const restore = useRef(null)
932
+
933
+ const [selection, setSelection] = useState(() => initial ?? defaultSelection(zone))
934
+ const [repeat, setRepeat] = useState(null)
935
+ const [busy, setBusy] = useState(false)
936
+ const [error, setError] = useState(null)
937
+
938
+ const resolved = useMemo(() => resolveSelection(selection, zone), [selection, zone])
939
+ const now = Date.now()
940
+ const tooSoon = resolved.ok !== true || resolved.instantMs < now + core.MIN_LEAD_MS
941
+
942
+ const quickPicks = useMemo(() => listQuickPicks(t, zone, now), [t, zone, Math.floor(now / 60_000)])
943
+
944
+ useEffect(() => {
945
+ restore.current = typeof document !== 'undefined' ? document.activeElement : null
946
+ const first = panel.current?.querySelector('button, select, input')
947
+ first?.focus?.()
948
+ return () => {
949
+ const target = restore.current
950
+ if (target !== null && typeof target?.focus === 'function') target.focus()
951
+ }
952
+ }, [])
953
+
954
+ const onKeyDown = useCallback((event) => {
955
+ if (event.key === 'Escape') {
956
+ event.preventDefault()
957
+ event.stopPropagation()
958
+ onClose()
959
+ return
960
+ }
961
+ if (event.key !== 'Tab' || panel.current === null) return
962
+ const focusable = [...panel.current.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')]
963
+ if (focusable.length === 0) return
964
+ const first = focusable[0]
965
+ const last = focusable[focusable.length - 1]
966
+ if (event.shiftKey && document.activeElement === first) {
967
+ event.preventDefault()
968
+ last.focus()
969
+ } else if (!event.shiftKey && document.activeElement === last) {
970
+ event.preventDefault()
971
+ first.focus()
972
+ }
973
+ }, [onClose])
974
+
975
+ const update = (patch) => setSelection(current => ({ ...current, ...patch }))
976
+
977
+ const submit = async () => {
978
+ if (busy || tooSoon) return
979
+ setBusy(true)
980
+ setError(null)
981
+ try {
982
+ await onSubmit({ selection, repeat, instantMs: resolved.instantMs })
983
+ } catch (failure) {
984
+ setError(failure)
985
+ setBusy(false)
986
+ }
987
+ }
988
+
989
+ const minDate = core.localParts(now, zone).date
990
+ const dayOfMonth = Number(selection.date.slice(8, 10))
991
+ const weekday = (() => {
992
+ const probe = resolved.ok === true ? resolved.instantMs : now
993
+ return WEEKDAY_KEYS[core.localParts(probe, zone).isoWeekday - 1]
994
+ })()
995
+
996
+ const summary = tooSoon
997
+ ? t('dialog.pastWarning')
998
+ : t('dialog.summary', {
999
+ time: core.describeDue(resolved.instantMs, locale, zone),
1000
+ zone: core.describeZone(resolved.instantMs, locale, zone),
1001
+ })
1002
+
1003
+ const primaryLabel = mode === 'reschedule'
1004
+ ? t('dialog.saveTime')
1005
+ : repeat === null ? t('dialog.submit') : t('dialog.createAutomation')
1006
+
1007
+ return ReactDOM.createPortal(
1008
+ h('div', {
1009
+ className: 'dsm-backdrop',
1010
+ onMouseDown: (event) => {
1011
+ if (event.target === event.currentTarget) onClose()
1012
+ },
1013
+ },
1014
+ h('div', {
1015
+ className: 'dsm-dialog',
1016
+ role: 'dialog',
1017
+ 'aria-modal': 'true',
1018
+ 'aria-labelledby': titleId,
1019
+ ref: panel,
1020
+ onKeyDown,
1021
+ },
1022
+ h('header', { className: 'dsm-dialog-head' },
1023
+ h('h2', { id: titleId, className: 'dsm-dialog-title' }, mode === 'reschedule' ? t('dialog.rescheduleTitle') : t('dialog.title')),
1024
+ h('button', { type: 'button', className: 'dsm-icon-btn', 'aria-label': t('dialog.close'), onClick: onClose }, h(CloseIcon))),
1025
+
1026
+ mode === 'create'
1027
+ ? h('div', { className: 'dsm-preview' },
1028
+ h('p', { className: 'dsm-preview-text' }, firstLine(previewText, 2) || t('dialog.noText')),
1029
+ attachmentCount > 0
1030
+ ? h('p', { className: 'dsm-preview-meta' }, attachmentCountText(t, attachmentCount))
1031
+ : null)
1032
+ : null,
1033
+
1034
+ quickPicks.length > 0
1035
+ ? h('div', { className: 'dsm-quick' },
1036
+ quickPicks.map(pick => h('button', {
1037
+ key: pick.id,
1038
+ type: 'button',
1039
+ className: 'dsm-chip',
1040
+ disabled: busy,
1041
+ onClick: () => setSelection(pick.selection),
1042
+ }, t(pick.label))))
1043
+ : null,
1044
+
1045
+ h('div', { className: 'dsm-fields' },
1046
+ h('label', { className: 'dsm-field dsm-field-date' },
1047
+ h('span', { className: 'dsm-label' }, t('dialog.date')),
1048
+ h('input', {
1049
+ className: 'dsm-input',
1050
+ type: 'date',
1051
+ value: selection.date,
1052
+ min: minDate,
1053
+ disabled: busy,
1054
+ onChange: (event) => {
1055
+ const value = event.target.value
1056
+ if (typeof value === 'string' && value.length === 10) update({ date: value })
1057
+ },
1058
+ })),
1059
+ h('label', { className: 'dsm-field' },
1060
+ h('span', { className: 'dsm-label' }, t('dialog.hour')),
1061
+ h('select', {
1062
+ className: 'dsm-select',
1063
+ value: String(selection.hour12),
1064
+ disabled: busy,
1065
+ onChange: (event) => update({ hour12: Number(event.target.value) }),
1066
+ }, HOURS.map(hour => h('option', { key: hour, value: String(hour) }, String(hour))))),
1067
+ h('label', { className: 'dsm-field' },
1068
+ h('span', { className: 'dsm-label' }, t('dialog.minute')),
1069
+ h('select', {
1070
+ className: 'dsm-select',
1071
+ value: String(selection.minute),
1072
+ disabled: busy,
1073
+ onChange: (event) => update({ minute: Number(event.target.value) }),
1074
+ }, MINUTE_STEPS.map(minute => h('option', { key: minute, value: String(minute) }, core.pad2(minute))))),
1075
+ h('div', { className: 'dsm-field dsm-field-meridiem', role: 'group', 'aria-label': t('dialog.meridiem') },
1076
+ ['AM', 'PM'].map(meridiem => h('button', {
1077
+ key: meridiem,
1078
+ type: 'button',
1079
+ className: `dsm-seg${selection.meridiem === meridiem ? ' dsm-seg-on' : ''}`,
1080
+ 'aria-pressed': selection.meridiem === meridiem,
1081
+ disabled: busy,
1082
+ onClick: () => update({ meridiem }),
1083
+ }, meridiem)))),
1084
+
1085
+ h('p', { className: `dsm-summary${tooSoon ? ' dsm-summary-bad' : ''}`, role: 'status' }, summary),
1086
+
1087
+ mode === 'create'
1088
+ ? h('div', { className: 'dsm-repeat' },
1089
+ h('label', { className: `dsm-check${repeatLocked ? ' dsm-check-off' : ''}` },
1090
+ h('input', {
1091
+ type: 'checkbox',
1092
+ checked: repeat !== null,
1093
+ disabled: busy || repeatLocked,
1094
+ onChange: (event) => setRepeat(event.target.checked ? 'daily' : null),
1095
+ }),
1096
+ h('span', null, t('dialog.repeat'))),
1097
+ repeat === null
1098
+ ? h('p', { className: 'dsm-hint' }, repeatLocked ? t('dialog.repeatLocked') : t('dialog.repeatHint'))
1099
+ : h('div', { className: 'dsm-repeat-body' },
1100
+ h('select', {
1101
+ className: 'dsm-select',
1102
+ value: repeat,
1103
+ disabled: busy,
1104
+ 'aria-label': t('dialog.repeat'),
1105
+ onChange: (event) => setRepeat(event.target.value),
1106
+ },
1107
+ core.REPEAT_FREQUENCIES.map(frequency => h('option', { key: frequency, value: frequency }, t(`repeat.${frequency}`)))),
1108
+ h('p', { className: 'dsm-hint' }, repeatDetail(t, repeat, weekday)),
1109
+ repeat === 'monthly' && core.skipsShortMonths(dayOfMonth)
1110
+ ? h('p', { className: 'dsm-hint dsm-hint-warn' }, t('dialog.repeatShortMonths'))
1111
+ : null))
1112
+ : null,
1113
+
1114
+ error !== null
1115
+ ? h('p', { className: 'dsm-error', role: 'alert' }, errorText(t, error))
1116
+ : null,
1117
+
1118
+ h('footer', { className: 'dsm-foot' },
1119
+ h('button', { type: 'button', className: 'dsm-btn-ghost', disabled: busy, onClick: onClose }, t('dialog.cancel')),
1120
+ h('button', {
1121
+ type: 'button',
1122
+ className: 'dsm-btn dsm-btn-primary',
1123
+ disabled: busy || tooSoon,
1124
+ onClick: submit,
1125
+ }, busy ? h('span', { className: 'dsm-spinner', 'aria-hidden': 'true' }) : null, primaryLabel)))),
1126
+ document.body)
1127
+ }
1128
+
1129
+ function listQuickPicks(t, zone, now) {
1130
+ const picks = []
1131
+ const current = selectionOf(now + 60 * 60_000, zone)
1132
+ picks.push({ id: 'hour', label: 'quick.inHour', selection: current })
1133
+
1134
+ const today = core.localParts(now, zone).date
1135
+ const evening = core.wallClockToInstant({ date: today, hour24: 20, minute: 0 }, zone)
1136
+ if (evening.ok === true && evening.instantMs > now + core.MIN_LEAD_MS) {
1137
+ picks.push({ id: 'evening', label: 'quick.thisEvening', selection: selectionOf(evening.instantMs, zone) })
1138
+ }
1139
+
1140
+ const tomorrow = core.wallClockToInstant({ date: addDays(today, 1), hour24: 9, minute: 0 }, zone)
1141
+ if (tomorrow.ok === true) {
1142
+ picks.push({ id: 'tomorrow', label: 'quick.tomorrow', selection: selectionOf(tomorrow.instantMs, zone) })
1143
+ }
1144
+
1145
+ const isoWeekday = core.localParts(now, zone).isoWeekday
1146
+ const toMonday = ((8 - isoWeekday) % 7) || 7
1147
+ const monday = core.wallClockToInstant({ date: addDays(today, toMonday), hour24: 9, minute: 0 }, zone)
1148
+ if (monday.ok === true) {
1149
+ picks.push({ id: 'monday', label: 'quick.nextMonday', selection: selectionOf(monday.instantMs, zone) })
1150
+ }
1151
+ return picks
1152
+ }
1153
+
1154
+ function repeatDetail(t, repeat, weekdayKey) {
1155
+ if (repeat === 'weekly') return t('repeat.detail.weekly', { weekday: t(weekdayKey) })
1156
+ if (repeat === 'monthly') return t('repeat.detail.monthly')
1157
+ return t(`repeat.detail.${repeat}`)
1158
+ }
1159
+
1160
+ // ---------------------------------------------------------------- create plumbing
1161
+
1162
+ /**
1163
+ * Turn the current composer draft into a create request and send it.
1164
+ *
1165
+ * Attachment ids are serialized by the concrete conversation service, so an
1166
+ * unfinished upload is reported instead of scheduling a partial attachment.
1167
+ */
1168
+ async function createScheduledMessage(environment, plan) {
1169
+ const { t, sessionId, draft, attachmentIds, inputActions } = environment
1170
+ const zone = currentZone()
1171
+ let images = []
1172
+ const fileReceipts = []
1173
+ if (attachmentIds.length > 0) {
1174
+ const conversation = conversationOf(sessionId)
1175
+ if (conversation === undefined || typeof conversation.serializeDraftAttachments !== 'function') {
1176
+ throw Object.assign(new Error(t('error.attachmentsUnavailable')), { code: 'internal' })
1177
+ }
1178
+ let serialized
1179
+ try {
1180
+ serialized = await conversation.serializeDraftAttachments(attachmentIds)
1181
+ } catch {
1182
+ throw Object.assign(new Error(t('error.uploading')), { code: 'internal' })
1183
+ }
1184
+ for (const part of serialized.attachments) {
1185
+ if (part.type === 'image') {
1186
+ images.push({
1187
+ mediaType: part.mediaType,
1188
+ data: part.data,
1189
+ ...(typeof part.name === 'string' && part.name.length > 0 ? { name: part.name } : {}),
1190
+ })
1191
+ } else if (part.type === 'file') {
1192
+ fileReceipts.push(part.receiptId)
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ const value = await postJson(`${API}/create`, {
1198
+ sessionId,
1199
+ dueAt: new Date(plan.instantMs).toISOString(),
1200
+ timeZone: zone,
1201
+ text: draft,
1202
+ images,
1203
+ fileReceipts,
1204
+ repeat: plan.repeat,
1205
+ })
1206
+
1207
+ const locale = currentLocale()
1208
+ const when = core.describeDue(plan.instantMs, locale, zone)
1209
+ if (value.kind === 'automation') {
1210
+ notifySession(sessionId, 'info', t('notice.automationCreated', { time: when }))
1211
+ setToast(t('notice.automationCreated', { time: when }))
1212
+ openTasksPanel()
1213
+ } else {
1214
+ try {
1215
+ inputActions.setDraft('')
1216
+ for (const id of attachmentIds) inputActions.removeAttachment(id)
1217
+ } catch {
1218
+ // A composer that already settled its draft is not an error here.
1219
+ }
1220
+ notifySession(sessionId, 'info', t('notice.scheduled', { time: when }))
1221
+ setToast(t('notice.scheduled', { time: when }))
1222
+ }
1223
+ bumpRefresh()
1224
+ return value
1225
+ }
1226
+
1227
+ function conversationOf(sessionId) {
1228
+ try {
1229
+ const scope = services.sessions?.scope?.(sessionId)
1230
+ if (scope === undefined) return undefined
1231
+ return scope.get?.('conversation')
1232
+ } catch {
1233
+ return undefined
1234
+ }
1235
+ }
1236
+
1237
+ function notifySession(sessionId, level, text) {
1238
+ try {
1239
+ const scope = services.sessions?.scope?.(sessionId)
1240
+ if (scope === undefined) return
1241
+ const conversation = scope.get?.('conversation')
1242
+ const hub = conversation?.input
1243
+ if (hub !== undefined && typeof hub.for === 'function') hub.for(scope).notify(level, text)
1244
+ } catch {
1245
+ // Notices are advisory: the strip already reports the new record.
1246
+ }
1247
+ }
1248
+
1249
+ function openTasksPanel() {
1250
+ try {
1251
+ services.layout?.selectPanel?.(TASKS_PANEL)
1252
+ } catch {
1253
+ // A profile without the task page simply keeps the current panel.
1254
+ }
1255
+ }
1256
+
1257
+ // ---------------------------------------------------------------- button
1258
+
1259
+ /**
1260
+ * Orange Schedule control in the composer tool row.
1261
+ *
1262
+ * Enter, Shift+Enter, Ctrl/Cmd+Enter and the Send button keep their meaning:
1263
+ * this only stages the current draft for later delivery.
1264
+ */
1265
+ function ScheduleButton(props) {
1266
+ const { t, inputActions } = props
1267
+ const sessionId = props.dsmSessionId ?? props.sessionId ?? props.session?.sessionId
1268
+ // `conversation.input.right` is a session-scope list slot: the owner passes
1269
+ // no per-seat `input` (only `conversation.input.dock` gets the `InputZone`
1270
+ // owner values). Live draft state arrives through the standard
1271
+ // `useInput` selector hook that every session-scope entry receives.
1272
+ const useInputHook = typeof props.useInput === 'function' ? props.useInput : useNoopInput
1273
+ const input = useInputHook(selectInput)
1274
+ const draft = typeof input?.draft === 'string' ? input.draft : ''
1275
+ const attachmentIds = Array.isArray(input?.attachmentIds) ? input.attachmentIds : []
1276
+ const phase = typeof input?.phase === 'string' ? input.phase : 'plain'
1277
+ const openSeq = useOpenRequest(sessionId)
1278
+ const [open, setOpen] = useState(false)
1279
+ const zone = useMemo(currentZone, [])
1280
+
1281
+ useEffect(() => {
1282
+ if (openSeq > 0) setOpen(true)
1283
+ }, [openSeq])
1284
+
1285
+ const hasContent = draft.trim().length > 0 || attachmentIds.length > 0
1286
+ const disabled = !hasContent || phase !== 'plain'
1287
+
1288
+ useEffect(() => {
1289
+ if (sessionId === undefined) return undefined
1290
+ composers.set(sessionId, {
1291
+ sessionId,
1292
+ draft,
1293
+ attachments: attachmentIds.length,
1294
+ phase,
1295
+ open: () => setOpen(true),
1296
+ })
1297
+ if (hasContent) preferComposer(sessionId)
1298
+ return () => {
1299
+ if (composers.get(sessionId)?.sessionId === sessionId) composers.delete(sessionId)
1300
+ }
1301
+ }, [sessionId, draft, attachmentIds.length, phase, hasContent])
1302
+
1303
+ if (sessionId === undefined) return null
1304
+
1305
+ const environment = { t, sessionId, draft, attachmentIds, inputActions }
1306
+
1307
+ return h(React.Fragment, null,
1308
+ h('button', {
1309
+ type: 'button',
1310
+ className: 'dsm-btn dsm-btn-schedule',
1311
+ disabled,
1312
+ 'aria-haspopup': 'dialog',
1313
+ 'aria-label': t('button.label'),
1314
+ title: disabled ? (phase !== 'plain' ? t('button.busyHint') : t('button.emptyHint')) : t('button.label'),
1315
+ onMouseDown: (event) => event.preventDefault(),
1316
+ onClick: () => setOpen(true),
1317
+ },
1318
+ h(ClockSendIcon),
1319
+ h('span', { className: 'dsm-btn-wide' }, t('button.label')),
1320
+ h('span', { className: 'dsm-btn-short' }, t('button.shortLabel'))),
1321
+
1322
+ open
1323
+ ? h(ScheduleDialog, {
1324
+ t,
1325
+ zone,
1326
+ mode: 'create',
1327
+ initial: defaultSelection(zone),
1328
+ repeatLocked: attachmentIds.length > 0,
1329
+ previewText: draft,
1330
+ attachmentCount: attachmentIds.length,
1331
+ onClose: () => setOpen(false),
1332
+ onSubmit: async (plan) => {
1333
+ await createScheduledMessage(environment, plan)
1334
+ setOpen(false)
1335
+ },
1336
+ })
1337
+ : null)
1338
+ }
1339
+
1340
+ // ---------------------------------------------------------------- dock
1341
+
1342
+ /** The composer strip that lists and manages this session's scheduled messages. */
1343
+ function ScheduleDock(props) {
1344
+ const { t } = props
1345
+ const sessionId = props.dsmSessionId ?? props.sessionId ?? props.session?.sessionId
1346
+ const refresh = useRefreshSignal()
1347
+ const zone = useMemo(currentZone, [])
1348
+ const locale = useMemo(currentLocale, [])
1349
+ const [items, setItems] = useState([])
1350
+ const [failure, setFailure] = useState(null)
1351
+ const [expanded, setExpanded] = useState(false)
1352
+ const [busyId, setBusyId] = useState(null)
1353
+ const [confirmId, setConfirmId] = useState(null)
1354
+ const [editing, setEditing] = useState(null)
1355
+
1356
+ useEffect(() => {
1357
+ if (sessionId === undefined) return undefined
1358
+ let cancelled = false
1359
+ listScheduled(sessionId).then((value) => {
1360
+ if (cancelled) return
1361
+ setItems(Array.isArray(value.items) ? value.items : [])
1362
+ setFailure(null)
1363
+ }, (error) => {
1364
+ if (cancelled) return
1365
+ setFailure(error)
1366
+ })
1367
+ return () => {
1368
+ cancelled = true
1369
+ }
1370
+ }, [sessionId, refresh])
1371
+
1372
+ useEffect(() => {
1373
+ const onFocus = () => bumpRefresh()
1374
+ window.addEventListener('focus', onFocus)
1375
+ return () => window.removeEventListener('focus', onFocus)
1376
+ }, [])
1377
+
1378
+ const nextDueMs = useMemo(() => {
1379
+ const pending = items.filter(item => item.status === 'pending')
1380
+ if (pending.length === 0) return null
1381
+ return Math.min(...pending.map(item => Date.parse(item.dueAt)))
1382
+ }, [items])
1383
+
1384
+ useEffect(() => {
1385
+ if (nextDueMs === null || !Number.isFinite(nextDueMs)) return undefined
1386
+ const wait = Math.max(nextDueMs - Date.now() + 2000, 1000)
1387
+ const timer = setTimeout(() => bumpRefresh(), Math.min(wait, 2_147_483_647))
1388
+ return () => clearTimeout(timer)
1389
+ }, [nextDueMs])
1390
+
1391
+ const act = useCallback(async (id, path, payload) => {
1392
+ setBusyId(id)
1393
+ try {
1394
+ await postJson(`${API}/${path}`, { id, ...payload })
1395
+ bumpRefresh()
1396
+ } catch (error) {
1397
+ setFailure(error)
1398
+ } finally {
1399
+ setBusyId(null)
1400
+ setConfirmId(null)
1401
+ }
1402
+ }, [])
1403
+
1404
+ if (sessionId === undefined) return null
1405
+
1406
+ const pending = items.filter(item => item.status === 'pending' || item.status === 'sending')
1407
+ const failed = items.filter(item => item.status === 'failed')
1408
+ const sent = items.filter(item => item.status === 'sent')
1409
+ const visible = items.length > 0
1410
+ if (!visible && toast === null) return null
1411
+
1412
+ const head = pending.length > 0
1413
+ ? t('dock.collapsed', {
1414
+ count: pending.length,
1415
+ next: nextDueMs === null ? '' : core.describeDue(nextDueMs, locale, zone),
1416
+ })
1417
+ : t('dock.collapsedEmpty')
1418
+
1419
+ return h('div', { className: 'dsm-dock' },
1420
+ toast !== null ? h('p', { className: 'dsm-toast', role: 'status' }, toast) : null,
1421
+
1422
+ visible
1423
+ ? h('button', {
1424
+ type: 'button',
1425
+ className: 'dsm-dock-head',
1426
+ 'aria-expanded': expanded,
1427
+ onClick: () => setExpanded(current => !current),
1428
+ }, h(BellIcon), h('span', null, head), h('span', { className: 'dsm-dock-caret', 'aria-hidden': 'true' }, expanded ? '▾' : '▸'))
1429
+ : null,
1430
+
1431
+ expanded
1432
+ ? h('ul', { className: 'dsm-dock-list' },
1433
+ pending.map(item => h(DockRow, {
1434
+ key: item.id,
1435
+ t,
1436
+ locale,
1437
+ zone,
1438
+ item,
1439
+ busy: busyId === item.id,
1440
+ confirming: confirmId === item.id,
1441
+ onSendNow: () => act(item.id, 'send-now', {}),
1442
+ onAskCancel: () => setConfirmId(item.id),
1443
+ onCancelDismiss: () => setConfirmId(null),
1444
+ onCancel: () => act(item.id, 'cancel', {}),
1445
+ onChangeTime: () => setEditing(item),
1446
+ })),
1447
+ failed.map(item => h(DockRow, {
1448
+ key: item.id,
1449
+ t,
1450
+ locale,
1451
+ zone,
1452
+ item,
1453
+ busy: busyId === item.id,
1454
+ confirming: confirmId === item.id,
1455
+ onRetry: () => act(item.id, 'retry', {}),
1456
+ onAskCancel: () => setConfirmId(item.id),
1457
+ onCancelDismiss: () => setConfirmId(null),
1458
+ onCancel: () => act(item.id, 'cancel', {}),
1459
+ })),
1460
+ sent.map(item => h(DockRow, { key: item.id, t, locale, zone, item, busy: false, confirming: false })))
1461
+ : null,
1462
+
1463
+ failure !== null
1464
+ ? h('p', { className: 'dsm-error dsm-dock-error', role: 'alert' }, errorText(t, failure))
1465
+ : null,
1466
+
1467
+ editing !== null
1468
+ ? h(ScheduleDialog, {
1469
+ t,
1470
+ zone,
1471
+ mode: 'reschedule',
1472
+ initial: Number.isFinite(Date.parse(editing.dueAt))
1473
+ ? selectionOf(Date.parse(editing.dueAt), zone)
1474
+ : defaultSelection(zone),
1475
+ repeatLocked: true,
1476
+ previewText: '',
1477
+ attachmentCount: 0,
1478
+ onClose: () => setEditing(null),
1479
+ onSubmit: async (plan) => {
1480
+ setBusyId(editing.id)
1481
+ try {
1482
+ await postJson(`${API}/reschedule`, { id: editing.id, dueAt: new Date(plan.instantMs).toISOString() })
1483
+ setToast(t('notice.rescheduled', { time: core.describeDue(plan.instantMs, locale, zone) }))
1484
+ bumpRefresh()
1485
+ setEditing(null)
1486
+ } finally {
1487
+ setBusyId(null)
1488
+ }
1489
+ },
1490
+ })
1491
+ : null)
1492
+ }
1493
+
1494
+ /** One scheduled-message row with its actions. */
1495
+ function DockRow(props) {
1496
+ const { t, locale, zone, item, busy, confirming, onSendNow, onRetry, onAskCancel, onCancel, onCancelDismiss, onChangeTime } = props
1497
+ const dueMs = Date.parse(item.dueAt)
1498
+ const time = Number.isFinite(dueMs) ? core.describeDue(dueMs, locale, zone) : item.dueAt
1499
+ const preview = firstLine(item.text, 1) || t('dock.noText')
1500
+ const meta = attachmentSummary(t, item.imageCount, item.fileCount)
1501
+
1502
+ if (item.status === 'sent') {
1503
+ return h('li', { className: 'dsm-dock-row dsm-dock-row-sent' },
1504
+ h('span', { className: 'dsm-dock-tick', 'aria-hidden': 'true' }, '✓'),
1505
+ h('span', { className: 'dsm-dock-text' }, preview),
1506
+ h('span', { className: 'dsm-dock-time' }, item.late === true ? t('dock.sentLate', { time }) : t('dock.sent', { time })))
1507
+ }
1508
+
1509
+ if (item.status === 'failed') {
1510
+ return h('li', { className: 'dsm-dock-row dsm-dock-row-failed' },
1511
+ h('span', { className: 'dsm-dock-text' }, preview),
1512
+ h('span', { className: 'dsm-dock-time' }, t('dock.failed', { time })),
1513
+ item.error === undefined ? null : h('p', { className: 'dsm-dock-error-text' }, item.error),
1514
+ h('div', { className: 'dsm-dock-actions' },
1515
+ h('button', { type: 'button', className: 'dsm-link', disabled: busy, onClick: onRetry }, t('dock.retry')),
1516
+ confirming
1517
+ ? h(React.Fragment, null,
1518
+ h('button', { type: 'button', className: 'dsm-link dsm-link-danger', disabled: busy, onClick: onCancel }, t('dock.confirmCancel')),
1519
+ h('button', { type: 'button', className: 'dsm-link', disabled: busy, onClick: onCancelDismiss }, t('dock.keep')))
1520
+ : h('button', { type: 'button', className: 'dsm-link', disabled: busy, onClick: onAskCancel }, t('dock.cancel'))))
1521
+ }
1522
+
1523
+ return h('li', { className: 'dsm-dock-row' },
1524
+ h('span', { className: 'dsm-dock-text' }, preview),
1525
+ h('span', { className: 'dsm-dock-time' }, item.status === 'sending' ? t('dock.sending') : time),
1526
+ meta.length > 0 ? h('span', { className: 'dsm-dock-meta' }, meta) : null,
1527
+ h('div', { className: 'dsm-dock-actions' },
1528
+ h('button', { type: 'button', className: 'dsm-link', disabled: busy, onClick: onSendNow }, t('dock.sendNow')),
1529
+ h('button', { type: 'button', className: 'dsm-link', disabled: busy, onClick: onChangeTime }, t('dock.changeTime')),
1530
+ confirming
1531
+ ? h(React.Fragment, null,
1532
+ h('button', { type: 'button', className: 'dsm-link dsm-link-danger', disabled: busy, onClick: onCancel }, t('dock.confirmCancel')),
1533
+ h('button', { type: 'button', className: 'dsm-link', disabled: busy, onClick: onCancelDismiss }, t('dock.keep')))
1534
+ : h('button', { type: 'button', className: 'dsm-link', disabled: busy, onClick: onAskCancel }, t('dock.cancel'))))
1535
+ }
1536
+
1537
+ // ---------------------------------------------------------------- styles
1538
+
1539
+ function stylesheet() {
1540
+ if (typeof document === 'undefined') return () => undefined
1541
+ const existing = document.getElementById(STYLE_ID)
1542
+ if (existing !== null) return () => undefined
1543
+ const style = document.createElement('style')
1544
+ style.id = STYLE_ID
1545
+ style.textContent = CSS
1546
+ document.head.appendChild(style)
1547
+ return () => style.remove()
1548
+ }
1549
+
1550
+ const CSS = `
1551
+ .dsm-btn{display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px;border:none;border-radius:999px;
1552
+ font:inherit;font-size:12px;font-weight:600;line-height:1;cursor:pointer;white-space:nowrap;
1553
+ background:${ORANGE};color:#fff;transition:background .15s ease,opacity .15s ease}
1554
+ .dsm-btn:hover:not([disabled]){background:${ORANGE_HOVER}}
1555
+ .dsm-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#3b82f6);outline-offset:2px}
1556
+ .dsm-btn[disabled]{opacity:.45;cursor:not-allowed}
1557
+ .dsm-btn-short{display:none}
1558
+ @media (max-width:380px){.dsm-btn-wide{display:none}.dsm-btn-short{display:inline}}
1559
+ .dsm-btn-ghost{height:32px;padding:0 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1,#d4d4d8);
1560
+ background:transparent;color:var(--dsw-alias-label-primary,#18181b);font:inherit;font-size:13px;cursor:pointer}
1561
+ .dsm-btn-ghost:hover:not([disabled]){background:var(--dsw-alias-bg-layer-2,#f4f4f5)}
1562
+ .dsm-btn-primary{height:32px;padding:0 14px;font-size:13px;border-radius:8px}
1563
+ .dsm-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;border-radius:6px;
1564
+ background:transparent;color:var(--dsw-alias-label-secondary,#52525b);cursor:pointer}
1565
+ .dsm-icon-btn:hover{background:var(--dsw-alias-bg-layer-2,#f4f4f5)}
1566
+
1567
+ .dsm-backdrop{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;
1568
+ background:rgba(9,9,11,.45);padding:16px}
1569
+ .dsm-dialog{width:min(420px,100%);max-height:calc(100vh - 32px);overflow:auto;display:flex;flex-direction:column;gap:12px;
1570
+ padding:16px;border-radius:16px;border:1px solid var(--dsw-alias-border-l1,#e4e4e7);
1571
+ background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#18181b);
1572
+ box-shadow:0 24px 64px rgba(9,9,11,.28);font-size:13px}
1573
+ .dsm-dialog-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
1574
+ .dsm-dialog-title{margin:0;font-size:15px;font-weight:650}
1575
+ .dsm-preview{display:flex;flex-direction:column;gap:4px;padding:10px;border-radius:10px;
1576
+ background:var(--dsw-alias-bg-layer-1,#fafafa);border:1px solid var(--dsw-alias-border-l1,#f0f0f1)}
1577
+ .dsm-preview-text{margin:0;font-size:12px;line-height:1.45;color:var(--dsw-alias-label-primary,#18181b);
1578
+ white-space:pre-wrap;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
1579
+ .dsm-preview-meta{margin:0;font-size:11px;color:var(--dsw-alias-label-secondary,#71717a)}
1580
+ .dsm-quick{display:flex;flex-wrap:wrap;gap:6px}
1581
+ .dsm-chip{height:26px;padding:0 10px;border-radius:999px;cursor:pointer;font:inherit;font-size:11.5px;
1582
+ border:1px solid var(--dsw-alias-border-l1,#e4e4e7);background:var(--dsw-alias-bg-layer-2,#f4f4f5);
1583
+ color:var(--dsw-alias-label-primary,#18181b)}
1584
+ .dsm-chip:hover:not([disabled]){border-color:${ORANGE};color:${ORANGE_HOVER}}
1585
+ .dsm-fields{display:grid;grid-template-columns:1.6fr .8fr .8fr auto;gap:8px;align-items:end}
1586
+ .dsm-field{display:flex;flex-direction:column;gap:4px;min-width:0}
1587
+ .dsm-field-meridiem{flex-direction:row;align-items:center;gap:4px}
1588
+ .dsm-label{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#71717a)}
1589
+ .dsm-input,.dsm-select{height:34px;min-width:0;padding:0 8px;border-radius:8px;font:inherit;font-size:13px;
1590
+ border:1px solid var(--dsw-alias-border-l1,#e4e4e7);background:var(--dsw-alias-bg-base,#fff);
1591
+ color:var(--dsw-alias-label-primary,#18181b)}
1592
+ .dsm-input:focus-visible,.dsm-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#3b82f6);outline-offset:1px}
1593
+ .dsm-seg{height:34px;min-width:38px;padding:0 8px;border-radius:8px;font:inherit;font-size:12px;font-weight:600;cursor:pointer;
1594
+ border:1px solid var(--dsw-alias-border-l1,#e4e4e7);background:var(--dsw-alias-bg-base,#fff);
1595
+ color:var(--dsw-alias-label-secondary,#71717a)}
1596
+ .dsm-seg-on{border-color:${ORANGE};color:#fff;background:${ORANGE}}
1597
+ .dsm-summary{margin:0;font-size:12px;line-height:1.45;color:var(--dsw-alias-label-secondary,#52525b)}
1598
+ .dsm-summary-bad{color:var(--dsw-alias-state-error-primary,#dc2626)}
1599
+ .dsm-repeat{display:flex;flex-direction:column;gap:8px;padding-top:4px;border-top:1px solid var(--dsw-alias-border-l1,#f0f0f1)}
1600
+ .dsm-check{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:600;cursor:pointer;min-height:32px}
1601
+ .dsm-check-off{opacity:.55;cursor:not-allowed}
1602
+ .dsm-repeat-body{display:flex;flex-direction:column;gap:6px}
1603
+ .dsm-hint{margin:0;font-size:11.5px;line-height:1.45;color:var(--dsw-alias-label-secondary,#71717a)}
1604
+ .dsm-hint-warn{color:var(--dsw-alias-state-warn-primary,#b45309)}
1605
+ .dsm-error{margin:0;font-size:12px;line-height:1.45;color:var(--dsw-alias-state-error-primary,#dc2626)}
1606
+ .dsm-foot{display:flex;justify-content:flex-end;gap:8px;padding-top:4px}
1607
+ .dsm-spinner{width:12px;height:12px;margin-right:6px;border-radius:50%;border:2px solid rgba(255,255,255,.45);
1608
+ border-top-color:#fff;animation:dsm-spin .7s linear infinite}
1609
+ @keyframes dsm-spin{to{transform:rotate(360deg)}}
1610
+
1611
+ .dsm-dock{display:flex;flex-direction:column;gap:4px;margin:0 0 6px;font-size:12px}
1612
+ .dsm-toast{margin:0 0 2px;padding:6px 10px;border-radius:8px;font-size:12px;
1613
+ background:var(--dsw-alias-bg-layer-2,#f4f4f5);color:var(--dsw-alias-label-primary,#18181b);
1614
+ border:1px solid var(--dsw-alias-border-l1,#e4e4e7)}
1615
+ .dsm-dock-head{display:inline-flex;align-items:center;gap:6px;min-height:26px;padding:2px 8px;border-radius:8px;
1616
+ border:1px dashed var(--dsw-alias-border-l1,#e4e4e7);background:transparent;cursor:pointer;
1617
+ color:var(--dsw-alias-label-secondary,#52525b);font:inherit;font-size:11.5px;text-align:left}
1618
+ .dsm-dock-head:hover{background:var(--dsw-alias-bg-layer-1,#fafafa)}
1619
+ .dsm-dock-caret{margin-left:auto;opacity:.7}
1620
+ .dsm-dock-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
1621
+ .dsm-dock-row{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px;padding:5px 8px;border-radius:8px;
1622
+ background:var(--dsw-alias-bg-layer-1,#fafafa);border:1px solid var(--dsw-alias-border-l1,#f0f0f1)}
1623
+ .dsm-dock-row-failed{border-color:var(--dsw-alias-state-error-primary,#dc2626)}
1624
+ .dsm-dock-row-sent{opacity:.6}
1625
+ .dsm-dock-text{flex:1 1 120px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
1626
+ color:var(--dsw-alias-label-primary,#18181b)}
1627
+ .dsm-dock-time{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary,#71717a);font-size:11px}
1628
+ .dsm-dock-meta{font-size:11px;color:var(--dsw-alias-label-secondary,#71717a)}
1629
+ .dsm-dock-tick{color:var(--dsw-alias-state-success-primary,#16a34a)}
1630
+ .dsm-dock-error-text{flex:1 1 100%;margin:0;font-size:11px;color:var(--dsw-alias-state-error-primary,#dc2626)}
1631
+ .dsm-dock-actions{display:flex;gap:6px;margin-left:auto}
1632
+ .dsm-dock-error{margin:2px 0 0}
1633
+ .dsm-link{height:22px;padding:0 4px;border:none;background:transparent;cursor:pointer;font:inherit;font-size:11.5px;
1634
+ color:var(--dsw-alias-brand-primary,#2563eb);border-radius:4px}
1635
+ .dsm-link:hover:not([disabled]){text-decoration:underline}
1636
+ .dsm-link-danger{color:var(--dsw-alias-state-error-primary,#dc2626)}
1637
+ .dsm-link[disabled]{opacity:.5;cursor:not-allowed}
1638
+
1639
+ @media (max-width:640px){
1640
+ .dsm-backdrop{align-items:flex-end;padding:0}
1641
+ .dsm-dialog{width:100%;max-height:88vh;border-radius:18px 18px 0 0;border-bottom:none;
1642
+ padding:16px 16px max(16px,env(safe-area-inset-bottom)) 16px;
1643
+ animation:dsm-rise .18s ease-out}
1644
+ .dsm-fields{grid-template-columns:1fr 1fr;gap:10px}
1645
+ .dsm-field-date{grid-column:1 / -1}
1646
+ .dsm-field-meridiem{grid-column:1 / -1;justify-content:flex-start}
1647
+ .dsm-btn,.dsm-btn-ghost,.dsm-btn-primary,.dsm-chip,.dsm-link{min-height:44px}
1648
+ .dsm-input,.dsm-select,.dsm-seg{height:44px;font-size:16px}
1649
+ .dsm-dialog{gap:14px}
1650
+ }
1651
+ @keyframes dsm-rise{from{transform:translateY(12px);opacity:.6}to{transform:none;opacity:1}}
1652
+ @media (prefers-reduced-motion:reduce){
1653
+ .dsm-dialog{animation:none}
1654
+ .dsm-btn{transition:none}
1655
+ .dsm-spinner{animation-duration:2s}
1656
+ }
1657
+ `
1658
+
1659
+ // ---------------------------------------------------------------- apply
1660
+
1661
+ /**
1662
+ * Register the button, the strip, the shortcut and both dictionaries.
1663
+ * @param ctx - browser plugin context.
1664
+ */
1665
+ function apply(ctx) {
1666
+ ctx.effect(() => ctx.locale.register(NS, { en: EN, zh: ZH }), 'dsh-scheduled-message: dictionaries')
1667
+ const t = ctx.locale.bind(NS)
1668
+ ctx.effect(() => stylesheet(), 'dsh-scheduled-message: styles')
1669
+
1670
+ ctx.slots.inject('conversation.input.right', () => ctx.slots.register({
1671
+ name: 'conversation.input.right',
1672
+ id: 'dsm-schedule',
1673
+ order: 900,
1674
+ locale: NS,
1675
+ inject: sessionId => ({ dsmSessionId: sessionId }),
1676
+ }, ScheduleButton))
1677
+
1678
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
1679
+ name: 'conversation.input.dock',
1680
+ id: 'dsm-scheduled',
1681
+ order: 30,
1682
+ locale: NS,
1683
+ inject: sessionId => ({ dsmSessionId: sessionId }),
1684
+ }, ScheduleDock))
1685
+
1686
+ // Optional services: each scope runs only when the profile provides it.
1687
+ ctx.inject(['sessions'], (scope) => {
1688
+ services.sessions = scope.sessions
1689
+ return () => {
1690
+ services.sessions = undefined
1691
+ }
1692
+ })
1693
+
1694
+ ctx.inject(['layout'], (scope) => {
1695
+ services.layout = scope.layout
1696
+ return () => {
1697
+ services.layout = undefined
1698
+ }
1699
+ })
1700
+
1701
+ ctx.inject(['shortcuts'], (scope) => {
1702
+ scope.effect(() => scope.shortcuts.register({
1703
+ id: SHORTCUT_ID,
1704
+ label: () => t('shortcut.label'),
1705
+ aliases: ['schedule', 'later', 'deliver'],
1706
+ defaults: Object.fromEntries(SHORTCUT_PROFILES.map(profile => [profile, { code: 'Enter', modifiers: ['alt'] }])),
1707
+ regions: ['editable'],
1708
+ modals: [],
1709
+ resolve(context) {
1710
+ if (context.region !== 'editable') return { status: 'pass' }
1711
+ const composer = pickComposer()
1712
+ if (composer === null) return { status: 'blocked', reason: t('notice.noComposer') }
1713
+ if (composer.phase !== 'plain') return { status: 'blocked', reason: t('notice.busy') }
1714
+ if (composer.draft.trim().length === 0 && composer.attachments === 0) {
1715
+ return { status: 'blocked', reason: t('notice.typeFirst') }
1716
+ }
1717
+ return {
1718
+ status: 'handled',
1719
+ run() {
1720
+ preferComposer(composer.sessionId)
1721
+ composer.open()
1722
+ },
1723
+ }
1724
+ },
1725
+ }), 'dsh-scheduled-message: schedule shortcut')
1726
+ })
1727
+ }
1728
+
1729
+ return {
1730
+ name: NS,
1731
+ inject: ['slots', 'locale'],
1732
+ apply,
1733
+ }
1734
+ }
1735
+
1736
+
1737
+ return createClientPlugin(require)
1738
+ },
1739
+ })