@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,611 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ActivityPane — Everything · Comments · Activity, with one composer.
|
|
3
|
+
*
|
|
4
|
+
* `docs/design-briefs/tasks-item-detail-full.md` §5.
|
|
5
|
+
*
|
|
6
|
+
* ## The composer is pinned to the bottom of the pane on EVERY tab
|
|
7
|
+
*
|
|
8
|
+
* Including Activity, which is the instruction that decides the whole
|
|
9
|
+
* structure. `Thread` pins a root composer of its own at the foot of the
|
|
10
|
+
* comments list, so mounting it inside the Comments tab and a second one for
|
|
11
|
+
* the other tabs would put two composers on screen at once — and switching
|
|
12
|
+
* tabs would unmount whichever one you were typing in. So the composer lives
|
|
13
|
+
* OUTSIDE the tab panels, mounted once, and `Thread` is asked to draw no root
|
|
14
|
+
* composer (`rootComposer={false}`, added to that primitive for this). Replies
|
|
15
|
+
* still work: they are gated on `onReply`, not on the root composer.
|
|
16
|
+
*
|
|
17
|
+
* The three panels are all mounted and toggled with `hidden` for the same
|
|
18
|
+
* reason — a scroll position, an expanded reply and a half-read thread all
|
|
19
|
+
* survive a tab change, and `hidden` takes them out of the accessibility tree
|
|
20
|
+
* and the tab ring while they are away.
|
|
21
|
+
*
|
|
22
|
+
* ## It fetches nothing
|
|
23
|
+
*
|
|
24
|
+
* Comments and activity arrive as props, already shaped. §6 is explicit that
|
|
25
|
+
* the activity feed is written server-side in the same transaction as the
|
|
26
|
+
* change — never derived on the client — so this component has no business
|
|
27
|
+
* inferring history from an item, and does not.
|
|
28
|
+
*
|
|
29
|
+
* ## It owns no thread behaviour either
|
|
30
|
+
*
|
|
31
|
+
* Every comment callback it declares is `ThreadProps`' own, forwarded
|
|
32
|
+
* unchanged to the `<Thread>` below. The pane decides WHERE a control lives —
|
|
33
|
+
* one composer, outside the panels — and never what one does. That is not a
|
|
34
|
+
* style preference: a prop it fails to forward is a control a host cannot draw
|
|
35
|
+
* at all, and three shipped mutations spent a release unreachable that way
|
|
36
|
+
* (Lightwork FU-0019). What is still not forwarded, and why, is listed on
|
|
37
|
+
* `ActivityPaneProps`, so the next gap is chosen rather than discovered.
|
|
38
|
+
*
|
|
39
|
+
* ## Everything is read-only
|
|
40
|
+
*
|
|
41
|
+
* The merged feed interleaves both by time so you can see what happened in
|
|
42
|
+
* order. Reacting, replying and collapsing live in Comments, where the thread
|
|
43
|
+
* is drawn in full — a feed that is half interactive and half not is worse
|
|
44
|
+
* than one that is honestly neither.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { useId, useMemo, useState, type ReactNode } from 'react'
|
|
48
|
+
import { Tabs } from '../tabs'
|
|
49
|
+
import { Avatar, asAvatarTint } from '../avatar'
|
|
50
|
+
import { cn } from '../lib/utils'
|
|
51
|
+
import { CommentBody } from '../thread/comment-body'
|
|
52
|
+
import { RelativeTime } from '../thread/relative-time'
|
|
53
|
+
import { Thread } from '../thread/thread'
|
|
54
|
+
import { ThreadComposer } from '../thread/composer'
|
|
55
|
+
import { useNowTick } from '../thread/use-now'
|
|
56
|
+
import type { ThreadAuthor, ThreadComment, ThreadReactionKey } from '../thread/types'
|
|
57
|
+
import type { ActivityEntry, FieldPerson } from './types'
|
|
58
|
+
|
|
59
|
+
export type ActivityTab = 'everything' | 'comments' | 'activity'
|
|
60
|
+
|
|
61
|
+
const TAB_ORDER: readonly ActivityTab[] = ['everything', 'comments', 'activity']
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The tabs a given pane actually shows.
|
|
65
|
+
*
|
|
66
|
+
* A host with no comment backend gets Everything and Activity, and Everything
|
|
67
|
+
* then means "the activity feed" rather than a merge of two sources — which is
|
|
68
|
+
* still true, just shorter. Dropping the tab rather than showing an empty
|
|
69
|
+
* Comments panel is the honest version: a tab that can never have content is a
|
|
70
|
+
* promise the product does not keep.
|
|
71
|
+
*/
|
|
72
|
+
function tabsFor(withComments: boolean): readonly ActivityTab[] {
|
|
73
|
+
return withComments ? TAB_ORDER : TAB_ORDER.filter((id) => id !== 'comments')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const TAB_LABEL: Readonly<Record<ActivityTab, string>> = {
|
|
77
|
+
everything: 'Everything',
|
|
78
|
+
comments: 'Comments',
|
|
79
|
+
activity: 'Activity',
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** In the interface's own voice: what this is, and what will appear here. */
|
|
83
|
+
const DEFAULT_EMPTY: Readonly<Record<ActivityTab, string>> = {
|
|
84
|
+
everything: 'Nothing here yet. Comments and changes to this record show up together.',
|
|
85
|
+
comments: 'No comments yet. Start the conversation.',
|
|
86
|
+
activity: 'No activity yet. Every change to this record is recorded here.',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ActivityPaneProps {
|
|
90
|
+
/** Controlled tab. Omit for internal state. */
|
|
91
|
+
tab?: ActivityTab | undefined
|
|
92
|
+
defaultTab?: ActivityTab | undefined
|
|
93
|
+
onTabChange?: ((next: ActivityTab) => void) | undefined
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Already shaped. The pane fetches nothing.
|
|
97
|
+
*
|
|
98
|
+
* Omit it — along with the composer trio below — for a host that has no
|
|
99
|
+
* comment backend at all. The Comments tab is then not rendered, rather than
|
|
100
|
+
* rendered empty. See `tabsFor`.
|
|
101
|
+
*/
|
|
102
|
+
comments?: readonly ThreadComment[] | undefined
|
|
103
|
+
/** Item history, in any order — the pane sorts by time. */
|
|
104
|
+
activity: readonly ActivityEntry[]
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The composer's draft, controlled by the host so it survives a remount.
|
|
108
|
+
*
|
|
109
|
+
* The trio is all-or-nothing: supply `onSubmit` and you get a composer,
|
|
110
|
+
* omit it and the pane draws none. `onSubmit` is the one that decides,
|
|
111
|
+
* because a composer with nowhere to post is worse than no composer.
|
|
112
|
+
*/
|
|
113
|
+
draft?: string | undefined
|
|
114
|
+
onDraftChange?: ((next: string) => void) | undefined
|
|
115
|
+
/** A new root comment. Omitted, the pane renders no composer. */
|
|
116
|
+
onSubmit?: ((body: string) => void) | undefined
|
|
117
|
+
/** A reply. Omitted, the thread offers no reply control at all. */
|
|
118
|
+
onReply?: ((body: string, parentId: string) => void) | undefined
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Forwarded verbatim to the `<Thread>` the Comments tab already mounts.
|
|
122
|
+
*
|
|
123
|
+
* These four are here because the pane used to declare `onSubmit` and
|
|
124
|
+
* `onReply` and stop. `CommentItem` gates its reaction PICKER on `onReact`
|
|
125
|
+
* and its overflow MENU ITEMS on `onEdit` / `onDelete`; a menu item whose
|
|
126
|
+
* callback is absent is not rendered, and the reaction chips still render
|
|
127
|
+
* DISABLED rather than vanishing. (An earlier draft of this docblock said
|
|
128
|
+
* the whole bar was "NOT RENDERED". That is the rule from `ThreadProps`'
|
|
129
|
+
* overflow-menu docblock, and it does not generalise to the engagement row —
|
|
130
|
+
* `reactions.tsx` renders `disabled={!canReact}`.) So a host composing this
|
|
131
|
+
* pane shipped an inert
|
|
132
|
+
* engagement row with no picker to open and a menu holding one line, while
|
|
133
|
+
* the three routes behind them had no caller in the browser at all. Nothing
|
|
134
|
+
* errored, which is how it survived a release; Lightwork's FU-0019 found it
|
|
135
|
+
* by opening the surface rather than by reading the source, and the source
|
|
136
|
+
* understates it.
|
|
137
|
+
*
|
|
138
|
+
* `onRetry` is the same omission with one sharper edge: the failed send it
|
|
139
|
+
* recovers was posted by THIS pane's own composer, so dropping it left the
|
|
140
|
+
* pane manufacturing a state and offering nobody a way out of it.
|
|
141
|
+
*
|
|
142
|
+
* The signatures are `ThreadProps`', unchanged, because a conduit that
|
|
143
|
+
* reshapes what passes through it is a second contract to keep in step —
|
|
144
|
+
* and this one is held by two repos. Absent still means no control, so a
|
|
145
|
+
* host that passes none renders exactly as it did on 0.1.0.
|
|
146
|
+
*
|
|
147
|
+
* STILL NOT FORWARDED, deliberately. This list is EXHAUSTIVE against
|
|
148
|
+
* `ThreadProps` — check it against that interface before adding one, because
|
|
149
|
+
* a list whose whole purpose is completeness is worse than no list when it
|
|
150
|
+
* is short: `onVote`, `onCopyLink`, `onCopyText`, `onSelectAuthor`,
|
|
151
|
+
* `onSelectMention`, `onOpenAttachment`, `resolveAttachmentUrl`,
|
|
152
|
+
* `onContinueThread`.
|
|
153
|
+
*
|
|
154
|
+
* The line is whether a MUTATION becomes unreachable. `onVote` fails it for
|
|
155
|
+
* a different reason — the pane never exposes `engagement`, so there is no
|
|
156
|
+
* vote control to receive it and forwarding it would be a dead prop.
|
|
157
|
+
* `onCopyText` only reports a clipboard outcome; "Copy text" renders without
|
|
158
|
+
* it. `onContinueThread` falls back to local state, so the button works
|
|
159
|
+
* unwired. The rest are reads.
|
|
160
|
+
*
|
|
161
|
+
* `onHideLinkPreview` USED to be on this list and should not have been: it is
|
|
162
|
+
* a write in the same pattern as the other mutations, and `link-preview.tsx`
|
|
163
|
+
* gates its dismiss control on the callback being present. Since the pane
|
|
164
|
+
* forwards `comments` verbatim — link previews included — a host got
|
|
165
|
+
* dismissible-looking rows with no route to the mutation. Same shape as the
|
|
166
|
+
* defect this whole change fixes, caught in review.
|
|
167
|
+
*/
|
|
168
|
+
onReact?: ((commentId: string, key: ThreadReactionKey) => void) | undefined
|
|
169
|
+
onEdit?: ((commentId: string) => void) | undefined
|
|
170
|
+
onDelete?: ((commentId: string) => void) | undefined
|
|
171
|
+
onRetry?: ((commentId: string) => void) | undefined
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A write, in the same pattern as the mutations above — `link-preview.tsx`
|
|
175
|
+
* gates its dismiss control on this being present, and the pane forwards
|
|
176
|
+
* `comments` verbatim, previews included. Without it a host draws rows that
|
|
177
|
+
* look dismissible and are not. Added in review, not in the original change.
|
|
178
|
+
*/
|
|
179
|
+
onHideLinkPreview?: ((commentId: string, urlHash: string) => void) | undefined
|
|
180
|
+
|
|
181
|
+
sending?: boolean | undefined
|
|
182
|
+
composerAvatar?: ReactNode | undefined
|
|
183
|
+
composerPlaceholder?: string | undefined
|
|
184
|
+
composerLabel?: string | undefined
|
|
185
|
+
composerMaxLength?: number | undefined
|
|
186
|
+
submitLabel?: string | undefined
|
|
187
|
+
|
|
188
|
+
/** Frozen clock, epoch ms. Omit and the shared 60s ticker drives the times. */
|
|
189
|
+
now?: number | undefined
|
|
190
|
+
locale?: string | undefined
|
|
191
|
+
/** Override any tab's empty state. */
|
|
192
|
+
emptyStates?: Partial<Record<ActivityTab, ReactNode>> | undefined
|
|
193
|
+
className?: string | undefined
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function ActivityPane({
|
|
197
|
+
tab,
|
|
198
|
+
defaultTab = 'everything',
|
|
199
|
+
onTabChange,
|
|
200
|
+
comments = [],
|
|
201
|
+
activity,
|
|
202
|
+
draft = '',
|
|
203
|
+
onDraftChange,
|
|
204
|
+
onSubmit,
|
|
205
|
+
onReply,
|
|
206
|
+
onReact,
|
|
207
|
+
onEdit,
|
|
208
|
+
onDelete,
|
|
209
|
+
onRetry,
|
|
210
|
+
onHideLinkPreview,
|
|
211
|
+
sending = false,
|
|
212
|
+
composerAvatar,
|
|
213
|
+
composerPlaceholder = 'Add a comment…',
|
|
214
|
+
composerLabel = 'Add a comment',
|
|
215
|
+
composerMaxLength,
|
|
216
|
+
submitLabel = 'Comment',
|
|
217
|
+
now,
|
|
218
|
+
locale,
|
|
219
|
+
emptyStates,
|
|
220
|
+
className,
|
|
221
|
+
}: ActivityPaneProps) {
|
|
222
|
+
const reactId = useId()
|
|
223
|
+
const [internalTab, setInternalTab] = useState<ActivityTab>(defaultTab)
|
|
224
|
+
const withComments = onSubmit !== undefined
|
|
225
|
+
const tabs = tabsFor(withComments)
|
|
226
|
+
const requested = tab ?? internalTab
|
|
227
|
+
// A host that drops Comments while it is the active tab (or that defaults to
|
|
228
|
+
// it) must not land on a panel that is not rendered.
|
|
229
|
+
const active = tabs.includes(requested) ? requested : (tabs[0] as ActivityTab)
|
|
230
|
+
const ticked = useNowTick(now === undefined)
|
|
231
|
+
const clock = now ?? ticked
|
|
232
|
+
|
|
233
|
+
const sortedActivity = useMemo(
|
|
234
|
+
() => [...activity].sort((a, b) => a.at - b.at),
|
|
235
|
+
[activity],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
// Oldest first, so the newest sits nearest the composer.
|
|
239
|
+
const merged = useMemo(() => {
|
|
240
|
+
const entries: FeedEntry[] = [
|
|
241
|
+
...comments.map((comment) => ({
|
|
242
|
+
kind: 'comment' as const,
|
|
243
|
+
at: comment.createdAt,
|
|
244
|
+
comment,
|
|
245
|
+
})),
|
|
246
|
+
...sortedActivity.map((entry) => ({
|
|
247
|
+
kind: 'activity' as const,
|
|
248
|
+
at: entry.at,
|
|
249
|
+
entry,
|
|
250
|
+
})),
|
|
251
|
+
]
|
|
252
|
+
return entries.sort((a, b) => a.at - b.at)
|
|
253
|
+
}, [comments, sortedActivity])
|
|
254
|
+
|
|
255
|
+
const empty = (id: ActivityTab): ReactNode => emptyStates?.[id] ?? DEFAULT_EMPTY[id]
|
|
256
|
+
|
|
257
|
+
// Tab -> panel wiring. The strip alone was a tablist whose tabs
|
|
258
|
+
// controlled nothing; each tab now points `aria-controls` at the panel it
|
|
259
|
+
// shows, and each panel is focusable so keyboard users can reach its
|
|
260
|
+
// scrolling content.
|
|
261
|
+
const idPrefix = `activity-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`
|
|
262
|
+
const panelDomId = (id: ActivityTab) => `${idPrefix}-panel-${id}`
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<section
|
|
266
|
+
data-slot="activity-pane"
|
|
267
|
+
aria-label="Activity"
|
|
268
|
+
className={cn('flex min-h-0 flex-1 flex-col', className)}
|
|
269
|
+
>
|
|
270
|
+
{/*
|
|
271
|
+
Matched to the field-list header on the other side of the split, so
|
|
272
|
+
the two panes' first rows sit on one line: same `--space-2` block
|
|
273
|
+
padding, same bottom border, and a control the same 36px tall (the
|
|
274
|
+
segmented track is 26 + 4 + 4 + 1 + 1, the same as that header's
|
|
275
|
+
SegmentedChoice). Change one and change the other.
|
|
276
|
+
*/}
|
|
277
|
+
<div
|
|
278
|
+
className="shrink-0 border-b"
|
|
279
|
+
style={{
|
|
280
|
+
paddingInline: 'var(--space-4)',
|
|
281
|
+
paddingBlock: 'var(--space-2)',
|
|
282
|
+
borderColor: 'rgb(var(--border))',
|
|
283
|
+
}}
|
|
284
|
+
>
|
|
285
|
+
<Tabs
|
|
286
|
+
size="sm"
|
|
287
|
+
variant="segmented"
|
|
288
|
+
stretch
|
|
289
|
+
idPrefix={idPrefix}
|
|
290
|
+
activeTabId={active}
|
|
291
|
+
onTabChange={(next) => {
|
|
292
|
+
const id = next as ActivityTab
|
|
293
|
+
if (tab === undefined) setInternalTab(id)
|
|
294
|
+
onTabChange?.(id)
|
|
295
|
+
}}
|
|
296
|
+
tabs={tabs.map((id) => ({
|
|
297
|
+
id,
|
|
298
|
+
label: TAB_LABEL[id],
|
|
299
|
+
panelId: panelDomId(id),
|
|
300
|
+
// A badge only when there is something to count. "Comments 0"
|
|
301
|
+
// says nothing the empty state does not, and in a segmented
|
|
302
|
+
// strip at the pane's 280px floor that zero is bought with the
|
|
303
|
+
// label — "Co… 0" instead of "Comments".
|
|
304
|
+
...(id === 'comments' && comments.length > 0
|
|
305
|
+
? { count: comments.length }
|
|
306
|
+
: {}),
|
|
307
|
+
...(id === 'activity' && activity.length > 0
|
|
308
|
+
? { count: activity.length }
|
|
309
|
+
: {}),
|
|
310
|
+
}))}
|
|
311
|
+
/>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
{/* EVERYTHING */}
|
|
315
|
+
<div
|
|
316
|
+
role="tabpanel"
|
|
317
|
+
id={panelDomId('everything')}
|
|
318
|
+
// Named by `aria-label`, not `aria-labelledby` back at the tab: two
|
|
319
|
+
// of the three tabs carry a count badge, so borrowing the tab's text
|
|
320
|
+
// would name the panel "Comments 3".
|
|
321
|
+
aria-label={TAB_LABEL.everything}
|
|
322
|
+
tabIndex={0}
|
|
323
|
+
hidden={active !== 'everything'}
|
|
324
|
+
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
|
325
|
+
style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
|
|
326
|
+
>
|
|
327
|
+
{merged.length === 0 ? (
|
|
328
|
+
<EmptyLine>{empty('everything')}</EmptyLine>
|
|
329
|
+
) : (
|
|
330
|
+
<ol className="flex list-none flex-col" style={{ gap: 'var(--space-3)', margin: 0, padding: 0 }}>
|
|
331
|
+
{merged.map((item) =>
|
|
332
|
+
item.kind === 'comment' ? (
|
|
333
|
+
<li key={`comment-${item.comment.id}`}>
|
|
334
|
+
<FeedLine
|
|
335
|
+
// WITHHELD on a tombstone, matching the thread's treatment
|
|
336
|
+
// exactly. `CommentItem` replaces the byline with the
|
|
337
|
+
// tombstone label and drops the avatar to `?`; printing the
|
|
338
|
+
// name here would mean the same comment answers "who wrote
|
|
339
|
+
// this" differently one tab apart.
|
|
340
|
+
//
|
|
341
|
+
// It matters more since FU-0032 than it did before. A
|
|
342
|
+
// neutral "Comment deleted" beside a name was a deletion
|
|
343
|
+
// nobody had to explain; "Comment removed by an admin"
|
|
344
|
+
// beside a name is a per-author public record of who got
|
|
345
|
+
// moderated, readable by everyone in the workspace.
|
|
346
|
+
author={
|
|
347
|
+
item.comment.deletedAt === null || item.comment.deletedAt === undefined
|
|
348
|
+
? (item.comment.author ?? null)
|
|
349
|
+
: null
|
|
350
|
+
}
|
|
351
|
+
at={item.at}
|
|
352
|
+
now={clock}
|
|
353
|
+
locale={locale}
|
|
354
|
+
body={
|
|
355
|
+
item.comment.deletedAt === null || item.comment.deletedAt === undefined ? (
|
|
356
|
+
<CommentBody bodyMd={item.comment.bodyMd} />
|
|
357
|
+
) : (
|
|
358
|
+
// The feed's register, not the thread's: a line in a
|
|
359
|
+
// sentence rather than a bracketed placeholder. The
|
|
360
|
+
// DISTINCTION is the same one, and it has to be, or a
|
|
361
|
+
// reader gets one answer on Everything and another on
|
|
362
|
+
// Comments about the same comment (FU-0032).
|
|
363
|
+
<span style={{ color: 'rgb(var(--text-tertiary))' }}>
|
|
364
|
+
{item.comment.moderated === true
|
|
365
|
+
? 'Comment removed by an admin'
|
|
366
|
+
: 'Comment deleted'}
|
|
367
|
+
</span>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
lead="commented"
|
|
371
|
+
/>
|
|
372
|
+
</li>
|
|
373
|
+
) : (
|
|
374
|
+
<li key={`activity-${item.entry.id}`}>
|
|
375
|
+
<ActivityLine entry={item.entry} now={clock} locale={locale} />
|
|
376
|
+
</li>
|
|
377
|
+
),
|
|
378
|
+
)}
|
|
379
|
+
</ol>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{/* COMMENTS — omitted entirely without a backend, not rendered empty. */}
|
|
384
|
+
{!withComments ? null : (
|
|
385
|
+
<div
|
|
386
|
+
role="tabpanel"
|
|
387
|
+
id={panelDomId('comments')}
|
|
388
|
+
// Named by `aria-label`, not `aria-labelledby` back at the tab: two
|
|
389
|
+
// of the three tabs carry a count badge, so borrowing the tab's text
|
|
390
|
+
// would name the panel "Comments 3".
|
|
391
|
+
aria-label={TAB_LABEL.comments}
|
|
392
|
+
tabIndex={0}
|
|
393
|
+
hidden={active !== 'comments'}
|
|
394
|
+
className="flex min-h-0 flex-1 flex-col"
|
|
395
|
+
style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
|
|
396
|
+
>
|
|
397
|
+
<Thread
|
|
398
|
+
variant="bare"
|
|
399
|
+
rootComposer={false}
|
|
400
|
+
comments={comments}
|
|
401
|
+
emptyState={empty('comments')}
|
|
402
|
+
{...(now === undefined ? {} : { now })}
|
|
403
|
+
{...(locale === undefined ? {} : { locale })}
|
|
404
|
+
{...(onReply === undefined
|
|
405
|
+
? {}
|
|
406
|
+
: {
|
|
407
|
+
onSubmit: (body: string, parentId?: string) => {
|
|
408
|
+
// Root submissions come from the pinned composer below, so
|
|
409
|
+
// anything arriving here without a parent is dropped rather
|
|
410
|
+
// than posted twice.
|
|
411
|
+
if (parentId !== undefined) onReply(body, parentId)
|
|
412
|
+
},
|
|
413
|
+
})}
|
|
414
|
+
{...(onReact === undefined ? {} : { onReact })}
|
|
415
|
+
{...(onEdit === undefined ? {} : { onEdit })}
|
|
416
|
+
{...(onDelete === undefined ? {} : { onDelete })}
|
|
417
|
+
{...(onRetry === undefined ? {} : { onRetry })}
|
|
418
|
+
{...(onHideLinkPreview === undefined ? {} : { onHideLinkPreview })}
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
421
|
+
)}
|
|
422
|
+
|
|
423
|
+
{/* ACTIVITY */}
|
|
424
|
+
<div
|
|
425
|
+
role="tabpanel"
|
|
426
|
+
id={panelDomId('activity')}
|
|
427
|
+
// Named by `aria-label`, not `aria-labelledby` back at the tab: two
|
|
428
|
+
// of the three tabs carry a count badge, so borrowing the tab's text
|
|
429
|
+
// would name the panel "Comments 3".
|
|
430
|
+
aria-label={TAB_LABEL.activity}
|
|
431
|
+
tabIndex={0}
|
|
432
|
+
hidden={active !== 'activity'}
|
|
433
|
+
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
|
|
434
|
+
style={{ paddingInline: 'var(--space-4)', paddingBlock: 'var(--space-3)' }}
|
|
435
|
+
>
|
|
436
|
+
{sortedActivity.length === 0 ? (
|
|
437
|
+
<EmptyLine>{empty('activity')}</EmptyLine>
|
|
438
|
+
) : (
|
|
439
|
+
<ol className="flex list-none flex-col" style={{ gap: 'var(--space-3)', margin: 0, padding: 0 }}>
|
|
440
|
+
{sortedActivity.map((entry) => (
|
|
441
|
+
<li key={entry.id}>
|
|
442
|
+
<ActivityLine entry={entry} now={clock} locale={locale} />
|
|
443
|
+
</li>
|
|
444
|
+
))}
|
|
445
|
+
</ol>
|
|
446
|
+
)}
|
|
447
|
+
</div>
|
|
448
|
+
|
|
449
|
+
{/* THE COMPOSER — one, mounted once, below every panel. Absent when
|
|
450
|
+
there is nowhere to post to; a composer that discards what you typed
|
|
451
|
+
is worse than no composer. */}
|
|
452
|
+
{onSubmit === undefined ? null : (
|
|
453
|
+
<div
|
|
454
|
+
data-slot="activity-composer"
|
|
455
|
+
className="shrink-0 border-t"
|
|
456
|
+
style={{
|
|
457
|
+
borderColor: 'rgb(var(--border))',
|
|
458
|
+
paddingInline: 'var(--space-4)',
|
|
459
|
+
paddingBlock: 'var(--space-3)',
|
|
460
|
+
}}
|
|
461
|
+
>
|
|
462
|
+
<ThreadComposer
|
|
463
|
+
label={composerLabel}
|
|
464
|
+
placeholder={composerPlaceholder}
|
|
465
|
+
submitLabel={submitLabel}
|
|
466
|
+
value={draft}
|
|
467
|
+
onChange={onDraftChange ?? (() => {})}
|
|
468
|
+
onSubmit={() => {
|
|
469
|
+
const body = draft.trim()
|
|
470
|
+
if (body === '') return
|
|
471
|
+
onSubmit(body)
|
|
472
|
+
}}
|
|
473
|
+
sending={sending}
|
|
474
|
+
{...(composerAvatar === undefined ? {} : { avatar: composerAvatar })}
|
|
475
|
+
{...(composerMaxLength === undefined ? {} : { maxLength: composerMaxLength })}
|
|
476
|
+
/>
|
|
477
|
+
</div>
|
|
478
|
+
)}
|
|
479
|
+
</section>
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
type FeedEntry =
|
|
484
|
+
| { kind: 'comment'; at: number; comment: ThreadComment }
|
|
485
|
+
| { kind: 'activity'; at: number; entry: ActivityEntry }
|
|
486
|
+
|
|
487
|
+
function EmptyLine({ children }: { children: ReactNode }) {
|
|
488
|
+
return (
|
|
489
|
+
<p
|
|
490
|
+
className="text-[12.5px] leading-[1.5]"
|
|
491
|
+
style={{ margin: 0, color: 'rgb(var(--text-tertiary))' }}
|
|
492
|
+
>
|
|
493
|
+
{children}
|
|
494
|
+
</p>
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Never fabricate a name (thread D5) — fall back through what exists. */
|
|
499
|
+
function actorName(actor: FieldPerson | ThreadAuthor | null | undefined): string {
|
|
500
|
+
if (actor === null || actor === undefined) return 'Someone'
|
|
501
|
+
if ('name' in actor && typeof actor.name === 'string' && actor.name.length > 0) {
|
|
502
|
+
return actor.name
|
|
503
|
+
}
|
|
504
|
+
if ('email' in actor && typeof actor.email === 'string' && actor.email.length > 0) {
|
|
505
|
+
return actor.email
|
|
506
|
+
}
|
|
507
|
+
return 'Someone'
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function FeedLine({
|
|
511
|
+
author,
|
|
512
|
+
at,
|
|
513
|
+
now,
|
|
514
|
+
locale,
|
|
515
|
+
lead,
|
|
516
|
+
body,
|
|
517
|
+
}: {
|
|
518
|
+
author: FieldPerson | ThreadAuthor | null
|
|
519
|
+
at: number
|
|
520
|
+
now: number
|
|
521
|
+
locale?: string | undefined
|
|
522
|
+
lead: ReactNode
|
|
523
|
+
body?: ReactNode | undefined
|
|
524
|
+
}) {
|
|
525
|
+
const tint = asAvatarTint(author?.tint)
|
|
526
|
+
const name = actorName(author)
|
|
527
|
+
return (
|
|
528
|
+
<div className="flex items-start" style={{ gap: 'var(--space-2)' }}>
|
|
529
|
+
<Avatar
|
|
530
|
+
name={author?.name ?? null}
|
|
531
|
+
size="sm"
|
|
532
|
+
src={author?.avatarUrl ?? null}
|
|
533
|
+
{...(author?.id === undefined || author.id === null ? {} : { seed: author.id })}
|
|
534
|
+
{...(tint === undefined ? {} : { tint })}
|
|
535
|
+
/>
|
|
536
|
+
<div className="min-w-0 flex-1">
|
|
537
|
+
<p className="text-[12.5px] leading-[1.5]" style={{ margin: 0, color: 'rgb(var(--text-secondary))' }}>
|
|
538
|
+
<span className="font-semibold" style={{ color: 'rgb(var(--foreground))' }}>
|
|
539
|
+
{name}
|
|
540
|
+
</span>{' '}
|
|
541
|
+
{lead}{' '}
|
|
542
|
+
<RelativeTime value={at} now={now} locale={locale} />
|
|
543
|
+
</p>
|
|
544
|
+
{body === undefined ? null : (
|
|
545
|
+
<div className="text-[12.5px] leading-[1.55]" style={{ marginBlockStart: 'var(--space-1)' }}>
|
|
546
|
+
{body}
|
|
547
|
+
</div>
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* `‹who› moved from ‹A› to ‹B› ‹when›` (§5), and the three degenerate cases
|
|
556
|
+
* the sentence has to cover — set, cleared, and a change the server described
|
|
557
|
+
* itself. Field names arrive user-facing ("Status"), never as schema words:
|
|
558
|
+
* §7's copy rule is a host responsibility this component cannot enforce, but
|
|
559
|
+
* it never invents one either.
|
|
560
|
+
*/
|
|
561
|
+
function ActivityLine({
|
|
562
|
+
entry,
|
|
563
|
+
now,
|
|
564
|
+
locale,
|
|
565
|
+
}: {
|
|
566
|
+
entry: ActivityEntry
|
|
567
|
+
now: number
|
|
568
|
+
locale?: string | undefined
|
|
569
|
+
}) {
|
|
570
|
+
const lead = ((): ReactNode => {
|
|
571
|
+
if (entry.summary !== undefined) return entry.summary
|
|
572
|
+
const { field, from, to } = entry
|
|
573
|
+
if (field === undefined) return 'updated this item'
|
|
574
|
+
const has = (value: string | null | undefined): value is string =>
|
|
575
|
+
typeof value === 'string' && value.length > 0
|
|
576
|
+
if (has(from) && has(to)) {
|
|
577
|
+
return (
|
|
578
|
+
<>
|
|
579
|
+
changed {field} from <Value>{from}</Value> to <Value>{to}</Value>
|
|
580
|
+
</>
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
if (has(to)) {
|
|
584
|
+
return (
|
|
585
|
+
<>
|
|
586
|
+
set {field} to <Value>{to}</Value>
|
|
587
|
+
</>
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
if (has(from)) return <>cleared {field}</>
|
|
591
|
+
return <>updated {field}</>
|
|
592
|
+
})()
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
<FeedLine
|
|
596
|
+
author={entry.actor ?? null}
|
|
597
|
+
at={entry.at}
|
|
598
|
+
now={now}
|
|
599
|
+
locale={locale}
|
|
600
|
+
lead={lead}
|
|
601
|
+
/>
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function Value({ children }: { children: ReactNode }) {
|
|
606
|
+
return (
|
|
607
|
+
<span className="font-medium" style={{ color: 'rgb(var(--foreground))' }}>
|
|
608
|
+
{children}
|
|
609
|
+
</span>
|
|
610
|
+
)
|
|
611
|
+
}
|