@lovett/ui 0.0.11 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- 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 +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- 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__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -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/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- 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/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -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 +611 -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 +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- 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 +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- 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/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- 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 +252 -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 +742 -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 +967 -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 +232 -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 +112 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread — the styled default skin over `useThread`.
|
|
3
|
+
*
|
|
4
|
+
* ADR-147 T3. This is ONE presentation of the hook, not the only one: a
|
|
5
|
+
* consumer that wants different chrome calls `useThread` and draws its own.
|
|
6
|
+
* Everything below the hook boundary is layout and tokens.
|
|
7
|
+
*
|
|
8
|
+
* DELIBERATELY NOT A DIALOG. The reference was a modal, and the port
|
|
9
|
+
* correction it needed was "never `if (!open) return null`, because that kills
|
|
10
|
+
* the open/close animation". The cleanest way to not regress that rule is to
|
|
11
|
+
* not own the dialog at all: the thread is a panel, its host decides whether
|
|
12
|
+
* it lives in a drawer, a modal or a column. Inside this file the same rule is
|
|
13
|
+
* applied where it does bite — the reply composer and the reaction picker stay
|
|
14
|
+
* mounted and are driven by `data-open`.
|
|
15
|
+
*
|
|
16
|
+
* ONE CARD PER ROOT THREAD. This is the structural decision the rest of the
|
|
17
|
+
* layout follows from. The first build gave every comment its own card-like
|
|
18
|
+
* object, which made a conversation read as a stack of unrelated notices and
|
|
19
|
+
* made depth expensive — a reply had to earn a whole new box. Now a root
|
|
20
|
+
* comment AND its entire descendant tree share one card, sibling roots are
|
|
21
|
+
* separate cards, and depth inside a card costs an indent and a hairline.
|
|
22
|
+
* A thread is the unit people actually think in; a comment is not.
|
|
23
|
+
*
|
|
24
|
+
* SURFACES follow CLAUDE.md's data-display recipe, and the recipe is what
|
|
25
|
+
* forces the panel to step DOWN rather than the cards to step up. The panel is
|
|
26
|
+
* the frame (`data-framed` → `--surface-frame`: 247 light / 21 dark) and each
|
|
27
|
+
* thread card is `--surface-card` (255 / 27) with a border and a shadow — the
|
|
28
|
+
* same board-column-to-card relationship the rest of the system uses, opaque in
|
|
29
|
+
* both directions. A card tinted onto the panel with `--surface-overlay*` is
|
|
30
|
+
* the washed-out defect; grey STRUCTURE inside the card (the date rule, the
|
|
31
|
+
* depth rails, the composer border) is required and is not a candidate for
|
|
32
|
+
* "cleanup".
|
|
33
|
+
*
|
|
34
|
+
* RADII are computed, not eyeballed (interface standards §1). The panel is
|
|
35
|
+
* `--radius-xl` (20) with `--space-3` (12) of padding, so a thread card is
|
|
36
|
+
* 20 − 12 = 8 = `--radius-sm`. The card then re-declares the same property one
|
|
37
|
+
* rung down for its own contents. `.ds-thread` in styles.css owns both values.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { CSSProperties, ReactElement, ReactNode } from 'react'
|
|
41
|
+
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
|
42
|
+
import { MessageSquare } from 'lucide-react'
|
|
43
|
+
import { Avatar, AvatarGroup, asAvatarTint } from '../avatar'
|
|
44
|
+
import Card from '../card'
|
|
45
|
+
import { cn } from '../lib/utils'
|
|
46
|
+
import { CommentItem, type ThreadRenderContext } from './comment'
|
|
47
|
+
import { ThreadComposer } from './composer'
|
|
48
|
+
import {
|
|
49
|
+
RichThreadComposer,
|
|
50
|
+
type RichComposerAttachmentProps,
|
|
51
|
+
type RichThreadComposerProps,
|
|
52
|
+
} from './rich-composer'
|
|
53
|
+
import { DEFAULT_INDENT_CLAMP_DEPTH } from './rail'
|
|
54
|
+
import { useThread, type UseThreadOptions } from './use-thread'
|
|
55
|
+
import type { MentionTarget } from './comment-body'
|
|
56
|
+
import type {
|
|
57
|
+
CommentDeliveryState,
|
|
58
|
+
CommentNode,
|
|
59
|
+
MentionSuggestionItem,
|
|
60
|
+
ThreadAttachment,
|
|
61
|
+
ThreadAuthor,
|
|
62
|
+
ThreadReactionKey,
|
|
63
|
+
ThreadSort,
|
|
64
|
+
ThreadVote,
|
|
65
|
+
} from './types'
|
|
66
|
+
|
|
67
|
+
/** React accepts CSS custom properties in `style`; the DOM typings do not declare them. */
|
|
68
|
+
type StyleWithVars = CSSProperties & Record<`--${string}`, string>
|
|
69
|
+
|
|
70
|
+
// `--thread-inner-radius` is NOT set here. It is declared once by `.ds-thread`
|
|
71
|
+
// in styles.css, which the `<section>` below always carries — duplicating it
|
|
72
|
+
// inline is exactly how the declared value and the effective one drift apart.
|
|
73
|
+
// Every consumer still reads it as `var(--thread-inner-radius, var(--radius-sm))`,
|
|
74
|
+
// so a host that forgot the stylesheet degrades to the right corner anyway.
|
|
75
|
+
const PANEL_STYLE: StyleWithVars = {
|
|
76
|
+
borderRadius: 'var(--radius-xl)',
|
|
77
|
+
padding: 'var(--space-3)',
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A thread card is the one place the concentric chain steps down a rung, and
|
|
82
|
+
* it does it by re-declaring the property its descendants already read rather
|
|
83
|
+
* than by introducing a second one. Everything inside — the reply composer,
|
|
84
|
+
* an attachment thumbnail, the failure notice — keeps asking exactly one
|
|
85
|
+
* question and gets the answer that is right for where it is mounted.
|
|
86
|
+
*/
|
|
87
|
+
const CARD_STYLE: StyleWithVars = {
|
|
88
|
+
borderRadius: 'var(--thread-inner-radius, var(--radius-sm))',
|
|
89
|
+
'--thread-inner-radius': 'var(--thread-card-inner-radius, var(--radius-xs))',
|
|
90
|
+
// No block-start padding: the first comment's own `--thread-row-gap` supplies
|
|
91
|
+
// it, so the card reads as an even `--space-3` inset all round while every
|
|
92
|
+
// comment box in the tree — first or not — keeps an identical top edge. That
|
|
93
|
+
// identity is what lets one derived `--thread-avatar-center` place every
|
|
94
|
+
// elbow in the tree.
|
|
95
|
+
padding: '0 var(--space-3) var(--space-3)',
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const SORTS: readonly { readonly id: ThreadSort; readonly label: string }[] = [
|
|
99
|
+
{ id: 'top', label: 'Top' },
|
|
100
|
+
{ id: 'new', label: 'Newest' },
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
function SortPills({
|
|
104
|
+
value,
|
|
105
|
+
onChange,
|
|
106
|
+
}: {
|
|
107
|
+
value: ThreadSort
|
|
108
|
+
onChange: (next: ThreadSort) => void
|
|
109
|
+
}) {
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
// A selected state must step UP to an opaque surface, never be an alpha
|
|
113
|
+
// tint on a translucent track (CLAUDE.md §2): the track is the recessed
|
|
114
|
+
// opaque `--surface-inset`, the active pill is `--surface-card` with an
|
|
115
|
+
// accent border. Two alpha layers composited is how a "raised" pill ends
|
|
116
|
+
// up rendering darker than the well it sits in.
|
|
117
|
+
className="inline-flex gap-[var(--space-1)] border p-[var(--space-1)]"
|
|
118
|
+
role="group"
|
|
119
|
+
aria-label="Sort comments"
|
|
120
|
+
style={{
|
|
121
|
+
borderRadius: 'var(--radius-sm)',
|
|
122
|
+
borderColor: 'rgb(var(--border))',
|
|
123
|
+
background: 'rgb(var(--surface-inset))',
|
|
124
|
+
}}
|
|
125
|
+
>
|
|
126
|
+
{SORTS.map((sort) => {
|
|
127
|
+
const active = sort.id === value
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
key={sort.id}
|
|
131
|
+
type="button"
|
|
132
|
+
aria-pressed={active}
|
|
133
|
+
onClick={() => {
|
|
134
|
+
onChange(sort.id)
|
|
135
|
+
}}
|
|
136
|
+
className={cn(
|
|
137
|
+
'inline-flex h-[24px] items-center px-[var(--space-2)] text-[12px] font-semibold',
|
|
138
|
+
'transition-[background-color,color,border-color,box-shadow] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
139
|
+
'motion-reduce:transition-none',
|
|
140
|
+
'focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
141
|
+
active
|
|
142
|
+
? 'border border-[rgb(var(--accent))] bg-[rgb(var(--surface-card))] text-[rgb(var(--accent-ink))] [box-shadow:var(--shadow-sm)]'
|
|
143
|
+
: 'border border-transparent text-[rgb(var(--text-tertiary))] hover:text-[rgb(var(--foreground))]',
|
|
144
|
+
)}
|
|
145
|
+
style={{ borderRadius: 'var(--radius-xs)' }}
|
|
146
|
+
>
|
|
147
|
+
{sort.label}
|
|
148
|
+
</button>
|
|
149
|
+
)
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const DAY = 86_400_000
|
|
156
|
+
|
|
157
|
+
/** Local midnight for an epoch value — the boundary a person means by "day". */
|
|
158
|
+
function startOfDay(value: number): number {
|
|
159
|
+
const date = new Date(value)
|
|
160
|
+
date.setHours(0, 0, 0, 0)
|
|
161
|
+
return date.getTime()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* "Today" / "Yesterday" / a written date. Derived from the INJECTED `now`, so
|
|
166
|
+
* a frozen clock produces a frozen label and the separators are as assertable
|
|
167
|
+
* as the timestamps beside them (D16).
|
|
168
|
+
*/
|
|
169
|
+
function dayLabel(value: number, now: number, locale?: string): string {
|
|
170
|
+
const days = Math.round((startOfDay(now) - startOfDay(value)) / DAY)
|
|
171
|
+
if (days <= 0) return 'Today'
|
|
172
|
+
if (days === 1) return 'Yesterday'
|
|
173
|
+
const date = new Date(value)
|
|
174
|
+
const sameYear = date.getFullYear() === new Date(now).getFullYear()
|
|
175
|
+
return date.toLocaleDateString(locale, {
|
|
176
|
+
day: 'numeric',
|
|
177
|
+
month: 'long',
|
|
178
|
+
...(sameYear ? {} : { year: 'numeric' }),
|
|
179
|
+
...(days < 7 ? { weekday: 'long' } : {}),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A date separator, sticky to the top of the scroll region.
|
|
185
|
+
*
|
|
186
|
+
* Grouping is by RUN, not by bucket: a separator is emitted whenever a root's
|
|
187
|
+
* day differs from the previous root's. Under `new` that is the obvious
|
|
188
|
+
* chronological grouping; under `top` a day can legitimately appear more than
|
|
189
|
+
* once, and showing that honestly is better than either suppressing the
|
|
190
|
+
* separators or lying about the order by re-bucketing a ranked list into
|
|
191
|
+
* calendar order.
|
|
192
|
+
*
|
|
193
|
+
* It carries the PANEL's surface because a sticky element with no background
|
|
194
|
+
* smears the cards scrolling under it. `--surface-frame` is the panel in the
|
|
195
|
+
* `panel` variant; a `bare` host that sits on a different surface overrides it
|
|
196
|
+
* the same way it overrides the panel's other assumptions.
|
|
197
|
+
*/
|
|
198
|
+
function DateSeparator({ label }: { label: string }) {
|
|
199
|
+
return (
|
|
200
|
+
<div
|
|
201
|
+
className="sticky top-0 z-[1] flex items-center gap-[var(--space-3)]"
|
|
202
|
+
style={{
|
|
203
|
+
paddingBlock: 'var(--space-2)',
|
|
204
|
+
background: 'rgb(var(--surface-frame))',
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
<span className="h-px flex-1" style={{ background: 'rgb(var(--border))' }} />
|
|
208
|
+
<span
|
|
209
|
+
className="text-[12px] font-medium"
|
|
210
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
211
|
+
>
|
|
212
|
+
{label}
|
|
213
|
+
</span>
|
|
214
|
+
<span className="h-px flex-1" style={{ background: 'rgb(var(--border))' }} />
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface ThreadParticipant {
|
|
220
|
+
readonly id: string
|
|
221
|
+
readonly name: string
|
|
222
|
+
readonly avatarUrl?: string | undefined
|
|
223
|
+
readonly tint?: string | undefined
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface ThreadProps extends UseThreadOptions, RichComposerAttachmentProps {
|
|
227
|
+
readonly onReact?: ((commentId: string, key: ThreadReactionKey) => void) | undefined
|
|
228
|
+
readonly onVote?: ((commentId: string, next: ThreadVote) => void) | undefined
|
|
229
|
+
readonly onRetry?: ((commentId: string) => void) | undefined
|
|
230
|
+
readonly onSelectAuthor?: ((author: ThreadAuthor) => void) | undefined
|
|
231
|
+
readonly onSelectMention?: ((target: MentionTarget) => void) | undefined
|
|
232
|
+
readonly onOpenAttachment?: ((attachment: ThreadAttachment) => void) | undefined
|
|
233
|
+
/**
|
|
234
|
+
* Resolve one attachment's short-lived URL (ADR-151 D6). The wire carries no
|
|
235
|
+
* URL — the storage key never leaves the worker — so the renderer asks for
|
|
236
|
+
* the bytes lazily, after the box is already reserved from the declared
|
|
237
|
+
* aspect ratio.
|
|
238
|
+
*/
|
|
239
|
+
readonly resolveAttachmentUrl?: ((attachmentId: string) => Promise<string | null>) | undefined
|
|
240
|
+
/**
|
|
241
|
+
* Hide one link preview on one comment (ADR-150 D7). Absent and no card
|
|
242
|
+
* renders a dismiss control — the affordance follows the wiring rather than
|
|
243
|
+
* appearing and doing nothing.
|
|
244
|
+
*
|
|
245
|
+
* The host owns the optimistic removal and the invalidate-on-error, in the
|
|
246
|
+
* same pattern as the existing mutations. `canHide` on the row is a rendering
|
|
247
|
+
* convenience; the server is the gate.
|
|
248
|
+
*/
|
|
249
|
+
readonly onHideLinkPreview?: ((commentId: string, urlHash: string) => void) | undefined
|
|
250
|
+
/**
|
|
251
|
+
* The byline overflow menu. An item whose callback is absent is NOT RENDERED
|
|
252
|
+
* — never rendered disabled, because a permanently greyed row teaches people
|
|
253
|
+
* the surface is broken rather than that they lack a permission.
|
|
254
|
+
*
|
|
255
|
+
* `onCopyText` is the odd one out by design: the body is already in hand, so
|
|
256
|
+
* "Copy text" appears whenever there is a body and a clipboard to write to,
|
|
257
|
+
* performs the write itself, and uses this callback only to report the
|
|
258
|
+
* outcome — `ok: false` covers a denied permission or an unfocused document,
|
|
259
|
+
* which a host will usually want to surface as a toast.
|
|
260
|
+
*/
|
|
261
|
+
readonly onCopyLink?: ((commentId: string) => void) | undefined
|
|
262
|
+
readonly onCopyText?: ((commentId: string, ok: boolean) => void) | undefined
|
|
263
|
+
readonly onEdit?: ((commentId: string) => void) | undefined
|
|
264
|
+
readonly onDelete?: ((commentId: string) => void) | undefined
|
|
265
|
+
readonly onContinueThread?: ((commentId: string) => void) | undefined
|
|
266
|
+
/** In flight. Disables the composers and shows "Sending…". */
|
|
267
|
+
readonly sending?: boolean | undefined
|
|
268
|
+
/**
|
|
269
|
+
* Nesting depth past which a level stops ADDING indent. Nesting itself stays
|
|
270
|
+
* uncapped — this only stops a ~380px task drawer running out of column
|
|
271
|
+
* around depth 9. Past it the rail is drawn as a stacking hairline border
|
|
272
|
+
* and collapse moves to the footer control; nothing is hidden and nothing
|
|
273
|
+
* becomes unreachable. Default 6 (≈144px of indent at `--space-6`).
|
|
274
|
+
*/
|
|
275
|
+
readonly indentClampDepth?: number | undefined
|
|
276
|
+
/** Rendered above the sort bar. A title, a resolve control, whatever the host needs. */
|
|
277
|
+
readonly header?: ReactNode | undefined
|
|
278
|
+
/** Faces on the thread, shown beside the count. `total` may exceed the list. */
|
|
279
|
+
readonly participants?: readonly ThreadParticipant[] | undefined
|
|
280
|
+
readonly participantTotal?: number | undefined
|
|
281
|
+
/** The current user's avatar, beside the root composer. */
|
|
282
|
+
readonly composerAvatar?: ReactNode | undefined
|
|
283
|
+
readonly composerPlaceholder?: string | undefined
|
|
284
|
+
readonly composerMaxLength?: number | undefined
|
|
285
|
+
/** Day separators between runs of root threads. */
|
|
286
|
+
readonly dateSeparators?: boolean | undefined
|
|
287
|
+
readonly emptyState?: ReactNode | undefined
|
|
288
|
+
readonly locale?: string | undefined
|
|
289
|
+
/** `panel` paints the opaque frame the cards sit on; `bare` inherits the host's. */
|
|
290
|
+
readonly variant?: 'panel' | 'bare' | undefined
|
|
291
|
+
/**
|
|
292
|
+
* Draw the pinned ROOT composer. Default true.
|
|
293
|
+
*
|
|
294
|
+
* `false` is for a host that pins a composer of its own BELOW the thread and
|
|
295
|
+
* needs exactly one on screen — the task detail's activity pane, whose
|
|
296
|
+
* composer stays put across Everything / Comments / Activity, so it cannot
|
|
297
|
+
* be the one this component owns. Replies are unaffected: they are still
|
|
298
|
+
* gated on `onSubmit`, so turning the root composer off does not turn
|
|
299
|
+
* replying off with it.
|
|
300
|
+
*/
|
|
301
|
+
readonly rootComposer?: boolean | undefined
|
|
302
|
+
/**
|
|
303
|
+
* Which composer the root composer IS (ADR-148 D5). `'rich'` mounts the
|
|
304
|
+
* TipTap surface behind one lazy boundary — the chunk is requested when a
|
|
305
|
+
* composer mounts, never by rendering the list — and falls back to the plain
|
|
306
|
+
* textarea if the chunk cannot load (D6) or the draft holds syntax outside
|
|
307
|
+
* the subset (D3 rule 2). `'plain'` is the default until the owner flips it
|
|
308
|
+
* after the eyeball pass. Both satisfy the same `value`/`onChange` seam, so
|
|
309
|
+
* nothing else here can tell them apart.
|
|
310
|
+
*/
|
|
311
|
+
/**
|
|
312
|
+
* Which composer to draw. Uncontrolled by default: this is the INITIAL
|
|
313
|
+
* value and the footer's switcher changes it from there, so the switcher
|
|
314
|
+
* works with no wiring. Pass `onComposerChange` to control it instead.
|
|
315
|
+
*/
|
|
316
|
+
/**
|
|
317
|
+
* The `@` picker's options, for the root composer and every reply
|
|
318
|
+
* (ADR-149 D6). The host filters a list it already has; the picker is a
|
|
319
|
+
* convenience and never an authorization gate, because the server re-checks
|
|
320
|
+
* every mention it extracts from `body_md` at write time.
|
|
321
|
+
*
|
|
322
|
+
* Absent — the default — `@` is ordinary text and no popup opens. It reaches
|
|
323
|
+
* the rich composer only; the plain textarea has no suggestion layer, by
|
|
324
|
+
* design (ADR-149 D2).
|
|
325
|
+
*/
|
|
326
|
+
readonly mentionItems?: ((query: string) => readonly MentionSuggestionItem[]) | undefined
|
|
327
|
+
readonly composer?: 'plain' | 'rich' | undefined
|
|
328
|
+
readonly onComposerChange?: ((composer: 'plain' | 'rich') => void) | undefined
|
|
329
|
+
readonly className?: string | undefined
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function Thread({
|
|
333
|
+
onReact,
|
|
334
|
+
onVote,
|
|
335
|
+
onRetry,
|
|
336
|
+
onSelectAuthor,
|
|
337
|
+
onSelectMention,
|
|
338
|
+
onOpenAttachment,
|
|
339
|
+
resolveAttachmentUrl,
|
|
340
|
+
onHideLinkPreview,
|
|
341
|
+
onCopyLink,
|
|
342
|
+
onCopyText,
|
|
343
|
+
onEdit,
|
|
344
|
+
onDelete,
|
|
345
|
+
onContinueThread,
|
|
346
|
+
sending = false,
|
|
347
|
+
indentClampDepth = DEFAULT_INDENT_CLAMP_DEPTH,
|
|
348
|
+
header,
|
|
349
|
+
participants,
|
|
350
|
+
participantTotal,
|
|
351
|
+
composerAvatar,
|
|
352
|
+
composerPlaceholder = 'Add a comment…',
|
|
353
|
+
composerMaxLength,
|
|
354
|
+
dateSeparators = true,
|
|
355
|
+
emptyState,
|
|
356
|
+
locale,
|
|
357
|
+
variant = 'panel',
|
|
358
|
+
rootComposer = true,
|
|
359
|
+
mentionItems,
|
|
360
|
+
uploadAttachment,
|
|
361
|
+
attachGif,
|
|
362
|
+
searchGifs,
|
|
363
|
+
attachmentAccept,
|
|
364
|
+
attachmentMaxPerComment,
|
|
365
|
+
attachmentMaxFileBytes,
|
|
366
|
+
attachmentMaxTotalBytes,
|
|
367
|
+
composer = 'plain',
|
|
368
|
+
onComposerChange,
|
|
369
|
+
className,
|
|
370
|
+
...threadOptions
|
|
371
|
+
}: ThreadProps) {
|
|
372
|
+
const thread = useThread(threadOptions)
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* THE live region's text, and the only state that feeds it.
|
|
376
|
+
*
|
|
377
|
+
* `seq` exists because a live region speaks when its text CHANGES, not when
|
|
378
|
+
* it is written: collapsing two different comments that happen to have the
|
|
379
|
+
* same number of replies produces byte-identical text and the second one
|
|
380
|
+
* would be silent. The rendered string alternates a trailing NBSP on the
|
|
381
|
+
* sequence, which every screen reader trims out of the spoken output but the
|
|
382
|
+
* DOM counts as a change. Cheaper and more reliable than the two-region
|
|
383
|
+
* swap, and it keeps ONE region rather than two.
|
|
384
|
+
*/
|
|
385
|
+
const [announcement, setAnnouncement] = useState<{
|
|
386
|
+
readonly text: string
|
|
387
|
+
readonly seq: number
|
|
388
|
+
}>({ text: '', seq: 0 })
|
|
389
|
+
|
|
390
|
+
const announce = useCallback((message: string) => {
|
|
391
|
+
setAnnouncement((prev) => ({ text: message, seq: prev.seq + 1 }))
|
|
392
|
+
}, [])
|
|
393
|
+
|
|
394
|
+
const announcementText =
|
|
395
|
+
announcement.text === ''
|
|
396
|
+
? ''
|
|
397
|
+
: announcement.seq % 2 === 0
|
|
398
|
+
? `${announcement.text}\u{00A0}`
|
|
399
|
+
: announcement.text
|
|
400
|
+
|
|
401
|
+
const comments = threadOptions.comments
|
|
402
|
+
const seenStates = useRef<ReadonlyMap<string, CommentDeliveryState> | null>(null)
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Content that changes with NO user action behind it has to announce itself.
|
|
406
|
+
*
|
|
407
|
+
* Two things do that here and neither used to say anything, while the one
|
|
408
|
+
* thing that already spoke was the failure (`role="alert"` on a failed send,
|
|
409
|
+
* comment.tsx): an optimistic comment landing, and the poll bringing in
|
|
410
|
+
* replies the viewer did not write. Success and other people's words were
|
|
411
|
+
* silent; only failure had a voice.
|
|
412
|
+
*
|
|
413
|
+
* The two are told apart by DELIVERY STATE rather than by an author id,
|
|
414
|
+
* which this primitive deliberately does not have: an optimistic send enters
|
|
415
|
+
* the list as `pending`, so an id that appears already `sent` arrived from
|
|
416
|
+
* the server and belongs to somebody else. `failed` -> `sent` counts as a
|
|
417
|
+
* landing too, because a successful retry is the same event.
|
|
418
|
+
*/
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
const next = new Map<string, CommentDeliveryState>()
|
|
421
|
+
for (const comment of comments) next.set(comment.id, comment.state ?? 'sent')
|
|
422
|
+
const previous = seenStates.current
|
|
423
|
+
seenStates.current = next
|
|
424
|
+
// FIRST RUN SEEDS THE BASELINE AND SAYS NOTHING. Announcing here would
|
|
425
|
+
// make every single page load read the whole comment count aloud, which is
|
|
426
|
+
// the documented way this pattern gets turned off by the people it is for.
|
|
427
|
+
if (previous === null) return
|
|
428
|
+
let landed = 0
|
|
429
|
+
let arrived = 0
|
|
430
|
+
for (const [id, state] of next) {
|
|
431
|
+
const before = previous.get(id)
|
|
432
|
+
if (before === undefined) {
|
|
433
|
+
if (state === 'sent') arrived += 1
|
|
434
|
+
} else if (before !== 'sent' && state === 'sent') {
|
|
435
|
+
landed += 1
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const parts: string[] = []
|
|
439
|
+
if (landed > 0) {
|
|
440
|
+
parts.push(landed === 1 ? 'Comment posted' : `${String(landed)} comments posted`)
|
|
441
|
+
}
|
|
442
|
+
if (arrived > 0) {
|
|
443
|
+
parts.push(arrived === 1 ? '1 new comment' : `${String(arrived)} new comments`)
|
|
444
|
+
}
|
|
445
|
+
if (parts.length > 0) announce(parts.join('. '))
|
|
446
|
+
}, [announce, comments])
|
|
447
|
+
|
|
448
|
+
// Uncontrolled unless the host asks to own it. `composer` seeds the state and
|
|
449
|
+
// keeps it in sync when a controlling host changes it; without a callback the
|
|
450
|
+
// switcher just works.
|
|
451
|
+
const [composerMode, setComposerMode] = useState<'plain' | 'rich'>(composer)
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
setComposerMode(composer)
|
|
454
|
+
}, [composer])
|
|
455
|
+
const activeComposer = onComposerChange === undefined ? composerMode : composer
|
|
456
|
+
const changeComposer = useCallback(
|
|
457
|
+
(next: 'plain' | 'rich') => {
|
|
458
|
+
setComposerMode(next)
|
|
459
|
+
onComposerChange?.(next)
|
|
460
|
+
},
|
|
461
|
+
[onComposerChange],
|
|
462
|
+
)
|
|
463
|
+
const ctx: ThreadRenderContext = {
|
|
464
|
+
composer: activeComposer,
|
|
465
|
+
onComposerChange: changeComposer,
|
|
466
|
+
thread,
|
|
467
|
+
sending,
|
|
468
|
+
indentClampDepth,
|
|
469
|
+
announce,
|
|
470
|
+
...(locale === undefined ? {} : { locale }),
|
|
471
|
+
...(composerAvatar === undefined ? {} : { replyAvatar: composerAvatar }),
|
|
472
|
+
...(onReact === undefined ? {} : { onReact }),
|
|
473
|
+
...(onVote === undefined ? {} : { onVote }),
|
|
474
|
+
...(onRetry === undefined ? {} : { onRetry }),
|
|
475
|
+
...(onSelectAuthor === undefined ? {} : { onSelectAuthor }),
|
|
476
|
+
...(onSelectMention === undefined ? {} : { onSelectMention }),
|
|
477
|
+
...(onOpenAttachment === undefined ? {} : { onOpenAttachment }),
|
|
478
|
+
...(resolveAttachmentUrl === undefined ? {} : { resolveAttachmentUrl }),
|
|
479
|
+
...(onHideLinkPreview === undefined ? {} : { onHideLinkPreview }),
|
|
480
|
+
...(onCopyLink === undefined ? {} : { onCopyLink }),
|
|
481
|
+
...(onCopyText === undefined ? {} : { onCopyText }),
|
|
482
|
+
...(onEdit === undefined ? {} : { onEdit }),
|
|
483
|
+
...(onDelete === undefined ? {} : { onDelete }),
|
|
484
|
+
...(onContinueThread === undefined ? {} : { onContinueThread }),
|
|
485
|
+
...(composerMaxLength === undefined ? {} : { composerMaxLength }),
|
|
486
|
+
...(mentionItems === undefined ? {} : { mentionItems }),
|
|
487
|
+
...(uploadAttachment === undefined ? {} : { uploadAttachment }),
|
|
488
|
+
...(attachGif === undefined ? {} : { attachGif }),
|
|
489
|
+
...(searchGifs === undefined ? {} : { searchGifs }),
|
|
490
|
+
...(attachmentAccept === undefined ? {} : { attachmentAccept }),
|
|
491
|
+
...(attachmentMaxPerComment === undefined ? {} : { attachmentMaxPerComment }),
|
|
492
|
+
...(attachmentMaxFileBytes === undefined ? {} : { attachmentMaxFileBytes }),
|
|
493
|
+
...(attachmentMaxTotalBytes === undefined ? {} : { attachmentMaxTotalBytes }),
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const panel = variant === 'panel'
|
|
497
|
+
// Annotated, not inferred — see the same note in comment.tsx: the plain
|
|
498
|
+
// composer satisfies the rich composer's (wider) props and ignores the seam.
|
|
499
|
+
const Composer: (props: RichThreadComposerProps) => ReactElement =
|
|
500
|
+
activeComposer === 'rich' ? RichThreadComposer : ThreadComposer
|
|
501
|
+
|
|
502
|
+
// Derived up front rather than by mutating a cursor inside the map: render
|
|
503
|
+
// stays pure, and the run boundaries are one readable expression instead of
|
|
504
|
+
// a side effect hidden in a JSX callback.
|
|
505
|
+
const separatorFor: readonly (string | null)[] = thread.roots.map((node, index) => {
|
|
506
|
+
if (!dateSeparators) return null
|
|
507
|
+
const previous = thread.roots[index - 1]
|
|
508
|
+
if (previous !== undefined && startOfDay(previous.createdAt) === startOfDay(node.createdAt)) {
|
|
509
|
+
return null
|
|
510
|
+
}
|
|
511
|
+
return dayLabel(node.createdAt, thread.now, locale)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Roots grouped into day RUNS, one `<ul>` per run.
|
|
516
|
+
*
|
|
517
|
+
* The list markup is not cosmetic: a thread IS a list and its nesting is the
|
|
518
|
+
* whole information structure, so without `<ul>`/`<li>` a screen reader gets
|
|
519
|
+
* no item count, no boundaries and no depth. A `<ul>` may contain only
|
|
520
|
+
* `<li>`, which leaves the day separator somewhere to go, and the answer is
|
|
521
|
+
* NOT inside the item. A sticky element is clipped to its own containing
|
|
522
|
+
* block, so a separator moved into a list item would unstick the moment that
|
|
523
|
+
* one comment scrolled past, and the rest of its run would sit under no
|
|
524
|
+
* heading at all. One list per run keeps the separator a sibling of the list
|
|
525
|
+
* — exactly where it is today — and the geometry byte-identical: the run
|
|
526
|
+
* `<ul>` is a zero-margin, zero-padding flex column carrying the SAME
|
|
527
|
+
* `--space-2` gap the outer column had, so every box keeps its old edges.
|
|
528
|
+
*/
|
|
529
|
+
const runs: {
|
|
530
|
+
readonly key: string
|
|
531
|
+
readonly separator: string | null
|
|
532
|
+
readonly nodes: CommentNode[]
|
|
533
|
+
}[] = []
|
|
534
|
+
thread.roots.forEach((node: CommentNode, index: number) => {
|
|
535
|
+
const separator = separatorFor[index] ?? null
|
|
536
|
+
const current = runs[runs.length - 1]
|
|
537
|
+
if (separator !== null || current === undefined) {
|
|
538
|
+
runs.push({ key: node.id, separator, nodes: [node] })
|
|
539
|
+
} else {
|
|
540
|
+
current.nodes.push(node)
|
|
541
|
+
}
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<section
|
|
546
|
+
aria-label="Comments"
|
|
547
|
+
className={cn('ds-thread flex min-h-0 flex-col', panel && 'ds-card-surface', className)}
|
|
548
|
+
{...(panel ? { 'data-framed': 'true' } : {})}
|
|
549
|
+
style={panel ? PANEL_STYLE : undefined}
|
|
550
|
+
>
|
|
551
|
+
{/* THE polite live region, and it is rendered EMPTY here on mount and
|
|
552
|
+
kept in the DOM for the life of the thread. A live region injected
|
|
553
|
+
into the document at the moment it has something to say is the
|
|
554
|
+
documented way to get an announcement that never arrives — the AT has
|
|
555
|
+
to be observing the node before the text lands. */}
|
|
556
|
+
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
|
557
|
+
{announcementText}
|
|
558
|
+
</div>
|
|
559
|
+
|
|
560
|
+
{header === undefined ? null : (
|
|
561
|
+
<div style={{ marginBlockEnd: 'var(--space-3)' }}>{header}</div>
|
|
562
|
+
)}
|
|
563
|
+
|
|
564
|
+
{/* Sticky sort bar. The rule under it is required structure, not
|
|
565
|
+
decoration — it is what separates the header from the list when the
|
|
566
|
+
list scrolls under it. */}
|
|
567
|
+
<div
|
|
568
|
+
className="flex items-center gap-[var(--space-3)] border-b"
|
|
569
|
+
style={{
|
|
570
|
+
borderColor: 'rgb(var(--border))',
|
|
571
|
+
paddingBlockEnd: 'var(--space-2)',
|
|
572
|
+
}}
|
|
573
|
+
>
|
|
574
|
+
<span
|
|
575
|
+
className="inline-flex items-center gap-[var(--space-1)] text-[13px] font-semibold"
|
|
576
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
577
|
+
>
|
|
578
|
+
<MessageSquare size={12} strokeWidth={2} aria-hidden="true" />
|
|
579
|
+
Comments
|
|
580
|
+
{/* Reserved slot: 12 → 13 must not move the sort pills. */}
|
|
581
|
+
<span
|
|
582
|
+
className="text-right tabular-nums"
|
|
583
|
+
style={{ minInlineSize: '2.5ch', color: 'rgb(var(--text-tertiary))' }}
|
|
584
|
+
>
|
|
585
|
+
{thread.totalCount}
|
|
586
|
+
</span>
|
|
587
|
+
</span>
|
|
588
|
+
|
|
589
|
+
{participants === undefined || participants.length === 0 ? null : (
|
|
590
|
+
<AvatarGroup
|
|
591
|
+
max={4}
|
|
592
|
+
total={participantTotal ?? participants.length}
|
|
593
|
+
className="ds-avatar-on-frame"
|
|
594
|
+
>
|
|
595
|
+
{participants.map((participant) => {
|
|
596
|
+
const tint = asAvatarTint(participant.tint)
|
|
597
|
+
return (
|
|
598
|
+
<Avatar
|
|
599
|
+
key={participant.id}
|
|
600
|
+
name={participant.name}
|
|
601
|
+
size="sm"
|
|
602
|
+
ring
|
|
603
|
+
seed={participant.id}
|
|
604
|
+
src={participant.avatarUrl ?? null}
|
|
605
|
+
{...(tint === undefined ? {} : { tint })}
|
|
606
|
+
/>
|
|
607
|
+
)
|
|
608
|
+
})}
|
|
609
|
+
</AvatarGroup>
|
|
610
|
+
)}
|
|
611
|
+
|
|
612
|
+
<div className="ms-auto">
|
|
613
|
+
<SortPills value={thread.sort} onChange={thread.setSort} />
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
<div className={cn(
|
|
618
|
+
'ds-thread__scroll min-h-0 flex-1 overflow-y-auto',
|
|
619
|
+
// Scroll stops here. Without containment a wheel gesture that hits
|
|
620
|
+
// the end of this list chains to PageShell's content region and the
|
|
621
|
+
// page moves under the thread.
|
|
622
|
+
'overscroll-contain',
|
|
623
|
+
)}>
|
|
624
|
+
{thread.roots.length === 0 ? (
|
|
625
|
+
<div
|
|
626
|
+
className="text-[13px]"
|
|
627
|
+
style={{ color: 'rgb(var(--text-tertiary))', paddingBlock: 'var(--space-4)' }}
|
|
628
|
+
>
|
|
629
|
+
{emptyState ?? 'No comments yet. Start the conversation.'}
|
|
630
|
+
</div>
|
|
631
|
+
) : (
|
|
632
|
+
<div className="flex flex-col gap-[var(--space-2)]">
|
|
633
|
+
{runs.map((run) => (
|
|
634
|
+
<Fragment key={run.key}>
|
|
635
|
+
{run.separator === null ? null : <DateSeparator label={run.separator} />}
|
|
636
|
+
{/* `role="list"` is restated because `list-style: none` drops
|
|
637
|
+
the implicit list role in Safari/VoiceOver, which would
|
|
638
|
+
hand back exactly the structure this markup exists to
|
|
639
|
+
give. `m-0 p-0` removes the UA list indent, which is the
|
|
640
|
+
only geometry a bare `<ul>` would have changed. */}
|
|
641
|
+
<ul
|
|
642
|
+
role="list"
|
|
643
|
+
className="m-0 flex list-none flex-col gap-[var(--space-2)] p-0"
|
|
644
|
+
>
|
|
645
|
+
{run.nodes.map((node) => (
|
|
646
|
+
// The `<li>` is the list item; the card is its chrome. The
|
|
647
|
+
// root CommentItem stays a `<div>` (`listItem={false}`)
|
|
648
|
+
// because the card has to sit BETWEEN the item and the
|
|
649
|
+
// comment — the comment's own block-start padding is what
|
|
650
|
+
// supplies the card's top inset.
|
|
651
|
+
<li key={node.id}>
|
|
652
|
+
{/* One card, the whole thread. `<Card>` rather than a
|
|
653
|
+
forked `<div>` with a background token — the
|
|
654
|
+
primitive owns the opaque surface, the border and the
|
|
655
|
+
shadow (CLAUDE.md §2). */}
|
|
656
|
+
<Card
|
|
657
|
+
// `h-full` + `overflow-hidden` are Card's defaults for
|
|
658
|
+
// a grid cell. Here the card is a flow item whose
|
|
659
|
+
// height is its content, and clipping would eat the
|
|
660
|
+
// focus ring on the rail hairline sitting at its
|
|
661
|
+
// inline-start edge.
|
|
662
|
+
className="h-auto overflow-visible"
|
|
663
|
+
style={CARD_STYLE}
|
|
664
|
+
>
|
|
665
|
+
<CommentItem
|
|
666
|
+
node={node}
|
|
667
|
+
ctx={ctx}
|
|
668
|
+
renderDepth={0}
|
|
669
|
+
listItem={false}
|
|
670
|
+
/>
|
|
671
|
+
</Card>
|
|
672
|
+
</li>
|
|
673
|
+
))}
|
|
674
|
+
</ul>
|
|
675
|
+
</Fragment>
|
|
676
|
+
))}
|
|
677
|
+
</div>
|
|
678
|
+
)}
|
|
679
|
+
</div>
|
|
680
|
+
|
|
681
|
+
{/* Pinned composer — a long thread stays replyable without scrolling
|
|
682
|
+
back to the top. */}
|
|
683
|
+
{thread.canReply && rootComposer ? (
|
|
684
|
+
<div
|
|
685
|
+
className="shrink-0 border-t"
|
|
686
|
+
style={{
|
|
687
|
+
borderColor: 'rgb(var(--border))',
|
|
688
|
+
paddingBlockStart: 'var(--space-3)',
|
|
689
|
+
marginBlockStart: 'var(--space-3)',
|
|
690
|
+
}}
|
|
691
|
+
>
|
|
692
|
+
<Composer
|
|
693
|
+
mode={activeComposer}
|
|
694
|
+
onModeChange={changeComposer}
|
|
695
|
+
label="Add a comment"
|
|
696
|
+
placeholder={composerPlaceholder}
|
|
697
|
+
value={thread.draft}
|
|
698
|
+
onChange={thread.setDraft}
|
|
699
|
+
onSubmit={thread.submit}
|
|
700
|
+
sending={sending}
|
|
701
|
+
{...(composerAvatar === undefined ? {} : { avatar: composerAvatar })}
|
|
702
|
+
{...(composerMaxLength === undefined ? {} : { maxLength: composerMaxLength })}
|
|
703
|
+
{...(mentionItems === undefined ? {} : { mentionItems })}
|
|
704
|
+
{...(uploadAttachment === undefined ? {} : { uploadAttachment })}
|
|
705
|
+
{...(attachGif === undefined ? {} : { attachGif })}
|
|
706
|
+
{...(searchGifs === undefined ? {} : { searchGifs })}
|
|
707
|
+
{...(attachmentAccept === undefined ? {} : { attachmentAccept })}
|
|
708
|
+
{...(attachmentMaxPerComment === undefined
|
|
709
|
+
? {}
|
|
710
|
+
: { attachmentMaxPerComment })}
|
|
711
|
+
{...(attachmentMaxFileBytes === undefined ? {} : { attachmentMaxFileBytes })}
|
|
712
|
+
{...(attachmentMaxTotalBytes === undefined ? {} : { attachmentMaxTotalBytes })}
|
|
713
|
+
/>
|
|
714
|
+
</div>
|
|
715
|
+
) : null}
|
|
716
|
+
</section>
|
|
717
|
+
)
|
|
718
|
+
}
|