domma-cms 0.116.0 → 0.116.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,19 +6,33 @@
6
6
  * that is restarting only delays it ("waiting to send"); opening this screen
7
7
  * tries again. A standalone site has the contact card and nothing else - it has
8
8
  * nowhere to send a conversation, so it says to email instead.
9
+ *
10
+ * Right-click a request (or its ⋮, or Shift+F10) to reply, mark it resolved or
11
+ * read, archive it or remove it from this site's list. Archive and remove are
12
+ * this site's own tidying: Domma keeps the conversation, and a new reply from
13
+ * Domma brings it straight back. Removing one that never reached Domma cancels it.
9
14
  */
10
15
 
11
- import {TONE, api, esc, listOf, openPanel, refreshBadge, stamp, telHref, threadHtml, when} from '../lib/kit.js';
16
+ import {THREAD, TONE, api, copyText, esc, listOf, openPanel, refreshBadge, rowKeys, stamp, telHref, threadRows, when} from '../lib/kit.js';
12
17
 
13
18
  const ROW = `
14
- <div class="sp-row" data-id="{{key}}" tabindex="0" role="button">
19
+ <div class="sp-row" data-id="{{key}}" tabindex="0" role="button" aria-haspopup="menu">
15
20
  <span class="sp-cat" data-bind-title="categoryLabel"><span data-icon="{{icon}}" data-icon-size="16"></span></span>
16
21
  <span class="sp-main"><strong data-bind-text="subject"></strong><small data-bind-text="meta"></small></span>
17
22
  <span class="sp-urgent" data-bind-hidden="!urgent">Urgent</span>
18
23
  <span class="sp-dot" data-bind-hidden="!unread" title="New reply from Domma"></span>
19
24
  <span class="sp-chip" data-bind-class="tone" data-bind-text="statusLabel"></span>
25
+ <button type="button" class="sp-kebab" aria-label="Actions" title="Actions">&#8942;</button>
20
26
  </div>`;
21
27
 
28
+ const FILTERS = [
29
+ {key: 'active', label: 'Requests'},
30
+ {key: 'archived', label: 'Archived'}
31
+ ];
32
+
33
+ /** The newest Domma message's time - what "read" is measured against. */
34
+ const lastDomma = (t) => [...(t?.messages || [])].reverse().find(m => m.from === 'domma')?.at || '';
35
+
22
36
  export async function mount(root, ctx) {
23
37
  const {kit, scope, here, meta, id} = ctx;
24
38
  const categories = meta.categories || [];
@@ -33,9 +47,13 @@ export async function mount(root, ctx) {
33
47
  const loading = M.observable(Boolean(ctx.mode.connected));
34
48
  const reachable = M.observable(true);
35
49
  const error = M.observable('');
50
+ const filter = M.observable('active');
36
51
  const rows = M.observable([]);
52
+ const counts = M.observable({});
37
53
  scope(M.effect(() => {
38
- rows.value = tickets.value.map(t => ({
54
+ const all = tickets.value;
55
+ counts.value = {active: all.filter(t => !t.archived).length, archived: all.filter(t => t.archived).length};
56
+ rows.value = all.filter(t => (filter.value === 'archived') === Boolean(t.archived)).map(t => ({
39
57
  key: t.ref,
40
58
  subject: t.subject,
41
59
  icon: iconOf(t.category),
@@ -43,10 +61,15 @@ export async function mount(root, ctx) {
43
61
  meta: [when(t.lastAt || t.createdAt), t.userName, plural((t.messages || []).length, 'message')].filter(Boolean).join(' · '),
44
62
  statusLabel: t.statusLabel || t.status,
45
63
  tone: `is-${TONE[t.status] || 'muted'}`,
46
- urgent: t.priority === 'urgent',
64
+ urgent: t.priority === 'urgent' && t.status !== 'closed',
47
65
  unread: Boolean(t.unread)
48
66
  }));
49
67
  }));
68
+ // Nothing archived any more: back to the list, rather than an empty tab with no way to see it.
69
+ scope(M.effect(() => { if (filter.value === 'archived' && !counts.value.archived) filter.value = 'active'; }));
70
+ const hasArchive = M.computed(() => Boolean(counts.value.archived) || filter.value === 'archived');
71
+ const emptyText = M.computed(() => (filter.value === 'archived' ? 'Nothing archived.'
72
+ : 'No requests yet. Stuck on something, or something not working? Ask for help - a person reads every one.'));
50
73
  const waitingCount = M.computed(() => tickets.value.filter(t => t.status === 'waiting'
51
74
  || (t.messages || []).some(m => m.pending)).length);
52
75
 
@@ -83,7 +106,11 @@ export async function mount(root, ctx) {
83
106
  <section class="sp-card" data-bind-hidden="!connected.value">
84
107
  <div class="sp-bar">
85
108
  <div class="sp-intro"><strong>Your requests</strong><span>Every conversation this site has had with Domma Support, whoever started it.</span></div>
86
- <span data-help-title="Your requests" data-help="A request is a conversation with the Domma team. It shows Waiting on Domma until we answer, Domma replied when it is your turn, and Resolved when either of us marks it done - writing again reopens it. Replies also arrive by email, and the sidebar badge counts the ones you have not opened."></span>
109
+ <span class="sp-spacer"></span>
110
+ <div class="sp-seg" role="tablist" aria-label="Show" data-bind-hidden="!hasArchive.value">
111
+ ${FILTERS.map(f => `<button type="button" role="tab" data-filter="${f.key}" data-bind-class="filter.value === '${f.key}' && 'is-on'">${esc(f.label)} <b data-bind-text="counts.value['${f.key}'] || 0"></b></button>`).join('')}
112
+ </div>
113
+ <span data-help-title="Your requests" data-help="A request is a conversation with the Domma team. It shows Waiting on Domma until we answer, Domma replied when it is your turn, and Resolved when either of us marks it done - writing again reopens it. Right-click a request (or use its ⋮ button, or Shift+F10) to archive it or remove it from this list - Domma keeps its copy either way, and a new reply from Domma brings it back. Replies also arrive by email, and the sidebar badge counts the ones you have not opened."></span>
87
114
  </div>
88
115
  <p class="sp-note is-warn" data-bind-hidden="!waitingCount.value">
89
116
  <span data-icon="clock" data-icon-size="14"></span> Something is waiting to send - it goes as soon as the Domma server answers.
@@ -92,7 +119,7 @@ export async function mount(root, ctx) {
92
119
  <p class="sp-note is-warn" data-bind-hidden="reachable.value || loading.value">Domma could not be reached just now, so what you see may be out of date.</p>
93
120
  <div class="sp-loading dm-loading-slot" data-bind-hidden="!loading.value">Loading...</div>
94
121
  <p class="sp-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
95
- <p class="sp-note" data-bind-hidden="loading.value || rows.value.length">No requests yet. Stuck on something, or something not working? Ask for help - a person reads every one.</p>
122
+ <p class="sp-note" data-bind-hidden="loading.value || rows.value.length" data-bind-text="emptyText.value"></p>
96
123
  <div class="sp-list" data-each="rows.value key=key"></div>
97
124
  </section>`;
98
125
  root.appendChild(view);
@@ -128,6 +155,60 @@ export async function mount(root, ctx) {
128
155
  }
129
156
 
130
157
  const byRef = (ref) => tickets.peek().find(t => t.ref === ref);
158
+ const patchLocal = (ref, change) => { tickets.value = tickets.peek().map(x => (x.ref === ref ? {...x, ...change} : x)); };
159
+ const fail = (err) => E.toast(err.message || String(err), {type: 'error'});
160
+
161
+ // ---- The moves: one definition each, for the row menu and the panel alike ----
162
+
163
+ const actions = {
164
+ async seen(t) {
165
+ const at = lastDomma(t);
166
+ if (!t.unread || !at) return;
167
+ await api.seen(t.ref, at);
168
+ patchLocal(t.ref, {unread: false});
169
+ refreshBadge();
170
+ },
171
+ async resolve(t) {
172
+ const r = await api.close(t.ref);
173
+ E.toast(r.message || 'Marked as resolved.', {type: r.state === 'sent' ? 'success' : 'warning'});
174
+ await load();
175
+ },
176
+ async archive(t, on) {
177
+ await api.shelf(t.ref, on ? 'archived' : null, t.stale ? undefined : lastDomma(t));
178
+ patchLocal(t.ref, {archived: on, ...(on && {unread: false})});
179
+ refreshBadge();
180
+ E.toast(on ? `Archived "${t.subject}". A new reply from Domma brings it back.` : `"${t.subject}" is back in your requests.`, {type: 'success'});
181
+ },
182
+ async remove(t) {
183
+ const local = t.status === 'waiting' || t.status === 'refused';
184
+ const ask = local
185
+ ? `Remove "${esc(t.subject)}"? If it has not gone to Domma yet, it will not be sent.`
186
+ : `Remove "${esc(t.subject)}" from this site's list? Domma keeps the conversation, and it comes back here if Domma replies again.`;
187
+ if (!await E.confirm(ask)) return false;
188
+ const r = await api.shelf(t.ref, 'removed', t.stale ? undefined : lastDomma(t));
189
+ tickets.value = tickets.peek().filter(x => x.ref !== t.ref);
190
+ refreshBadge();
191
+ E.toast(r.state === 'discarded' ? 'Removed - it will not be sent.'
192
+ : local ? 'Removed from this list - it had already started on its way, so Domma has it.' : 'Removed from this list.', {type: 'success'});
193
+ return true;
194
+ }
195
+ };
196
+ const act = (name, ...args) => async (t) => { try { return await actions[name](t, ...args); } catch (err) { fail(err); return false; } };
197
+
198
+ function menuItems(t) {
199
+ const local = t.status === 'waiting' || t.status === 'refused';
200
+ return [
201
+ {label: local ? 'Open' : 'Open and reply', icon: 'message-square', action: () => open(t.ref)},
202
+ ...(t.unread ? [{label: 'Mark as read', icon: 'check', action: () => act('seen')(t)}] : []),
203
+ ...(!local && t.status !== 'closed' && !t.stale ? [{label: 'Mark resolved', icon: 'check-circle', action: () => act('resolve')(t)}] : []),
204
+ {type: 'divider'},
205
+ t.archived ? {label: 'Back to your requests', icon: 'inbox', action: () => act('archive', false)(t)}
206
+ : {label: 'Archive', icon: 'archive', action: () => act('archive', true)(t)},
207
+ {label: 'Copy reference', icon: 'copy', action: () => copyText(t.ref, 'Reference copied')},
208
+ {type: 'divider'},
209
+ {label: local ? 'Remove (do not send)' : 'Remove from this list', icon: 'trash', danger: true, action: () => act('remove')(t)}
210
+ ];
211
+ }
131
212
 
132
213
  function compose() {
133
214
  const f = {category: M.observable('help'), priority: M.observable('normal'), subject: M.observable(''), body: M.observable(''),
@@ -138,16 +219,16 @@ export async function mount(root, ctx) {
138
219
  model: f,
139
220
  html: `<div class="sp-form">
140
221
  <label class="form-label">What is it about?</label>
141
- <div class="sp-choices" data-group="category" role="radiogroup" aria-label="Category">
142
- ${categories.map(c => `<button type="button" role="radio" data-value="${esc(c.key)}" data-bind-class="category.value === '${esc(c.key)}' && 'is-on'"><span data-icon="${esc(c.icon)}" data-icon-size="16"></span>${esc(c.label)}</button>`).join('')}
222
+ <div class="sp-choices" role="radiogroup" aria-label="Category">
223
+ ${categories.map(c => `<button type="button" role="radio" data-on-click="pick('category', '${esc(c.key)}')" data-bind-class="category.value === '${esc(c.key)}' && 'is-on'" data-bind-aria-checked="category.value === '${esc(c.key)}' ? 'true' : 'false'"><span data-icon="${esc(c.icon)}" data-icon-size="16"></span>${esc(c.label)}</button>`).join('')}
143
224
  </div>
144
225
  <label class="form-label" for="sp-subject">Subject</label>
145
226
  <input id="sp-subject" class="form-input" maxlength="140" data-model="subject.value" placeholder="One line - e.g. My contact form stopped sending">
146
227
  <label class="form-label" for="sp-body">How can we help? <span data-help="What you were doing, what you expected and what happened instead. A page address or the screen you were on helps us get there faster."></span></label>
147
228
  <textarea id="sp-body" class="form-textarea" rows="8" maxlength="8000" data-model="body.value"></textarea>
148
229
  <label class="form-label">How urgent is it?</label>
149
- <div class="sp-choices is-two" data-group="priority" role="radiogroup" aria-label="Priority">
150
- ${priorities.map(p => `<button type="button" role="radio" data-value="${esc(p.key)}" data-bind-class="priority.value === '${esc(p.key)}' && 'is-on'"><strong>${esc(p.label)}</strong><small>${esc(p.hint)}</small></button>`).join('')}
230
+ <div class="sp-choices is-two" role="radiogroup" aria-label="Priority">
231
+ ${priorities.map(p => `<button type="button" role="radio" data-value="${esc(p.key)}" data-on-click="pick('priority', '${esc(p.key)}')" data-bind-class="priority.value === '${esc(p.key)}' && 'is-on'" data-bind-aria-checked="priority.value === '${esc(p.key)}' ? 'true' : 'false'"><strong>${esc(p.label)}</strong><small>${esc(p.hint)}</small></button>`).join('')}
151
232
  </div>
152
233
  <label class="form-label" for="sp-where">Where <span data-help="The page or admin screen it is about, if there is one - e.g. /contact, or Pages. Optional."></span></label>
153
234
  <input id="sp-where" class="form-input" maxlength="300" data-model="where.value" placeholder="Optional">
@@ -158,6 +239,7 @@ export async function mount(root, ctx) {
158
239
  </div>
159
240
  </div>`,
160
241
  methods: {
242
+ pick: (group, value) => { if (f[group]) f[group].value = value; },
161
243
  cancel: () => panel?.close(),
162
244
  async send() {
163
245
  if (f.busy.peek()) return;
@@ -168,9 +250,10 @@ export async function mount(root, ctx) {
168
250
  E.toast(r.message || 'Sent.', {type: r.state === 'sent' ? 'success' : 'warning'});
169
251
  panel?.close();
170
252
  refreshBadge();
253
+ filter.value = 'active';
171
254
  await load();
172
255
  } catch (err) {
173
- E.toast(err.message || String(err), {type: 'error'});
256
+ fail(err);
174
257
  } finally {
175
258
  f.busy.value = false;
176
259
  }
@@ -178,11 +261,6 @@ export async function mount(root, ctx) {
178
261
  }
179
262
  }).then(p => {
180
263
  panel = p;
181
- p.panel.addEventListener('click', (e) => {
182
- const b = e.target.closest('[data-value]');
183
- const group = b?.closest('[data-group]')?.dataset.group;
184
- if (group) f[group].value = b.dataset.value;
185
- });
186
264
  p.panel.querySelector('#sp-subject')?.focus();
187
265
  });
188
266
  }
@@ -190,100 +268,106 @@ export async function mount(root, ctx) {
190
268
  async function open(ref) {
191
269
  const t0 = byRef(ref);
192
270
  if (!t0) return;
271
+ // The panel follows the list's copy, so a reload or a move from the row menu shows here at once.
272
+ const cur = M.computed(() => tickets.value.find(x => x.ref === ref) || t0);
273
+ const thread = M.computed(() => threadRows(cur.value.messages || [], 'customer'));
274
+ const status = M.computed(() => cur.value.status);
275
+ const statusLabel = M.computed(() => cur.value.statusLabel || cur.value.status);
276
+ const tone = M.computed(() => `is-${TONE[cur.value.status] || 'muted'}`);
277
+ const archived = M.computed(() => Boolean(cur.value.archived));
193
278
  const draft = M.observable('');
279
+ // Bindings may not call methods (.trim()), so the test is a computed.
280
+ const hasDraft = M.computed(() => Boolean(draft.value.trim()));
194
281
  const busy = M.observable(false);
195
- const status = M.observable(t0.status);
196
282
  const canWrite = !['waiting', 'refused'].includes(t0.status);
197
283
  let panel = null;
198
- const render = () => {
199
- const t = byRef(ref) || t0;
200
- status.value = t.status;
201
- const box = panel?.panel.querySelector('.sp-thread-host');
202
- if (box) { box.innerHTML = threadHtml(t.messages, 'customer'); box.scrollTop = box.scrollHeight; }
203
- const chip = panel?.panel.querySelector('[data-ref="status"]');
204
- if (chip) { chip.textContent = t.statusLabel || t.status; chip.className = `sp-chip is-${TONE[t.status] || 'muted'}`; }
205
- };
206
- const markSeen = async () => {
207
- const t = byRef(ref);
208
- const at = [...(t?.messages || [])].reverse().find(m => m.from === 'domma')?.at;
209
- if (t?.unread && at) {
210
- try { await api.seen(ref, at); tickets.value = tickets.peek().map(x => (x.ref === ref ? {...x, unread: false} : x)); refreshBadge(); }
211
- catch { /* the badge catches up next time */ }
212
- }
284
+ let stopScroll = () => {};
285
+ let menu = null;
286
+ const run = async (fn) => {
287
+ if (busy.peek()) return;
288
+ busy.value = true;
289
+ try { await fn(); } catch (err) { fail(err); } finally { busy.value = false; }
213
290
  };
214
291
  panel = await openPanel({
215
292
  title: t0.subject,
216
293
  size: 'lg',
217
- model: {draft, busy, status},
294
+ model: {thread, status, statusLabel, tone, archived, draft, hasDraft, busy},
295
+ cleanup: () => { stopScroll(); menu?.destroy?.(); [cur, thread, status, statusLabel, tone, archived, hasDraft].forEach(c => c.dispose?.()); },
218
296
  html: `<div class="sp-detail">
219
297
  <dl class="sp-facts">
220
- <div><dt>Status</dt><dd><span data-ref="status" class="sp-chip"></span></dd></div>
298
+ <div><dt>Status</dt><dd><span class="sp-chip" data-bind-class="tone.value" data-bind-text="statusLabel.value"></span>
299
+ <span class="sp-chip is-muted" data-bind-hidden="!archived.value">Archived</span></dd></div>
221
300
  <div><dt>About</dt><dd>${esc(labelOf(t0.category))}${t0.priority === 'urgent' ? ' · <span class="sp-urgent">Urgent</span>' : ''}</dd></div>
222
301
  <div><dt>Opened</dt><dd>${esc(stamp(t0.createdAt))}${t0.userName ? ` by ${esc(t0.userName)}` : ''}</dd></div>
223
302
  ${t0.where ? `<div><dt>Where</dt><dd>${esc(t0.where)}</dd></div>` : ''}
224
303
  ${t0.lastError ? `<div><dt>Last try</dt><dd>${esc(t0.lastError)}</dd></div>` : ''}
225
304
  </dl>
226
- <div class="sp-thread-host"></div>
305
+ <div class="sp-thread-host">${THREAD}</div>
227
306
  ${canWrite ? `<div class="sp-reply">
228
307
  <label class="form-label" for="sp-write" data-bind-text="status.value === 'closed' ? 'Write again (this reopens it)' : 'Reply'"></label>
229
308
  <textarea id="sp-write" class="form-textarea" rows="4" maxlength="8000" data-model="draft.value" placeholder="Write to Domma Support"></textarea>
230
309
  <div class="sp-actions">
231
310
  <button type="button" class="btn btn-sm btn-ghost" data-on-click="resolve" data-bind-hidden="status.value === 'closed'" data-bind-disabled="busy.value"><span data-icon="check-circle" data-icon-size="13"></span> Mark resolved</button>
311
+ <button type="button" class="btn btn-sm btn-ghost" data-on-click="archive" data-bind-disabled="busy.value">
312
+ <span data-icon="archive" data-icon-size="13"></span> <span data-bind-text="archived.value ? 'Back to your requests' : 'Archive'"></span></button>
232
313
  <span class="sp-spacer"></span>
233
- <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !draft.value.trim()"><span data-icon="send" data-icon-size="13"></span> Send</button>
314
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="send" data-bind-disabled="busy.value || !hasDraft.value"><span data-icon="send" data-icon-size="13"></span> Send</button>
234
315
  </div>
235
316
  </div>` : '<p class="sp-note">You can write again once this request reaches Domma.</p>'}
236
317
  </div>`,
237
318
  methods: {
238
- async send() {
239
- if (busy.peek() || !draft.peek().trim()) return;
240
- busy.value = true;
241
- try {
242
- const r = await api.write(ref, draft.peek());
243
- if (r.state !== 'sent') E.toast(r.message, {type: 'warning'});
244
- draft.value = '';
245
- await load();
246
- render();
247
- } catch (err) {
248
- E.toast(err.message || String(err), {type: 'error'});
249
- } finally {
250
- busy.value = false;
251
- }
252
- },
253
- async resolve() {
254
- if (busy.peek()) return;
255
- busy.value = true;
256
- try {
257
- const r = await api.close(ref);
258
- E.toast(r.message || 'Marked as resolved.', {type: r.state === 'sent' ? 'success' : 'warning'});
259
- await load();
260
- render();
261
- } catch (err) {
262
- E.toast(err.message || String(err), {type: 'error'});
263
- } finally {
264
- busy.value = false;
265
- }
266
- }
319
+ send: () => run(async () => {
320
+ if (!draft.peek().trim()) return;
321
+ const r = await api.write(ref, draft.peek());
322
+ if (r.state !== 'sent') E.toast(r.message, {type: 'warning'});
323
+ draft.value = '';
324
+ await load();
325
+ }),
326
+ resolve: () => run(() => actions.resolve(cur.peek())),
327
+ archive: () => run(async () => {
328
+ const on = !archived.peek();
329
+ await actions.archive(cur.peek(), on);
330
+ if (on) panel?.close();
331
+ })
332
+ }
333
+ });
334
+ const box = panel.panel.querySelector('.sp-thread-host');
335
+ stopScroll = M.effect(() => { thread.value; Promise.resolve().then(() => { M.flush?.(); box.scrollTop = box.scrollHeight; }); });
336
+ menu = E.contextMenu(box, {
337
+ cursor: 'glyph', match: '.sp-msg', inherit: false, density: 'compact',
338
+ items: (li) => {
339
+ const m = thread.peek().find(x => x.key === li.dataset.key);
340
+ return m ? [{label: 'Copy message', icon: 'copy', action: () => copyText(m.body, 'Message copied')}] : [];
267
341
  }
268
342
  });
269
- render();
270
- await markSeen();
343
+ await act('seen')(cur.peek());
271
344
  }
272
345
 
273
- const bindings = M.applyBindings({connected, contact, loading, reachable, error, rows, waitingCount}, view, {
346
+ const bindings = M.applyBindings({connected, contact, loading, reachable, error, filter, rows, counts, hasArchive, emptyText, waitingCount}, view, {
274
347
  methods: {compose, reload: () => load()}
275
348
  });
276
349
  scope(() => bindings.dispose());
277
350
 
278
- const onClick = (e) => { const row = e.target.closest('.sp-row'); if (row) open(row.dataset.id); };
279
- const onKey = (e) => { const row = e.target.closest('.sp-row'); if (row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); open(row.dataset.id); } };
280
- listEl.addEventListener('click', onClick);
281
- listEl.addEventListener('keydown', onKey);
282
- scope(() => { listEl.removeEventListener('click', onClick); listEl.removeEventListener('keydown', onKey); });
351
+ const seg = view.querySelector('.sp-seg');
352
+ const onFilter = (e) => { const b = e.target.closest('[data-filter]'); if (b) filter.value = b.dataset.filter; };
353
+ seg.addEventListener('click', onFilter);
354
+ scope(() => seg.removeEventListener('click', onFilter));
355
+
356
+ // Rows: click or Enter opens; right-click, the ⋮ or Shift+F10 = the menu.
357
+ const rowMenu = E.contextMenu(listEl, {
358
+ cursor: 'glyph', match: '.sp-row', inherit: false, density: 'compact',
359
+ items: (row) => { const t = byRef(row.dataset.id); return t ? menuItems(t) : []; }
360
+ });
361
+ scope(() => rowMenu?.destroy?.());
362
+ scope(rowKeys(listEl, {rowSel: '.sp-row', kebabSel: '.sp-kebab', openMenuAt: kit.openMenuAt, primary: open}));
283
363
 
284
364
  I.scan(view);
285
365
  if (connected.peek()) {
286
366
  await load();
287
- if (id && here()) open(id);
367
+ if (id && here()) {
368
+ // A link to an archived request shows it where it is.
369
+ if (byRef(id)?.archived) filter.value = 'archived';
370
+ open(id);
371
+ }
288
372
  }
289
373
  }