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/LICENSE +21 -0
- package/README.md +113 -0
- package/client.js +1739 -0
- package/cordis.patch.yml +3 -0
- package/docs/DETAIL.md +440 -0
- package/docs/preview.png +0 -0
- package/icon.svg +13 -0
- package/index.js +694 -0
- package/lib/core.js +466 -0
- package/locale/en.json +91 -0
- package/locale/zh.json +91 -0
- package/package.json +65 -0
- package/scripts/build-client.mjs +99 -0
- package/scripts/check.mjs +101 -0
- package/scripts/host-smoke.mjs +433 -0
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
|
+
})
|