@rathnasgala2/template 3.1.0 → 3.2.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.
@@ -30,6 +30,22 @@
30
30
  * POSTs carry an `Idempotency-Key` that is reused while the same draft is
31
31
  * retried.
32
32
  *
33
+ * Appeals: a signed-in reader's thread read also carries that reader's own
34
+ * removed or held comments, each with an `own` object (`state` REMOVED or HELD,
35
+ * the `appeal` so far or null, and `canAppeal`) and no body. Such a comment is
36
+ * shown as "Removed by the site's moderators" (held: "Hidden while the
37
+ * moderators review it"), headed by a one-line context, "Your comment from
38
+ * <relative date>" (from `createdAt`), that tells the reader which comment it
39
+ * is; when `canAppeal` is true it offers an Appeal button that opens a note
40
+ * field (up to 1,000 characters) and posts `{note}` to
41
+ * `/v2/public/comments/{commentId}/appeals` with the bearer token. The
42
+ * appeal's state (pending, restored, kept removed) is written under the
43
+ * comment, and only there: sending an appeal updates the comment's state and
44
+ * redraws it, it never adds a sentence of its own, so "Appeal sent. ..." shows
45
+ * once, from the state, whether the reader has just sent it or the thread was
46
+ * read again. There is one appeal per removal; the moderators decide, never the
47
+ * script.
48
+ *
33
49
  * Not used, by design: iframes, popups (`window.open`), `postMessage`,
34
50
  * FedCM, cookies, third-party scripts or requests.
35
51
  *
@@ -116,6 +132,22 @@
116
132
  showMoreReplies: 'Show more replies',
117
133
  deletedTombstone: 'This comment was deleted.',
118
134
  removedTombstone: 'This comment was removed.',
135
+ ownFrom: 'Your comment from ',
136
+ removedOwn: "Removed by the site's moderators",
137
+ heldOwn: 'Hidden while the moderators review it',
138
+ appeal: 'Appeal',
139
+ appealAria: 'Appeal this decision',
140
+ appealLabel:
141
+ 'Tell the moderators why this should be restored (up to 1,000 characters)',
142
+ appealSend: 'Send',
143
+ appealSent: 'Appeal sent. The moderators will look again.',
144
+ appealRestored: 'Restored after review',
145
+ appealDenied: 'Kept removed after review',
146
+ appealEmpty: 'Write why you are appealing first.',
147
+ appealTooLong: "That's longer than 1,000 characters.",
148
+ appealExists: 'You already appealed this decision.',
149
+ appealNotAllowed: "This comment can't be appealed.",
150
+ appealTooMany: "You've sent several appeals today. Try again tomorrow.",
119
151
  reportTitle: 'Report this comment',
120
152
  reportReason: 'Reason',
121
153
  reportNote: 'Details (optional)',
@@ -196,7 +228,9 @@
196
228
  * @typedef {{accessToken: string, expiresAt: string, reader: ReaderView}} Session
197
229
  * @typedef {{lowerBound: number, display: string, label: string}} PublicCount
198
230
  * @typedef {{isAuthor: boolean, canEdit: boolean, canDelete: boolean, canReply: boolean}} CommentViewer
199
- * @typedef {{id: string, parentId: string | null, depth: number, state: 'VISIBLE' | 'REMOVED' | 'DELETED', author: ReaderView | null, body: string | null, createdAt: string, editedAt: string | null, replies: CommentView[], moreReplies: boolean, viewer: CommentViewer}} CommentView
231
+ * @typedef {{state: 'PENDING' | 'RESTORED' | 'DENIED', decidedAt?: string}} CommentAppeal
232
+ * @typedef {{state: 'REMOVED' | 'HELD', appeal: CommentAppeal | null, canAppeal: boolean}} CommentOwn
233
+ * @typedef {{id: string, parentId: string | null, depth: number, state: 'VISIBLE' | 'REMOVED' | 'DELETED' | 'HELD', author: ReaderView | null, body: string | null, createdAt: string, editedAt: string | null, replies: CommentView[], moreReplies: boolean, viewer: CommentViewer, own?: CommentOwn}} CommentView
200
234
  * @typedef {{items: CommentView[], nextCursor: string | null, count: PublicCount | null}} CommentPage
201
235
  * @typedef {{key: string, label: string, visual: {kind: string, token: string}, count: PublicCount | null, viewerActive: boolean}} ReactionItem
202
236
  * @typedef {{enabled: boolean, items: ReactionItem[]}} ReactionsBlock
@@ -219,9 +253,10 @@
219
253
  };
220
254
  const composers = new Set();
221
255
  let rootComposer = /** @type {Composer | null} */ (null);
222
- let inline = /** @type {{composer: Composer, item: CommentItem} | null} */ (
223
- null
224
- );
256
+ let inline =
257
+ /** @type {{composer: Composer, item: CommentItem, trigger: HTMLElement | null} | null} */ (
258
+ null
259
+ );
225
260
  let loadToken = 0;
226
261
 
227
262
  // ------------------------------------------------------------------- helpers
@@ -261,6 +296,47 @@
261
296
  return node;
262
297
  }
263
298
 
299
+ /**
300
+ * One place for the disabled state: the native attribute (which blocks
301
+ * activation and focus) and `aria-disabled` together, so the base layer's
302
+ * `:disabled` / `[aria-disabled="true"]` rule and assistive technology agree.
303
+ *
304
+ * @param {HTMLElement} node
305
+ * @param {boolean} on
306
+ */
307
+ function setDisabled(node, on) {
308
+ /** @type {HTMLButtonElement} */ (node).disabled = on;
309
+ if (on) node.setAttribute('aria-disabled', 'true');
310
+ else node.removeAttribute('aria-disabled');
311
+ }
312
+
313
+ /**
314
+ * The working (pending) state: disabled for activation, but marked
315
+ * `aria-busy` so the base layer draws a spinner instead of the muted
316
+ * disabled look.
317
+ *
318
+ * @param {HTMLElement} node
319
+ * @param {boolean} on
320
+ */
321
+ function setBusy(node, on) {
322
+ setDisabled(node, on);
323
+ if (on) node.setAttribute('aria-busy', 'true');
324
+ else node.removeAttribute('aria-busy');
325
+ }
326
+
327
+ /**
328
+ * A trigger whose action is already in progress (its inline composer is
329
+ * open) stays focusable, so it is `aria-disabled` only, and ignores clicks.
330
+ *
331
+ * @param {HTMLElement | null} trigger
332
+ * @param {boolean} on
333
+ */
334
+ function holdTrigger(trigger, on) {
335
+ if (!trigger) return;
336
+ if (on) trigger.setAttribute('aria-disabled', 'true');
337
+ else trigger.removeAttribute('aria-disabled');
338
+ }
339
+
264
340
  let idCounter = 0;
265
341
  /**
266
342
  * @param {string} prefix
@@ -833,7 +909,7 @@
833
909
  continue;
834
910
  }
835
911
  node.hidden = false;
836
- node.disabled = false;
912
+ setDisabled(node, false);
837
913
  setPressed(node, !!item.viewerActive);
838
914
  setCount(node, item.count, 'reactions');
839
915
  }
@@ -1006,6 +1082,22 @@
1006
1082
  }
1007
1083
  }
1008
1084
 
1085
+ /**
1086
+ * A comment's `<time>` element: the given text, the machine-readable date
1087
+ * and the full date as its tooltip.
1088
+ *
1089
+ * @param {string} iso
1090
+ * @param {string} text
1091
+ * @returns {HTMLElement}
1092
+ */
1093
+ function timeElement(iso, text) {
1094
+ const time = make('time', 'g-comment__time', text);
1095
+ time.setAttribute('datetime', iso);
1096
+ const title = fullDate(iso);
1097
+ if (title) time.setAttribute('title', title);
1098
+ return time;
1099
+ }
1100
+
1009
1101
  // ------------------------------------------------------------------ comments
1010
1102
  /**
1011
1103
  * @param {PublicCount|null|undefined} count
@@ -1023,13 +1115,65 @@
1023
1115
  }
1024
1116
  }
1025
1117
 
1118
+ /**
1119
+ * The state of the signed-in reader's own removed or held comment, which
1120
+ * arrives with an `own` object and no body.
1121
+ *
1122
+ * @param {CommentView} comment
1123
+ * @returns {CommentOwn | null}
1124
+ */
1125
+ function ownView(comment) {
1126
+ const own = comment.own;
1127
+ return own &&
1128
+ typeof own === 'object' &&
1129
+ (own.state === 'REMOVED' || own.state === 'HELD')
1130
+ ? own
1131
+ : null;
1132
+ }
1133
+
1134
+ /**
1135
+ * The one-line context of the reader's own removed or held comment, which
1136
+ * arrives with no body: "Your comment from <relative date>". Null when the
1137
+ * comment has no usable `createdAt`.
1138
+ *
1139
+ * @param {CommentView} comment
1140
+ * @returns {HTMLElement | null}
1141
+ */
1142
+ function ownContext(comment) {
1143
+ if (!Number.isFinite(Date.parse(comment.createdAt))) return null;
1144
+ return timeElement(
1145
+ comment.createdAt,
1146
+ MESSAGES.ownFrom + timeLabel(comment.createdAt, false),
1147
+ );
1148
+ }
1149
+
1150
+ /**
1151
+ * The line saying where the reader's appeal stands, if there is one.
1152
+ *
1153
+ * @param {CommentView} comment
1154
+ * @returns {HTMLElement | null}
1155
+ */
1156
+ function appealResult(comment) {
1157
+ const appeal = comment.own && comment.own.appeal;
1158
+ const text = !appeal
1159
+ ? ''
1160
+ : appeal.state === 'PENDING'
1161
+ ? MESSAGES.appealSent
1162
+ : appeal.state === 'RESTORED'
1163
+ ? MESSAGES.appealRestored
1164
+ : appeal.state === 'DENIED'
1165
+ ? MESSAGES.appealDenied
1166
+ : '';
1167
+ return text ? make('p', 'g-interactions__notice', text) : null;
1168
+ }
1169
+
1026
1170
  /**
1027
1171
  * @param {CommentView} comment
1028
1172
  * @returns {boolean}
1029
1173
  */
1030
1174
  function canReplyTo(comment) {
1031
1175
  if (!live.commentsOpen || !live.allowReplies) return false;
1032
- if (comment.state !== 'VISIBLE') return false;
1176
+ if (comment.state !== 'VISIBLE' || ownView(comment)) return false;
1033
1177
  if (comment.depth >= live.maxDepth - 1) return false;
1034
1178
  if (session && comment.viewer && comment.viewer.canReply === false) {
1035
1179
  return false;
@@ -1061,31 +1205,49 @@
1061
1205
  const existingReplies = item.querySelector(':scope > .g-comment__replies');
1062
1206
  while (item.firstChild) item.removeChild(item.firstChild);
1063
1207
  item._comment = comment;
1064
- item.classList.toggle('g-comment--tombstone', comment.state !== 'VISIBLE');
1208
+ const own = ownView(comment);
1209
+ item.classList.toggle(
1210
+ 'g-comment--tombstone',
1211
+ comment.state !== 'VISIBLE' || !!own,
1212
+ );
1065
1213
  item.classList.toggle(
1066
1214
  'g-comment--mine',
1067
- !!(comment.viewer && comment.viewer.isAuthor),
1215
+ !!own || !!(comment.viewer && comment.viewer.isAuthor),
1068
1216
  );
1069
1217
 
1070
1218
  const head = make('div', 'g-comment__head');
1071
- if (comment.state === 'VISIBLE' && comment.author) {
1219
+ if (own) {
1220
+ // No body comes back for these, so say which comment it is by its date.
1221
+ const context = ownContext(comment);
1222
+ if (context) item.appendChild(context);
1223
+ item.appendChild(
1224
+ make(
1225
+ 'p',
1226
+ 'g-comment__body',
1227
+ own.state === 'HELD' ? MESSAGES.heldOwn : MESSAGES.removedOwn,
1228
+ ),
1229
+ );
1230
+ const result = appealResult(comment);
1231
+ if (result) item.appendChild(result);
1232
+ if (own.canAppeal === true) item.appendChild(renderAppealAction(item));
1233
+ } else if (comment.state === 'VISIBLE' && comment.author) {
1072
1234
  const avatar = make('span', 'g-comment__avatar', comment.author.initials);
1073
1235
  avatar.setAttribute('aria-hidden', 'true');
1074
1236
  head.appendChild(avatar);
1075
1237
  head.appendChild(
1076
1238
  make('span', 'g-comment__author', comment.author.displayName),
1077
1239
  );
1078
- const time = make(
1079
- 'time',
1080
- 'g-comment__time',
1081
- timeLabel(comment.createdAt, !!comment.editedAt),
1240
+ head.appendChild(
1241
+ timeElement(
1242
+ comment.createdAt,
1243
+ timeLabel(comment.createdAt, !!comment.editedAt),
1244
+ ),
1082
1245
  );
1083
- time.setAttribute('datetime', comment.createdAt);
1084
- const title = fullDate(comment.createdAt);
1085
- if (title) time.setAttribute('title', title);
1086
- head.appendChild(time);
1087
1246
  item.appendChild(head);
1088
1247
  item.appendChild(renderBody(comment.body || ''));
1248
+ // A comment restored on appeal says so.
1249
+ const result = appealResult(comment);
1250
+ if (result) item.appendChild(result);
1089
1251
  item.appendChild(renderActions(item, comment));
1090
1252
  } else {
1091
1253
  item.appendChild(
@@ -1147,7 +1309,7 @@
1147
1309
  let cursor = /** @type {string | null} */ (null);
1148
1310
  let first = true;
1149
1311
  more.addEventListener('click', async () => {
1150
- more.disabled = true;
1312
+ setBusy(more, true);
1151
1313
  try {
1152
1314
  const query = new URLSearchParams({ parentId: comment.id });
1153
1315
  if (cursor) query.set('cursor', cursor);
@@ -1164,9 +1326,9 @@
1164
1326
  list.appendChild(renderComment(reply));
1165
1327
  cursor = page.nextCursor || null;
1166
1328
  if (!cursor) more.remove();
1167
- else more.disabled = false;
1329
+ else setBusy(more, false);
1168
1330
  } catch (error) {
1169
- more.disabled = false;
1331
+ setBusy(more, false);
1170
1332
  announce(failureMessage(error));
1171
1333
  }
1172
1334
  });
@@ -1187,9 +1349,10 @@
1187
1349
  MESSAGES.reply,
1188
1350
  MESSAGES.reply + ' to ' + name,
1189
1351
  );
1190
- reply.addEventListener('click', () =>
1191
- openInlineComposer(item, 'reply', reply),
1192
- );
1352
+ reply.addEventListener('click', () => {
1353
+ if (reply.getAttribute('aria-disabled') === 'true') return;
1354
+ openInlineComposer(item, 'reply', reply);
1355
+ });
1193
1356
  actions.appendChild(reply);
1194
1357
  }
1195
1358
  if (comment.viewer && comment.viewer.canEdit) {
@@ -1198,9 +1361,10 @@
1198
1361
  MESSAGES.edit,
1199
1362
  MESSAGES.edit + ' your comment',
1200
1363
  );
1201
- edit.addEventListener('click', () =>
1202
- openInlineComposer(item, 'edit', edit),
1203
- );
1364
+ edit.addEventListener('click', () => {
1365
+ if (edit.getAttribute('aria-disabled') === 'true') return;
1366
+ openInlineComposer(item, 'edit', edit);
1367
+ });
1204
1368
  actions.appendChild(edit);
1205
1369
  }
1206
1370
  if (comment.viewer && comment.viewer.canDelete) {
@@ -1256,8 +1420,8 @@
1256
1420
  }
1257
1421
  });
1258
1422
  yes.addEventListener('click', async () => {
1259
- yes.disabled = true;
1260
- no.disabled = true;
1423
+ setBusy(yes, true);
1424
+ setDisabled(no, true);
1261
1425
  try {
1262
1426
  await api(
1263
1427
  'DELETE',
@@ -1266,8 +1430,8 @@
1266
1430
  applyDeleted(item);
1267
1431
  announce(MESSAGES.deleted);
1268
1432
  } catch (error) {
1269
- yes.disabled = false;
1270
- no.disabled = false;
1433
+ setBusy(yes, false);
1434
+ setDisabled(no, false);
1271
1435
  announce(failureMessage(error));
1272
1436
  }
1273
1437
  });
@@ -1479,7 +1643,7 @@
1479
1643
  return;
1480
1644
  }
1481
1645
  composer.busy = true;
1482
- submit.disabled = true;
1646
+ setDisabled(submit, true);
1483
1647
  submit.setAttribute('aria-busy', 'true');
1484
1648
  showError('');
1485
1649
  try {
@@ -1506,7 +1670,7 @@
1506
1670
  showError(failureMessage(failure));
1507
1671
  } finally {
1508
1672
  composer.busy = false;
1509
- submit.disabled = false;
1673
+ setDisabled(submit, false);
1510
1674
  submit.removeAttribute('aria-busy');
1511
1675
  }
1512
1676
  });
@@ -1562,6 +1726,7 @@
1562
1726
  function closeInline() {
1563
1727
  if (inline) {
1564
1728
  inline.composer.close();
1729
+ holdTrigger(inline.trigger, false);
1565
1730
  inline = null;
1566
1731
  }
1567
1732
  }
@@ -1577,6 +1742,7 @@
1577
1742
  const comment = item._comment;
1578
1743
  const done = (/** @type {any} */ result) => {
1579
1744
  /** @type {{composer: Composer}} */ (inline).composer.close();
1745
+ holdTrigger(trigger, false);
1580
1746
  inline = null;
1581
1747
  if (mode === 'edit') {
1582
1748
  fillComment(item, result);
@@ -1593,12 +1759,14 @@
1593
1759
  body: mode === 'edit' ? comment.body || '' : presetBody || '',
1594
1760
  onDone: done,
1595
1761
  onCancel: () => {
1762
+ holdTrigger(trigger, false);
1596
1763
  inline = null;
1597
1764
  if (trigger && trigger.isConnected) focusNode(trigger);
1598
1765
  else focusNode(item);
1599
1766
  },
1600
1767
  });
1601
- inline = { composer, item };
1768
+ holdTrigger(trigger, true);
1769
+ inline = { composer, item, trigger };
1602
1770
  const replies = item.querySelector(':scope > .g-comment__replies');
1603
1771
  if (replies) item.insertBefore(composer.element, replies);
1604
1772
  else item.appendChild(composer.element);
@@ -1685,7 +1853,7 @@
1685
1853
  });
1686
1854
  cancel.addEventListener('click', finish);
1687
1855
  send.addEventListener('click', async () => {
1688
- send.disabled = true;
1856
+ setBusy(send, true);
1689
1857
  error.hidden = true;
1690
1858
  /** @type {{reasonCode: string, note?: string}} */
1691
1859
  const payload = { reasonCode: select.value };
@@ -1702,7 +1870,7 @@
1702
1870
  finish();
1703
1871
  announce(MESSAGES.reported);
1704
1872
  } catch (failure) {
1705
- send.disabled = false;
1873
+ setBusy(send, false);
1706
1874
  error.textContent = failureMessage(failure);
1707
1875
  error.hidden = false;
1708
1876
  }
@@ -1721,6 +1889,171 @@
1721
1889
  focusNode(select);
1722
1890
  }
1723
1891
 
1892
+ // -------------------------------------------------------------------- appeal
1893
+ /**
1894
+ * @param {CommentItem} item
1895
+ * @returns {HTMLElement}
1896
+ */
1897
+ function renderAppealAction(item) {
1898
+ const actions = make('div', 'g-comment__actions');
1899
+ const appeal = button(
1900
+ 'g-comment__action',
1901
+ MESSAGES.appeal,
1902
+ MESSAGES.appealAria,
1903
+ );
1904
+ appeal.addEventListener('click', () => openAppeal(item, appeal));
1905
+ actions.appendChild(appeal);
1906
+ return actions;
1907
+ }
1908
+
1909
+ /**
1910
+ * The sentence for a failed appeal; the problems an appeal adds are named
1911
+ * here, everything else is worded as for any other request.
1912
+ *
1913
+ * @param {unknown} error
1914
+ * @returns {string}
1915
+ */
1916
+ function appealFailureMessage(error) {
1917
+ if (error instanceof ApiError && error.kind === 'problem') {
1918
+ if (error.code === 'APPEAL_EXISTS') return MESSAGES.appealExists;
1919
+ if (error.code === 'APPEAL_NOT_ALLOWED') return MESSAGES.appealNotAllowed;
1920
+ if (error.status === 429 || error.code === 'RATE_LIMITED') {
1921
+ return MESSAGES.appealTooMany;
1922
+ }
1923
+ }
1924
+ return failureMessage(error);
1925
+ }
1926
+
1927
+ /**
1928
+ * Show the new state of an appeal under its comment.
1929
+ *
1930
+ * @param {CommentItem} item
1931
+ * @param {CommentAppeal | null} appeal
1932
+ */
1933
+ function applyAppeal(item, appeal) {
1934
+ const comment = item._comment;
1935
+ fillComment(
1936
+ item,
1937
+ Object.assign({}, comment, {
1938
+ own: Object.assign({}, comment.own, { appeal, canAppeal: false }),
1939
+ }),
1940
+ );
1941
+ focusNode(item);
1942
+ }
1943
+
1944
+ /**
1945
+ * @param {CommentItem} item
1946
+ * @param {HTMLElement} trigger
1947
+ */
1948
+ function openAppeal(item, trigger) {
1949
+ if (!session) {
1950
+ startSignIn({ kind: 'signin' });
1951
+ return;
1952
+ }
1953
+ const wrapper = make('div', 'g-composer');
1954
+ const inputId = uniqueId('appeal');
1955
+ const label = make('label', '', MESSAGES.appealLabel);
1956
+ label.setAttribute('for', inputId);
1957
+ const input = make('textarea', 'g-composer__input');
1958
+ input.id = inputId;
1959
+ input.rows = 3;
1960
+ input.setAttribute('maxlength', String(NOTE_LIMIT));
1961
+ const error = make('p', 'g-composer__error');
1962
+ error.setAttribute('role', 'alert');
1963
+ error.hidden = true;
1964
+ const actions = make('div', 'g-composer__actions');
1965
+ const send = button('g-composer__submit', MESSAGES.appealSend);
1966
+ const cancel = button('g-composer__cancel', MESSAGES.cancel);
1967
+ actions.appendChild(send);
1968
+ actions.appendChild(cancel);
1969
+ for (const node of [label, input, error, actions]) {
1970
+ wrapper.appendChild(node);
1971
+ }
1972
+
1973
+ /**
1974
+ * @param {string} text
1975
+ */
1976
+ const showError = (text) => {
1977
+ error.textContent = text;
1978
+ error.hidden = !text;
1979
+ if (text) announceNow(text);
1980
+ };
1981
+ const close = () => {
1982
+ wrapper.remove();
1983
+ trigger.hidden = false;
1984
+ };
1985
+ cancel.addEventListener('click', () => {
1986
+ close();
1987
+ focusNode(trigger);
1988
+ });
1989
+ input.addEventListener('input', () => showError(''));
1990
+ input.addEventListener('keydown', (event) => {
1991
+ if (event.key === 'Escape') {
1992
+ event.preventDefault();
1993
+ cancel.click();
1994
+ }
1995
+ });
1996
+ send.addEventListener('click', async () => {
1997
+ if (!session) {
1998
+ startSignIn({ kind: 'signin' });
1999
+ return;
2000
+ }
2001
+ const note = input.value.trim();
2002
+ if (!note) {
2003
+ showError(MESSAGES.appealEmpty);
2004
+ return;
2005
+ }
2006
+ if (codePoints(note) > NOTE_LIMIT) {
2007
+ showError(MESSAGES.appealTooLong);
2008
+ return;
2009
+ }
2010
+ setDisabled(send, true);
2011
+ send.setAttribute('aria-busy', 'true');
2012
+ showError('');
2013
+ try {
2014
+ await api(
2015
+ 'POST',
2016
+ '/v2/public/comments/' +
2017
+ encodeURIComponent(item._comment.id) +
2018
+ '/appeals',
2019
+ { body: { note } },
2020
+ );
2021
+ // "Appeal sent" is drawn by the redraw alone, under the comment where
2022
+ // focus lands; the status line does not repeat it.
2023
+ close();
2024
+ applyAppeal(item, { state: 'PENDING' });
2025
+ } catch (failure) {
2026
+ const message = appealFailureMessage(failure);
2027
+ if (
2028
+ failure instanceof ApiError &&
2029
+ (failure.code === 'APPEAL_EXISTS' ||
2030
+ failure.code === 'APPEAL_NOT_ALLOWED')
2031
+ ) {
2032
+ // The server's answer is final for this removal: stop offering it.
2033
+ close();
2034
+ applyAppeal(
2035
+ item,
2036
+ failure.code === 'APPEAL_EXISTS'
2037
+ ? item._comment.own?.appeal || { state: 'PENDING' }
2038
+ : item._comment.own?.appeal || null,
2039
+ );
2040
+ announce(message);
2041
+ } else {
2042
+ setDisabled(send, false);
2043
+ showError(message);
2044
+ }
2045
+ } finally {
2046
+ send.removeAttribute('aria-busy');
2047
+ }
2048
+ });
2049
+
2050
+ trigger.hidden = true;
2051
+ const replies = item.querySelector(':scope > .g-comment__replies');
2052
+ if (replies) item.insertBefore(wrapper, replies);
2053
+ else item.appendChild(wrapper);
2054
+ focusNode(input);
2055
+ }
2056
+
1724
2057
  // ------------------------------------------------------------------- loading
1725
2058
  /**
1726
2059
  * @param {CommentPage} page
@@ -1803,7 +2136,7 @@
1803
2136
 
1804
2137
  async function loadMore() {
1805
2138
  if (!live.nextCursor || !moreEl) return;
1806
- moreEl.disabled = true;
2139
+ setBusy(moreEl, true);
1807
2140
  try {
1808
2141
  const query = new URLSearchParams({ cursor: live.nextCursor });
1809
2142
  const page = await api(
@@ -1814,7 +2147,7 @@
1814
2147
  } catch (error) {
1815
2148
  announce(failureMessage(error));
1816
2149
  } finally {
1817
- moreEl.disabled = false;
2150
+ setBusy(moreEl, false);
1818
2151
  }
1819
2152
  }
1820
2153
 
package/types/index.d.ts CHANGED
@@ -54,10 +54,23 @@ export interface RenderableBody {
54
54
  renderPolicy: { name: string; version: string; digest: string };
55
55
  }
56
56
 
57
+ /**
58
+ * A `contentEdition`-shaped object: which article a `kind: "edition"` document
59
+ * restates, at what depth, and where it came from. A stale edition never
60
+ * reaches the renderer, so `sourceDigest` is carried but not compared.
61
+ */
62
+ export interface ContentEdition {
63
+ of: string;
64
+ kind: 'QUICK_READ' | 'STANDARD' | 'DEEP_DIVE';
65
+ sourceDigest: string;
66
+ generation: { provider: string; model: string; generationId: string };
67
+ approvedAt: string;
68
+ }
69
+
57
70
  /** A `contentFrontmatterNormalized`-shaped record's fields this renderer reads. */
58
71
  export interface ContentFrontmatterNormalized {
59
72
  id: string;
60
- kind: 'article' | 'page';
73
+ kind: 'article' | 'page' | 'edition';
61
74
  title: string;
62
75
  description?: string;
63
76
  language: string;
@@ -74,6 +87,20 @@ export interface ContentFrontmatterNormalized {
74
87
  hero?: ResolvedMediaRef;
75
88
  socialImage?: ResolvedFileRef;
76
89
  redirects: string[];
90
+ /** Present exactly when `kind` is `edition`. */
91
+ edition?: ContentEdition;
92
+ }
93
+
94
+ /**
95
+ * A `contentBuildMediaFile`-shaped entry: one repository file a document
96
+ * references. Every image the document's body references must be one of
97
+ * these (an image media type).
98
+ */
99
+ export interface ContentBuildMediaFile {
100
+ path: string;
101
+ sourceDigest: string;
102
+ mediaType: string;
103
+ byteLength: number;
77
104
  }
78
105
 
79
106
  /** A `contentBuildRecord`-shaped record. */
@@ -87,6 +114,7 @@ export interface ContentBuildRecord {
87
114
  sourceRevision: string;
88
115
  sourceDigest: string;
89
116
  resolvedAuthorIds: string[];
117
+ media?: ContentBuildMediaFile[];
90
118
  }
91
119
 
92
120
  /** A `socialLink`-shaped reference. */
@@ -174,6 +202,8 @@ export interface AppearanceNormalized {
174
202
  fontAssets: ResolvedFileRef[];
175
203
  tokens: Record<string, never>;
176
204
  source: NormalizedSource;
205
+ /** The footer attribution switch; absent means the attribution shows. */
206
+ attribution?: { showMadeWith: boolean };
177
207
  }
178
208
 
179
209
  /** The subset of `build-input:2.0.0` this renderer reads. */