@rathnasgala2/template 3.1.0 → 3.2.0

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
@@ -1006,6 +1040,22 @@
1006
1040
  }
1007
1041
  }
1008
1042
 
1043
+ /**
1044
+ * A comment's `<time>` element: the given text, the machine-readable date
1045
+ * and the full date as its tooltip.
1046
+ *
1047
+ * @param {string} iso
1048
+ * @param {string} text
1049
+ * @returns {HTMLElement}
1050
+ */
1051
+ function timeElement(iso, text) {
1052
+ const time = make('time', 'g-comment__time', text);
1053
+ time.setAttribute('datetime', iso);
1054
+ const title = fullDate(iso);
1055
+ if (title) time.setAttribute('title', title);
1056
+ return time;
1057
+ }
1058
+
1009
1059
  // ------------------------------------------------------------------ comments
1010
1060
  /**
1011
1061
  * @param {PublicCount|null|undefined} count
@@ -1023,13 +1073,65 @@
1023
1073
  }
1024
1074
  }
1025
1075
 
1076
+ /**
1077
+ * The state of the signed-in reader's own removed or held comment, which
1078
+ * arrives with an `own` object and no body.
1079
+ *
1080
+ * @param {CommentView} comment
1081
+ * @returns {CommentOwn | null}
1082
+ */
1083
+ function ownView(comment) {
1084
+ const own = comment.own;
1085
+ return own &&
1086
+ typeof own === 'object' &&
1087
+ (own.state === 'REMOVED' || own.state === 'HELD')
1088
+ ? own
1089
+ : null;
1090
+ }
1091
+
1092
+ /**
1093
+ * The one-line context of the reader's own removed or held comment, which
1094
+ * arrives with no body: "Your comment from <relative date>". Null when the
1095
+ * comment has no usable `createdAt`.
1096
+ *
1097
+ * @param {CommentView} comment
1098
+ * @returns {HTMLElement | null}
1099
+ */
1100
+ function ownContext(comment) {
1101
+ if (!Number.isFinite(Date.parse(comment.createdAt))) return null;
1102
+ return timeElement(
1103
+ comment.createdAt,
1104
+ MESSAGES.ownFrom + timeLabel(comment.createdAt, false),
1105
+ );
1106
+ }
1107
+
1108
+ /**
1109
+ * The line saying where the reader's appeal stands, if there is one.
1110
+ *
1111
+ * @param {CommentView} comment
1112
+ * @returns {HTMLElement | null}
1113
+ */
1114
+ function appealResult(comment) {
1115
+ const appeal = comment.own && comment.own.appeal;
1116
+ const text = !appeal
1117
+ ? ''
1118
+ : appeal.state === 'PENDING'
1119
+ ? MESSAGES.appealSent
1120
+ : appeal.state === 'RESTORED'
1121
+ ? MESSAGES.appealRestored
1122
+ : appeal.state === 'DENIED'
1123
+ ? MESSAGES.appealDenied
1124
+ : '';
1125
+ return text ? make('p', 'g-interactions__notice', text) : null;
1126
+ }
1127
+
1026
1128
  /**
1027
1129
  * @param {CommentView} comment
1028
1130
  * @returns {boolean}
1029
1131
  */
1030
1132
  function canReplyTo(comment) {
1031
1133
  if (!live.commentsOpen || !live.allowReplies) return false;
1032
- if (comment.state !== 'VISIBLE') return false;
1134
+ if (comment.state !== 'VISIBLE' || ownView(comment)) return false;
1033
1135
  if (comment.depth >= live.maxDepth - 1) return false;
1034
1136
  if (session && comment.viewer && comment.viewer.canReply === false) {
1035
1137
  return false;
@@ -1061,31 +1163,49 @@
1061
1163
  const existingReplies = item.querySelector(':scope > .g-comment__replies');
1062
1164
  while (item.firstChild) item.removeChild(item.firstChild);
1063
1165
  item._comment = comment;
1064
- item.classList.toggle('g-comment--tombstone', comment.state !== 'VISIBLE');
1166
+ const own = ownView(comment);
1167
+ item.classList.toggle(
1168
+ 'g-comment--tombstone',
1169
+ comment.state !== 'VISIBLE' || !!own,
1170
+ );
1065
1171
  item.classList.toggle(
1066
1172
  'g-comment--mine',
1067
- !!(comment.viewer && comment.viewer.isAuthor),
1173
+ !!own || !!(comment.viewer && comment.viewer.isAuthor),
1068
1174
  );
1069
1175
 
1070
1176
  const head = make('div', 'g-comment__head');
1071
- if (comment.state === 'VISIBLE' && comment.author) {
1177
+ if (own) {
1178
+ // No body comes back for these, so say which comment it is by its date.
1179
+ const context = ownContext(comment);
1180
+ if (context) item.appendChild(context);
1181
+ item.appendChild(
1182
+ make(
1183
+ 'p',
1184
+ 'g-comment__body',
1185
+ own.state === 'HELD' ? MESSAGES.heldOwn : MESSAGES.removedOwn,
1186
+ ),
1187
+ );
1188
+ const result = appealResult(comment);
1189
+ if (result) item.appendChild(result);
1190
+ if (own.canAppeal === true) item.appendChild(renderAppealAction(item));
1191
+ } else if (comment.state === 'VISIBLE' && comment.author) {
1072
1192
  const avatar = make('span', 'g-comment__avatar', comment.author.initials);
1073
1193
  avatar.setAttribute('aria-hidden', 'true');
1074
1194
  head.appendChild(avatar);
1075
1195
  head.appendChild(
1076
1196
  make('span', 'g-comment__author', comment.author.displayName),
1077
1197
  );
1078
- const time = make(
1079
- 'time',
1080
- 'g-comment__time',
1081
- timeLabel(comment.createdAt, !!comment.editedAt),
1198
+ head.appendChild(
1199
+ timeElement(
1200
+ comment.createdAt,
1201
+ timeLabel(comment.createdAt, !!comment.editedAt),
1202
+ ),
1082
1203
  );
1083
- time.setAttribute('datetime', comment.createdAt);
1084
- const title = fullDate(comment.createdAt);
1085
- if (title) time.setAttribute('title', title);
1086
- head.appendChild(time);
1087
1204
  item.appendChild(head);
1088
1205
  item.appendChild(renderBody(comment.body || ''));
1206
+ // A comment restored on appeal says so.
1207
+ const result = appealResult(comment);
1208
+ if (result) item.appendChild(result);
1089
1209
  item.appendChild(renderActions(item, comment));
1090
1210
  } else {
1091
1211
  item.appendChild(
@@ -1721,6 +1841,171 @@
1721
1841
  focusNode(select);
1722
1842
  }
1723
1843
 
1844
+ // -------------------------------------------------------------------- appeal
1845
+ /**
1846
+ * @param {CommentItem} item
1847
+ * @returns {HTMLElement}
1848
+ */
1849
+ function renderAppealAction(item) {
1850
+ const actions = make('div', 'g-comment__actions');
1851
+ const appeal = button(
1852
+ 'g-comment__action',
1853
+ MESSAGES.appeal,
1854
+ MESSAGES.appealAria,
1855
+ );
1856
+ appeal.addEventListener('click', () => openAppeal(item, appeal));
1857
+ actions.appendChild(appeal);
1858
+ return actions;
1859
+ }
1860
+
1861
+ /**
1862
+ * The sentence for a failed appeal; the problems an appeal adds are named
1863
+ * here, everything else is worded as for any other request.
1864
+ *
1865
+ * @param {unknown} error
1866
+ * @returns {string}
1867
+ */
1868
+ function appealFailureMessage(error) {
1869
+ if (error instanceof ApiError && error.kind === 'problem') {
1870
+ if (error.code === 'APPEAL_EXISTS') return MESSAGES.appealExists;
1871
+ if (error.code === 'APPEAL_NOT_ALLOWED') return MESSAGES.appealNotAllowed;
1872
+ if (error.status === 429 || error.code === 'RATE_LIMITED') {
1873
+ return MESSAGES.appealTooMany;
1874
+ }
1875
+ }
1876
+ return failureMessage(error);
1877
+ }
1878
+
1879
+ /**
1880
+ * Show the new state of an appeal under its comment.
1881
+ *
1882
+ * @param {CommentItem} item
1883
+ * @param {CommentAppeal | null} appeal
1884
+ */
1885
+ function applyAppeal(item, appeal) {
1886
+ const comment = item._comment;
1887
+ fillComment(
1888
+ item,
1889
+ Object.assign({}, comment, {
1890
+ own: Object.assign({}, comment.own, { appeal, canAppeal: false }),
1891
+ }),
1892
+ );
1893
+ focusNode(item);
1894
+ }
1895
+
1896
+ /**
1897
+ * @param {CommentItem} item
1898
+ * @param {HTMLElement} trigger
1899
+ */
1900
+ function openAppeal(item, trigger) {
1901
+ if (!session) {
1902
+ startSignIn({ kind: 'signin' });
1903
+ return;
1904
+ }
1905
+ const wrapper = make('div', 'g-composer');
1906
+ const inputId = uniqueId('appeal');
1907
+ const label = make('label', '', MESSAGES.appealLabel);
1908
+ label.setAttribute('for', inputId);
1909
+ const input = make('textarea', 'g-composer__input');
1910
+ input.id = inputId;
1911
+ input.rows = 3;
1912
+ input.setAttribute('maxlength', String(NOTE_LIMIT));
1913
+ const error = make('p', 'g-composer__error');
1914
+ error.setAttribute('role', 'alert');
1915
+ error.hidden = true;
1916
+ const actions = make('div', 'g-composer__actions');
1917
+ const send = button('g-composer__submit', MESSAGES.appealSend);
1918
+ const cancel = button('g-composer__cancel', MESSAGES.cancel);
1919
+ actions.appendChild(send);
1920
+ actions.appendChild(cancel);
1921
+ for (const node of [label, input, error, actions]) {
1922
+ wrapper.appendChild(node);
1923
+ }
1924
+
1925
+ /**
1926
+ * @param {string} text
1927
+ */
1928
+ const showError = (text) => {
1929
+ error.textContent = text;
1930
+ error.hidden = !text;
1931
+ if (text) announceNow(text);
1932
+ };
1933
+ const close = () => {
1934
+ wrapper.remove();
1935
+ trigger.hidden = false;
1936
+ };
1937
+ cancel.addEventListener('click', () => {
1938
+ close();
1939
+ focusNode(trigger);
1940
+ });
1941
+ input.addEventListener('input', () => showError(''));
1942
+ input.addEventListener('keydown', (event) => {
1943
+ if (event.key === 'Escape') {
1944
+ event.preventDefault();
1945
+ cancel.click();
1946
+ }
1947
+ });
1948
+ send.addEventListener('click', async () => {
1949
+ if (!session) {
1950
+ startSignIn({ kind: 'signin' });
1951
+ return;
1952
+ }
1953
+ const note = input.value.trim();
1954
+ if (!note) {
1955
+ showError(MESSAGES.appealEmpty);
1956
+ return;
1957
+ }
1958
+ if (codePoints(note) > NOTE_LIMIT) {
1959
+ showError(MESSAGES.appealTooLong);
1960
+ return;
1961
+ }
1962
+ send.disabled = true;
1963
+ send.setAttribute('aria-busy', 'true');
1964
+ showError('');
1965
+ try {
1966
+ await api(
1967
+ 'POST',
1968
+ '/v2/public/comments/' +
1969
+ encodeURIComponent(item._comment.id) +
1970
+ '/appeals',
1971
+ { body: { note } },
1972
+ );
1973
+ // "Appeal sent" is drawn by the redraw alone, under the comment where
1974
+ // focus lands; the status line does not repeat it.
1975
+ close();
1976
+ applyAppeal(item, { state: 'PENDING' });
1977
+ } catch (failure) {
1978
+ const message = appealFailureMessage(failure);
1979
+ if (
1980
+ failure instanceof ApiError &&
1981
+ (failure.code === 'APPEAL_EXISTS' ||
1982
+ failure.code === 'APPEAL_NOT_ALLOWED')
1983
+ ) {
1984
+ // The server's answer is final for this removal: stop offering it.
1985
+ close();
1986
+ applyAppeal(
1987
+ item,
1988
+ failure.code === 'APPEAL_EXISTS'
1989
+ ? item._comment.own?.appeal || { state: 'PENDING' }
1990
+ : item._comment.own?.appeal || null,
1991
+ );
1992
+ announce(message);
1993
+ } else {
1994
+ send.disabled = false;
1995
+ showError(message);
1996
+ }
1997
+ } finally {
1998
+ send.removeAttribute('aria-busy');
1999
+ }
2000
+ });
2001
+
2002
+ trigger.hidden = true;
2003
+ const replies = item.querySelector(':scope > .g-comment__replies');
2004
+ if (replies) item.insertBefore(wrapper, replies);
2005
+ else item.appendChild(wrapper);
2006
+ focusNode(input);
2007
+ }
2008
+
1724
2009
  // ------------------------------------------------------------------- loading
1725
2010
  /**
1726
2011
  * @param {CommentPage} page
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. */