@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
|
@@ -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()
|
|
@@ -660,3 +740,191 @@ describe('Thread — MEDIUM 4: the comments are a real list', () => {
|
|
|
660
740
|
expect(commentBox('c-many').style.paddingInlineStart).toBe('')
|
|
661
741
|
})
|
|
662
742
|
})
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* The per-comment capability seam.
|
|
746
|
+
*
|
|
747
|
+
* 0.2.0 gated Edit and Delete on "a callback exists and this is not a
|
|
748
|
+
* tombstone" and nothing else, so a host that wired both drew both on every
|
|
749
|
+
* live comment — including ones the viewer did not write, where the server's
|
|
750
|
+
* `AND author_id = ?` answers 404 and always will.
|
|
751
|
+
*
|
|
752
|
+
* These assert at the rendered MENU ITEM, never at the prop. A capability that
|
|
753
|
+
* arrives and changes nothing is the same defect one layer further in.
|
|
754
|
+
*/
|
|
755
|
+
describe('Thread — canEdit and canDelete', () => {
|
|
756
|
+
/** Two comments by two people; only the first is the viewer's. */
|
|
757
|
+
const AUTHORSHIP: readonly ThreadComment[] = [
|
|
758
|
+
{
|
|
759
|
+
id: 'c-mine',
|
|
760
|
+
author: { id: 'usr_ada', name: 'Ada Whitfield' },
|
|
761
|
+
bodyMd: 'I wrote this one.',
|
|
762
|
+
createdAt: FIXTURE_NOW - 60_000,
|
|
763
|
+
canEdit: true,
|
|
764
|
+
canDelete: true,
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
id: 'c-theirs',
|
|
768
|
+
author: { id: 'usr_lee', name: 'Lee Ndiaye' },
|
|
769
|
+
bodyMd: 'And this one is not mine.',
|
|
770
|
+
createdAt: FIXTURE_NOW - 30_000,
|
|
771
|
+
canEdit: false,
|
|
772
|
+
canDelete: false,
|
|
773
|
+
},
|
|
774
|
+
]
|
|
775
|
+
|
|
776
|
+
function openMenu(commentId: string, name: string): void {
|
|
777
|
+
fireEvent.click(
|
|
778
|
+
within(commentBox(commentId)).getByRole('button', {
|
|
779
|
+
name: `More actions for ${name}'s comment`,
|
|
780
|
+
}),
|
|
781
|
+
)
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
it('draws Edit and Delete on a comment the viewer authored', () => {
|
|
785
|
+
render(
|
|
786
|
+
<Thread
|
|
787
|
+
comments={AUTHORSHIP}
|
|
788
|
+
now={FIXTURE_NOW}
|
|
789
|
+
locale="en-GB"
|
|
790
|
+
defaultSort="new"
|
|
791
|
+
onEdit={vi.fn()}
|
|
792
|
+
onDelete={vi.fn()}
|
|
793
|
+
/>,
|
|
794
|
+
)
|
|
795
|
+
openMenu('c-mine', 'Ada Whitfield')
|
|
796
|
+
expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeTruthy()
|
|
797
|
+
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeTruthy()
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
it('draws NEITHER on a comment the viewer did not author', () => {
|
|
801
|
+
render(
|
|
802
|
+
<Thread
|
|
803
|
+
comments={AUTHORSHIP}
|
|
804
|
+
now={FIXTURE_NOW}
|
|
805
|
+
locale="en-GB"
|
|
806
|
+
defaultSort="new"
|
|
807
|
+
onEdit={vi.fn()}
|
|
808
|
+
onDelete={vi.fn()}
|
|
809
|
+
// A third item, so the menu still has a reason to exist and the two
|
|
810
|
+
// that matter are provably absent FROM it rather than absent because
|
|
811
|
+
// the whole trigger vanished. Both cases are asserted; this is the one
|
|
812
|
+
// where the viewer can see the menu they are not being offered.
|
|
813
|
+
onCopyLink={vi.fn()}
|
|
814
|
+
/>,
|
|
815
|
+
)
|
|
816
|
+
openMenu('c-theirs', 'Lee Ndiaye')
|
|
817
|
+
expect(screen.getByRole('menuitem', { name: 'Copy link' })).toBeTruthy()
|
|
818
|
+
// Absent, not disabled. A greyed row still claims the action exists for
|
|
819
|
+
// somebody, which is the lie in a smaller font.
|
|
820
|
+
expect(screen.queryByRole('menuitem', { name: 'Edit' })).toBeNull()
|
|
821
|
+
expect(screen.queryByRole('menuitem', { name: 'Delete' })).toBeNull()
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it('separates the two predicates — an admin may remove what it may not rewrite', () => {
|
|
825
|
+
const MODERATOR: readonly ThreadComment[] = [
|
|
826
|
+
{
|
|
827
|
+
id: 'c-theirs',
|
|
828
|
+
author: { id: 'usr_lee', name: 'Lee Ndiaye' },
|
|
829
|
+
bodyMd: 'Somebody else wrote this.',
|
|
830
|
+
createdAt: FIXTURE_NOW - 30_000,
|
|
831
|
+
canEdit: false,
|
|
832
|
+
canDelete: true,
|
|
833
|
+
},
|
|
834
|
+
]
|
|
835
|
+
render(
|
|
836
|
+
<Thread
|
|
837
|
+
comments={MODERATOR}
|
|
838
|
+
now={FIXTURE_NOW}
|
|
839
|
+
locale="en-GB"
|
|
840
|
+
defaultSort="new"
|
|
841
|
+
onEdit={vi.fn()}
|
|
842
|
+
onDelete={vi.fn()}
|
|
843
|
+
/>,
|
|
844
|
+
)
|
|
845
|
+
openMenu('c-theirs', 'Lee Ndiaye')
|
|
846
|
+
expect(screen.queryByRole('menuitem', { name: 'Edit' })).toBeNull()
|
|
847
|
+
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeTruthy()
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('still reports the id from a comment that kept its controls', () => {
|
|
851
|
+
const onDelete = vi.fn()
|
|
852
|
+
render(
|
|
853
|
+
<Thread
|
|
854
|
+
comments={AUTHORSHIP}
|
|
855
|
+
now={FIXTURE_NOW}
|
|
856
|
+
locale="en-GB"
|
|
857
|
+
defaultSort="new"
|
|
858
|
+
onDelete={onDelete}
|
|
859
|
+
/>,
|
|
860
|
+
)
|
|
861
|
+
openMenu('c-mine', 'Ada Whitfield')
|
|
862
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete' }))
|
|
863
|
+
expect(onDelete).toHaveBeenCalledWith('c-mine')
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('draws both when the flags are ABSENT — every 0.2.0 host is here', () => {
|
|
867
|
+
// The uncomfortable half, asserted rather than assumed: absent means TRUE,
|
|
868
|
+
// so a host that upgrades without mapping the fields keeps exactly the
|
|
869
|
+
// render it has, defect and all. The alternative silently strips the edit
|
|
870
|
+
// control off the viewer's own comments and nothing errors.
|
|
871
|
+
renderThread({ onEdit: vi.fn(), onDelete: vi.fn() })
|
|
872
|
+
openMenu('c-one-word', 'Lee Ndiaye')
|
|
873
|
+
expect(screen.getByRole('menuitem', { name: 'Edit' })).toBeTruthy()
|
|
874
|
+
expect(screen.getByRole('menuitem', { name: 'Delete' })).toBeTruthy()
|
|
875
|
+
})
|
|
876
|
+
|
|
877
|
+
it('offers no overflow at all when the flags are the only thing left in it', () => {
|
|
878
|
+
// Both callbacks wired, both refused, no clipboard under jsdom and no
|
|
879
|
+
// `onCopyLink`: `CommentActions` has nothing to put in a menu and draws no
|
|
880
|
+
// trigger. An empty menu behind a button is the greyed row again.
|
|
881
|
+
render(
|
|
882
|
+
<Thread
|
|
883
|
+
comments={AUTHORSHIP}
|
|
884
|
+
now={FIXTURE_NOW}
|
|
885
|
+
locale="en-GB"
|
|
886
|
+
defaultSort="new"
|
|
887
|
+
onEdit={vi.fn()}
|
|
888
|
+
onDelete={vi.fn()}
|
|
889
|
+
/>,
|
|
890
|
+
)
|
|
891
|
+
expect(
|
|
892
|
+
within(commentBox('c-theirs')).queryByRole('button', {
|
|
893
|
+
name: "More actions for Lee Ndiaye's comment",
|
|
894
|
+
}),
|
|
895
|
+
).toBeNull()
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
it('keeps the tombstone rule — a deleted comment offers neither, flags or not', () => {
|
|
899
|
+
const TOMB: readonly ThreadComment[] = [
|
|
900
|
+
{
|
|
901
|
+
id: 'c-tomb',
|
|
902
|
+
author: { id: 'usr_ada', name: 'Ada Whitfield' },
|
|
903
|
+
bodyMd: '',
|
|
904
|
+
createdAt: FIXTURE_NOW - 60_000,
|
|
905
|
+
deletedAt: FIXTURE_NOW - 30_000,
|
|
906
|
+
canEdit: true,
|
|
907
|
+
canDelete: true,
|
|
908
|
+
},
|
|
909
|
+
]
|
|
910
|
+
render(
|
|
911
|
+
<Thread
|
|
912
|
+
comments={TOMB}
|
|
913
|
+
now={FIXTURE_NOW}
|
|
914
|
+
locale="en-GB"
|
|
915
|
+
defaultSort="new"
|
|
916
|
+
onEdit={vi.fn()}
|
|
917
|
+
onDelete={vi.fn()}
|
|
918
|
+
onCopyLink={vi.fn()}
|
|
919
|
+
/>,
|
|
920
|
+
)
|
|
921
|
+
fireEvent.click(
|
|
922
|
+
within(commentBox('c-tomb')).getByRole('button', {
|
|
923
|
+
name: "More actions for [deleted]'s comment",
|
|
924
|
+
}),
|
|
925
|
+
)
|
|
926
|
+
expect(screen.getByRole('menuitem', { name: 'Copy link' })).toBeTruthy()
|
|
927
|
+
expect(screen.queryByRole('menuitem', { name: 'Edit' })).toBeNull()
|
|
928
|
+
expect(screen.queryByRole('menuitem', { name: 'Delete' })).toBeNull()
|
|
929
|
+
})
|
|
930
|
+
})
|
|
@@ -592,7 +592,7 @@ export function GifPicker({ search, onPick, className }: GifPickerProps) {
|
|
|
592
592
|
onClick={() => {
|
|
593
593
|
onPick(gif)
|
|
594
594
|
}}
|
|
595
|
-
className="block overflow-hidden border transition-[border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-[rgb(var(--border-strong))] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)] motion-reduce:transition-none"
|
|
595
|
+
className="block overflow-hidden border transition-[border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:border-[rgb(var(--border-strong))] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus-inset)] motion-reduce:transition-none"
|
|
596
596
|
style={{
|
|
597
597
|
borderRadius: 'var(--radius-sm)',
|
|
598
598
|
borderColor: 'rgb(var(--border))',
|
package/src/thread/comment.tsx
CHANGED
|
@@ -171,6 +171,14 @@ export interface ThreadRenderContext extends RichComposerAttachmentProps {
|
|
|
171
171
|
*/
|
|
172
172
|
readonly onCopyLink?: ((commentId: string) => void) | undefined
|
|
173
173
|
readonly onCopyText?: ((commentId: string, ok: boolean) => void) | undefined
|
|
174
|
+
/**
|
|
175
|
+
* Edit and delete are gated TWICE, and the second gate is the per-comment
|
|
176
|
+
* one: the callback here says the host wired the mutation, and `canEdit` /
|
|
177
|
+
* `canDelete` on the row say whether this viewer may use it on THAT comment
|
|
178
|
+
* (see `ThreadComment`). Wiring the callback alone draws the item on every
|
|
179
|
+
* live comment, which is what 0.2.0 did and what the server then refused.
|
|
180
|
+
* Absent flags keep the item, so nothing a 0.2.0 host draws disappears.
|
|
181
|
+
*/
|
|
174
182
|
readonly onEdit?: ((commentId: string) => void) | undefined
|
|
175
183
|
readonly onDelete?: ((commentId: string) => void) | undefined
|
|
176
184
|
/**
|
|
@@ -212,6 +220,53 @@ function authorLabel(author: ThreadAuthor | null | undefined): string {
|
|
|
212
220
|
return 'Unknown member'
|
|
213
221
|
}
|
|
214
222
|
|
|
223
|
+
/**
|
|
224
|
+
* THE tombstone strings — two acts, and a deliberate word for each.
|
|
225
|
+
*
|
|
226
|
+
* A comment becomes a tombstone two ways now (FU-0031, FU-0032): its author
|
|
227
|
+
* deleted it, or somebody else removed it. `[deleted]` was written when only
|
|
228
|
+
* the first existed, and it is read by the person whose comment is gone —
|
|
229
|
+
* telling them they did it themselves when they did not is the whole defect.
|
|
230
|
+
*
|
|
231
|
+
* REMOVED, not deleted. The two verbs carry the entire distinction, so each
|
|
232
|
+
* one is reserved for exactly one act: you delete your own, somebody removes
|
|
233
|
+
* yours. Any pair that shared a verb would need a qualifier to be read at all.
|
|
234
|
+
*
|
|
235
|
+
* "AN ADMIN", not "a workspace admin" and not "a moderator". The ONE thing
|
|
236
|
+
* `moderated` licenses the render to claim is that the author did not do it —
|
|
237
|
+
* that is the whole content of the flag, and how a host derives it is the
|
|
238
|
+
* host's business, not this module's. (An earlier draft asserted it comes from
|
|
239
|
+
* `deleted_by <> author_id`. That is one consumer's wire and appears nowhere in
|
|
240
|
+
* this repository; stating another product's schema as fact here is exactly the
|
|
241
|
+
* coupling this module's entity-agnostic claim rules out.) Naming a ROLE rather
|
|
242
|
+
* than a person keeps the claim true — nothing obliges a host to send an actor,
|
|
243
|
+
* and a string with a slot for one is a string somebody eventually fills.
|
|
244
|
+
* "Workspace" is one host's noun and this module is entity-agnostic by its own
|
|
245
|
+
* opening claim (types.ts); "moderator" is a forum's word for a role that
|
|
246
|
+
* exists in neither consumer. "Admin" is what both of them actually call the
|
|
247
|
+
* person who can do this, which is the test interface copy actually has to
|
|
248
|
+
* pass.
|
|
249
|
+
*
|
|
250
|
+
* TWO STRINGS PER ACT, because they land in two different slots. The byline is
|
|
251
|
+
* the AUTHOR position: a sentence there reads as an attribution, and "removed
|
|
252
|
+
* by an admin" sitting where a name goes says the admin wrote it. It also
|
|
253
|
+
* feeds two accessible names — "Collapse replies to X" and "More actions for
|
|
254
|
+
* X's comment" — which a sentence makes unusable. So the byline takes the
|
|
255
|
+
* short form and the body, which is the interface speaking in its own voice,
|
|
256
|
+
* takes the sentence.
|
|
257
|
+
*
|
|
258
|
+
* The brackets and the lower case are unchanged from `[deleted]`: that form is
|
|
259
|
+
* what marks a line as the system talking rather than a person, and a
|
|
260
|
+
* tombstone that stopped looking like one would be a worse regression than the
|
|
261
|
+
* one being fixed.
|
|
262
|
+
*/
|
|
263
|
+
const TOMBSTONE: Readonly<
|
|
264
|
+
Record<'deleted' | 'moderated', { readonly byline: string; readonly body: string }>
|
|
265
|
+
> = {
|
|
266
|
+
deleted: { byline: '[deleted]', body: '[deleted]' },
|
|
267
|
+
moderated: { byline: '[removed]', body: '[removed by an admin]' },
|
|
268
|
+
}
|
|
269
|
+
|
|
215
270
|
/** "1 reply" / "8 replies" — the live region's wording, in one place. */
|
|
216
271
|
function replyCountLabel(count: number): string {
|
|
217
272
|
return `${String(count)} ${count === 1 ? 'reply' : 'replies'}`
|
|
@@ -375,7 +430,12 @@ export function CommentItem({
|
|
|
375
430
|
const collapsed = thread.isCollapsed(node.id)
|
|
376
431
|
const deleted = node.deletedAt != null
|
|
377
432
|
const state = node.state ?? 'sent'
|
|
378
|
-
|
|
433
|
+
// `moderated` says nothing about a live comment — nobody has removed one —
|
|
434
|
+
// so it is read HERE, under `deleted`, and nowhere else in the render. A
|
|
435
|
+
// comment carrying the flag without a `deletedAt` is not a tombstone and
|
|
436
|
+
// does not become one.
|
|
437
|
+
const tombstone = deleted ? TOMBSTONE[node.moderated === true ? 'moderated' : 'deleted'] : null
|
|
438
|
+
const label = tombstone === null ? authorLabel(node.author) : tombstone.byline
|
|
379
439
|
const descendants = countDescendants(node)
|
|
380
440
|
const atCap = renderDepth >= thread.maxDepth && !continued
|
|
381
441
|
const { visible, hidden } = thread.visibleReplies(node)
|
|
@@ -401,6 +461,44 @@ export function CommentItem({
|
|
|
401
461
|
// rather than trusted as a domain value (D6).
|
|
402
462
|
const tint = asAvatarTint(author?.tint)
|
|
403
463
|
const canEngage = !deleted && state === 'sent'
|
|
464
|
+
// The two capability gates, computed once and read only by CommentActions.
|
|
465
|
+
//
|
|
466
|
+
// Three conditions each, and each is a different question. `ctx.onEdit`
|
|
467
|
+
// asks whether the HOST wired the mutation at all; `deleted` asks whether
|
|
468
|
+
// there is anything left to act on; `node.canEdit` asks whether THIS viewer
|
|
469
|
+
// may act on THIS comment. 0.2.0 asked only the first two, so a host that
|
|
470
|
+
// wired both mutations drew both on everybody's comments and the server
|
|
471
|
+
// answered 404 forever.
|
|
472
|
+
//
|
|
473
|
+
// `!== false` rather than `=== true`: absent means the control is drawn,
|
|
474
|
+
// which is the render every 0.2.0 host already has (types.ts states the
|
|
475
|
+
// trade this makes and why the safer-looking default is worse). Only an
|
|
476
|
+
// explicit `false` takes an item away.
|
|
477
|
+
//
|
|
478
|
+
// Neither is an authorization decision. The server re-checks authorship on
|
|
479
|
+
// the edit and rank on the moderated delete; this decides what is DRAWN.
|
|
480
|
+
//
|
|
481
|
+
// `onReact` AND `onRetry` WERE CHECKED FOR THE SAME DEFECT AND DO NOT HAVE
|
|
482
|
+
// IT, which is worth stating so the next reader does not re-derive it and
|
|
483
|
+
// add two flags nothing needs.
|
|
484
|
+
//
|
|
485
|
+
// • React is not an authored act. Acknowledging somebody else's remark is
|
|
486
|
+
// the entire point of a reaction, so authorship is not a predicate any
|
|
487
|
+
// host's reaction route can sensibly carry, and there is no live comment
|
|
488
|
+
// on which the picker is drawn and cannot work. `canEngage` already
|
|
489
|
+
// withholds it from a tombstone and from a send still in flight — the
|
|
490
|
+
// two comments this RENDER refuses to offer it on.
|
|
491
|
+
//
|
|
492
|
+
// • Retry is unreachable on anybody else's comment, resting on an
|
|
493
|
+
// invariant rather than on a check. It is drawn only for
|
|
494
|
+
// `state === 'failed'`, and both non-`sent` delivery states are
|
|
495
|
+
// viewer-local: a comment that never reached the server, exists in one
|
|
496
|
+
// browser, and has no other viewer. There is no authorship question to
|
|
497
|
+
// ask about a row nobody else can see. That invariant is load-bearing
|
|
498
|
+
// enough to skip a flag, so it is stated on `CommentDeliveryState`
|
|
499
|
+
// where a host will actually read it — not only here.
|
|
500
|
+
const canEdit = ctx.onEdit !== undefined && !deleted && node.canEdit !== false
|
|
501
|
+
const canDelete = ctx.onDelete !== undefined && !deleted && node.canDelete !== false
|
|
404
502
|
const indentClampDepth = ctx.indentClampDepth ?? DEFAULT_INDENT_CLAMP_DEPTH
|
|
405
503
|
|
|
406
504
|
// A reply draws the segment of the connector that spans its own box.
|
|
@@ -561,14 +659,14 @@ export function CommentItem({
|
|
|
561
659
|
ctx.onCopyLink?.(node.id)
|
|
562
660
|
},
|
|
563
661
|
})}
|
|
564
|
-
{...(
|
|
662
|
+
{...(!canEdit
|
|
565
663
|
? {}
|
|
566
664
|
: {
|
|
567
665
|
onEdit: () => {
|
|
568
666
|
ctx.onEdit?.(node.id)
|
|
569
667
|
},
|
|
570
668
|
})}
|
|
571
|
-
{...(
|
|
669
|
+
{...(!canDelete
|
|
572
670
|
? {}
|
|
573
671
|
: {
|
|
574
672
|
onDelete: () => {
|
|
@@ -597,9 +695,9 @@ export function CommentItem({
|
|
|
597
695
|
</p>
|
|
598
696
|
)}
|
|
599
697
|
|
|
600
|
-
{
|
|
698
|
+
{tombstone !== null ? (
|
|
601
699
|
<p className="text-[13px] italic" style={{ color: 'rgb(var(--text-tertiary))' }}>
|
|
602
|
-
|
|
700
|
+
{tombstone.body}
|
|
603
701
|
</p>
|
|
604
702
|
) : (
|
|
605
703
|
<CommentBody
|
|
@@ -624,7 +722,7 @@ export function CommentItem({
|
|
|
624
722
|
deliberately attached outranks what a scanner found in their
|
|
625
723
|
prose. A tombstone shows none — the worker already returns none
|
|
626
724
|
for one (ADR-150 D8), and this is the second lock on it, because
|
|
627
|
-
|
|
725
|
+
a tombstone followed by three intact cards would leak the body
|
|
628
726
|
the tombstone exists to remove. */}
|
|
629
727
|
{node.linkPreviews === undefined || node.linkPreviews.length === 0 || deleted ? null : (
|
|
630
728
|
<LinkPreviews
|
package/src/thread/composer.tsx
CHANGED
|
@@ -189,7 +189,10 @@ export function ThreadComposer({
|
|
|
189
189
|
'min-w-0 flex-1 overflow-hidden border',
|
|
190
190
|
'transition-[border-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
191
191
|
'motion-reduce:transition-none',
|
|
192
|
-
|
|
192
|
+
// INSET, because this composer is mounted inside the `overflow-hidden` that
|
|
193
|
+
// `comment.tsx`'s grid-rows reply reveal requires. An outset ring is
|
|
194
|
+
// clipped there on three sides and the indicator vanishes.
|
|
195
|
+
'focus-within:border-[rgb(var(--accent))] focus-within:[box-shadow:var(--ring-focus-inset)]',
|
|
193
196
|
)}
|
|
194
197
|
style={{
|
|
195
198
|
// Concentric: the host panel declares --thread-inner-radius as its
|
package/src/thread/reactions.tsx
CHANGED
|
@@ -117,7 +117,8 @@ const CHIP_BASE = cn(
|
|
|
117
117
|
'inline-flex items-center justify-center gap-[var(--space-1)] border px-[var(--space-2)] text-[12px] font-semibold tabular-nums',
|
|
118
118
|
'transition-[background-color,border-color,color,box-shadow,transform] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
119
119
|
'motion-reduce:transition-none',
|
|
120
|
-
|
|
120
|
+
// INSET: chips sit inside clipping ancestors on both hosts.
|
|
121
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus-inset)]',
|
|
121
122
|
'active:scale-[0.96] motion-reduce:active:scale-100',
|
|
122
123
|
'disabled:cursor-not-allowed disabled:opacity-60',
|
|
123
124
|
)
|
package/src/thread/types.ts
CHANGED
|
@@ -42,6 +42,16 @@ export interface ThreadReactionCount {
|
|
|
42
42
|
* ADR-147 D17 — optimistic send is a first-class state, not a nicety.
|
|
43
43
|
* A comment silently lost to a bad connection is the worst thing this surface
|
|
44
44
|
* can do to someone, and it is the most likely.
|
|
45
|
+
*
|
|
46
|
+
* `pending` AND `failed` ARE VIEWER-LOCAL, and that is an invariant a host has
|
|
47
|
+
* to hold rather than a thing this type can enforce. Both describe a comment
|
|
48
|
+
* that has not landed: it exists in one browser, no other viewer can see it,
|
|
49
|
+
* and no read should ever return either value. A host that puts `failed` on a
|
|
50
|
+
* row it fetched is describing somebody ELSE's comment as the viewer's own
|
|
51
|
+
* failed send, and the retry drawn beside it — the one control on this surface
|
|
52
|
+
* with no capability flag, because it never needed one — becomes the same lie
|
|
53
|
+
* `canEdit` exists to stop. Absent means `sent`, which is what every read
|
|
54
|
+
* should leave it as.
|
|
45
55
|
*/
|
|
46
56
|
export type CommentDeliveryState = 'sent' | 'pending' | 'failed'
|
|
47
57
|
|
|
@@ -149,6 +159,105 @@ export interface ThreadComment {
|
|
|
149
159
|
readonly editedAt?: number | null | undefined
|
|
150
160
|
/** Set = tombstone (D4). The row still renders and KEEPS its subtree. */
|
|
151
161
|
readonly deletedAt?: number | null | undefined
|
|
162
|
+
/**
|
|
163
|
+
* TRUE = the tombstone above was made by somebody OTHER than the author
|
|
164
|
+
* (FU-0031, FU-0032). Meaningless without `deletedAt`, and read nowhere else.
|
|
165
|
+
*
|
|
166
|
+
* One boolean rather than a `deletedBy` author, and that is a decision, not
|
|
167
|
+
* a shortcut: the wire deliberately never carries who removed a comment, so
|
|
168
|
+
* a field that could hold a name would be a field somebody eventually fills.
|
|
169
|
+
* A boolean cannot leak an identity it does not have.
|
|
170
|
+
*
|
|
171
|
+
* OPTIONAL, and absent means `[deleted]` — the render every host on 0.1.0
|
|
172
|
+
* already gets. A host with no moderation concept never sets it and never
|
|
173
|
+
* sees a word change.
|
|
174
|
+
*/
|
|
175
|
+
readonly moderated?: boolean | undefined
|
|
176
|
+
/**
|
|
177
|
+
* Whether to DRAW the edit control on THIS comment.
|
|
178
|
+
*
|
|
179
|
+
* 0.2.0 forwarded `onEdit` and `onDelete` through to the thread and gated
|
|
180
|
+
* both menu items on nothing but "a callback exists, and this is not a
|
|
181
|
+
* tombstone". There was no authorship check anywhere in the render, so a
|
|
182
|
+
* host that wired the two mutations drew them on every live comment,
|
|
183
|
+
* including the ones the viewer did not write. Every server this was pointed
|
|
184
|
+
* at refused correctly, which is exactly what makes it the wrong kind of
|
|
185
|
+
* bug. It is not a hole; it is a LIE. A menu item that exists and can never
|
|
186
|
+
* work tells somebody they may do a thing they may not, and it is worse than
|
|
187
|
+
* the absent item, because the absent item at least agrees with the server.
|
|
188
|
+
*
|
|
189
|
+
* TWO flags rather than one `viewerIsAuthor`, and the argument for the
|
|
190
|
+
* second one is already in this interface. `moderated` exists because a
|
|
191
|
+
* comment can be removed by somebody OTHER than its author (FU-0031,
|
|
192
|
+
* FU-0032) — so a surface that can produce that tombstone has a delete
|
|
193
|
+
* predicate that is NOT authorship, while its edit stays author-only. This
|
|
194
|
+
* module cannot say what any host's predicates ARE, and does not try to; it
|
|
195
|
+
* only has to be able to spell two that differ, and `moderated` is already
|
|
196
|
+
* proof that they do. One consumer's arrangement is the existence proof
|
|
197
|
+
* rather than the rule: a moderated delete on a route of its own behind an
|
|
198
|
+
* admin rank, and the author-gated edit deliberately left alone, because the
|
|
199
|
+
* rank that removes somebody's words is not the rank that rewrites them.
|
|
200
|
+
* A single `viewerIsAuthor` cannot express that pair, and the day it has to,
|
|
201
|
+
* the fix is a breaking rename rather than a field.
|
|
202
|
+
*
|
|
203
|
+
* (Naming a consumer here is a narrower act than the one the TOMBSTONE
|
|
204
|
+
* docblock in `comment.tsx` rules out. That one would have told every host
|
|
205
|
+
* how to DERIVE a value — a schema stated as this module's fact. This one
|
|
206
|
+
* cites an arrangement to show a shape is reachable, and constrains nobody:
|
|
207
|
+
* a host whose two predicates are identical sets both flags the same way and
|
|
208
|
+
* never notices the seam has two halves.)
|
|
209
|
+
*
|
|
210
|
+
* A per-comment field rather than a predicate in the render context, because
|
|
211
|
+
* `ThreadLinkPreview.canHide` is already that shape and a second shape for
|
|
212
|
+
* the same idea is a second thing to learn. It also means nothing between
|
|
213
|
+
* here and the control needs new plumbing: `<Thread>` and `<ActivityPane>`
|
|
214
|
+
* forward `comments` verbatim, so the capability arrives with the row it
|
|
215
|
+
* describes.
|
|
216
|
+
*
|
|
217
|
+
* The SHAPE is `canHide`'s; the DEFAULT is its opposite, and that is worth
|
|
218
|
+
* saying out loud before somebody reasons from the precedent and guesses
|
|
219
|
+
* backwards. `canHide` is REQUIRED and therefore fails closed — a card whose
|
|
220
|
+
* host says nothing draws no dismiss control. These two are OPTIONAL and
|
|
221
|
+
* fail open. The difference is not principle, it is history: link previews
|
|
222
|
+
* were new when `canHide` landed, so requiring it cost no host anything,
|
|
223
|
+
* whereas edit and delete already render for everyone and a required field
|
|
224
|
+
* would be a breaking change that silently removes controls until it is
|
|
225
|
+
* supplied. "ABSENT MEANS TRUE" below is the whole of that argument.
|
|
226
|
+
*
|
|
227
|
+
* Deriving it here from `author.id` against a viewer id was the other
|
|
228
|
+
* candidate and is rejected twice over: a derived rule recomputed on the
|
|
229
|
+
* client is a second copy waiting to disagree with the server's, and
|
|
230
|
+
* `author.id` is nullable by D5 — `ON DELETE SET NULL` would quietly turn
|
|
231
|
+
* "the author is gone" into "you wrote this".
|
|
232
|
+
*
|
|
233
|
+
* ABSENT MEANS TRUE, and that is the uncomfortable half of this. A host on
|
|
234
|
+
* 0.2.0 that wired `onEdit` and upgrades without touching its data keeps
|
|
235
|
+
* precisely the render it has today, defect included. The alternative —
|
|
236
|
+
* absent means false — fixes that host by silently taking the edit control
|
|
237
|
+
* off the viewer's OWN comments until it maps two new fields: a release in
|
|
238
|
+
* which nobody can edit anything, nothing errors, no test in the host fails,
|
|
239
|
+
* and it survives to production for the same reason FU-0019 did. Trading a
|
|
240
|
+
* known lie for a new silent regression is not a trade, so the default
|
|
241
|
+
* preserves the render and this docblock is the notice. The seam is one
|
|
242
|
+
* field wide; a host that means it should set it.
|
|
243
|
+
*
|
|
244
|
+
* RENDERING ONLY, and never an authorization decision — the same words
|
|
245
|
+
* `canHide` is written in, for the same reason. The server re-checks
|
|
246
|
+
* authorship on every edit and rank on every moderated delete; a client that
|
|
247
|
+
* flips this to `true` earns a 404, not an edit.
|
|
248
|
+
*/
|
|
249
|
+
readonly canEdit?: boolean | undefined
|
|
250
|
+
/**
|
|
251
|
+
* Whether to DRAW the delete control on THIS comment. Everything above
|
|
252
|
+
* applies, including that absent means true and that this decides RENDERING
|
|
253
|
+
* only — the server remains the gate and refuses on its own authority.
|
|
254
|
+
*
|
|
255
|
+
* It is separate from `canEdit` so the two predicates can differ, which on a
|
|
256
|
+
* surface with moderation they already do: an admin may remove a comment
|
|
257
|
+
* they may not rewrite, so `canDelete: true` with `canEdit: false` is the
|
|
258
|
+
* shape that describes them and there is no way to spell it with one flag.
|
|
259
|
+
*/
|
|
260
|
+
readonly canDelete?: boolean | undefined
|
|
152
261
|
readonly reactions?: readonly ThreadReactionCount[] | undefined
|
|
153
262
|
readonly attachments?: readonly ThreadAttachment[] | undefined
|
|
154
263
|
/**
|
package/src/tokens.css
CHANGED
|
@@ -402,6 +402,19 @@
|
|
|
402
402
|
Both resolve through the theme-aware tokens, so the light block needs no
|
|
403
403
|
override — var() resolves at use time. */
|
|
404
404
|
--ring-focus: 0 0 0 2px rgb(var(--accent));
|
|
405
|
+
/* The SAME ring, painted inward. An outset box-shadow is clipped by ANY
|
|
406
|
+
ancestor with a non-visible overflow, and when that happens the focus
|
|
407
|
+
indicator does not degrade — it disappears, which is a WCAG 2.4.7 failure
|
|
408
|
+
rather than a cosmetic one.
|
|
409
|
+
Use this at a clipping boundary: inside a scroll container, inside the
|
|
410
|
+
`overflow-hidden` a grid-rows reveal needs, inside a Card that keeps its
|
|
411
|
+
default clip. It cannot be clipped by an ancestor because it paints inside
|
|
412
|
+
the element's own border box.
|
|
413
|
+
Found the long way on 2026-09-09: the reply composer, the reaction chips
|
|
414
|
+
and the GIF picker all lost their ring, and `thread.tsx` had already been
|
|
415
|
+
forced to override a Card's `overflow-hidden` to `overflow-visible` to keep
|
|
416
|
+
one. That override is the cost this token removes. */
|
|
417
|
+
--ring-focus-inset: inset 0 0 0 2px rgb(var(--accent));
|
|
405
418
|
--ring-error: 0 0 0 2px rgb(var(--destructive));
|
|
406
419
|
|
|
407
420
|
--radius-xs: 6px;
|