@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,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommentBody — the stored markdown, rendered.
|
|
3
|
+
*
|
|
4
|
+
* ADR-147 D7 and D11.
|
|
5
|
+
*
|
|
6
|
+
* SECURITY, stated once and permanently: `rehype-raw` is NEVER enabled on this
|
|
7
|
+
* surface, and no `rehypePlugins` are passed at all. `react-markdown` does not
|
|
8
|
+
* execute raw HTML unless it is turned on, so the safe state is the default
|
|
9
|
+
* state and this file's job is to not leave it. Comment bodies are user input
|
|
10
|
+
* and will quote LLM output; the repo has zero `rehype-*` packages and zero
|
|
11
|
+
* `dangerouslySetInnerHTML`, and this decision protects that rather than
|
|
12
|
+
* repairing it. Adding either here is a security change requiring its own ADR.
|
|
13
|
+
*
|
|
14
|
+
* MENTIONS (D11) are ordinary markdown links with a scheme:
|
|
15
|
+
*
|
|
16
|
+
* @[Marcus Webb](user:usr_9f2) @[Slice B migration](task_item:tit_44a)
|
|
17
|
+
*
|
|
18
|
+
* Two things follow that are easy to get wrong:
|
|
19
|
+
*
|
|
20
|
+
* • `react-markdown`'s default `urlTransform` drops any protocol it does not
|
|
21
|
+
* recognise, so `user:usr_9f2` would silently become `href=""` and the
|
|
22
|
+
* mention would render as inert text. The transform below preserves
|
|
23
|
+
* exactly the three mention schemes and hands everything else to the
|
|
24
|
+
* default sanitiser unchanged — the allowlist is the point; widening it is
|
|
25
|
+
* how `javascript:` gets back in.
|
|
26
|
+
* • The component map comes from the existing `MarkdownRenderer`
|
|
27
|
+
* (`markdownComponents`) rather than being rewritten. `MarkdownRenderer`
|
|
28
|
+
* itself takes no component overrides, so a mention-aware surface has to
|
|
29
|
+
* drive `ReactMarkdown` with that same map plus one `a` override; forking
|
|
30
|
+
* the map is what puts two markdown looks in one product. The remark
|
|
31
|
+
* plugin list (`markdownRemarkPlugins` — GFM plus ADR-148 D13's underline)
|
|
32
|
+
* is shared the same way, so the two surfaces cannot parse differently.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { AnchorHTMLAttributes, ImgHTMLAttributes, ReactNode } from 'react'
|
|
36
|
+
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'
|
|
37
|
+
import { markdownComponents, markdownRemarkPlugins } from '../markdown'
|
|
38
|
+
import { cn } from '../lib/utils'
|
|
39
|
+
|
|
40
|
+
const MENTION_SCHEMES = ['user', 'task_item', 'task_board'] as const
|
|
41
|
+
export type MentionTargetType = (typeof MENTION_SCHEMES)[number]
|
|
42
|
+
|
|
43
|
+
export interface MentionTarget {
|
|
44
|
+
readonly type: MentionTargetType
|
|
45
|
+
readonly id: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MENTION_PATTERN = new RegExp(`^(${MENTION_SCHEMES.join('|')}):(.+)$`)
|
|
49
|
+
|
|
50
|
+
/** Narrows without a cast — the scheme list is the allowlist, in one place. */
|
|
51
|
+
function isMentionScheme(value: string): value is MentionTargetType {
|
|
52
|
+
return MENTION_SCHEMES.some((scheme) => scheme === value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse `user:usr_9f2` into a target. Returns null for anything else. */
|
|
56
|
+
export function parseMentionHref(href: string | undefined): MentionTarget | null {
|
|
57
|
+
if (href === undefined) return null
|
|
58
|
+
const match = MENTION_PATTERN.exec(href)
|
|
59
|
+
if (match === null) return null
|
|
60
|
+
const type = match[1]
|
|
61
|
+
const id = match[2]
|
|
62
|
+
if (type === undefined || id === undefined) return null
|
|
63
|
+
if (!isMentionScheme(type)) return null
|
|
64
|
+
return { type, id }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function urlTransform(value: string): string {
|
|
68
|
+
return parseMentionHref(value) === null ? defaultUrlTransform(value) : value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CommentBodyProps {
|
|
72
|
+
readonly bodyMd: string
|
|
73
|
+
/** Called when a mention chip is activated. Omitted renders an inert chip. */
|
|
74
|
+
readonly onSelectMention?: ((target: MentionTarget) => void) | undefined
|
|
75
|
+
readonly className?: string | undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function CommentBody({ bodyMd, onSelectMention, className }: CommentBodyProps) {
|
|
79
|
+
const components = {
|
|
80
|
+
...markdownComponents,
|
|
81
|
+
// Paragraph spacing inside a comment is tighter than the document default
|
|
82
|
+
// the shared map assumes — a comment is a remark, not an article.
|
|
83
|
+
p: ({ children }: { children?: ReactNode }) => (
|
|
84
|
+
<p className="mb-[var(--space-2)] leading-[1.55] last:mb-0" style={{ color: 'rgb(var(--foreground))' }}>
|
|
85
|
+
{children}
|
|
86
|
+
</p>
|
|
87
|
+
),
|
|
88
|
+
// Images are outside the composer's subset — ADR-148 D3: "images-by-markdown
|
|
89
|
+
// out; ADR-151 owns attachments" — but `` is still typeable. It
|
|
90
|
+
// renders as its alt text and nothing else: no `<img>`, so no request to a
|
|
91
|
+
// user-supplied URL and no empty-`src` element when the transform strips a
|
|
92
|
+
// dangerous one. ADR-151: this is the entry to revisit when attachments land.
|
|
93
|
+
img: ({ alt }: ImgHTMLAttributes<HTMLImageElement>) =>
|
|
94
|
+
alt === undefined || alt === '' ? null : <span>{alt}</span>,
|
|
95
|
+
a: ({ href, children }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
|
96
|
+
const mention = parseMentionHref(href)
|
|
97
|
+
if (mention === null) {
|
|
98
|
+
return (
|
|
99
|
+
<a
|
|
100
|
+
href={href}
|
|
101
|
+
className="underline-offset-2 hover:underline"
|
|
102
|
+
style={{ color: 'rgb(var(--accent-ink))' }}
|
|
103
|
+
target="_blank"
|
|
104
|
+
rel="noopener noreferrer"
|
|
105
|
+
>
|
|
106
|
+
{children}
|
|
107
|
+
</a>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
const interactive = onSelectMention !== undefined
|
|
111
|
+
const Tag = interactive ? 'button' : 'span'
|
|
112
|
+
return (
|
|
113
|
+
<Tag
|
|
114
|
+
{...(interactive ? { type: 'button' as const, onClick: () => { onSelectMention(mention) } } : {})}
|
|
115
|
+
className={cn(
|
|
116
|
+
'inline-flex items-center align-baseline text-[12.5px] font-semibold',
|
|
117
|
+
'transition-[background-color,border-color] duration-[var(--dur-fast)] ease-[var(--ease-out)]',
|
|
118
|
+
'motion-reduce:transition-none',
|
|
119
|
+
'border border-[rgb(var(--border))] bg-[rgb(var(--surface-inset))] text-[rgb(var(--accent-ink))]',
|
|
120
|
+
interactive &&
|
|
121
|
+
'hover:border-[rgb(var(--accent))] hover:bg-[rgb(var(--surface-hover))] focus-visible:outline-none focus-visible:[box-shadow:var(--ring-focus)]',
|
|
122
|
+
)}
|
|
123
|
+
style={{
|
|
124
|
+
borderRadius: 'var(--radius-sm)',
|
|
125
|
+
paddingInline: 'var(--space-1)',
|
|
126
|
+
}}
|
|
127
|
+
>
|
|
128
|
+
{children}
|
|
129
|
+
</Tag>
|
|
130
|
+
)
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
className={cn('text-[13px]', className)}
|
|
137
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
138
|
+
>
|
|
139
|
+
<ReactMarkdown
|
|
140
|
+
components={components}
|
|
141
|
+
remarkPlugins={markdownRemarkPlugins}
|
|
142
|
+
urlTransform={urlTransform}
|
|
143
|
+
>
|
|
144
|
+
{bodyMd}
|
|
145
|
+
</ReactMarkdown>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* comment-tree — a flat comment list becomes a reply tree.
|
|
3
|
+
*
|
|
4
|
+
* Ported from `@lovett/community-ui`'s `internal/comment-tree.ts`, with the
|
|
5
|
+
* D4 correction. The shape contract is unchanged and worth restating: a
|
|
6
|
+
* consumer with NO threading data (every `parentId` undefined) gets every
|
|
7
|
+
* comment back as a childless top-level node — the same flat list it put in —
|
|
8
|
+
* and a consumer with real reply-to data gets full nesting, from the same
|
|
9
|
+
* input shape. Nothing has to know in advance which kind of thread it has.
|
|
10
|
+
*
|
|
11
|
+
* WHAT CHANGED FROM THE REFERENCE (ADR-147 D4)
|
|
12
|
+
*
|
|
13
|
+
* The reference re-parents an orphan to top level, unconditionally:
|
|
14
|
+
*
|
|
15
|
+
* // An unmatched parentId (parent not present in this list — deleted, or
|
|
16
|
+
* // simply not fetched) falls back to top-level rather than being dropped.
|
|
17
|
+
*
|
|
18
|
+
* Those two cases are not the same case.
|
|
19
|
+
*
|
|
20
|
+
* • "not fetched" — the parent row is genuinely absent (pagination, a
|
|
21
|
+
* filtered view). Rendering the reply at top level is the least-wrong
|
|
22
|
+
* thing available, but it is a LIE unless it is labelled, so we flag it
|
|
23
|
+
* with `orphanedParentId` and the UI says "in reply to a comment that
|
|
24
|
+
* isn't shown".
|
|
25
|
+
*
|
|
26
|
+
* • "deleted" — the parent is a TOMBSTONE, and a tombstone is a row that is
|
|
27
|
+
* still there (`deleted_at` set, body blanked on read). It matches by id
|
|
28
|
+
* like any other parent, so its children stay nested under it. If deletion
|
|
29
|
+
* ever dropped the row instead, this builder would silently move replies
|
|
30
|
+
* out from under what they were replying to and reshape the conversation.
|
|
31
|
+
*
|
|
32
|
+
* A cycle (a.parent = b, b.parent = a — only reachable through corrupt data,
|
|
33
|
+
* but a stack overflow is not an acceptable response to corrupt data) is
|
|
34
|
+
* treated as the orphan case rather than followed.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { CommentNode, ThreadComment, ThreadSort } from './types'
|
|
38
|
+
|
|
39
|
+
interface MutableNode extends ThreadComment {
|
|
40
|
+
replies: MutableNode[]
|
|
41
|
+
depth: number
|
|
42
|
+
orphanedParentId: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Walk up from `parentId`; report whether the chain reaches `childId`.
|
|
47
|
+
* Bounded by the map size so a pre-existing cycle above us cannot spin.
|
|
48
|
+
*/
|
|
49
|
+
function createsCycle(
|
|
50
|
+
byId: ReadonlyMap<string, MutableNode>,
|
|
51
|
+
parentId: string,
|
|
52
|
+
childId: string,
|
|
53
|
+
): boolean {
|
|
54
|
+
let cursor: string | null = parentId
|
|
55
|
+
let steps = 0
|
|
56
|
+
while (cursor !== null) {
|
|
57
|
+
if (cursor === childId) return true
|
|
58
|
+
// Budget blown: the chain is longer than the whole list, which is only
|
|
59
|
+
// possible if it loops. Treat it as a cycle rather than keep walking.
|
|
60
|
+
if (steps > byId.size) return true
|
|
61
|
+
const node: MutableNode | undefined = byId.get(cursor)
|
|
62
|
+
if (node === undefined) return false
|
|
63
|
+
cursor = node.parentId ?? null
|
|
64
|
+
steps += 1
|
|
65
|
+
}
|
|
66
|
+
// The chain terminated at a real root. No cycle.
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assignDepth(node: MutableNode, depth: number): void {
|
|
71
|
+
node.depth = depth
|
|
72
|
+
for (const reply of node.replies) assignDepth(reply, depth + 1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Group a flat comment list into the reply tree the recursive renderer needs. */
|
|
76
|
+
export function buildCommentTree(
|
|
77
|
+
comments: readonly ThreadComment[],
|
|
78
|
+
): readonly CommentNode[] {
|
|
79
|
+
const byId = new Map<string, MutableNode>()
|
|
80
|
+
for (const comment of comments) {
|
|
81
|
+
byId.set(comment.id, {
|
|
82
|
+
...comment,
|
|
83
|
+
replies: [],
|
|
84
|
+
depth: 0,
|
|
85
|
+
orphanedParentId: null,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const roots: MutableNode[] = []
|
|
90
|
+
for (const comment of comments) {
|
|
91
|
+
const node = byId.get(comment.id)
|
|
92
|
+
if (node === undefined) continue
|
|
93
|
+
|
|
94
|
+
const parentId = comment.parentId ?? null
|
|
95
|
+
if (parentId === null) {
|
|
96
|
+
roots.push(node)
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const parent = byId.get(parentId)
|
|
101
|
+
if (parent === undefined || createsCycle(byId, parentId, comment.id)) {
|
|
102
|
+
// D4: absent parent, or corrupt data. Never dropped, never silently
|
|
103
|
+
// promoted — surfaced at top level AND flagged.
|
|
104
|
+
node.orphanedParentId = parentId
|
|
105
|
+
roots.push(node)
|
|
106
|
+
continue
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
parent.replies.push(node)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const root of roots) assignDepth(root, 0)
|
|
113
|
+
return roots
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Total nodes in the forest, including every nested reply. */
|
|
117
|
+
export function countCommentTree(nodes: readonly CommentNode[]): number {
|
|
118
|
+
return nodes.reduce((n, node) => n + 1 + countCommentTree(node.replies), 0)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Replies below this node, at every depth. Excludes the node itself. */
|
|
122
|
+
export function countDescendants(node: CommentNode): number {
|
|
123
|
+
return countCommentTree(node.replies)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The engagement figure `top` ranks by: the explicit vote score when the
|
|
128
|
+
* surface uses votes, otherwise the sum of reaction counts.
|
|
129
|
+
*/
|
|
130
|
+
export function engagementScore(node: CommentNode): number {
|
|
131
|
+
if (node.score !== undefined) return node.score
|
|
132
|
+
return (node.reactions ?? []).reduce((n, r) => n + r.count, 0)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Undelivered comments sort last under `top`, so a retry stays where it was typed. */
|
|
136
|
+
function isUndelivered(node: CommentNode): boolean {
|
|
137
|
+
return node.state === 'pending' || node.state === 'failed'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function byCreatedAtAsc(a: CommentNode, b: CommentNode): number {
|
|
141
|
+
return a.createdAt - b.createdAt || a.id.localeCompare(b.id)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function byTop(a: CommentNode, b: CommentNode): number {
|
|
145
|
+
return (
|
|
146
|
+
Number(isUndelivered(a)) - Number(isUndelivered(b)) ||
|
|
147
|
+
Number(b.pinned ?? false) - Number(a.pinned ?? false) ||
|
|
148
|
+
engagementScore(b) - engagementScore(a) ||
|
|
149
|
+
byCreatedAtAsc(a, b)
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function byNewest(a: CommentNode, b: CommentNode): number {
|
|
154
|
+
return b.createdAt - a.createdAt || b.id.localeCompare(a.id)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sortReplies(nodes: readonly CommentNode[]): readonly CommentNode[] {
|
|
158
|
+
// Replies are ALWAYS chronological, whichever sort the roots use.
|
|
159
|
+
//
|
|
160
|
+
// A decision ADR-147 did not make, recorded here: Reddit re-sorts every
|
|
161
|
+
// level, which turns a nested exchange into a pile of non-sequiturs under
|
|
162
|
+
// "new" — the reply lands above the line it answers. Roots are a LIST and
|
|
163
|
+
// want ranking; a reply chain is a CONVERSATION and wants order. Ranking
|
|
164
|
+
// therefore applies to roots only.
|
|
165
|
+
return [...nodes]
|
|
166
|
+
.sort(byCreatedAtAsc)
|
|
167
|
+
.map((node) => ({ ...node, replies: sortReplies(node.replies) }))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Sort the roots by the chosen order; every reply level stays chronological.
|
|
172
|
+
* Deterministic: every comparator falls through to a stable id tie-break, so
|
|
173
|
+
* the same input always produces the same order (CLAUDE.md §7).
|
|
174
|
+
*/
|
|
175
|
+
export function sortCommentTree(
|
|
176
|
+
roots: readonly CommentNode[],
|
|
177
|
+
sort: ThreadSort,
|
|
178
|
+
): readonly CommentNode[] {
|
|
179
|
+
return [...roots]
|
|
180
|
+
.sort(sort === 'top' ? byTop : byNewest)
|
|
181
|
+
.map((node) => ({ ...node, replies: sortReplies(node.replies) }))
|
|
182
|
+
}
|