@lovett/ui 0.1.0 → 0.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.
- package/dist/index.d.ts +203 -17
- package/dist/index.js +88 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/display-store.test.tsx +120 -21
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/detail/__tests__/activity-pane.test.tsx +184 -1
- package/src/detail/activity-pane.tsx +107 -3
- package/src/display-store.tsx +61 -2
- package/src/index.ts +7 -0
- package/src/sortable.tsx +230 -25
- package/src/thread/__tests__/fixtures/thread-fixture.ts +17 -0
- package/src/thread/__tests__/thread.test.tsx +80 -0
- package/src/thread/comment.tsx +56 -4
- package/src/thread/types.ts +14 -0
package/src/sortable.tsx
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* - <DragHandle> is a Lucide-grip handle pre-wired to the listeners
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { useRef, useState } from 'react'
|
|
12
|
+
import { useEffect, useRef, useState } from 'react'
|
|
13
13
|
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
|
14
14
|
import {
|
|
15
15
|
DndContext,
|
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
MeasuringStrategy,
|
|
18
18
|
closestCenter,
|
|
19
19
|
closestCorners,
|
|
20
|
+
pointerWithin,
|
|
20
21
|
useDroppable,
|
|
21
22
|
KeyboardSensor,
|
|
22
23
|
PointerSensor,
|
|
23
24
|
useSensor,
|
|
24
25
|
useSensors,
|
|
26
|
+
type CollisionDetection,
|
|
25
27
|
type DragEndEvent,
|
|
26
28
|
type DragOverEvent,
|
|
27
29
|
type DragStartEvent,
|
|
@@ -203,15 +205,63 @@ export function DragHandle({
|
|
|
203
205
|
* as a click and not a 1px drag
|
|
204
206
|
*
|
|
205
207
|
* Added:
|
|
206
|
-
* - `
|
|
207
|
-
*
|
|
208
|
-
*
|
|
208
|
+
* - pointer-first collision (`pointerFirstCollision` below), which resolves
|
|
209
|
+
* the drop target from the CURSOR and falls back to `closestCorners` when
|
|
210
|
+
* there is no cursor. That fallback is not a nicety: it is the keyboard
|
|
211
|
+
* drag's only path.
|
|
209
212
|
* - `MeasuringStrategy.Always`, because a container's height changes as
|
|
210
213
|
* cards enter and leave it and a cached rect drops the item in the wrong
|
|
211
214
|
* lane.
|
|
212
215
|
* - screen-reader announcements naming the destination container, not its id.
|
|
213
216
|
* ========================================================================== */
|
|
214
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Pointer first, `closestCorners` second — dnd-kit's documented shape for a
|
|
220
|
+
* multi-container board (the pointer, then a rect-based fallback; which rect
|
|
221
|
+
* strategy is the choice made at the bottom of this note), and the answer to
|
|
222
|
+
* two defects rather than one.
|
|
223
|
+
*
|
|
224
|
+
* The history is a ladder, and each rung is still true:
|
|
225
|
+
*
|
|
226
|
+
* `closestCenter` measured the dragged card's CENTRE against each droppable
|
|
227
|
+
* and mis-resolved whenever the card was taller than the row it was over,
|
|
228
|
+
* which is the normal case on a board with variable-height cards.
|
|
229
|
+
*
|
|
230
|
+
* `closestCorners` measured the card's four CORNERS instead. Better, and
|
|
231
|
+
* still wrong in the same family: it measures the CARD. A tall card's
|
|
232
|
+
* corners stay nearer its source lane until most of its body has crossed the
|
|
233
|
+
* boundary, so the user has to drag the whole card into the destination
|
|
234
|
+
* rather than point at it. Reported as "I have to literally drag the full
|
|
235
|
+
* entire card over the lane so it catches it".
|
|
236
|
+
*
|
|
237
|
+
* `pointerWithin` measures the POINTER, so a card is caught by the lane the
|
|
238
|
+
* cursor is over regardless of how tall the card is. Card height stops being
|
|
239
|
+
* an input at all, which is why this does not reintroduce the defect
|
|
240
|
+
* `closestCorners` was adopted to fix — it retires the whole measurement.
|
|
241
|
+
*
|
|
242
|
+
* `pointerWithin` returns EVERY droppable containing the pointer, ordered by
|
|
243
|
+
* the pointer's mean distance to that rect's four corners — so the tightest
|
|
244
|
+
* box around the cursor wins. A pointer inside a card yields [card, column]
|
|
245
|
+
* and `over` resolves to the card, which is what gives a cross-column drop its
|
|
246
|
+
* insertion INDEX. A pointer in the column's empty space below the last card
|
|
247
|
+
* yields just the column, and the item appends. Both are wanted.
|
|
248
|
+
*
|
|
249
|
+
* It returns nothing at all when `pointerCoordinates` is null, and dnd-kit
|
|
250
|
+
* derives those from the activator event's `clientX`/`clientY`. A KeyboardEvent
|
|
251
|
+
* has neither, so EVERY keyboard drag takes the fallback — a pointer-only
|
|
252
|
+
* strategy would leave `over` permanently null and break cross-column keyboard
|
|
253
|
+
* movement silently, gates all green. The fallback is therefore `closestCorners`
|
|
254
|
+
* and not `rectIntersection`: keyboard drags keep the exact resolution they
|
|
255
|
+
* have been tested against, and the pointer-outside-every-droppable case (the
|
|
256
|
+
* board's margins and gutters) keeps resolving to the nearest lane rather than
|
|
257
|
+
* to nothing. `rectIntersection` would answer that case by area of overlap —
|
|
258
|
+
* "most of the card", the defect above, back in the gutters.
|
|
259
|
+
*/
|
|
260
|
+
export const pointerFirstCollision: CollisionDetection = (args) => {
|
|
261
|
+
const byPointer = pointerWithin(args)
|
|
262
|
+
return byPointer.length > 0 ? byPointer : closestCorners(args)
|
|
263
|
+
}
|
|
264
|
+
|
|
215
265
|
/** containerId -> the ids it holds, in order. */
|
|
216
266
|
export type SortableContainers = Readonly<Record<string, readonly string[]>>
|
|
217
267
|
|
|
@@ -228,11 +278,30 @@ export interface MultiSortableListProps {
|
|
|
228
278
|
/** Container ids in board order. Also the set treated as drop targets. */
|
|
229
279
|
containerOrder: readonly string[]
|
|
230
280
|
/**
|
|
231
|
-
* Commit the move. Called
|
|
232
|
-
*
|
|
233
|
-
* not
|
|
281
|
+
* Commit the move. Called from the drop handler with the arrangement the
|
|
282
|
+
* board is already showing, so the consumer's job is to make `containers`
|
|
283
|
+
* agree — it is not being asked to produce a frame.
|
|
284
|
+
*
|
|
285
|
+
* It used to say "called synchronously ... so a `setState` here batches with
|
|
286
|
+
* the primitive's own reset". True, and it described the wrong consumer. A
|
|
287
|
+
* consumer that patches its cache synchronously never saw the ghost; one
|
|
288
|
+
* that patches from a mutation's `onMutate` — a microtask at best, an
|
|
289
|
+
* `await cancelQueries` at worst — is not in that batch, and got a frame of
|
|
290
|
+
* the pre-drag arrangement. See the preview note below for what replaced it.
|
|
234
291
|
*/
|
|
235
292
|
onMove: (move: MultiSortableMove, next: SortableContainers) => void
|
|
293
|
+
/**
|
|
294
|
+
* Override the drop-target strategy. Defaults to `pointerFirstCollision`,
|
|
295
|
+
* whose reasoning is in its own docblock.
|
|
296
|
+
*
|
|
297
|
+
* A prop because this component has now changed collision strategy twice
|
|
298
|
+
* (`closestCenter` → `closestCorners` → pointer-first) and both times a
|
|
299
|
+
* consumer had to wait for a release of this package to get the fix. The
|
|
300
|
+
* escape hatch costs one optional prop; a third such wait costs a release.
|
|
301
|
+
* Overriding it is opting out of the reasoning above, including the keyboard
|
|
302
|
+
* fallback — a bare `pointerWithin` here disables keyboard dragging.
|
|
303
|
+
*/
|
|
304
|
+
collisionDetection?: CollisionDetection | undefined
|
|
236
305
|
/**
|
|
237
306
|
* What follows the pointer. Without this the card appears to vanish.
|
|
238
307
|
*
|
|
@@ -251,9 +320,11 @@ export interface MultiSortableListProps {
|
|
|
251
320
|
* and the container the drag is currently resolved INTO.
|
|
252
321
|
*
|
|
253
322
|
* The second argument exists because `useDroppable().isOver` cannot answer
|
|
254
|
-
* it on a board:
|
|
255
|
-
* whenever a column holds one
|
|
256
|
-
*
|
|
323
|
+
* it on a board: every collision strategy here resolves `over` to a sortable
|
|
324
|
+
* ITEM whenever a column holds one — `closestCorners` by distance, and
|
|
325
|
+
* `pointerWithin` because a pointer inside a card is inside its column too
|
|
326
|
+
* and the card's centre is nearer — so the column's own droppable never wins
|
|
327
|
+
* and its `isOver` measured `false` on every column during real pointer AND
|
|
257
328
|
* keyboard drags. The container has to be derived from the resolved `over`
|
|
258
329
|
* id — the same `containerOf` the announcements already use.
|
|
259
330
|
*/
|
|
@@ -263,6 +334,66 @@ export interface MultiSortableListProps {
|
|
|
263
334
|
) => ReactNode
|
|
264
335
|
}
|
|
265
336
|
|
|
337
|
+
/**
|
|
338
|
+
* The arrangement the board renders INSTEAD of `containers`, and the thing
|
|
339
|
+
* `containers` has to catch up to.
|
|
340
|
+
*
|
|
341
|
+
* Defect: dropping a card in another lane showed it snap back to its origin
|
|
342
|
+
* and then forward again — "like an invisible or ghost presence". The old
|
|
343
|
+
* `handleDragEnd` cleared the preview and then called `onMove`. Clearing means
|
|
344
|
+
* rendering from `containers`, and `containers` is the consumer's cache, which
|
|
345
|
+
* at that instant still holds the PRE-DRAG arrangement. One frame in the old
|
|
346
|
+
* lane, then the consumer's patch lands and moves it again. That frame is the
|
|
347
|
+
* ghost.
|
|
348
|
+
*
|
|
349
|
+
* Reordering the two lines does not fix it. Both are in one discrete event
|
|
350
|
+
* handler, React flushes that as one batch, and an asynchronous consumer patch
|
|
351
|
+
* is not in the batch either way — the render it produces still has the
|
|
352
|
+
* preview cleared and `containers` stale. The fix is not to clear the preview
|
|
353
|
+
* at the drop at all: hold it, and let the next `containers` supersede it.
|
|
354
|
+
*
|
|
355
|
+
* So the preview is held against the exact `containers` value it was committed
|
|
356
|
+
* over. Any new one means the consumer has spoken — a patch, a rollback, a
|
|
357
|
+
* refetch, all of them authoritative — and the preview is dropped in the same
|
|
358
|
+
* render, not a frame later.
|
|
359
|
+
*
|
|
360
|
+
* WHEN THE CONSUMER NEVER PATCHES. A held preview is a claim that a move
|
|
361
|
+
* landed. A consumer that rejects the move, fails its write without rolling
|
|
362
|
+
* back, or simply ignores `onMove` never produces a new `containers`, and an
|
|
363
|
+
* unbounded hold would leave the board asserting a move that did not happen —
|
|
364
|
+
* silently, until something unrelated invalidated the query. So the hold is
|
|
365
|
+
* bounded: after `PREVIEW_SETTLE_MS` the preview expires and `containers`
|
|
366
|
+
* wins, which renders as the card returning to its lane. That is the correct
|
|
367
|
+
* picture of a rejected move, and the only picture available, because the
|
|
368
|
+
* primitive has no other way to be told.
|
|
369
|
+
*
|
|
370
|
+
* The cost is paid by a consumer that commits WITHOUT an optimistic patch and
|
|
371
|
+
* waits for a server round trip: past the deadline the card returns and then
|
|
372
|
+
* moves again when the response lands. That is the ghost, delayed — and it is
|
|
373
|
+
* honest, because a board that does not yet know the move landed should not be
|
|
374
|
+
* drawing it. The fix for that consumer is an optimistic patch, which is the
|
|
375
|
+
* pattern this component is shaped around.
|
|
376
|
+
*/
|
|
377
|
+
interface DragPreview {
|
|
378
|
+
arrangement: SortableContainers
|
|
379
|
+
/** The `containers` identity this was committed over. A new one supersedes. */
|
|
380
|
+
basis: SortableContainers
|
|
381
|
+
/** False while the drag is in flight — a live preview is never superseded. */
|
|
382
|
+
settled: boolean
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* How long a settled preview outlives the drop before `containers` wins.
|
|
387
|
+
*
|
|
388
|
+
* Floor: dnd-kit's drop animation is 250ms, and the preview has to still be
|
|
389
|
+
* under the overlay when it lands or the overlay animates onto an empty slot.
|
|
390
|
+
* Ceiling: a rejected move has to read as rejected rather than as accepted,
|
|
391
|
+
* and half a second is about where a snap-back stops looking like a response.
|
|
392
|
+
* The healthy path never reaches either — an optimistic patch supersedes this
|
|
393
|
+
* in a frame or two, and the timer is cleared.
|
|
394
|
+
*/
|
|
395
|
+
export const PREVIEW_SETTLE_MS = 500
|
|
396
|
+
|
|
266
397
|
function moveBetween(
|
|
267
398
|
arrangement: SortableContainers,
|
|
268
399
|
itemId: string,
|
|
@@ -284,6 +415,7 @@ export function MultiSortableList({
|
|
|
284
415
|
containers,
|
|
285
416
|
containerOrder,
|
|
286
417
|
onMove,
|
|
418
|
+
collisionDetection = pointerFirstCollision,
|
|
287
419
|
renderOverlay,
|
|
288
420
|
labelForItem,
|
|
289
421
|
labelForContainer,
|
|
@@ -296,11 +428,59 @@ export function MultiSortableList({
|
|
|
296
428
|
|
|
297
429
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
298
430
|
const [overContainerId, setOverContainerId] = useState<string | null>(null)
|
|
299
|
-
const [preview, setPreview] = useState<
|
|
431
|
+
const [preview, setPreview] = useState<DragPreview | null>(null)
|
|
300
432
|
// The arrangement the drag started from, so a cancel restores it exactly.
|
|
301
433
|
const originRef = useRef<SortableContainers | null>(null)
|
|
434
|
+
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
435
|
+
|
|
436
|
+
useEffect(
|
|
437
|
+
() => () => {
|
|
438
|
+
if (settleTimer.current !== null) clearTimeout(settleTimer.current)
|
|
439
|
+
},
|
|
440
|
+
[],
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
const stopSettleTimer = () => {
|
|
444
|
+
if (settleTimer.current !== null) {
|
|
445
|
+
clearTimeout(settleTimer.current)
|
|
446
|
+
settleTimer.current = null
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Derived, not stored: on the render where `containers` finally carries the
|
|
451
|
+
// move, the board is already right — no second commit, no frame in between.
|
|
452
|
+
const superseded = preview !== null && preview.settled && preview.basis !== containers
|
|
453
|
+
const arrangement = preview !== null && !superseded ? preview.arrangement : containers
|
|
302
454
|
|
|
303
|
-
|
|
455
|
+
// And a supersede is FINAL. `containers` can return to the exact object the
|
|
456
|
+
// preview was held against — a rolled-back optimistic write restores the
|
|
457
|
+
// previous reference, not a copy of it — and a preview still sitting in state
|
|
458
|
+
// would then stop looking superseded and draw the move a second time, after
|
|
459
|
+
// the consumer had undone it. Dropping it here rather than in an effect keeps
|
|
460
|
+
// that to zero frames: React re-runs this render with the state already gone.
|
|
461
|
+
// The pending timer is left to fire into a null preview, where it is a no-op.
|
|
462
|
+
if (superseded) setPreview(null)
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Show `next` until `containers` says otherwise. Called at every exit from a
|
|
466
|
+
* drag — committed, no-op, dropped outside, cancelled — so there is exactly
|
|
467
|
+
* one place where the board stops previewing.
|
|
468
|
+
*
|
|
469
|
+
* `next === containers` covers the ordinary cancel: the board is already
|
|
470
|
+
* rendering the truth, so there is nothing to hold and no timer to run.
|
|
471
|
+
*/
|
|
472
|
+
const holdPreview = (next: SortableContainers) => {
|
|
473
|
+
stopSettleTimer()
|
|
474
|
+
if (next === containers) {
|
|
475
|
+
setPreview(null)
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
setPreview({ arrangement: next, basis: containers, settled: true })
|
|
479
|
+
settleTimer.current = setTimeout(() => {
|
|
480
|
+
settleTimer.current = null
|
|
481
|
+
setPreview(null)
|
|
482
|
+
}, PREVIEW_SETTLE_MS)
|
|
483
|
+
}
|
|
304
484
|
|
|
305
485
|
const containerOf = (id: string): string | null => {
|
|
306
486
|
if (containerOrder.includes(id)) return id
|
|
@@ -316,8 +496,14 @@ export function MultiSortableList({
|
|
|
316
496
|
const handleDragStart = (event: DragStartEvent) => {
|
|
317
497
|
const id = String(event.active.id)
|
|
318
498
|
setActiveId(id)
|
|
319
|
-
|
|
320
|
-
|
|
499
|
+
stopSettleTimer()
|
|
500
|
+
// `arrangement`, not `containers`. A previous drop may still be held while
|
|
501
|
+
// its consumer patch is in flight, and rebasing on `containers` here would
|
|
502
|
+
// yank that card back to its old lane the moment the user picks up the
|
|
503
|
+
// next one — the ghost again, one drag later. The drag starts from what is
|
|
504
|
+
// on screen, which is also what the user is dragging.
|
|
505
|
+
originRef.current = arrangement
|
|
506
|
+
setPreview({ arrangement, basis: containers, settled: false })
|
|
321
507
|
// The card starts over its own column, so the drop target is lit from the
|
|
322
508
|
// first frame rather than only once the pointer crosses a boundary.
|
|
323
509
|
setOverContainerId(containerOf(id))
|
|
@@ -341,7 +527,11 @@ export function MultiSortableList({
|
|
|
341
527
|
const overItems = arrangement[to] ?? []
|
|
342
528
|
const overIndex = overItems.indexOf(overId)
|
|
343
529
|
const index = overIndex === -1 ? overItems.length : overIndex
|
|
344
|
-
setPreview(
|
|
530
|
+
setPreview({
|
|
531
|
+
arrangement: moveBetween(arrangement, itemId, from, to, index),
|
|
532
|
+
basis: containers,
|
|
533
|
+
settled: false,
|
|
534
|
+
})
|
|
345
535
|
}
|
|
346
536
|
|
|
347
537
|
const handleDragEnd = (event: DragEndEvent) => {
|
|
@@ -355,8 +545,11 @@ export function MultiSortableList({
|
|
|
355
545
|
setOverContainerId(null)
|
|
356
546
|
originRef.current = null
|
|
357
547
|
|
|
548
|
+
// Dropped outside every column, or an id the origin does not hold. Nothing
|
|
549
|
+
// is committed, so nothing will patch: the board goes back to what it was
|
|
550
|
+
// showing when the drag began.
|
|
358
551
|
if (!over || !from) {
|
|
359
|
-
|
|
552
|
+
holdPreview(origin)
|
|
360
553
|
return
|
|
361
554
|
}
|
|
362
555
|
|
|
@@ -367,22 +560,33 @@ export function MultiSortableList({
|
|
|
367
560
|
const index = overIndex === -1 ? overItems.length : overIndex
|
|
368
561
|
const next = moveBetween(origin, itemId, from, to, index)
|
|
369
562
|
|
|
370
|
-
|
|
371
|
-
if (from === to && (origin[from] ?? []).indexOf(itemId) === index)
|
|
563
|
+
// Same lane, same slot. No `onMove`, therefore no patch to wait for.
|
|
564
|
+
if (from === to && (origin[from] ?? []).indexOf(itemId) === index) {
|
|
565
|
+
holdPreview(origin)
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Hold BEFORE handing over. The primitive's own state is then complete and
|
|
570
|
+
// consistent whatever `onMove` does, including throw.
|
|
571
|
+
holdPreview(next)
|
|
372
572
|
onMove({ itemId, fromContainerId: from, toContainerId: to, toIndex: index }, next)
|
|
373
573
|
}
|
|
374
574
|
|
|
375
575
|
const handleDragCancel = () => {
|
|
376
576
|
setActiveId(null)
|
|
377
577
|
setOverContainerId(null)
|
|
578
|
+
const origin = originRef.current ?? containers
|
|
378
579
|
originRef.current = null
|
|
379
|
-
setPreview(null)
|
|
580
|
+
// Not `setPreview(null)`: clearing renders `containers`, which is only the
|
|
581
|
+
// right answer when the drag started from it. If a previous drop is still
|
|
582
|
+
// held, cancelling this one must return to THAT, not undo both.
|
|
583
|
+
holdPreview(origin)
|
|
380
584
|
}
|
|
381
585
|
|
|
382
586
|
return (
|
|
383
587
|
<DndContext
|
|
384
588
|
sensors={sensors}
|
|
385
|
-
collisionDetection={
|
|
589
|
+
collisionDetection={collisionDetection}
|
|
386
590
|
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
|
387
591
|
onDragStart={handleDragStart}
|
|
388
592
|
onDragOver={handleDragOver}
|
|
@@ -424,11 +628,12 @@ export function MultiSortableList({
|
|
|
424
628
|
* accent (design brief §4), and only the consumer knows what its border is.
|
|
425
629
|
*
|
|
426
630
|
* `isOver` here is `useDroppable`'s raw answer and is NOT the board's drag-over
|
|
427
|
-
* signal:
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
631
|
+
* signal: `over` resolves to a sortable item whenever the column holds one, so
|
|
632
|
+
* this reads `false` for the column the card is actually over. It is kept
|
|
633
|
+
* because the droppable registration is what makes an EMPTY column a drop
|
|
634
|
+
* target at all — and, under pointer-first collision, what makes the column's
|
|
635
|
+
* empty space below the last card a target for an append. The state a consumer
|
|
636
|
+
* should paint comes from `MultiSortableList`'s `overContainerId`.
|
|
432
637
|
*/
|
|
433
638
|
export function SortableDropZone({
|
|
434
639
|
id,
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* • 8 replies on one parent — 3 visible, 5 hidden (D-port)
|
|
12
12
|
* • a DELETED parent with live children — D4, the tombstone that keeps its
|
|
13
13
|
* subtree instead of orphaning it
|
|
14
|
+
* • a MODERATED tombstone — FU-0032, the second way a comment
|
|
15
|
+
* becomes one; `moderated` is all
|
|
16
|
+
* that separates it from the first
|
|
14
17
|
* • a reply whose parent is ABSENT — D4's other half: "not fetched" is
|
|
15
18
|
* not "deleted", and must be labelled
|
|
16
19
|
* • a pending and a failed send — D17
|
|
@@ -143,6 +146,20 @@ export const THREAD_FIXTURE: readonly ThreadComment[] = [
|
|
|
143
146
|
createdAt: ago(4 * HOUR + 20 * MINUTE),
|
|
144
147
|
},
|
|
145
148
|
|
|
149
|
+
// ── FU-0032: a tombstone somebody ELSE made. Structurally identical to
|
|
150
|
+
// the one above — same blanked body, same `deletedAt` — and `moderated`
|
|
151
|
+
// is the entire difference, which is the point: without it the two are
|
|
152
|
+
// the same row and the reader is told the wrong thing about one of them.
|
|
153
|
+
// The author is still withheld, and the wire never carries who removed it.
|
|
154
|
+
{
|
|
155
|
+
id: 'c-moderated',
|
|
156
|
+
author: { id: 'usr_kai', name: 'Kai Moreau' },
|
|
157
|
+
bodyMd: '',
|
|
158
|
+
createdAt: ago(5 * HOUR + 30 * MINUTE),
|
|
159
|
+
deletedAt: ago(3 * HOUR),
|
|
160
|
+
moderated: true,
|
|
161
|
+
},
|
|
162
|
+
|
|
146
163
|
// ── D4's other half: the parent is ABSENT from this page, not deleted. ──
|
|
147
164
|
{
|
|
148
165
|
id: 'c-orphan',
|
|
@@ -145,6 +145,86 @@ describe('Thread — D4 deletion and orphans', () => {
|
|
|
145
145
|
})
|
|
146
146
|
})
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* FU-0032 — a tombstone has to say which removal it was.
|
|
150
|
+
*
|
|
151
|
+
* `[deleted]` covered one case because, when it was written, an author
|
|
152
|
+
* deleting their own comment was the only way a comment became a tombstone.
|
|
153
|
+
* An admin redacting somebody else's is the second, and the reader it lands
|
|
154
|
+
* hardest on is the person whose comment is gone: `[deleted]` tells them they
|
|
155
|
+
* did it themselves.
|
|
156
|
+
*
|
|
157
|
+
* The load-bearing tests here are the negative ones. `moderated` absent or
|
|
158
|
+
* false has to render the 0.1.0 string exactly, because every existing host is
|
|
159
|
+
* on that path and not one of them will ever set the field.
|
|
160
|
+
*/
|
|
161
|
+
describe('Thread — FU-0032 a moderated tombstone', () => {
|
|
162
|
+
/** One field, four states. Local because it is a permutation, not shared data. */
|
|
163
|
+
const TOMBSTONES: readonly ThreadComment[] = [
|
|
164
|
+
{
|
|
165
|
+
id: 't-self',
|
|
166
|
+
author: { id: 'usr_ada', name: 'Ada Whitfield' },
|
|
167
|
+
bodyMd: '',
|
|
168
|
+
createdAt: FIXTURE_NOW - 2 * 3_600_000,
|
|
169
|
+
deletedAt: FIXTURE_NOW - 60_000,
|
|
170
|
+
moderated: false,
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 't-absent',
|
|
174
|
+
author: { id: 'usr_lee', name: 'Lee Ndiaye' },
|
|
175
|
+
bodyMd: '',
|
|
176
|
+
createdAt: FIXTURE_NOW - 3 * 3_600_000,
|
|
177
|
+
deletedAt: FIXTURE_NOW - 60_000,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: 't-live',
|
|
181
|
+
author: { id: 'usr_dana', name: 'Dana Ortiz' },
|
|
182
|
+
bodyMd: 'Still here.',
|
|
183
|
+
createdAt: FIXTURE_NOW - 4 * 3_600_000,
|
|
184
|
+
moderated: true,
|
|
185
|
+
},
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
it('says the comment was removed rather than deleted', () => {
|
|
189
|
+
renderThread()
|
|
190
|
+
// The byline stands in for the author, so it takes the SHORT form: a
|
|
191
|
+
// sentence in the name slot reads as an attribution, and the one thing
|
|
192
|
+
// this render must never do is imply who removed it.
|
|
193
|
+
expect(within(byline('c-moderated')).getByText('[removed]')).toBeTruthy()
|
|
194
|
+
// The body is the interface's own line, so it is where the sentence goes.
|
|
195
|
+
expect(
|
|
196
|
+
within(commentBox('c-moderated')).getByText('[removed by an admin]', { selector: 'p' }),
|
|
197
|
+
).toBeTruthy()
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('withholds the author of a moderated comment, exactly as of a deleted one', () => {
|
|
201
|
+
renderThread()
|
|
202
|
+
expect(within(commentBox('c-moderated')).queryByText('Kai Moreau')).toBeNull()
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('renders [deleted] when moderated is false — the 0.1.0 string, unchanged', () => {
|
|
206
|
+
render(<Thread comments={TOMBSTONES} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
|
|
207
|
+
expect(within(byline('t-self')).getByText('[deleted]')).toBeTruthy()
|
|
208
|
+
expect(within(commentBox('t-self')).getByText('[deleted]', { selector: 'p' })).toBeTruthy()
|
|
209
|
+
expect(within(commentBox('t-self')).queryByText(/removed/)).toBeNull()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('renders [deleted] when moderated is absent — every 0.1.0 host is here', () => {
|
|
213
|
+
render(<Thread comments={TOMBSTONES} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
|
|
214
|
+
expect(within(byline('t-absent')).getByText('[deleted]')).toBeTruthy()
|
|
215
|
+
expect(within(commentBox('t-absent')).getByText('[deleted]', { selector: 'p' })).toBeTruthy()
|
|
216
|
+
expect(within(commentBox('t-absent')).queryByText(/removed/)).toBeNull()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('ignores moderated on a comment that is not a tombstone at all', () => {
|
|
220
|
+
render(<Thread comments={TOMBSTONES} now={FIXTURE_NOW} locale="en-GB" defaultSort="new" />)
|
|
221
|
+
const box = commentBox('t-live')
|
|
222
|
+
expect(within(box).getByText('Still here.')).toBeTruthy()
|
|
223
|
+
expect(within(byline('t-live')).getByText('Dana Ortiz')).toBeTruthy()
|
|
224
|
+
expect(within(box).queryByText(/removed/)).toBeNull()
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
148
228
|
describe('Thread — collapse, paging and the depth cap', () => {
|
|
149
229
|
it('shows three replies then "Show 5 more replies"', () => {
|
|
150
230
|
renderThread()
|
package/src/thread/comment.tsx
CHANGED
|
@@ -212,6 +212,53 @@ function authorLabel(author: ThreadAuthor | null | undefined): string {
|
|
|
212
212
|
return 'Unknown member'
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* THE tombstone strings — two acts, and a deliberate word for each.
|
|
217
|
+
*
|
|
218
|
+
* A comment becomes a tombstone two ways now (FU-0031, FU-0032): its author
|
|
219
|
+
* deleted it, or somebody else removed it. `[deleted]` was written when only
|
|
220
|
+
* the first existed, and it is read by the person whose comment is gone —
|
|
221
|
+
* telling them they did it themselves when they did not is the whole defect.
|
|
222
|
+
*
|
|
223
|
+
* REMOVED, not deleted. The two verbs carry the entire distinction, so each
|
|
224
|
+
* one is reserved for exactly one act: you delete your own, somebody removes
|
|
225
|
+
* yours. Any pair that shared a verb would need a qualifier to be read at all.
|
|
226
|
+
*
|
|
227
|
+
* "AN ADMIN", not "a workspace admin" and not "a moderator". The ONE thing
|
|
228
|
+
* `moderated` licenses the render to claim is that the author did not do it —
|
|
229
|
+
* that is the whole content of the flag, and how a host derives it is the
|
|
230
|
+
* host's business, not this module's. (An earlier draft asserted it comes from
|
|
231
|
+
* `deleted_by <> author_id`. That is one consumer's wire and appears nowhere in
|
|
232
|
+
* this repository; stating another product's schema as fact here is exactly the
|
|
233
|
+
* coupling this module's entity-agnostic claim rules out.) Naming a ROLE rather
|
|
234
|
+
* than a person keeps the claim true — nothing obliges a host to send an actor,
|
|
235
|
+
* and a string with a slot for one is a string somebody eventually fills.
|
|
236
|
+
* "Workspace" is one host's noun and this module is entity-agnostic by its own
|
|
237
|
+
* opening claim (types.ts); "moderator" is a forum's word for a role that
|
|
238
|
+
* exists in neither consumer. "Admin" is what both of them actually call the
|
|
239
|
+
* person who can do this, which is the test interface copy actually has to
|
|
240
|
+
* pass.
|
|
241
|
+
*
|
|
242
|
+
* TWO STRINGS PER ACT, because they land in two different slots. The byline is
|
|
243
|
+
* the AUTHOR position: a sentence there reads as an attribution, and "removed
|
|
244
|
+
* by an admin" sitting where a name goes says the admin wrote it. It also
|
|
245
|
+
* feeds two accessible names — "Collapse replies to X" and "More actions for
|
|
246
|
+
* X's comment" — which a sentence makes unusable. So the byline takes the
|
|
247
|
+
* short form and the body, which is the interface speaking in its own voice,
|
|
248
|
+
* takes the sentence.
|
|
249
|
+
*
|
|
250
|
+
* The brackets and the lower case are unchanged from `[deleted]`: that form is
|
|
251
|
+
* what marks a line as the system talking rather than a person, and a
|
|
252
|
+
* tombstone that stopped looking like one would be a worse regression than the
|
|
253
|
+
* one being fixed.
|
|
254
|
+
*/
|
|
255
|
+
const TOMBSTONE: Readonly<
|
|
256
|
+
Record<'deleted' | 'moderated', { readonly byline: string; readonly body: string }>
|
|
257
|
+
> = {
|
|
258
|
+
deleted: { byline: '[deleted]', body: '[deleted]' },
|
|
259
|
+
moderated: { byline: '[removed]', body: '[removed by an admin]' },
|
|
260
|
+
}
|
|
261
|
+
|
|
215
262
|
/** "1 reply" / "8 replies" — the live region's wording, in one place. */
|
|
216
263
|
function replyCountLabel(count: number): string {
|
|
217
264
|
return `${String(count)} ${count === 1 ? 'reply' : 'replies'}`
|
|
@@ -375,7 +422,12 @@ export function CommentItem({
|
|
|
375
422
|
const collapsed = thread.isCollapsed(node.id)
|
|
376
423
|
const deleted = node.deletedAt != null
|
|
377
424
|
const state = node.state ?? 'sent'
|
|
378
|
-
|
|
425
|
+
// `moderated` says nothing about a live comment — nobody has removed one —
|
|
426
|
+
// so it is read HERE, under `deleted`, and nowhere else in the render. A
|
|
427
|
+
// comment carrying the flag without a `deletedAt` is not a tombstone and
|
|
428
|
+
// does not become one.
|
|
429
|
+
const tombstone = deleted ? TOMBSTONE[node.moderated === true ? 'moderated' : 'deleted'] : null
|
|
430
|
+
const label = tombstone === null ? authorLabel(node.author) : tombstone.byline
|
|
379
431
|
const descendants = countDescendants(node)
|
|
380
432
|
const atCap = renderDepth >= thread.maxDepth && !continued
|
|
381
433
|
const { visible, hidden } = thread.visibleReplies(node)
|
|
@@ -597,9 +649,9 @@ export function CommentItem({
|
|
|
597
649
|
</p>
|
|
598
650
|
)}
|
|
599
651
|
|
|
600
|
-
{
|
|
652
|
+
{tombstone !== null ? (
|
|
601
653
|
<p className="text-[13px] italic" style={{ color: 'rgb(var(--text-tertiary))' }}>
|
|
602
|
-
|
|
654
|
+
{tombstone.body}
|
|
603
655
|
</p>
|
|
604
656
|
) : (
|
|
605
657
|
<CommentBody
|
|
@@ -624,7 +676,7 @@ export function CommentItem({
|
|
|
624
676
|
deliberately attached outranks what a scanner found in their
|
|
625
677
|
prose. A tombstone shows none — the worker already returns none
|
|
626
678
|
for one (ADR-150 D8), and this is the second lock on it, because
|
|
627
|
-
|
|
679
|
+
a tombstone followed by three intact cards would leak the body
|
|
628
680
|
the tombstone exists to remove. */}
|
|
629
681
|
{node.linkPreviews === undefined || node.linkPreviews.length === 0 || deleted ? null : (
|
|
630
682
|
<LinkPreviews
|
package/src/thread/types.ts
CHANGED
|
@@ -149,6 +149,20 @@ export interface ThreadComment {
|
|
|
149
149
|
readonly editedAt?: number | null | undefined
|
|
150
150
|
/** Set = tombstone (D4). The row still renders and KEEPS its subtree. */
|
|
151
151
|
readonly deletedAt?: number | null | undefined
|
|
152
|
+
/**
|
|
153
|
+
* TRUE = the tombstone above was made by somebody OTHER than the author
|
|
154
|
+
* (FU-0031, FU-0032). Meaningless without `deletedAt`, and read nowhere else.
|
|
155
|
+
*
|
|
156
|
+
* One boolean rather than a `deletedBy` author, and that is a decision, not
|
|
157
|
+
* a shortcut: the wire deliberately never carries who removed a comment, so
|
|
158
|
+
* a field that could hold a name would be a field somebody eventually fills.
|
|
159
|
+
* A boolean cannot leak an identity it does not have.
|
|
160
|
+
*
|
|
161
|
+
* OPTIONAL, and absent means `[deleted]` — the render every host on 0.1.0
|
|
162
|
+
* already gets. A host with no moderation concept never sets it and never
|
|
163
|
+
* sees a word change.
|
|
164
|
+
*/
|
|
165
|
+
readonly moderated?: boolean | undefined
|
|
152
166
|
readonly reactions?: readonly ThreadReactionCount[] | undefined
|
|
153
167
|
readonly attachments?: readonly ThreadAttachment[] | undefined
|
|
154
168
|
/**
|