@lovett/ui 0.0.10 → 0.1.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/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +6064 -80
- package/dist/index.js +21561 -9696
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1586 -2
- package/dist/theme-v2.css +228 -0
- package/dist/tokens.css +121 -0
- package/package.json +8 -1
- package/src/__tests__/anchor.test.tsx +422 -0
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/combobox.test.tsx +677 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +208 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +572 -0
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/helpers/geometry.ts +58 -0
- package/src/__tests__/layer-stack.test.tsx +228 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/popover.test.tsx +460 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/select.test.tsx +543 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/__tests__/tooltip.test.tsx +355 -0
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/calculator-shell-v2.tsx +19 -39
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/combobox.tsx +796 -0
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +186 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +507 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +242 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +412 -151
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +402 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/anchor.ts +427 -0
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +74 -0
- package/src/lib/layer-stack.ts +215 -0
- package/src/lib/refs.ts +31 -0
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/metric-card.tsx +57 -22
- package/src/modal.tsx +73 -66
- package/src/popover.tsx +407 -0
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/select.tsx +646 -0
- package/src/sortable.tsx +315 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-row.tsx +108 -70
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1586 -2
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +235 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +662 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +915 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +218 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +121 -0
- package/src/tooltip.tsx +297 -0
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommentItem — one comment and, recursively, everything under it.
|
|
3
|
+
*
|
|
4
|
+
* LAYOUT. Two columns and three rows, which is what lets the rail be a
|
|
5
|
+
* connector instead of a margin rule (see rail.tsx for the geometry):
|
|
6
|
+
*
|
|
7
|
+
* ┌───────────────┬──────────────────────────────┐
|
|
8
|
+
* │ avatar │ byline · body · action row │ row 1
|
|
9
|
+
* │ stem + circle │ │
|
|
10
|
+
* ├───────────────┴──────────────────────────────┤
|
|
11
|
+
* │ (reply composer, hung at --thread-content-x) │ row 2
|
|
12
|
+
* ├──────────────────────────────────────────────┤
|
|
13
|
+
* │ replies, each indenting itself by --thread- │ row 3
|
|
14
|
+
* │ indent and drawing its own connector segment │
|
|
15
|
+
* └──────────────────────────────────────────────┘
|
|
16
|
+
*
|
|
17
|
+
* The frame around that grid is a `<li>` (`listItem`, the default), because a
|
|
18
|
+
* thread is a LIST and its nesting is the whole information structure. It costs
|
|
19
|
+
* no geometry: the grid is a CHILD of the frame, not the frame itself, so the
|
|
20
|
+
* element swap is block-for-block — a `list-item` box with `list-style: none`
|
|
21
|
+
* lays out exactly as the `<div>` did, and the padding that positions every
|
|
22
|
+
* connector stays on the same box. `display: contents` was never a candidate;
|
|
23
|
+
* it strips the list semantics in some AT and Safari versions, which is the
|
|
24
|
+
* entire thing being bought here. A ROOT passes `listItem={false}`: its `<li>`
|
|
25
|
+
* lives outside its `<Card>` in thread.tsx, since the comment's own block-start
|
|
26
|
+
* padding is what supplies the card's top inset.
|
|
27
|
+
*
|
|
28
|
+
* The avatar column occupies row 1 only, so it stretches to exactly the height
|
|
29
|
+
* of the comment's own content. That single fact is what pins the collapse
|
|
30
|
+
* circle level with the action row without anything measuring anything — and
|
|
31
|
+
* it keeps the circle there when a reply composer opens underneath, because the
|
|
32
|
+
* composer is in row 2.
|
|
33
|
+
*
|
|
34
|
+
* HIERARCHY IS TYPE AND SPACE, NOT BOXES. `↩ Reply` and the `•••` overflow sit
|
|
35
|
+
* at the inline-end of the byline, and they are QUIET — muted, borderless, no
|
|
36
|
+
* fill until hover. That combination is the point: an earlier pass put a
|
|
37
|
+
* BORDERED Reply there and the button competed with the name for the same line,
|
|
38
|
+
* which is a boxes-over-type failure rather than a placement one. Stripping the
|
|
39
|
+
* box resolves it, and it serves the product goal too (owner, 2026-09-06):
|
|
40
|
+
* Reply should be slightly out of the way, so people reach for the main comment
|
|
41
|
+
* box instead of threading every remark. See actions.tsx for why this is a
|
|
42
|
+
* deliberate, documented exception to CLAUDE.md §2's ghost-button rule.
|
|
43
|
+
*
|
|
44
|
+
* The row below the body is therefore ENGAGEMENT only — emoji reactions or
|
|
45
|
+
* votes, plus the in-flight notice. One purpose per row.
|
|
46
|
+
*
|
|
47
|
+
* SPACING. `--thread-row-gap` (12) separates comments; `--space-1` (4)
|
|
48
|
+
* separates the groups inside one. Four times the intra-group step, so grouping
|
|
49
|
+
* is carried by space alone and there is not a divider anywhere in the tree.
|
|
50
|
+
* That the gap is a comment's OWN block-start padding rather than a container
|
|
51
|
+
* gap is load-bearing: sibling boxes have to be flush for their connector
|
|
52
|
+
* segments to concatenate into one line.
|
|
53
|
+
*
|
|
54
|
+
* Corrections to the original port, all still in force:
|
|
55
|
+
* 1. Classes + public tokens, never inline `tok()` styles reaching internals.
|
|
56
|
+
* 2. Lucide icons, never the text glyphs the reference used.
|
|
57
|
+
* 3. Engagement state is props/callbacks, never local `useState`.
|
|
58
|
+
* 4. Nothing that animates open/closed is conditionally rendered.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { useState } from 'react'
|
|
62
|
+
import type { CSSProperties, ReactElement, ReactNode } from 'react'
|
|
63
|
+
import {
|
|
64
|
+
ChevronRight,
|
|
65
|
+
CornerDownRight,
|
|
66
|
+
FileText,
|
|
67
|
+
RotateCw,
|
|
68
|
+
TriangleAlert,
|
|
69
|
+
} from 'lucide-react'
|
|
70
|
+
import { Avatar, asAvatarTint } from '../avatar'
|
|
71
|
+
import { Button } from '../button'
|
|
72
|
+
import { cn } from '../lib/utils'
|
|
73
|
+
import { CommentActions } from './actions'
|
|
74
|
+
import { CommentBody, type MentionTarget } from './comment-body'
|
|
75
|
+
import { ThreadComposer } from './composer'
|
|
76
|
+
import {
|
|
77
|
+
RichThreadComposer,
|
|
78
|
+
type RichComposerAttachmentProps,
|
|
79
|
+
type RichThreadComposerProps,
|
|
80
|
+
} from './rich-composer'
|
|
81
|
+
import { CommentVote, ReactionBar } from './reactions'
|
|
82
|
+
import { RelativeTime } from './relative-time'
|
|
83
|
+
import { countDescendants } from './comment-tree'
|
|
84
|
+
import { AttachmentGif, AttachmentImage } from './attachments'
|
|
85
|
+
import { LinkPreview } from './link-preview'
|
|
86
|
+
import {
|
|
87
|
+
CollapsedReplies,
|
|
88
|
+
DEFAULT_INDENT_CLAMP_DEPTH,
|
|
89
|
+
LineIcon,
|
|
90
|
+
ThreadConnector,
|
|
91
|
+
ThreadRail,
|
|
92
|
+
ThreadTrunkSegment,
|
|
93
|
+
} from './rail'
|
|
94
|
+
import type { UseThreadResult } from './use-thread'
|
|
95
|
+
import type {
|
|
96
|
+
CommentNode,
|
|
97
|
+
MentionSuggestionItem,
|
|
98
|
+
ThreadAttachment,
|
|
99
|
+
ThreadAuthor,
|
|
100
|
+
ThreadLinkPreview,
|
|
101
|
+
ThreadReactionKey,
|
|
102
|
+
ThreadVote,
|
|
103
|
+
} from './types'
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Everything the recursive renderer needs, assembled once at the root.
|
|
107
|
+
* One object rather than fourteen props threaded through every level — the
|
|
108
|
+
* reference passed four and had already started to sprawl.
|
|
109
|
+
*/
|
|
110
|
+
export interface ThreadRenderContext extends RichComposerAttachmentProps {
|
|
111
|
+
readonly thread: UseThreadResult
|
|
112
|
+
readonly locale?: string | undefined
|
|
113
|
+
readonly sending?: boolean | undefined
|
|
114
|
+
readonly replyAvatar?: ReactNode | undefined
|
|
115
|
+
/**
|
|
116
|
+
* Levels deeper than this stop ADDING indent. Purely presentational, which is
|
|
117
|
+
* why it lives here and not in the hook: nesting itself is uncapped, and the
|
|
118
|
+
* stored depth is never touched.
|
|
119
|
+
*/
|
|
120
|
+
readonly indentClampDepth?: number | undefined
|
|
121
|
+
/**
|
|
122
|
+
* Writes to the ONE polite live region `<Thread>` owns. Collapse and expand
|
|
123
|
+
* go through it because `aria-expanded` reports that a state changed and
|
|
124
|
+
* says nothing about what happened to the eight comments that just left the
|
|
125
|
+
* page. Absent for a consumer driving `CommentItem` itself; the calls are
|
|
126
|
+
* optional and the tree renders identically without it.
|
|
127
|
+
*/
|
|
128
|
+
readonly announce?: ((message: string) => void) | undefined
|
|
129
|
+
/**
|
|
130
|
+
* The `@` picker's options (ADR-149 D6). Absent, the popup never opens and
|
|
131
|
+
* `@` is ordinary text — which is what every host that has not wired a
|
|
132
|
+
* directory yet should get.
|
|
133
|
+
*
|
|
134
|
+
* A CONVENIENCE, never a permission boundary: the server parses `body_md` at
|
|
135
|
+
* write time and re-checks every mention it extracts, whether this offered
|
|
136
|
+
* it or someone typed it by hand.
|
|
137
|
+
*/
|
|
138
|
+
readonly mentionItems?: ((query: string) => readonly MentionSuggestionItem[]) | undefined
|
|
139
|
+
readonly onReact?: ((commentId: string, key: ThreadReactionKey) => void) | undefined
|
|
140
|
+
readonly onVote?: ((commentId: string, next: ThreadVote) => void) | undefined
|
|
141
|
+
readonly onRetry?: ((commentId: string) => void) | undefined
|
|
142
|
+
readonly onSelectAuthor?: ((author: ThreadAuthor) => void) | undefined
|
|
143
|
+
readonly onSelectMention?: ((target: MentionTarget) => void) | undefined
|
|
144
|
+
readonly onOpenAttachment?: ((attachment: ThreadAttachment) => void) | undefined
|
|
145
|
+
/**
|
|
146
|
+
* Resolve one attachment's short-lived URL (ADR-151 D6).
|
|
147
|
+
*
|
|
148
|
+
* The wire carries no URL — the storage key never leaves the worker — so the
|
|
149
|
+
* renderer is handed the SHAPE of the slot and asks for the bytes lazily.
|
|
150
|
+
* Omit it and every attachment renders as its placeholder, at the size it
|
|
151
|
+
* would have been: the layout never depends on whether this is supplied, or
|
|
152
|
+
* on whether it succeeded.
|
|
153
|
+
*/
|
|
154
|
+
readonly resolveAttachmentUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
|
|
155
|
+
/**
|
|
156
|
+
* Hide one link preview on one comment (ADR-150 D7).
|
|
157
|
+
*
|
|
158
|
+
* Absent and no card renders a dismiss control at all, which is the right
|
|
159
|
+
* default for a host that has not wired the mutation: an affordance that
|
|
160
|
+
* silently does nothing is worse than no affordance. The card also needs
|
|
161
|
+
* `canHide` — the host's optimistic removal and error invalidation live in
|
|
162
|
+
* the host, exactly as they do for reactions.
|
|
163
|
+
*/
|
|
164
|
+
readonly onHideLinkPreview?: ((commentId: string, urlHash: string) => void) | undefined
|
|
165
|
+
/**
|
|
166
|
+
* The byline overflow menu. Each item is rendered only when its callback
|
|
167
|
+
* exists — an absent capability is an absent item, never a disabled one,
|
|
168
|
+
* because a permanently greyed row teaches people the surface is broken.
|
|
169
|
+
* `onCopyText` is the exception in shape: the body is already in hand, so the
|
|
170
|
+
* clipboard write happens here and the callback only reports the outcome.
|
|
171
|
+
*/
|
|
172
|
+
readonly onCopyLink?: ((commentId: string) => void) | undefined
|
|
173
|
+
readonly onCopyText?: ((commentId: string, ok: boolean) => void) | undefined
|
|
174
|
+
readonly onEdit?: ((commentId: string) => void) | undefined
|
|
175
|
+
readonly onDelete?: ((commentId: string) => void) | undefined
|
|
176
|
+
/**
|
|
177
|
+
* Called instead of expanding in place when a FINITE `maxDepth` is hit — a
|
|
178
|
+
* consumer that has a dedicated permalink view wants to navigate there.
|
|
179
|
+
* Unreachable by default, because the default depth is uncapped.
|
|
180
|
+
*/
|
|
181
|
+
readonly onContinueThread?: ((commentId: string) => void) | undefined
|
|
182
|
+
readonly composerMaxLength?: number | undefined
|
|
183
|
+
/**
|
|
184
|
+
* Which composer replies use (ADR-148 D5). Mirrors `ThreadProps.composer`.
|
|
185
|
+
* Threaded through the context rather than read from a store so a consumer
|
|
186
|
+
* driving `CommentItem` directly gets the plain composer unless it asks.
|
|
187
|
+
*/
|
|
188
|
+
readonly composer?: 'plain' | 'rich' | undefined
|
|
189
|
+
/** Lets a REPLY composer's switcher change the whole thread's mode. */
|
|
190
|
+
readonly onComposerChange?: ((composer: 'plain' | 'rich') => void) | undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The two-column frame. Declared once so every level is byte-identical. */
|
|
194
|
+
const GRID_STYLE: CSSProperties = {
|
|
195
|
+
display: 'grid',
|
|
196
|
+
gridTemplateColumns: 'var(--thread-avatar) minmax(0, 1fr)',
|
|
197
|
+
columnGap: 'var(--thread-gap)',
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* ADR-147 D5/D6 — the display fallback lives in the component, never in the
|
|
202
|
+
* data. `users.name` / `given_name` / `family_name` are nullable by standing
|
|
203
|
+
* policy precisely so nothing fabricates one at write time; that policy only
|
|
204
|
+
* holds if the read side degrades honestly instead of crashing.
|
|
205
|
+
*/
|
|
206
|
+
function authorLabel(author: ThreadAuthor | null | undefined): string {
|
|
207
|
+
if (author == null) return 'Deleted user'
|
|
208
|
+
const name = author.name?.trim()
|
|
209
|
+
if (name !== undefined && name !== '') return name
|
|
210
|
+
const email = author.email?.trim()
|
|
211
|
+
if (email !== undefined && email !== '') return email
|
|
212
|
+
return 'Unknown member'
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** "1 reply" / "8 replies" — the live region's wording, in one place. */
|
|
216
|
+
function replyCountLabel(count: number): string {
|
|
217
|
+
return `${String(count)} ${count === 1 ? 'reply' : 'replies'}`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function formatBytes(bytes: number): string {
|
|
221
|
+
if (!Number.isFinite(bytes) || bytes < 0) return ''
|
|
222
|
+
if (bytes < 1024) return `${String(bytes)} B`
|
|
223
|
+
if (bytes < 1024 * 1024) return `${String(Math.round(bytes / 1024))} KB`
|
|
224
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function Attachments({
|
|
228
|
+
attachments,
|
|
229
|
+
onOpen,
|
|
230
|
+
resolveUrl,
|
|
231
|
+
}: {
|
|
232
|
+
attachments: readonly ThreadAttachment[]
|
|
233
|
+
onOpen?: ((attachment: ThreadAttachment) => void) | undefined
|
|
234
|
+
resolveUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
|
|
235
|
+
}) {
|
|
236
|
+
// Three buckets, not two, since ADR-151 D4: a Giphy mp4 renders through
|
|
237
|
+
// `AttachmentGif`; an uploaded image/gif remains in `images`, where
|
|
238
|
+
// `AttachmentImage` gives it the equivalent still-frame treatment.
|
|
239
|
+
const images = attachments.filter((a) => a.kind === 'image')
|
|
240
|
+
const gifs = attachments.filter((a) => a.kind === 'gif')
|
|
241
|
+
const files = attachments.filter((a) => a.kind === 'file')
|
|
242
|
+
return (
|
|
243
|
+
<div
|
|
244
|
+
className="flex flex-col gap-[var(--space-2)]"
|
|
245
|
+
style={{ marginBlockStart: 'var(--space-2)' }}
|
|
246
|
+
>
|
|
247
|
+
{images.length === 0 && gifs.length === 0 ? null : (
|
|
248
|
+
<div className="flex flex-wrap gap-[var(--space-2)]">
|
|
249
|
+
{images.map((attachment) => (
|
|
250
|
+
<AttachmentImage
|
|
251
|
+
key={attachment.id}
|
|
252
|
+
attachment={attachment}
|
|
253
|
+
onOpen={onOpen}
|
|
254
|
+
resolveUrl={resolveUrl}
|
|
255
|
+
/>
|
|
256
|
+
))}
|
|
257
|
+
{gifs.map((attachment) => (
|
|
258
|
+
<AttachmentGif key={attachment.id} attachment={attachment} resolveUrl={resolveUrl} />
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
{files.map((attachment) => (
|
|
263
|
+
<button
|
|
264
|
+
key={attachment.id}
|
|
265
|
+
type="button"
|
|
266
|
+
disabled={onOpen === undefined}
|
|
267
|
+
onClick={() => {
|
|
268
|
+
onOpen?.(attachment)
|
|
269
|
+
}}
|
|
270
|
+
className={cn(
|
|
271
|
+
// Optical padding: 8 inline against 4 block, because the line box
|
|
272
|
+
// already carries space the glyphs do not fill.
|
|
273
|
+
'inline-flex max-w-full items-center gap-[var(--space-2)] self-start border px-[var(--space-2)] py-[var(--space-1)] text-[12px]',
|
|
274
|
+
'border-[rgb(var(--border))] bg-[rgb(var(--surface-inset))] text-[rgb(var(--text-secondary))]',
|
|
275
|
+
'transition-[background-color,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] motion-reduce:transition-none',
|
|
276
|
+
'enabled:hover:border-[rgb(var(--border-strong))] enabled:hover:bg-[rgb(var(--surface-hover))]',
|
|
277
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
278
|
+
'disabled:cursor-default',
|
|
279
|
+
)}
|
|
280
|
+
style={{ borderRadius: 'var(--thread-inner-radius, var(--radius-sm))' }}
|
|
281
|
+
>
|
|
282
|
+
<LineIcon>
|
|
283
|
+
<FileText size={12} strokeWidth={1.5} aria-hidden="true" />
|
|
284
|
+
</LineIcon>
|
|
285
|
+
<span className="truncate">{attachment.name}</span>
|
|
286
|
+
{attachment.sizeBytes === undefined ? null : (
|
|
287
|
+
<span className="tabular-nums" style={{ color: 'rgb(var(--text-tertiary))' }}>
|
|
288
|
+
{formatBytes(attachment.sizeBytes)}
|
|
289
|
+
</span>
|
|
290
|
+
)}
|
|
291
|
+
</button>
|
|
292
|
+
))}
|
|
293
|
+
</div>
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Every visible card on one comment (ADR-150 D8).
|
|
299
|
+
*
|
|
300
|
+
* There is ONE of these, reached by the one recursive `CommentItem`, so a root
|
|
301
|
+
* and a reply are rendered by the same code by construction rather than by
|
|
302
|
+
* two call sites agreeing. Ordering comes from the worker (`sort_order`,
|
|
303
|
+
* document order) and is not re-sorted here.
|
|
304
|
+
*/
|
|
305
|
+
function LinkPreviews({
|
|
306
|
+
commentId,
|
|
307
|
+
previews,
|
|
308
|
+
onHide,
|
|
309
|
+
}: {
|
|
310
|
+
commentId: string
|
|
311
|
+
previews: readonly ThreadLinkPreview[]
|
|
312
|
+
onHide?: ((commentId: string, urlHash: string) => void) | undefined
|
|
313
|
+
}) {
|
|
314
|
+
return (
|
|
315
|
+
<div
|
|
316
|
+
className="flex flex-col gap-[var(--space-2)]"
|
|
317
|
+
style={{ marginBlockStart: 'var(--space-2)' }}
|
|
318
|
+
>
|
|
319
|
+
{previews.map((preview) => (
|
|
320
|
+
<LinkPreview
|
|
321
|
+
key={preview.urlHash}
|
|
322
|
+
preview={preview}
|
|
323
|
+
{...(onHide === undefined
|
|
324
|
+
? {}
|
|
325
|
+
: {
|
|
326
|
+
onHide: () => {
|
|
327
|
+
onHide(commentId, preview.urlHash)
|
|
328
|
+
},
|
|
329
|
+
})}
|
|
330
|
+
/>
|
|
331
|
+
))}
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export interface CommentItemProps {
|
|
337
|
+
readonly node: CommentNode
|
|
338
|
+
readonly ctx: ThreadRenderContext
|
|
339
|
+
/**
|
|
340
|
+
* RENDER depth. Only two things read it: the (uncapped by default)
|
|
341
|
+
* `maxDepth`, and the indent clamp. `node.depth` is the true stored depth.
|
|
342
|
+
*/
|
|
343
|
+
readonly renderDepth: number
|
|
344
|
+
/**
|
|
345
|
+
* Last of its sibling run. The last reply TERMINATES the connector at its own
|
|
346
|
+
* avatar; every other one carries the line on to the next. Roots are always
|
|
347
|
+
* last — a root has no rail above it to carry.
|
|
348
|
+
*/
|
|
349
|
+
readonly isLast?: boolean | undefined
|
|
350
|
+
/**
|
|
351
|
+
* Render the frame as an `<li>`. Defaults to true, because the recursive
|
|
352
|
+
* case is the common one and a comment is a list item: every reply is a
|
|
353
|
+
* direct child of its parent's replies `<ul>`. Pass false wherever the
|
|
354
|
+
* `<li>` is supplied by the caller instead — `<Thread>` does exactly that
|
|
355
|
+
* for a ROOT, whose `<li>` has to sit OUTSIDE its `<Card>` (the comment's own
|
|
356
|
+
* block-start padding is what supplies the card's top inset). A custom skin
|
|
357
|
+
* built on `useThread` owns its own root list and should do the same.
|
|
358
|
+
*/
|
|
359
|
+
readonly listItem?: boolean | undefined
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function CommentItem({
|
|
363
|
+
node,
|
|
364
|
+
ctx,
|
|
365
|
+
renderDepth,
|
|
366
|
+
isLast = true,
|
|
367
|
+
listItem = true,
|
|
368
|
+
}: CommentItemProps) {
|
|
369
|
+
const { thread } = ctx
|
|
370
|
+
// "Continue this thread" expands the subtree in place when a consumer set a
|
|
371
|
+
// finite maxDepth and has nowhere to navigate to. Local, and correctly lost
|
|
372
|
+
// on unmount: it is a view preference, not content.
|
|
373
|
+
const [continued, setContinued] = useState(false)
|
|
374
|
+
|
|
375
|
+
const collapsed = thread.isCollapsed(node.id)
|
|
376
|
+
const deleted = node.deletedAt != null
|
|
377
|
+
const state = node.state ?? 'sent'
|
|
378
|
+
const label = deleted ? '[deleted]' : authorLabel(node.author)
|
|
379
|
+
const descendants = countDescendants(node)
|
|
380
|
+
const atCap = renderDepth >= thread.maxDepth && !continued
|
|
381
|
+
const { visible, hidden } = thread.visibleReplies(node)
|
|
382
|
+
const hasChildren = node.replies.length > 0
|
|
383
|
+
const replyOpen = thread.isReplyingTo(node.id)
|
|
384
|
+
// ADR-148 D5: reply composers are rich only after their first open. Every
|
|
385
|
+
// comment keeps its reply composer MOUNTED (correction 4 — nothing that
|
|
386
|
+
// animates is conditionally rendered), so without this latch a 24-comment
|
|
387
|
+
// thread would mount 24 ProseMirror editors before anyone replied. Once
|
|
388
|
+
// opened it stays rich, so the closing animation never swaps content
|
|
389
|
+
// mid-flight (the collapse rule: content keeps its size while closing).
|
|
390
|
+
const [replyEverOpened, setReplyEverOpened] = useState(false)
|
|
391
|
+
if (replyOpen && !replyEverOpened) setReplyEverOpened(true)
|
|
392
|
+
// Annotated rather than inferred so the ADR-149/151 seams can be passed to the
|
|
393
|
+
// union: `RichThreadComposerProps` extends `ThreadComposerProps`, so the
|
|
394
|
+
// plain composer satisfies this signature and simply never receives the
|
|
395
|
+
// rich-only capabilities.
|
|
396
|
+
const ReplyComposer: (props: RichThreadComposerProps) => ReactElement =
|
|
397
|
+
ctx.composer === 'rich' && replyEverOpened ? RichThreadComposer : ThreadComposer
|
|
398
|
+
// Narrow once, so the byline never needs a cast to satisfy the callback.
|
|
399
|
+
const author = node.author ?? null
|
|
400
|
+
// `avatar_color` is a nullable TEXT column, so it is parsed at this boundary
|
|
401
|
+
// rather than trusted as a domain value (D6).
|
|
402
|
+
const tint = asAvatarTint(author?.tint)
|
|
403
|
+
const canEngage = !deleted && state === 'sent'
|
|
404
|
+
const indentClampDepth = ctx.indentClampDepth ?? DEFAULT_INDENT_CLAMP_DEPTH
|
|
405
|
+
|
|
406
|
+
// A reply draws the segment of the connector that spans its own box.
|
|
407
|
+
const connected = renderDepth > 0
|
|
408
|
+
// Past the clamp the indent stops growing, so there is no gutter to curve in
|
|
409
|
+
// and the line drops straight onto the child's avatar instead.
|
|
410
|
+
const clamped = renderDepth > indentClampDepth
|
|
411
|
+
// The rail only exists where it leads somewhere. A leaf comment costs no
|
|
412
|
+
// chrome at all, which is most of why depth got cheap.
|
|
413
|
+
const railed = hasChildren && !atCap
|
|
414
|
+
// Deliberately WITHOUT the count. The summary chip below a collapsed subtree
|
|
415
|
+
// names the count ("Show 8 replies to Priya Nair"); the circle names the
|
|
416
|
+
// toggle. Two controls that do the same thing may share a target, but they
|
|
417
|
+
// must not share an accessible name, or a screen-reader user hears the same
|
|
418
|
+
// control twice and cannot tell which one they are on.
|
|
419
|
+
const collapseLabel = `${collapsed ? 'Show' : 'Collapse'} replies to ${label}`
|
|
420
|
+
// ENGAGEMENT only — Reply lives in the byline now. One purpose per row.
|
|
421
|
+
const showActions = state === 'pending' || (canEngage && thread.engagement !== 'none')
|
|
422
|
+
|
|
423
|
+
// The frame element. A `list-item` box with `list-style: none` (set by the
|
|
424
|
+
// `<ul>` and inherited) is laid out exactly as a block, so this swap moves
|
|
425
|
+
// nothing: same padding, same containing block for the connectors.
|
|
426
|
+
const Frame = listItem ? 'li' : 'div'
|
|
427
|
+
|
|
428
|
+
return (
|
|
429
|
+
<Frame
|
|
430
|
+
className="relative"
|
|
431
|
+
data-comment-id={node.id}
|
|
432
|
+
style={{
|
|
433
|
+
// The comment's OWN top padding, never a container gap — sibling boxes
|
|
434
|
+
// must be flush for the connector segments to join.
|
|
435
|
+
paddingBlockStart: 'var(--thread-row-gap)',
|
|
436
|
+
...(connected && !clamped ? { paddingInlineStart: 'var(--thread-indent)' } : {}),
|
|
437
|
+
}}
|
|
438
|
+
>
|
|
439
|
+
{connected ? <ThreadConnector clamped={clamped} isLast={isLast} /> : null}
|
|
440
|
+
|
|
441
|
+
<div style={GRID_STYLE}>
|
|
442
|
+
{/* AVATAR COLUMN — row 1 only, so it stretches to the height of this
|
|
443
|
+
comment's own content and the circle lands on the action row. */}
|
|
444
|
+
<div className="flex flex-col items-center" style={{ gridColumn: 1, gridRow: 1 }}>
|
|
445
|
+
<Avatar
|
|
446
|
+
// `decorative` because the name is rendered right beside it —
|
|
447
|
+
// otherwise a screen reader announces the same person twice.
|
|
448
|
+
decorative
|
|
449
|
+
name={author?.name ?? author?.email ?? null}
|
|
450
|
+
size="md"
|
|
451
|
+
src={author?.avatarUrl ?? null}
|
|
452
|
+
{...(deleted || author === null ? { initial: '?' } : {})}
|
|
453
|
+
{...(tint === undefined ? {} : { tint })}
|
|
454
|
+
{...(author?.id == null ? {} : { seed: author.id })}
|
|
455
|
+
/>
|
|
456
|
+
{railed ? (
|
|
457
|
+
<ThreadRail
|
|
458
|
+
collapsed={collapsed}
|
|
459
|
+
label={collapseLabel}
|
|
460
|
+
onToggle={() => {
|
|
461
|
+
thread.toggleCollapse(node.id)
|
|
462
|
+
// `collapsed` is the state BEFORE the toggle, so the message
|
|
463
|
+
// names the state being entered.
|
|
464
|
+
ctx.announce?.(
|
|
465
|
+
`${collapsed ? 'Expanded' : 'Collapsed'} ${replyCountLabel(descendants)}`,
|
|
466
|
+
)
|
|
467
|
+
}}
|
|
468
|
+
/>
|
|
469
|
+
) : null}
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
{/* CONTENT COLUMN — row 1. Identity, then the body, then one action
|
|
473
|
+
row. Nothing here is a box; the hierarchy is weight and space. */}
|
|
474
|
+
<div className="min-w-0" style={{ gridColumn: 2, gridRow: 1 }}>
|
|
475
|
+
<div
|
|
476
|
+
data-thread-byline
|
|
477
|
+
// Deliberately NOT `flex-wrap`. A wrapping byline drops the action
|
|
478
|
+
// cluster onto a second line the moment a name gets long, which is
|
|
479
|
+
// the reflow this row must never do; the identity group absorbs the
|
|
480
|
+
// pressure by truncating instead.
|
|
481
|
+
className="flex items-center gap-[var(--space-2)]"
|
|
482
|
+
// Optically centres the name against the face beside it. The floor
|
|
483
|
+
// is the avatar PLUS the trailing gap, because `border-box` counts
|
|
484
|
+
// padding into `min-block-size`: without the addition the content
|
|
485
|
+
// box would be 28 and the text would centre 2px above the avatar.
|
|
486
|
+
style={{
|
|
487
|
+
minBlockSize: 'calc(var(--thread-avatar) + var(--space-1))',
|
|
488
|
+
paddingBlockEnd: 'var(--space-1)',
|
|
489
|
+
}}
|
|
490
|
+
>
|
|
491
|
+
<div className="flex min-w-0 flex-1 items-center gap-[var(--space-2)]">
|
|
492
|
+
{ctx.onSelectAuthor !== undefined && !deleted && author !== null ? (
|
|
493
|
+
<button
|
|
494
|
+
type="button"
|
|
495
|
+
onClick={() => {
|
|
496
|
+
ctx.onSelectAuthor?.(author)
|
|
497
|
+
}}
|
|
498
|
+
className={cn(
|
|
499
|
+
'min-w-0 truncate text-[14px] font-semibold text-[rgb(var(--foreground))]',
|
|
500
|
+
'underline-offset-2 hover:underline',
|
|
501
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
502
|
+
)}
|
|
503
|
+
style={{ borderRadius: 'var(--radius-xs)' }}
|
|
504
|
+
>
|
|
505
|
+
{label}
|
|
506
|
+
</button>
|
|
507
|
+
) : (
|
|
508
|
+
<span
|
|
509
|
+
className="min-w-0 truncate text-[14px] font-semibold"
|
|
510
|
+
style={{
|
|
511
|
+
color: deleted ? 'rgb(var(--text-tertiary))' : 'rgb(var(--foreground))',
|
|
512
|
+
}}
|
|
513
|
+
>
|
|
514
|
+
{label}
|
|
515
|
+
</span>
|
|
516
|
+
)}
|
|
517
|
+
{author?.role == null || deleted ? null : (
|
|
518
|
+
<span
|
|
519
|
+
className="shrink-0 text-[10.5px] font-semibold tracking-[0.04em] uppercase"
|
|
520
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
521
|
+
>
|
|
522
|
+
{author.role}
|
|
523
|
+
</span>
|
|
524
|
+
)}
|
|
525
|
+
<span
|
|
526
|
+
className="flex shrink-0 items-center gap-[var(--space-1)] text-[12px]"
|
|
527
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
528
|
+
>
|
|
529
|
+
<RelativeTime
|
|
530
|
+
value={node.createdAt}
|
|
531
|
+
now={thread.now}
|
|
532
|
+
{...(ctx.locale === undefined ? {} : { locale: ctx.locale })}
|
|
533
|
+
/>
|
|
534
|
+
{node.editedAt == null ? null : <span>· edited</span>}
|
|
535
|
+
</span>
|
|
536
|
+
</div>
|
|
537
|
+
|
|
538
|
+
<CommentActions
|
|
539
|
+
label={label}
|
|
540
|
+
replyOpen={replyOpen}
|
|
541
|
+
{...(canEngage && thread.canReply
|
|
542
|
+
? {
|
|
543
|
+
onReplyToggle: () => {
|
|
544
|
+
if (replyOpen) thread.cancelReply()
|
|
545
|
+
else thread.startReply(node.id)
|
|
546
|
+
},
|
|
547
|
+
}
|
|
548
|
+
: {})}
|
|
549
|
+
{...(deleted || node.bodyMd === '' ? {} : { copyText: node.bodyMd })}
|
|
550
|
+
{...(ctx.onCopyText === undefined
|
|
551
|
+
? {}
|
|
552
|
+
: {
|
|
553
|
+
onCopyText: (ok: boolean) => {
|
|
554
|
+
ctx.onCopyText?.(node.id, ok)
|
|
555
|
+
},
|
|
556
|
+
})}
|
|
557
|
+
{...(ctx.onCopyLink === undefined
|
|
558
|
+
? {}
|
|
559
|
+
: {
|
|
560
|
+
onCopyLink: () => {
|
|
561
|
+
ctx.onCopyLink?.(node.id)
|
|
562
|
+
},
|
|
563
|
+
})}
|
|
564
|
+
{...(ctx.onEdit === undefined || deleted
|
|
565
|
+
? {}
|
|
566
|
+
: {
|
|
567
|
+
onEdit: () => {
|
|
568
|
+
ctx.onEdit?.(node.id)
|
|
569
|
+
},
|
|
570
|
+
})}
|
|
571
|
+
{...(ctx.onDelete === undefined || deleted
|
|
572
|
+
? {}
|
|
573
|
+
: {
|
|
574
|
+
onDelete: () => {
|
|
575
|
+
ctx.onDelete?.(node.id)
|
|
576
|
+
},
|
|
577
|
+
})}
|
|
578
|
+
/>
|
|
579
|
+
</div>
|
|
580
|
+
|
|
581
|
+
{node.orphanedParentId === null ? null : (
|
|
582
|
+
// ADR-147 D4: this reply named a parent that is NOT in this list.
|
|
583
|
+
// It is shown at top level because dropping it would be worse,
|
|
584
|
+
// but it is labelled, because silently promoting it would claim
|
|
585
|
+
// it was a new remark rather than an answer to something.
|
|
586
|
+
<p
|
|
587
|
+
className="flex items-start gap-[var(--space-1)] text-[11.5px] italic"
|
|
588
|
+
style={{
|
|
589
|
+
color: 'rgb(var(--text-tertiary))',
|
|
590
|
+
marginBlockEnd: 'var(--space-1)',
|
|
591
|
+
}}
|
|
592
|
+
>
|
|
593
|
+
<LineIcon>
|
|
594
|
+
<CornerDownRight size={10} strokeWidth={1.5} aria-hidden="true" />
|
|
595
|
+
</LineIcon>
|
|
596
|
+
In reply to a comment that isn’t shown here
|
|
597
|
+
</p>
|
|
598
|
+
)}
|
|
599
|
+
|
|
600
|
+
{deleted ? (
|
|
601
|
+
<p className="text-[13px] italic" style={{ color: 'rgb(var(--text-tertiary))' }}>
|
|
602
|
+
[deleted]
|
|
603
|
+
</p>
|
|
604
|
+
) : (
|
|
605
|
+
<CommentBody
|
|
606
|
+
bodyMd={node.bodyMd}
|
|
607
|
+
{...(ctx.onSelectMention === undefined
|
|
608
|
+
? {}
|
|
609
|
+
: { onSelectMention: ctx.onSelectMention })}
|
|
610
|
+
/>
|
|
611
|
+
)}
|
|
612
|
+
|
|
613
|
+
{node.attachments === undefined || node.attachments.length === 0 || deleted ? null : (
|
|
614
|
+
<Attachments
|
|
615
|
+
attachments={node.attachments}
|
|
616
|
+
{...(ctx.onOpenAttachment === undefined ? {} : { onOpen: ctx.onOpenAttachment })}
|
|
617
|
+
{...(ctx.resolveAttachmentUrl === undefined
|
|
618
|
+
? {}
|
|
619
|
+
: { resolveUrl: ctx.resolveAttachmentUrl })}
|
|
620
|
+
/>
|
|
621
|
+
)}
|
|
622
|
+
|
|
623
|
+
{/* Link cards sit BELOW the person's own attachments: what they
|
|
624
|
+
deliberately attached outranks what a scanner found in their
|
|
625
|
+
prose. A tombstone shows none — the worker already returns none
|
|
626
|
+
for one (ADR-150 D8), and this is the second lock on it, because
|
|
627
|
+
"[deleted]" followed by three intact cards would leak the body
|
|
628
|
+
the tombstone exists to remove. */}
|
|
629
|
+
{node.linkPreviews === undefined || node.linkPreviews.length === 0 || deleted ? null : (
|
|
630
|
+
<LinkPreviews
|
|
631
|
+
commentId={node.id}
|
|
632
|
+
previews={node.linkPreviews}
|
|
633
|
+
{...(ctx.onHideLinkPreview === undefined
|
|
634
|
+
? {}
|
|
635
|
+
: { onHide: ctx.onHideLinkPreview })}
|
|
636
|
+
/>
|
|
637
|
+
)}
|
|
638
|
+
|
|
639
|
+
{/* THE engagement row. Emoji reactions (or votes) and the in-flight
|
|
640
|
+
notice share one line — the "+" used to sit on a line of its own
|
|
641
|
+
with nothing beside it, which read as an orphan rather than a
|
|
642
|
+
control. `--space-1` from the body above it, a quarter of the gap
|
|
643
|
+
to the next comment, so the grouping is unambiguous without a
|
|
644
|
+
rule. */}
|
|
645
|
+
{showActions ? (
|
|
646
|
+
<div
|
|
647
|
+
className="flex flex-wrap items-center gap-[var(--space-2)]"
|
|
648
|
+
style={{
|
|
649
|
+
marginBlockStart: 'var(--space-1)',
|
|
650
|
+
minBlockSize: 'var(--thread-action-h, var(--space-8))',
|
|
651
|
+
}}
|
|
652
|
+
>
|
|
653
|
+
{canEngage && thread.engagement === 'reactions' ? (
|
|
654
|
+
<ReactionBar
|
|
655
|
+
reactions={node.reactions ?? []}
|
|
656
|
+
{...(ctx.onReact === undefined
|
|
657
|
+
? {}
|
|
658
|
+
: {
|
|
659
|
+
onReact: (key: ThreadReactionKey) => {
|
|
660
|
+
ctx.onReact?.(node.id, key)
|
|
661
|
+
},
|
|
662
|
+
})}
|
|
663
|
+
/>
|
|
664
|
+
) : null}
|
|
665
|
+
|
|
666
|
+
{canEngage && thread.engagement === 'votes' ? (
|
|
667
|
+
<CommentVote
|
|
668
|
+
score={node.score ?? 0}
|
|
669
|
+
viewerVote={node.viewerVote ?? 0}
|
|
670
|
+
{...(ctx.onVote === undefined
|
|
671
|
+
? {}
|
|
672
|
+
: {
|
|
673
|
+
onVote: (next: ThreadVote) => {
|
|
674
|
+
ctx.onVote?.(node.id, next)
|
|
675
|
+
},
|
|
676
|
+
})}
|
|
677
|
+
/>
|
|
678
|
+
) : null}
|
|
679
|
+
|
|
680
|
+
{state === 'pending' ? (
|
|
681
|
+
<span
|
|
682
|
+
className="text-[11.5px] font-semibold"
|
|
683
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
684
|
+
>
|
|
685
|
+
Sending…
|
|
686
|
+
</span>
|
|
687
|
+
) : null}
|
|
688
|
+
</div>
|
|
689
|
+
) : null}
|
|
690
|
+
|
|
691
|
+
{state === 'failed' ? (
|
|
692
|
+
// role="alert": a send that failed after the person already
|
|
693
|
+
// looked away has to announce itself rather than wait to be
|
|
694
|
+
// noticed. ADR-147 D17.
|
|
695
|
+
<div
|
|
696
|
+
role="alert"
|
|
697
|
+
// TWO alignment axes, and they want opposite things — which is why
|
|
698
|
+
// this is two rows rather than one.
|
|
699
|
+
//
|
|
700
|
+
// OUTER is `items-center`: the message and the retry are siblings
|
|
701
|
+
// of unequal height (the button is taller than a line of 12px
|
|
702
|
+
// text), so aligning them to the start left the message riding
|
|
703
|
+
// ~2px above the button's centre line. Centring is what makes the
|
|
704
|
+
// two read as one row.
|
|
705
|
+
//
|
|
706
|
+
// INNER stays `items-start` so the icon keeps its `1lh`
|
|
707
|
+
// first-line alignment when the message wraps. Centring there too
|
|
708
|
+
// would drop the glyph to the middle of a three-line block, which
|
|
709
|
+
// is the defect the LineIcon wrapper exists to prevent.
|
|
710
|
+
className="flex flex-wrap items-center gap-[var(--space-2)] border px-[var(--space-2)] py-[var(--space-1)] text-[12px]"
|
|
711
|
+
style={{
|
|
712
|
+
marginBlockStart: 'var(--space-2)',
|
|
713
|
+
borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
|
|
714
|
+
borderColor: 'rgb(var(--destructive))',
|
|
715
|
+
background: 'rgb(var(--destructive-bg))',
|
|
716
|
+
color: 'rgb(var(--destructive-ink))',
|
|
717
|
+
}}
|
|
718
|
+
>
|
|
719
|
+
<span className="flex min-w-0 flex-1 items-start gap-[var(--space-2)]">
|
|
720
|
+
<LineIcon>
|
|
721
|
+
<TriangleAlert size={12} strokeWidth={1.75} aria-hidden="true" />
|
|
722
|
+
</LineIcon>
|
|
723
|
+
<span className="min-w-0 flex-1">
|
|
724
|
+
{node.errorText ?? 'This comment did not send.'}
|
|
725
|
+
</span>
|
|
726
|
+
</span>
|
|
727
|
+
{ctx.onRetry === undefined ? null : (
|
|
728
|
+
<Button
|
|
729
|
+
variant="secondary"
|
|
730
|
+
size="sm"
|
|
731
|
+
leadingIcon={<RotateCw size={10} strokeWidth={1.75} aria-hidden="true" />}
|
|
732
|
+
onClick={() => {
|
|
733
|
+
ctx.onRetry?.(node.id)
|
|
734
|
+
}}
|
|
735
|
+
>
|
|
736
|
+
Try again
|
|
737
|
+
</Button>
|
|
738
|
+
)}
|
|
739
|
+
</div>
|
|
740
|
+
) : null}
|
|
741
|
+
|
|
742
|
+
{hasChildren && atCap ? (
|
|
743
|
+
<Button
|
|
744
|
+
variant="secondary"
|
|
745
|
+
size="sm"
|
|
746
|
+
className="mt-[var(--space-2)]"
|
|
747
|
+
trailingIcon={<ChevronRight size={12} strokeWidth={1.75} aria-hidden="true" />}
|
|
748
|
+
onClick={() => {
|
|
749
|
+
if (ctx.onContinueThread !== undefined) ctx.onContinueThread(node.id)
|
|
750
|
+
else setContinued(true)
|
|
751
|
+
}}
|
|
752
|
+
>
|
|
753
|
+
Continue this thread
|
|
754
|
+
</Button>
|
|
755
|
+
) : null}
|
|
756
|
+
</div>
|
|
757
|
+
|
|
758
|
+
{/* ROW 2 — the reply composer, hung on the same alignment edge as the
|
|
759
|
+
body. It sits BELOW the avatar column rather than inside it so that
|
|
760
|
+
opening it cannot drag the collapse circle off the action row; the
|
|
761
|
+
trunk segment keeps the line unbroken across it for as long as it is
|
|
762
|
+
open, and is zero-height while it is closed. */}
|
|
763
|
+
<div
|
|
764
|
+
className="relative"
|
|
765
|
+
style={{
|
|
766
|
+
gridColumn: '1 / -1',
|
|
767
|
+
gridRow: 2,
|
|
768
|
+
paddingInlineStart: 'var(--thread-content-x)',
|
|
769
|
+
}}
|
|
770
|
+
>
|
|
771
|
+
{railed && !collapsed ? <ThreadTrunkSegment /> : null}
|
|
772
|
+
{/* Mounted, driven by data-open — correction 4. Unmounting this would
|
|
773
|
+
make the open/close transition unobservable and would also throw
|
|
774
|
+
away whatever was typed on a stray re-render. `grid-rows` from
|
|
775
|
+
0fr to 1fr animates the CONTAINER: the content keeps its natural
|
|
776
|
+
size throughout and is clipped, so nothing reflows mid-collapse. */}
|
|
777
|
+
<div
|
|
778
|
+
data-open={replyOpen}
|
|
779
|
+
// Every control inside is `disabled` while closed, so nothing is
|
|
780
|
+
// focusable and hiding it from the a11y tree is honest rather
|
|
781
|
+
// than a focus trap. It stays MOUNTED — correction 4.
|
|
782
|
+
aria-hidden={!replyOpen}
|
|
783
|
+
className={cn(
|
|
784
|
+
'grid grid-rows-[0fr] opacity-0 data-[open=true]:grid-rows-[1fr] data-[open=true]:opacity-100',
|
|
785
|
+
'transition-[grid-template-rows,opacity] duration-[var(--dur-base)] ease-[var(--ease-out)]',
|
|
786
|
+
'motion-reduce:transition-none',
|
|
787
|
+
)}
|
|
788
|
+
>
|
|
789
|
+
<div className="overflow-hidden">
|
|
790
|
+
<div style={{ paddingBlockStart: 'var(--space-2)' }}>
|
|
791
|
+
<ReplyComposer
|
|
792
|
+
{...(ctx.composer === undefined ? {} : { mode: ctx.composer })}
|
|
793
|
+
{...(ctx.onComposerChange === undefined
|
|
794
|
+
? {}
|
|
795
|
+
: { onModeChange: ctx.onComposerChange })}
|
|
796
|
+
label={`Reply to ${label}`}
|
|
797
|
+
placeholder={`Reply to ${label}…`}
|
|
798
|
+
submitLabel="Reply"
|
|
799
|
+
replyingTo={label}
|
|
800
|
+
value={thread.replyDraft}
|
|
801
|
+
onChange={thread.setReplyDraft}
|
|
802
|
+
onSubmit={thread.submitReply}
|
|
803
|
+
onCancel={thread.cancelReply}
|
|
804
|
+
rows={2}
|
|
805
|
+
toolbar={false}
|
|
806
|
+
disabled={!replyOpen}
|
|
807
|
+
{...(ctx.sending === undefined ? {} : { sending: ctx.sending })}
|
|
808
|
+
{...(ctx.composerMaxLength === undefined
|
|
809
|
+
? {}
|
|
810
|
+
: { maxLength: ctx.composerMaxLength })}
|
|
811
|
+
{...(ctx.replyAvatar === undefined ? {} : { avatar: ctx.replyAvatar })}
|
|
812
|
+
{...(ctx.mentionItems === undefined ? {} : { mentionItems: ctx.mentionItems })}
|
|
813
|
+
{...(ctx.uploadAttachment === undefined
|
|
814
|
+
? {}
|
|
815
|
+
: { uploadAttachment: ctx.uploadAttachment })}
|
|
816
|
+
{...(ctx.attachGif === undefined ? {} : { attachGif: ctx.attachGif })}
|
|
817
|
+
{...(ctx.searchGifs === undefined ? {} : { searchGifs: ctx.searchGifs })}
|
|
818
|
+
{...(ctx.attachmentAccept === undefined
|
|
819
|
+
? {}
|
|
820
|
+
: { attachmentAccept: ctx.attachmentAccept })}
|
|
821
|
+
{...(ctx.attachmentMaxPerComment === undefined
|
|
822
|
+
? {}
|
|
823
|
+
: { attachmentMaxPerComment: ctx.attachmentMaxPerComment })}
|
|
824
|
+
{...(ctx.attachmentMaxFileBytes === undefined
|
|
825
|
+
? {}
|
|
826
|
+
: { attachmentMaxFileBytes: ctx.attachmentMaxFileBytes })}
|
|
827
|
+
{...(ctx.attachmentMaxTotalBytes === undefined
|
|
828
|
+
? {}
|
|
829
|
+
: { attachmentMaxTotalBytes: ctx.attachmentMaxTotalBytes })}
|
|
830
|
+
/>
|
|
831
|
+
</div>
|
|
832
|
+
</div>
|
|
833
|
+
</div>
|
|
834
|
+
</div>
|
|
835
|
+
|
|
836
|
+
{/* ROW 3 — the replies. Collapsed, the subtree becomes a summary and the
|
|
837
|
+
rail simply terminates at the circle, exactly as the reference's
|
|
838
|
+
does. No container gap: every reply's own block-start padding is the
|
|
839
|
+
separation, so the connector segments stay flush. */}
|
|
840
|
+
{!hasChildren || atCap ? null : (
|
|
841
|
+
<div style={{ gridColumn: '1 / -1', gridRow: 3 }}>
|
|
842
|
+
{collapsed ? (
|
|
843
|
+
<div
|
|
844
|
+
style={{
|
|
845
|
+
paddingBlockStart: 'var(--thread-row-gap)',
|
|
846
|
+
paddingInlineStart: 'var(--thread-indent)',
|
|
847
|
+
}}
|
|
848
|
+
>
|
|
849
|
+
<CollapsedReplies
|
|
850
|
+
node={node}
|
|
851
|
+
count={descendants}
|
|
852
|
+
now={thread.now}
|
|
853
|
+
parentLabel={label}
|
|
854
|
+
{...(ctx.locale === undefined ? {} : { locale: ctx.locale })}
|
|
855
|
+
onExpand={() => {
|
|
856
|
+
thread.setCollapsed(node.id, false)
|
|
857
|
+
ctx.announce?.(`Expanded ${replyCountLabel(descendants)}`)
|
|
858
|
+
}}
|
|
859
|
+
/>
|
|
860
|
+
</div>
|
|
861
|
+
) : (
|
|
862
|
+
<>
|
|
863
|
+
{/* One `<ul>` per NESTING LEVEL — the depth is the structure,
|
|
864
|
+
so a reader gets it from the markup rather than from an
|
|
865
|
+
indent they cannot see. Zero margin and zero padding, so
|
|
866
|
+
the list contributes no box of its own: the replies keep
|
|
867
|
+
the flush top edges their connector segments need.
|
|
868
|
+
`role="list"` is restated because `list-style: none` drops
|
|
869
|
+
the implicit role in Safari/VoiceOver.
|
|
870
|
+
|
|
871
|
+
"Show N more" stays OUTSIDE the list. It is a control, not
|
|
872
|
+
a reply, and inside it would inflate the announced item
|
|
873
|
+
count past the number of comments actually there. */}
|
|
874
|
+
<ul role="list" className="m-0 list-none p-0">
|
|
875
|
+
{visible.map((reply, index) => (
|
|
876
|
+
<CommentItem
|
|
877
|
+
key={reply.id}
|
|
878
|
+
node={reply}
|
|
879
|
+
ctx={ctx}
|
|
880
|
+
// The line stops at the last reply's avatar — unless a
|
|
881
|
+
// "Show N more" follows, in which case it carries on to it.
|
|
882
|
+
isLast={index === visible.length - 1 && hidden === 0}
|
|
883
|
+
// `continued` restarts the render budget for this subtree;
|
|
884
|
+
// the STORED depth is untouched (D3 — capping display loses
|
|
885
|
+
// nothing, capping storage loses data).
|
|
886
|
+
renderDepth={continued ? 0 : renderDepth + 1}
|
|
887
|
+
/>
|
|
888
|
+
))}
|
|
889
|
+
</ul>
|
|
890
|
+
{hidden > 0 ? (
|
|
891
|
+
<div
|
|
892
|
+
style={{
|
|
893
|
+
paddingBlockStart: 'var(--thread-row-gap)',
|
|
894
|
+
paddingInlineStart: 'var(--thread-indent)',
|
|
895
|
+
}}
|
|
896
|
+
>
|
|
897
|
+
<Button
|
|
898
|
+
variant="secondary"
|
|
899
|
+
size="sm"
|
|
900
|
+
onClick={() => {
|
|
901
|
+
thread.expandReplies(node.id)
|
|
902
|
+
}}
|
|
903
|
+
>
|
|
904
|
+
Show {hidden} more {hidden === 1 ? 'reply' : 'replies'}
|
|
905
|
+
</Button>
|
|
906
|
+
</div>
|
|
907
|
+
) : null}
|
|
908
|
+
</>
|
|
909
|
+
)}
|
|
910
|
+
</div>
|
|
911
|
+
)}
|
|
912
|
+
</div>
|
|
913
|
+
</Frame>
|
|
914
|
+
)
|
|
915
|
+
}
|