@lovett/ui 0.1.0 → 0.2.3
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/{chunk-RBYWGBQ2.js → chunk-GP7BKVZC.js} +8 -5
- package/dist/chunk-GP7BKVZC.js.map +1 -0
- package/dist/index.d.ts +316 -17
- package/dist/index.js +96 -23
- package/dist/index.js.map +1 -1
- package/dist/{rich-composer-impl-5NO443A6.js → rich-composer-impl-F5PQVFZT.js} +3 -3
- package/dist/{rich-composer-impl-5NO443A6.js.map → rich-composer-impl-F5PQVFZT.js.map} +1 -1
- package/dist/styles.css +53 -1
- package/dist/theme-v2.css +26 -0
- package/dist/tokens.css +13 -0
- package/package.json +2 -2
- package/src/__tests__/clip-reserve.test.ts +431 -0
- 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 +218 -1
- package/src/detail/activity-pane.tsx +117 -3
- package/src/display-store.tsx +61 -2
- package/src/index.ts +7 -0
- package/src/sortable.tsx +230 -25
- package/src/styles.css +53 -1
- package/src/theme-v2.css +26 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +17 -0
- package/src/thread/__tests__/thread.test.tsx +268 -0
- package/src/thread/attachments.tsx +1 -1
- package/src/thread/comment.tsx +104 -6
- package/src/thread/composer.tsx +4 -1
- package/src/thread/reactions.tsx +2 -1
- package/src/thread/types.ts +109 -0
- package/src/tokens.css +13 -0
- package/dist/chunk-RBYWGBQ2.js.map +0 -1
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { useState } from 'react'
|
|
10
10
|
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
-
import { render, screen, within } from '@testing-library/react'
|
|
11
|
+
import { cleanup, render, screen, within } from '@testing-library/react'
|
|
12
12
|
import userEvent from '@testing-library/user-event'
|
|
13
13
|
import { ActivityPane } from '../activity-pane'
|
|
14
14
|
import type { ThreadComment } from '../../thread/types'
|
|
@@ -184,3 +184,220 @@ describe('without a comment backend', () => {
|
|
|
184
184
|
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
|
185
185
|
})
|
|
186
186
|
})
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* FU-0019 — the pane is a conduit, and it used to be a leaky one.
|
|
190
|
+
*
|
|
191
|
+
* `ActivityPaneProps` declared `onSubmit` and `onReply` and stopped, so three
|
|
192
|
+
* mutations `<Thread>` already accepted could not be reached through the pane
|
|
193
|
+
* at all. Nothing errored, which is why it survived a release: `CommentItem`
|
|
194
|
+
* draws no control for a callback it was not given, so a host shipped an inert
|
|
195
|
+
* engagement row and a one-line overflow menu while the routes behind them had
|
|
196
|
+
* no caller anywhere in the browser.
|
|
197
|
+
*
|
|
198
|
+
* These assert at the CONTROL, never at the prop. A prop that arrives and
|
|
199
|
+
* lands nowhere is the same bug one layer further in.
|
|
200
|
+
*/
|
|
201
|
+
describe('ActivityPane — forwarding the thread callbacks', () => {
|
|
202
|
+
const WIRED: readonly ThreadComment[] = [
|
|
203
|
+
{
|
|
204
|
+
id: 'c1',
|
|
205
|
+
author: { id: 'u1', name: 'Ada Lovelace' },
|
|
206
|
+
bodyMd: 'Looks right to me.',
|
|
207
|
+
createdAt: NOW - 60_000,
|
|
208
|
+
reactions: [{ key: 'up', count: 7, viewerReacted: true }],
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
id: 'c-failed',
|
|
212
|
+
author: { id: 'u2', name: 'Grace Hopper' },
|
|
213
|
+
bodyMd: 'This one never left the device.',
|
|
214
|
+
createdAt: NOW - 30_000,
|
|
215
|
+
state: 'failed',
|
|
216
|
+
errorText: 'This comment did not send.',
|
|
217
|
+
},
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
/** Mounts the pane and opens Comments, which is where `<Thread>` lives. */
|
|
221
|
+
async function comments(
|
|
222
|
+
props: Partial<React.ComponentProps<typeof ActivityPane>> = {},
|
|
223
|
+
): Promise<HTMLElement> {
|
|
224
|
+
render(
|
|
225
|
+
<ActivityPane
|
|
226
|
+
comments={WIRED}
|
|
227
|
+
activity={activity}
|
|
228
|
+
draft=""
|
|
229
|
+
onDraftChange={() => {}}
|
|
230
|
+
onSubmit={() => {}}
|
|
231
|
+
now={NOW}
|
|
232
|
+
locale="en-US"
|
|
233
|
+
{...props}
|
|
234
|
+
/>,
|
|
235
|
+
)
|
|
236
|
+
await userEvent.click(screen.getByRole('tab', { name: /Comments/ }))
|
|
237
|
+
return panel('Comments')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
it('reports a reaction key from the chip the thread drew', async () => {
|
|
241
|
+
const onReact = vi.fn()
|
|
242
|
+
const shown = await comments({ onReact })
|
|
243
|
+
await userEvent.click(within(shown).getByRole('button', { name: 'Agree — 7' }))
|
|
244
|
+
expect(onReact).toHaveBeenCalledWith('c1', 'up')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('offers a picker, so a reaction can come into existence at all', async () => {
|
|
248
|
+
const shown = await comments({ onReact: vi.fn() })
|
|
249
|
+
expect(within(shown).getByRole('button', { name: 'Add a reaction' })).toBeInTheDocument()
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('puts Edit and Delete in the overflow menu and reports the comment id', async () => {
|
|
253
|
+
const onEdit = vi.fn()
|
|
254
|
+
const onDelete = vi.fn()
|
|
255
|
+
const shown = await comments({ onEdit, onDelete })
|
|
256
|
+
await userEvent.click(
|
|
257
|
+
within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
|
|
258
|
+
)
|
|
259
|
+
await userEvent.click(screen.getByRole('menuitem', { name: 'Edit' }))
|
|
260
|
+
expect(onEdit).toHaveBeenCalledWith('c1')
|
|
261
|
+
|
|
262
|
+
await userEvent.click(
|
|
263
|
+
within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
|
|
264
|
+
)
|
|
265
|
+
const remove = screen.getByRole('menuitem', { name: 'Delete' })
|
|
266
|
+
expect(remove.getAttribute('data-variant')).toBe('destructive')
|
|
267
|
+
await userEvent.click(remove)
|
|
268
|
+
expect(onDelete).toHaveBeenCalledWith('c1')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('recovers a send that failed in its OWN composer', async () => {
|
|
272
|
+
const onRetry = vi.fn()
|
|
273
|
+
const shown = await comments({ onRetry })
|
|
274
|
+
await userEvent.click(within(shown).getByRole('button', { name: 'Try again' }))
|
|
275
|
+
expect(onRetry).toHaveBeenCalledWith('c-failed')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('carries a comment’s canEdit / canDelete through to the menu', async () => {
|
|
279
|
+
// The pane forwards `comments` verbatim, so the capability needs no prop
|
|
280
|
+
// of its own here — but "needs no plumbing" is a claim, and this is the
|
|
281
|
+
// assertion behind it. Wiring the two callbacks is not enough on its own.
|
|
282
|
+
const REFUSED: readonly ThreadComment[] = [
|
|
283
|
+
{
|
|
284
|
+
id: 'c1',
|
|
285
|
+
author: { id: 'u1', name: 'Ada Lovelace' },
|
|
286
|
+
bodyMd: 'Somebody else wrote this.',
|
|
287
|
+
createdAt: NOW - 60_000,
|
|
288
|
+
canEdit: false,
|
|
289
|
+
canDelete: false,
|
|
290
|
+
},
|
|
291
|
+
]
|
|
292
|
+
const shown = await comments({ comments: REFUSED, onEdit: vi.fn(), onDelete: vi.fn() })
|
|
293
|
+
// Both refused, no clipboard under jsdom and the pane never forwards
|
|
294
|
+
// `onCopyLink`, so there is nothing left to put in a menu and no trigger
|
|
295
|
+
// is drawn. Which is the point: the two items are gone, not greyed.
|
|
296
|
+
expect(
|
|
297
|
+
within(shown).queryByRole('button', { name: "More actions for Ada Lovelace's comment" }),
|
|
298
|
+
).toBeNull()
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('draws both when a comment states no capability — the 0.2.0 render', async () => {
|
|
302
|
+
// The other half of the default, at the control. `WIRED` sets neither
|
|
303
|
+
// flag, so a host that upgrades without mapping them loses nothing.
|
|
304
|
+
const shown = await comments({ onEdit: vi.fn(), onDelete: vi.fn() })
|
|
305
|
+
await userEvent.click(
|
|
306
|
+
within(shown).getByRole('button', { name: "More actions for Ada Lovelace's comment" }),
|
|
307
|
+
)
|
|
308
|
+
expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeInTheDocument()
|
|
309
|
+
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeInTheDocument()
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('draws none of them for a host that wires none — the 0.1.0 render', async () => {
|
|
313
|
+
const shown = await comments()
|
|
314
|
+
// What FU-0019 measured in a browser: a written reaction is readable and
|
|
315
|
+
// inert, and there is no picker, so a reaction can never be created.
|
|
316
|
+
expect(within(shown).getByRole('button', { name: 'Agree — 7' })).toBeDisabled()
|
|
317
|
+
expect(within(shown).queryByRole('button', { name: 'Add a reaction' })).toBeNull()
|
|
318
|
+
expect(within(shown).queryByRole('button', { name: 'Try again' })).toBeNull()
|
|
319
|
+
// The failure itself is still announced; only the recovery is missing.
|
|
320
|
+
expect(within(shown).getByText('This comment did not send.')).toBeInTheDocument()
|
|
321
|
+
// And no overflow menu at all: with no Edit, no Delete, no Copy link and
|
|
322
|
+
// no clipboard under jsdom, `CommentActions` has nothing to put in one and
|
|
323
|
+
// renders none. In a real browser it holds exactly one line — "Copy text",
|
|
324
|
+
// the single item that needs no callback — which is what FU-0019 measured.
|
|
325
|
+
expect(
|
|
326
|
+
within(shown).queryByRole('button', { name: "More actions for Ada Lovelace's comment" }),
|
|
327
|
+
).toBeNull()
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* FU-0032 — the Everything feed draws its own tombstone, and it has to agree
|
|
333
|
+
* with the one the Comments tab draws for the same comment.
|
|
334
|
+
*/
|
|
335
|
+
describe('ActivityPane — a moderated comment in the merged feed', () => {
|
|
336
|
+
// One tombstone, two removals. Derived from a single base so the ONLY
|
|
337
|
+
// difference between the two renders is the field under test.
|
|
338
|
+
const BASE: ThreadComment = {
|
|
339
|
+
id: 'c-mod',
|
|
340
|
+
author: { id: 'u1', name: 'Ada Lovelace' },
|
|
341
|
+
bodyMd: '',
|
|
342
|
+
createdAt: NOW - 60_000,
|
|
343
|
+
deletedAt: NOW - 30_000,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const REMOVED: readonly ThreadComment[] = [{ ...BASE, moderated: true }]
|
|
347
|
+
const SELF: readonly ThreadComment[] = [{ ...BASE, id: 'c-self', moderated: false }]
|
|
348
|
+
|
|
349
|
+
function feed(list: readonly ThreadComment[]): HTMLElement {
|
|
350
|
+
render(
|
|
351
|
+
<ActivityPane
|
|
352
|
+
comments={list}
|
|
353
|
+
activity={[]}
|
|
354
|
+
draft=""
|
|
355
|
+
onDraftChange={() => {}}
|
|
356
|
+
onSubmit={() => {}}
|
|
357
|
+
now={NOW}
|
|
358
|
+
/>,
|
|
359
|
+
)
|
|
360
|
+
return panel('Everything')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
it('says an admin removed it', () => {
|
|
364
|
+
expect(feed(REMOVED)).toHaveTextContent('Comment removed by an admin')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('still says "Comment deleted" for an author\'s own delete', () => {
|
|
368
|
+
const shown = feed(SELF)
|
|
369
|
+
expect(shown).toHaveTextContent('Comment deleted')
|
|
370
|
+
expect(shown).not.toHaveTextContent('removed')
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The feed WITHHOLDS the author of a tombstone, exactly as the thread does.
|
|
375
|
+
*
|
|
376
|
+
* Found in review. `FeedLine` was given `author` unconditionally, so the
|
|
377
|
+
* Everything tab printed "Ada Lovelace commented" beside "Comment removed by
|
|
378
|
+
* an admin" while the Comments tab one click away replaced her byline with
|
|
379
|
+
* `[removed]` and dropped her avatar to `?`. The same comment answered "who
|
|
380
|
+
* wrote this" two different ways depending on which tab you were on.
|
|
381
|
+
*
|
|
382
|
+
* It was harmless while the line read "Comment deleted" — a deletion nobody
|
|
383
|
+
* had to account for. FU-0032 is what made it matter: a name beside "removed
|
|
384
|
+
* by an admin" is a per-author public record of who got moderated, readable
|
|
385
|
+
* by everyone in the workspace, and it is the one fact the thread render
|
|
386
|
+
* deliberately refuses to publish.
|
|
387
|
+
*
|
|
388
|
+
* Asserted for BOTH removals, because the leak was never specific to
|
|
389
|
+
* moderation — only its consequence was.
|
|
390
|
+
*/
|
|
391
|
+
it('withholds the author of a tombstone, on both kinds of removal', () => {
|
|
392
|
+
expect(feed(REMOVED)).not.toHaveTextContent('Ada Lovelace')
|
|
393
|
+
cleanup()
|
|
394
|
+
expect(feed(SELF)).not.toHaveTextContent('Ada Lovelace')
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it('still names the author of a comment that is NOT a tombstone', () => {
|
|
398
|
+
const live: readonly ThreadComment[] = [
|
|
399
|
+
{ ...BASE, id: 'c-live', deletedAt: null, bodyMd: 'Still here.' },
|
|
400
|
+
]
|
|
401
|
+
expect(feed(live)).toHaveTextContent('Ada Lovelace')
|
|
402
|
+
})
|
|
403
|
+
})
|
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
* change — never derived on the client — so this component has no business
|
|
27
27
|
* inferring history from an item, and does not.
|
|
28
28
|
*
|
|
29
|
+
* ## It owns no thread behaviour either
|
|
30
|
+
*
|
|
31
|
+
* Every comment callback it declares is `ThreadProps`' own, forwarded
|
|
32
|
+
* unchanged to the `<Thread>` below. The pane decides WHERE a control lives —
|
|
33
|
+
* one composer, outside the panels — and never what one does. That is not a
|
|
34
|
+
* style preference: a prop it fails to forward is a control a host cannot draw
|
|
35
|
+
* at all, and three shipped mutations spent a release unreachable that way
|
|
36
|
+
* (Lightwork FU-0019). What is still not forwarded, and why, is listed on
|
|
37
|
+
* `ActivityPaneProps`, so the next gap is chosen rather than discovered.
|
|
38
|
+
*
|
|
29
39
|
* ## Everything is read-only
|
|
30
40
|
*
|
|
31
41
|
* The merged feed interleaves both by time so you can see what happened in
|
|
@@ -43,7 +53,7 @@ import { RelativeTime } from '../thread/relative-time'
|
|
|
43
53
|
import { Thread } from '../thread/thread'
|
|
44
54
|
import { ThreadComposer } from '../thread/composer'
|
|
45
55
|
import { useNowTick } from '../thread/use-now'
|
|
46
|
-
import type { ThreadAuthor, ThreadComment } from '../thread/types'
|
|
56
|
+
import type { ThreadAuthor, ThreadComment, ThreadReactionKey } from '../thread/types'
|
|
47
57
|
import type { ActivityEntry, FieldPerson } from './types'
|
|
48
58
|
|
|
49
59
|
export type ActivityTab = 'everything' | 'comments' | 'activity'
|
|
@@ -106,6 +116,78 @@ export interface ActivityPaneProps {
|
|
|
106
116
|
onSubmit?: ((body: string) => void) | undefined
|
|
107
117
|
/** A reply. Omitted, the thread offers no reply control at all. */
|
|
108
118
|
onReply?: ((body: string, parentId: string) => void) | undefined
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Forwarded verbatim to the `<Thread>` the Comments tab already mounts.
|
|
122
|
+
*
|
|
123
|
+
* These four are here because the pane used to declare `onSubmit` and
|
|
124
|
+
* `onReply` and stop. `CommentItem` gates its reaction PICKER on `onReact`
|
|
125
|
+
* and its overflow MENU ITEMS on `onEdit` / `onDelete`; a menu item whose
|
|
126
|
+
* callback is absent is not rendered, and the reaction chips still render
|
|
127
|
+
* DISABLED rather than vanishing. (An earlier draft of this docblock said
|
|
128
|
+
* the whole bar was "NOT RENDERED". That is the rule from `ThreadProps`'
|
|
129
|
+
* overflow-menu docblock, and it does not generalise to the engagement row —
|
|
130
|
+
* `reactions.tsx` renders `disabled={!canReact}`.) So a host composing this
|
|
131
|
+
* pane shipped an inert
|
|
132
|
+
* engagement row with no picker to open and a menu holding one line, while
|
|
133
|
+
* the three routes behind them had no caller in the browser at all. Nothing
|
|
134
|
+
* errored, which is how it survived a release; Lightwork's FU-0019 found it
|
|
135
|
+
* by opening the surface rather than by reading the source, and the source
|
|
136
|
+
* understates it.
|
|
137
|
+
*
|
|
138
|
+
* WIRING `onEdit` OR `onDelete` IS HALF THE JOB. The callback says the host
|
|
139
|
+
* has the mutation; `canEdit` / `canDelete` on each `ThreadComment` say
|
|
140
|
+
* whether this viewer may use it on THAT comment, and the pane forwards
|
|
141
|
+
* `comments` verbatim, so they arrive with no plumbing here. Set them, or
|
|
142
|
+
* every live comment in the feed gets an Edit the server will 404 — which is
|
|
143
|
+
* what forwarding these two without them shipped as, and the same shape as
|
|
144
|
+
* the `onHideLinkPreview` note below. Absent means the item is drawn, so
|
|
145
|
+
* nothing this pane renders today disappears on upgrade; that is a
|
|
146
|
+
* deliberate default and `ThreadComment` argues it.
|
|
147
|
+
*
|
|
148
|
+
* `onRetry` is the same omission with one sharper edge: the failed send it
|
|
149
|
+
* recovers was posted by THIS pane's own composer, so dropping it left the
|
|
150
|
+
* pane manufacturing a state and offering nobody a way out of it.
|
|
151
|
+
*
|
|
152
|
+
* The signatures are `ThreadProps`', unchanged, because a conduit that
|
|
153
|
+
* reshapes what passes through it is a second contract to keep in step —
|
|
154
|
+
* and this one is held by two repos. Absent still means no control, so a
|
|
155
|
+
* host that passes none renders exactly as it did on 0.1.0.
|
|
156
|
+
*
|
|
157
|
+
* STILL NOT FORWARDED, deliberately. This list is EXHAUSTIVE against
|
|
158
|
+
* `ThreadProps` — check it against that interface before adding one, because
|
|
159
|
+
* a list whose whole purpose is completeness is worse than no list when it
|
|
160
|
+
* is short: `onVote`, `onCopyLink`, `onCopyText`, `onSelectAuthor`,
|
|
161
|
+
* `onSelectMention`, `onOpenAttachment`, `resolveAttachmentUrl`,
|
|
162
|
+
* `onContinueThread`.
|
|
163
|
+
*
|
|
164
|
+
* The line is whether a MUTATION becomes unreachable. `onVote` fails it for
|
|
165
|
+
* a different reason — the pane never exposes `engagement`, so there is no
|
|
166
|
+
* vote control to receive it and forwarding it would be a dead prop.
|
|
167
|
+
* `onCopyText` only reports a clipboard outcome; "Copy text" renders without
|
|
168
|
+
* it. `onContinueThread` falls back to local state, so the button works
|
|
169
|
+
* unwired. The rest are reads.
|
|
170
|
+
*
|
|
171
|
+
* `onHideLinkPreview` USED to be on this list and should not have been: it is
|
|
172
|
+
* a write in the same pattern as the other mutations, and `link-preview.tsx`
|
|
173
|
+
* gates its dismiss control on the callback being present. Since the pane
|
|
174
|
+
* forwards `comments` verbatim — link previews included — a host got
|
|
175
|
+
* dismissible-looking rows with no route to the mutation. Same shape as the
|
|
176
|
+
* defect this whole change fixes, caught in review.
|
|
177
|
+
*/
|
|
178
|
+
onReact?: ((commentId: string, key: ThreadReactionKey) => void) | undefined
|
|
179
|
+
onEdit?: ((commentId: string) => void) | undefined
|
|
180
|
+
onDelete?: ((commentId: string) => void) | undefined
|
|
181
|
+
onRetry?: ((commentId: string) => void) | undefined
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A write, in the same pattern as the mutations above — `link-preview.tsx`
|
|
185
|
+
* gates its dismiss control on this being present, and the pane forwards
|
|
186
|
+
* `comments` verbatim, previews included. Without it a host draws rows that
|
|
187
|
+
* look dismissible and are not. Added in review, not in the original change.
|
|
188
|
+
*/
|
|
189
|
+
onHideLinkPreview?: ((commentId: string, urlHash: string) => void) | undefined
|
|
190
|
+
|
|
109
191
|
sending?: boolean | undefined
|
|
110
192
|
composerAvatar?: ReactNode | undefined
|
|
111
193
|
composerPlaceholder?: string | undefined
|
|
@@ -131,6 +213,11 @@ export function ActivityPane({
|
|
|
131
213
|
onDraftChange,
|
|
132
214
|
onSubmit,
|
|
133
215
|
onReply,
|
|
216
|
+
onReact,
|
|
217
|
+
onEdit,
|
|
218
|
+
onDelete,
|
|
219
|
+
onRetry,
|
|
220
|
+
onHideLinkPreview,
|
|
134
221
|
sending = false,
|
|
135
222
|
composerAvatar,
|
|
136
223
|
composerPlaceholder = 'Add a comment…',
|
|
@@ -255,7 +342,22 @@ export function ActivityPane({
|
|
|
255
342
|
item.kind === 'comment' ? (
|
|
256
343
|
<li key={`comment-${item.comment.id}`}>
|
|
257
344
|
<FeedLine
|
|
258
|
-
|
|
345
|
+
// WITHHELD on a tombstone, matching the thread's treatment
|
|
346
|
+
// exactly. `CommentItem` replaces the byline with the
|
|
347
|
+
// tombstone label and drops the avatar to `?`; printing the
|
|
348
|
+
// name here would mean the same comment answers "who wrote
|
|
349
|
+
// this" differently one tab apart.
|
|
350
|
+
//
|
|
351
|
+
// It matters more since FU-0032 than it did before. A
|
|
352
|
+
// neutral "Comment deleted" beside a name was a deletion
|
|
353
|
+
// nobody had to explain; "Comment removed by an admin"
|
|
354
|
+
// beside a name is a per-author public record of who got
|
|
355
|
+
// moderated, readable by everyone in the workspace.
|
|
356
|
+
author={
|
|
357
|
+
item.comment.deletedAt === null || item.comment.deletedAt === undefined
|
|
358
|
+
? (item.comment.author ?? null)
|
|
359
|
+
: null
|
|
360
|
+
}
|
|
259
361
|
at={item.at}
|
|
260
362
|
now={clock}
|
|
261
363
|
locale={locale}
|
|
@@ -263,8 +365,15 @@ export function ActivityPane({
|
|
|
263
365
|
item.comment.deletedAt === null || item.comment.deletedAt === undefined ? (
|
|
264
366
|
<CommentBody bodyMd={item.comment.bodyMd} />
|
|
265
367
|
) : (
|
|
368
|
+
// The feed's register, not the thread's: a line in a
|
|
369
|
+
// sentence rather than a bracketed placeholder. The
|
|
370
|
+
// DISTINCTION is the same one, and it has to be, or a
|
|
371
|
+
// reader gets one answer on Everything and another on
|
|
372
|
+
// Comments about the same comment (FU-0032).
|
|
266
373
|
<span style={{ color: 'rgb(var(--text-tertiary))' }}>
|
|
267
|
-
|
|
374
|
+
{item.comment.moderated === true
|
|
375
|
+
? 'Comment removed by an admin'
|
|
376
|
+
: 'Comment deleted'}
|
|
268
377
|
</span>
|
|
269
378
|
)
|
|
270
379
|
}
|
|
@@ -312,6 +421,11 @@ export function ActivityPane({
|
|
|
312
421
|
if (parentId !== undefined) onReply(body, parentId)
|
|
313
422
|
},
|
|
314
423
|
})}
|
|
424
|
+
{...(onReact === undefined ? {} : { onReact })}
|
|
425
|
+
{...(onEdit === undefined ? {} : { onEdit })}
|
|
426
|
+
{...(onDelete === undefined ? {} : { onDelete })}
|
|
427
|
+
{...(onRetry === undefined ? {} : { onRetry })}
|
|
428
|
+
{...(onHideLinkPreview === undefined ? {} : { onHideLinkPreview })}
|
|
315
429
|
/>
|
|
316
430
|
</div>
|
|
317
431
|
)}
|
package/src/display-store.tsx
CHANGED
|
@@ -21,10 +21,21 @@
|
|
|
21
21
|
* PERSISTENCE IS PER SCOPE. `scope` is whatever partition the host needs —
|
|
22
22
|
* for a lens that is the brand id, because ADR-116 D10 requires brand A's
|
|
23
23
|
* preferences never to surface on brand B. The key is
|
|
24
|
-
*
|
|
24
|
+
* `<prefix>:<name>:<scope>:display`, matching CLAUDE.md §3's namespacing
|
|
25
25
|
* rule. Changing `scope` re-reads: the provider does not remount, so an
|
|
26
26
|
* in-flight draft elsewhere in the tree survives a brand switch.
|
|
27
27
|
*
|
|
28
|
+
* THE PREFIX IS THE PRODUCT, AND ITS DEFAULT IS FROZEN. This package is a
|
|
29
|
+
* dependency of more than one app on more than one origin, and `workspace` is
|
|
30
|
+
* only the right first segment for one of them: a host whose own convention is
|
|
31
|
+
* `lightwork:<area>:<id>:<key>` was getting the one key on its board that read
|
|
32
|
+
* as somebody else's. So `prefix` is an option — and its default is `workspace`
|
|
33
|
+
* FOREVER, not because that name is good but because these keys already exist
|
|
34
|
+
* in operators' browsers. A store holds a preference the operator set; changing
|
|
35
|
+
* the default segment would not move that preference to a new key, it would
|
|
36
|
+
* walk past it and open at the defaults. The suite pins the unprefixed key as a
|
|
37
|
+
* literal for that reason.
|
|
38
|
+
*
|
|
28
39
|
* READING IS DEFENSIVE. `localStorage` is untrusted input — a hand-edited
|
|
29
40
|
* value, a key left behind by an older build, a private window that throws on
|
|
30
41
|
* access. Every persisted key is checked against the default's TYPE and, when
|
|
@@ -117,6 +128,33 @@ export interface DisplayStore<T extends DisplaySettings> {
|
|
|
117
128
|
readonly storageKey: (scope: string) => string
|
|
118
129
|
}
|
|
119
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Everything about the store that is not its settings.
|
|
133
|
+
*
|
|
134
|
+
* An OBJECT rather than a fifth positional string, because `allowed` and
|
|
135
|
+
* `migrations` are both optional and already both objects: a call reading
|
|
136
|
+
* `createDisplayStore('board', DEFAULTS, undefined, undefined, 'lightwork')`
|
|
137
|
+
* puts two bare strings at opposite ends of an argument list and names
|
|
138
|
+
* neither. `{ prefix: 'lightwork' }` says what it is at the call site, and the
|
|
139
|
+
* next option that earns its place goes beside it rather than becoming a sixth
|
|
140
|
+
* positional.
|
|
141
|
+
*/
|
|
142
|
+
export interface DisplayStoreOptions {
|
|
143
|
+
/**
|
|
144
|
+
* The key's first segment — the PRODUCT, above the store's own `name`.
|
|
145
|
+
*
|
|
146
|
+
* Omit it and the key is `workspace:<name>:<scope>:display`, exactly as it
|
|
147
|
+
* has always been. Pass one and the store moves wholesale onto it, so two
|
|
148
|
+
* apps sharing this primitive on one origin under the same `name` cannot
|
|
149
|
+
* read or overwrite each other.
|
|
150
|
+
*
|
|
151
|
+
* It is not a migration seam. A store that changes prefix does not carry its
|
|
152
|
+
* stored settings across; it opens at its defaults on the new key and leaves
|
|
153
|
+
* the old one behind. Choose it once, when the store is written.
|
|
154
|
+
*/
|
|
155
|
+
prefix?: string
|
|
156
|
+
}
|
|
157
|
+
|
|
120
158
|
function isDisplayValue(value: unknown): value is DisplayValue {
|
|
121
159
|
if (typeof value === 'string' || typeof value === 'boolean') return true
|
|
122
160
|
return typeof value === 'number' && Number.isFinite(value)
|
|
@@ -127,8 +165,29 @@ export function createDisplayStore<T extends DisplaySettings>(
|
|
|
127
165
|
defaults: T,
|
|
128
166
|
allowed?: DisplayAllowed<T>,
|
|
129
167
|
migrations?: DisplayMigrations<T>,
|
|
168
|
+
options?: DisplayStoreOptions,
|
|
130
169
|
): DisplayStore<T> {
|
|
131
|
-
|
|
170
|
+
// Resolved once, at construction, so the key cannot differ between the read
|
|
171
|
+
// on mount and the write on the next change.
|
|
172
|
+
const prefix = options?.prefix ?? 'workspace'
|
|
173
|
+
// An EMPTY prefix is refused rather than defaulted, and loudly, because both
|
|
174
|
+
// quieter options are worse. Falling back to `workspace` would hand a caller
|
|
175
|
+
// who asked for isolation the shared namespace instead — silently, and
|
|
176
|
+
// discoverable only as two products reading each other's settings. Accepting
|
|
177
|
+
// it produces `:board:ws_1:display`, whose empty first segment collides with
|
|
178
|
+
// every other empty-prefix caller: precisely the cross-product this option
|
|
179
|
+
// exists to prevent, reintroduced by the option itself.
|
|
180
|
+
//
|
|
181
|
+
// A throw at CONSTRUCTION is safe where a throw on read would not be: the
|
|
182
|
+
// store is built once, at module scope, so this fires before any UI exists
|
|
183
|
+
// rather than under a user mid-session. `useDisplay` outside a Provider
|
|
184
|
+
// already throws for the same reason.
|
|
185
|
+
if (prefix === '') {
|
|
186
|
+
throw new Error(
|
|
187
|
+
'createDisplayStore: `prefix` cannot be empty — omit the option to use the default.',
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
const storageKey = (scope: string) => `${prefix}:${name}:${scope}:display`
|
|
132
191
|
|
|
133
192
|
/** One key's persisted value, or `null` when it does not hold up. */
|
|
134
193
|
function validate<K extends keyof T & string>(key: K, input: unknown): T[K] | null {
|
package/src/index.ts
CHANGED
|
@@ -78,6 +78,12 @@ export { SortableList, SortableItem, DragHandle } from './sortable'
|
|
|
78
78
|
export {
|
|
79
79
|
MultiSortableList,
|
|
80
80
|
SortableDropZone,
|
|
81
|
+
// The default `collisionDetection`, exported so a consumer overriding the
|
|
82
|
+
// prop can COMPOSE with it rather than replace it. Without this, the only way
|
|
83
|
+
// to add one rule is to reimplement pointer-first resolution — which is how a
|
|
84
|
+
// component ends up with two collision strategies that disagree.
|
|
85
|
+
pointerFirstCollision,
|
|
86
|
+
PREVIEW_SETTLE_MS,
|
|
81
87
|
type MultiSortableListProps,
|
|
82
88
|
type MultiSortableMove,
|
|
83
89
|
type SortableContainers,
|
|
@@ -672,6 +678,7 @@ export {
|
|
|
672
678
|
type DisplayAllowed,
|
|
673
679
|
type DisplayMigrations,
|
|
674
680
|
type DisplayProviderProps,
|
|
681
|
+
type DisplayStoreOptions,
|
|
675
682
|
} from './display-store'
|
|
676
683
|
// ── ADR-146 — the analytics primitive set ─────────────────────────────
|
|
677
684
|
//
|