@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.
- package/README.md +136 -43
- package/package.json +2 -2
- package/src/core/index.js +48 -8
- package/src/core/internal/appearance/base-layer.js +31 -2
- package/src/core/internal/chrome.js +5 -2
- package/src/core/internal/components.js +69 -10
- package/src/core/internal/editions.js +297 -0
- package/src/core/internal/feeds.js +8 -18
- package/src/core/internal/media/content-images.js +262 -0
- package/src/core/internal/media/gif-probe.js +162 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +21 -0
- package/src/core/internal/media/pipeline.js +73 -40
- package/src/core/internal/media/sniff.js +15 -2
- package/src/core/internal/messages.js +10 -1
- package/src/core/internal/page-kinds.js +97 -25
- package/src/core/internal/skeleton.js +25 -4
- package/src/core/internal/source-inventory.js +4 -0
- package/src/modules/interactions/browser/gala-interactions.js +370 -37
- package/types/index.d.ts +31 -1
|
@@ -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 {{
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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
|
|
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
|
|
1329
|
+
else setBusy(more, false);
|
|
1168
1330
|
} catch (error) {
|
|
1169
|
-
more
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1260
|
-
no
|
|
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
|
|
1270
|
-
no
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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. */
|