@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.31036673d3f8

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.
Files changed (279) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1139 -329
  3. package/dist-cjs/canvas/anchor-lifecycle.js +148 -0
  4. package/dist-cjs/canvas/anchor-lifecycle.js.map +7 -0
  5. package/dist-cjs/canvas/cluster-input.js +2 -2
  6. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  7. package/dist-cjs/canvas/comment-body.js +1 -1
  8. package/dist-cjs/canvas/comment-body.js.map +2 -2
  9. package/dist-cjs/canvas/comment-drafts.js +54 -0
  10. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  11. package/dist-cjs/canvas/comment-mutations.js +123 -0
  12. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  13. package/dist-cjs/canvas/comment-reactions.js +163 -0
  14. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  15. package/dist-cjs/canvas/comment-render.js +5 -3
  16. package/dist-cjs/canvas/comment-render.js.map +2 -2
  17. package/dist-cjs/canvas/comment-store.js +19 -9
  18. package/dist-cjs/canvas/comment-store.js.map +2 -2
  19. package/dist-cjs/canvas/comment-tool.js +64 -14
  20. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  21. package/dist-cjs/canvas/comments-filter-menu.js +20 -38
  22. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  23. package/dist-cjs/canvas/comments-overflow-menu.js +22 -27
  24. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  25. package/dist-cjs/canvas/comments-overlay.js +429 -474
  26. package/dist-cjs/canvas/comments-overlay.js.map +2 -2
  27. package/dist-cjs/canvas/comments-sidebar.js +35 -32
  28. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  29. package/dist-cjs/canvas/comments-visibility-toggle.js +65 -0
  30. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  31. package/dist-cjs/canvas/context.js +17 -0
  32. package/dist-cjs/canvas/context.js.map +7 -0
  33. package/dist-cjs/canvas/hooks.js +22 -3
  34. package/dist-cjs/canvas/hooks.js.map +2 -2
  35. package/dist-cjs/canvas/options.js +19 -2
  36. package/dist-cjs/canvas/options.js.map +2 -2
  37. package/dist-cjs/canvas/pin-stacking.js +56 -0
  38. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  39. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  40. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  41. package/dist-cjs/canvas/state.js +28 -14
  42. package/dist-cjs/canvas/state.js.map +2 -2
  43. package/dist-cjs/canvas/thread-preview.js +185 -0
  44. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  45. package/dist-cjs/canvas/thread-stack.js +199 -0
  46. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  47. package/dist-cjs/canvas/thread-state.js +67 -21
  48. package/dist-cjs/canvas/thread-state.js.map +2 -2
  49. package/dist-cjs/canvas/thread-view.js +397 -0
  50. package/dist-cjs/canvas/thread-view.js.map +7 -0
  51. package/dist-cjs/clustering/computeClusterTable.js +4 -4
  52. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  53. package/dist-cjs/clustering/runtime.js +119 -7
  54. package/dist-cjs/clustering/runtime.js.map +2 -2
  55. package/dist-cjs/clustering/types.js.map +1 -1
  56. package/dist-cjs/index.d.ts +763 -333
  57. package/dist-cjs/index.js +46 -28
  58. package/dist-cjs/index.js.map +2 -2
  59. package/dist-cjs/ui/byline.js +6 -4
  60. package/dist-cjs/ui/byline.js.map +2 -2
  61. package/dist-cjs/ui/comment-card.js +15 -8
  62. package/dist-cjs/ui/comment-card.js.map +2 -2
  63. package/dist-cjs/ui/comment-composer.js +137 -36
  64. package/dist-cjs/ui/comment-composer.js.map +2 -2
  65. package/dist-cjs/ui/comment-pin.js +24 -2
  66. package/dist-cjs/ui/comment-pin.js.map +2 -2
  67. package/dist-cjs/ui/comment-thread.js +9 -7
  68. package/dist-cjs/ui/comment-thread.js.map +2 -2
  69. package/dist-cjs/ui/comments-list.js +30 -18
  70. package/dist-cjs/ui/comments-list.js.map +2 -2
  71. package/dist-cjs/ui/count-badge.js +3 -2
  72. package/dist-cjs/ui/count-badge.js.map +2 -2
  73. package/dist-cjs/ui/emoji-picker.js +54 -0
  74. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  75. package/dist-cjs/ui/empty-state.js +3 -3
  76. package/dist-cjs/ui/empty-state.js.map +2 -2
  77. package/dist-cjs/ui/format-time.js +4 -1
  78. package/dist-cjs/ui/format-time.js.map +2 -2
  79. package/dist-cjs/ui/reaction-picker.js +73 -0
  80. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  81. package/dist-cjs/ui/reaction.js +59 -6
  82. package/dist-cjs/ui/reaction.js.map +2 -2
  83. package/dist-cjs/ui/reactions.js +20 -7
  84. package/dist-cjs/ui/reactions.js.map +2 -2
  85. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  86. package/dist-cjs/ui/reply-count.js.map +7 -0
  87. package/dist-cjs/ui/send-button.js +20 -1
  88. package/dist-cjs/ui/send-button.js.map +2 -2
  89. package/dist-cjs/ui/{comment-text.js → tooltip-button.js} +12 -9
  90. package/dist-cjs/ui/tooltip-button.js.map +7 -0
  91. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  92. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  93. package/dist-esm/canvas/cluster-input.mjs +2 -2
  94. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  95. package/dist-esm/canvas/comment-body.mjs +1 -1
  96. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  97. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  98. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  99. package/dist-esm/canvas/comment-mutations.mjs +103 -0
  100. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  101. package/dist-esm/canvas/comment-reactions.mjs +152 -0
  102. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  103. package/dist-esm/canvas/comment-render.mjs +5 -3
  104. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  105. package/dist-esm/canvas/comment-store.mjs +19 -9
  106. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  107. package/dist-esm/canvas/comment-tool.mjs +67 -17
  108. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  109. package/dist-esm/canvas/comments-filter-menu.mjs +20 -38
  110. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  111. package/dist-esm/canvas/comments-overflow-menu.mjs +23 -27
  112. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  113. package/dist-esm/canvas/comments-overlay.mjs +450 -479
  114. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  115. package/dist-esm/canvas/comments-sidebar.mjs +38 -35
  116. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  117. package/dist-esm/canvas/comments-visibility-toggle.mjs +45 -0
  118. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  119. package/dist-esm/canvas/context.mjs +1 -0
  120. package/dist-esm/canvas/context.mjs.map +7 -0
  121. package/dist-esm/canvas/hooks.mjs +28 -5
  122. package/dist-esm/canvas/hooks.mjs.map +2 -2
  123. package/dist-esm/canvas/options.mjs +21 -3
  124. package/dist-esm/canvas/options.mjs.map +2 -2
  125. package/dist-esm/canvas/pin-stacking.mjs +36 -0
  126. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  127. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  128. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  129. package/dist-esm/canvas/state.mjs +29 -19
  130. package/dist-esm/canvas/state.mjs.map +2 -2
  131. package/dist-esm/canvas/thread-preview.mjs +170 -0
  132. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  133. package/dist-esm/canvas/thread-stack.mjs +190 -0
  134. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  135. package/dist-esm/canvas/thread-state.mjs +71 -22
  136. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  137. package/dist-esm/canvas/thread-view.mjs +402 -0
  138. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  139. package/dist-esm/clustering/computeClusterTable.mjs +4 -4
  140. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  141. package/dist-esm/clustering/runtime.mjs +119 -7
  142. package/dist-esm/clustering/runtime.mjs.map +2 -2
  143. package/dist-esm/index.d.mts +763 -333
  144. package/dist-esm/index.mjs +87 -41
  145. package/dist-esm/index.mjs.map +2 -2
  146. package/dist-esm/ui/byline.mjs +6 -4
  147. package/dist-esm/ui/byline.mjs.map +2 -2
  148. package/dist-esm/ui/comment-card.mjs +15 -8
  149. package/dist-esm/ui/comment-card.mjs.map +2 -2
  150. package/dist-esm/ui/comment-composer.mjs +148 -36
  151. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  152. package/dist-esm/ui/comment-pin.mjs +24 -2
  153. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  154. package/dist-esm/ui/comment-thread.mjs +9 -7
  155. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  156. package/dist-esm/ui/comments-list.mjs +43 -29
  157. package/dist-esm/ui/comments-list.mjs.map +2 -2
  158. package/dist-esm/ui/count-badge.mjs +3 -2
  159. package/dist-esm/ui/count-badge.mjs.map +2 -2
  160. package/dist-esm/ui/emoji-picker.mjs +34 -0
  161. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  162. package/dist-esm/ui/empty-state.mjs +3 -3
  163. package/dist-esm/ui/empty-state.mjs.map +2 -2
  164. package/dist-esm/ui/format-time.mjs +4 -1
  165. package/dist-esm/ui/format-time.mjs.map +2 -2
  166. package/dist-esm/ui/reaction-picker.mjs +60 -0
  167. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  168. package/dist-esm/ui/reaction.mjs +60 -7
  169. package/dist-esm/ui/reaction.mjs.map +2 -2
  170. package/dist-esm/ui/reactions.mjs +21 -8
  171. package/dist-esm/ui/reactions.mjs.map +2 -2
  172. package/dist-esm/ui/reply-count.mjs +9 -0
  173. package/dist-esm/ui/reply-count.mjs.map +7 -0
  174. package/dist-esm/ui/send-button.mjs +20 -1
  175. package/dist-esm/ui/send-button.mjs.map +2 -2
  176. package/dist-esm/ui/tooltip-button.mjs +12 -0
  177. package/dist-esm/ui/tooltip-button.mjs.map +7 -0
  178. package/package.json +6 -6
  179. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  180. package/src/canvas/anchor-lifecycle.ts +205 -0
  181. package/src/canvas/canvas.css +461 -46
  182. package/src/canvas/cluster-input.test.ts +31 -23
  183. package/src/canvas/cluster-input.ts +3 -4
  184. package/src/canvas/comment-body.tsx +1 -1
  185. package/src/canvas/comment-drafts.test.ts +55 -0
  186. package/src/canvas/comment-drafts.ts +46 -0
  187. package/src/canvas/comment-mutations.test.ts +410 -0
  188. package/src/canvas/comment-mutations.ts +288 -0
  189. package/src/canvas/comment-reactions.test.ts +132 -0
  190. package/src/canvas/comment-reactions.tsx +245 -0
  191. package/src/canvas/comment-render.test.ts +1 -1
  192. package/src/canvas/comment-render.ts +9 -5
  193. package/src/canvas/comment-store.ts +67 -27
  194. package/src/canvas/comment-tool.test.ts +162 -0
  195. package/src/canvas/comment-tool.tsx +105 -23
  196. package/src/canvas/comments-filter-menu.tsx +23 -32
  197. package/src/canvas/comments-overflow-menu.tsx +14 -21
  198. package/src/canvas/comments-overlay.tsx +695 -622
  199. package/src/canvas/comments-sidebar.test.ts +45 -0
  200. package/src/canvas/comments-sidebar.tsx +76 -58
  201. package/src/canvas/comments-visibility-toggle.tsx +49 -0
  202. package/src/canvas/context.ts +47 -0
  203. package/src/canvas/hooks.ts +52 -11
  204. package/src/canvas/options.test.ts +52 -2
  205. package/src/canvas/options.ts +133 -9
  206. package/src/canvas/pin-stacking.test.ts +114 -0
  207. package/src/canvas/pin-stacking.ts +57 -0
  208. package/src/canvas/sidebar-filters.ts +4 -3
  209. package/src/canvas/state.ts +92 -35
  210. package/src/canvas/thread-preview.test.ts +91 -0
  211. package/src/canvas/thread-preview.tsx +304 -0
  212. package/src/canvas/thread-stack.tsx +252 -0
  213. package/src/canvas/thread-state.test.ts +209 -0
  214. package/src/canvas/thread-state.ts +137 -36
  215. package/src/canvas/thread-view.tsx +516 -0
  216. package/src/clustering/computeClusterTable.test.ts +24 -23
  217. package/src/clustering/computeClusterTable.ts +5 -5
  218. package/src/clustering/runtime.test.ts +217 -7
  219. package/src/clustering/runtime.ts +171 -17
  220. package/src/clustering/types.ts +9 -9
  221. package/src/index.ts +76 -40
  222. package/src/ui/byline.tsx +7 -5
  223. package/src/ui/comment-card.tsx +20 -10
  224. package/src/ui/comment-composer.tsx +206 -40
  225. package/src/ui/comment-pin.tsx +26 -2
  226. package/src/ui/comment-thread.tsx +13 -6
  227. package/src/ui/comments-list.test.ts +31 -0
  228. package/src/ui/comments-list.tsx +45 -21
  229. package/src/ui/comments.css +566 -281
  230. package/src/ui/count-badge.tsx +9 -2
  231. package/src/ui/emoji-picker.test.ts +25 -0
  232. package/src/ui/emoji-picker.tsx +76 -0
  233. package/src/ui/empty-state.tsx +3 -3
  234. package/src/ui/format-time.ts +7 -2
  235. package/src/ui/reaction-picker.tsx +109 -0
  236. package/src/ui/reaction.tsx +121 -6
  237. package/src/ui/reactions.tsx +69 -10
  238. package/src/ui/reply-count.ts +16 -0
  239. package/src/ui/send-button.tsx +18 -3
  240. package/src/ui/tooltip-button.tsx +20 -0
  241. package/dist-cjs/canvas/region-options.js +0 -49
  242. package/dist-cjs/canvas/region-options.js.map +0 -7
  243. package/dist-cjs/ui/avatar.js +0 -43
  244. package/dist-cjs/ui/avatar.js.map +0 -7
  245. package/dist-cjs/ui/comment-mention.js +0 -42
  246. package/dist-cjs/ui/comment-mention.js.map +0 -7
  247. package/dist-cjs/ui/comment-text.js.map +0 -7
  248. package/dist-cjs/ui/mention-list.js +0 -70
  249. package/dist-cjs/ui/mention-list.js.map +0 -7
  250. package/dist-cjs/ui/mention-suggestion.js +0 -206
  251. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  252. package/dist-cjs/ui/mention.js.map +0 -7
  253. package/dist-cjs/ui/render-markdown.js +0 -63
  254. package/dist-cjs/ui/render-markdown.js.map +0 -7
  255. package/dist-esm/canvas/region-options.mjs +0 -29
  256. package/dist-esm/canvas/region-options.mjs.map +0 -7
  257. package/dist-esm/ui/avatar.mjs +0 -23
  258. package/dist-esm/ui/avatar.mjs.map +0 -7
  259. package/dist-esm/ui/comment-mention.mjs +0 -22
  260. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  261. package/dist-esm/ui/comment-text.mjs +0 -9
  262. package/dist-esm/ui/comment-text.mjs.map +0 -7
  263. package/dist-esm/ui/mention-list.mjs +0 -50
  264. package/dist-esm/ui/mention-list.mjs.map +0 -7
  265. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  266. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  267. package/dist-esm/ui/mention.mjs +0 -11
  268. package/dist-esm/ui/mention.mjs.map +0 -7
  269. package/dist-esm/ui/render-markdown.mjs +0 -45
  270. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  271. package/src/canvas/region-options.ts +0 -57
  272. package/src/ui/avatar.tsx +0 -31
  273. package/src/ui/comment-mention.ts +0 -47
  274. package/src/ui/comment-text.tsx +0 -12
  275. package/src/ui/mention-list.tsx +0 -106
  276. package/src/ui/mention-suggestion.test.ts +0 -18
  277. package/src/ui/mention-suggestion.tsx +0 -285
  278. package/src/ui/mention.tsx +0 -9
  279. package/src/ui/render-markdown.tsx +0 -72
@@ -4,8 +4,26 @@ import {
4
4
  type TLComment,
5
5
  type TLCommentThread,
6
6
  type TLHistoryBatchOptions,
7
+ type TLShapeId,
8
+ type VecLike,
7
9
  useEditor,
10
+ useValue,
8
11
  } from 'tldraw'
12
+ import { isAllowedReactionEmoji, type EmojiPickerProps } from '../ui/emoji-picker'
13
+ import { type ReactionTooltipProps } from '../ui/reaction'
14
+
15
+ /**
16
+ * The gesture that's creating a shape anchor, passed to
17
+ * {@link CommentingOptions.shouldBePrecise}: the target shape, the page point of the release, and
18
+ * whether Alt was held.
19
+ *
20
+ * @public
21
+ */
22
+ export interface ShapeCommentPrecisionContext {
23
+ readonly shapeId: TLShapeId
24
+ readonly point: VecLike
25
+ readonly altKey: boolean
26
+ }
9
27
 
10
28
  /**
11
29
  * Component overrides for the batteries-included comments layer. Each slot replaces a built-in
@@ -20,12 +38,40 @@ export interface CommentingComponents {
20
38
  PinContent?: ComponentType<{ thread: TLCommentThread; comments: TLComment[] }>
21
39
  /** A sidebar row's preview. Replaces the plaintext default. */
22
40
  ThreadPreview?: ComponentType<{ comment: TLComment }>
41
+ /**
42
+ * A reaction's visual, given its token. The default renders the token string for the OS emoji
43
+ * font to draw (so the token is the emoji glyph). Override this to render your own palette —
44
+ * return an `<img>` for custom emoji, an SVG, or anything. The token is whatever your picker
45
+ * emits and is what gets stored/synced; this only controls how it's drawn.
46
+ */
47
+ ReactionContent?: ComponentType<{ token: string }>
48
+ /**
49
+ * What the add-reaction button opens: the thing that produces a reaction token. Replaces the
50
+ * default `<EmojiPicker>` grid. Pairs with `ReactionContent` (which draws whatever tokens this
51
+ * emits) and with `isAllowedReaction` (which has to accept them).
52
+ */
53
+ ReactionPalette?: ComponentType<EmojiPickerProps>
54
+ /**
55
+ * The hover affordance naming who reacted with an emoji. It receives the reactors and the pill
56
+ * (as `children`) and returns the whole thing — so it owns the tooltip, its box, size, shape, and
57
+ * position. Replaces the default (`DefaultReactionTooltip`). For a simple wording change, translate
58
+ * the `comments.reacted-*` strings instead; reach for this to change the structure — a different
59
+ * box, avatars, a banner anywhere on screen.
60
+ */
61
+ ReactionTooltip?: ComponentType<ReactionTooltipProps>
62
+ /** Shown where a composer would sit when the viewer can't compose (see
63
+ * {@link CommentingOptions.canComment} — a signed-out viewer, a viewer role, a host that
64
+ * turns commenting off). `context` says which surface is rendering it: the bottom of an open
65
+ * thread popover (`'thread'`) or the placement popover the comment tool opens (`'pending'`).
66
+ * Unset, those surfaces render nothing. */
67
+ ComposerFallback?: ComponentType<{ context: 'pending' | 'thread' }>
23
68
  }
24
69
 
25
70
  /**
26
71
  * Configuration for the commenting layer. Static config only — pass it once via
27
72
  * `CommentTool.configure({ ... })`, mirroring `ShapeUtil.configure`. Live, reactive values
28
- * (`currentUserId`, `resolveName`, read-status callbacks) stay as props on `<CanvasComments>`.
73
+ * (`currentUserId`, author resolution, read-status callbacks) are the `CommentingContext`, passed as
74
+ * props to each commenting surface.
29
75
  *
30
76
  * For defaults, see {@link defaultCommentingOptions}.
31
77
  *
@@ -55,16 +101,60 @@ export interface CommentingOptions {
55
101
  // ── Feature toggles ──────────────────────────────────────────────────────────────────────
56
102
  /** Fold nearby pins into count badges as the camera zooms out. */
57
103
  readonly enableClustering: boolean
104
+ /**
105
+ * Whether a user may hold several emoji reactions on one comment. `true` (the default) is the
106
+ * Slack model: each emoji toggles independently. `false` is single-select: picking a new emoji
107
+ * replaces the user's existing reaction. Note this is enforced client-side; the server accepts
108
+ * per-emoji records either way.
109
+ */
110
+ readonly allowMultipleReactions: boolean
111
+ /**
112
+ * Whether a token may be added as a reaction. Defaults to {@link isAllowedReactionEmoji} against
113
+ * the built-in emoji palette, which is what keeps a scripted client from writing junk `emoji`
114
+ * values the picker would never offer. Override it alongside a custom `ReactionPalette` so the
115
+ * tokens that palette emits get through. Removals aren't checked — a reaction carrying an
116
+ * off-palette token must still be clearable.
117
+ */
118
+ isAllowedReaction(token: string): boolean
119
+ /**
120
+ * Whether dragging the comment tool out creates a region anchor — a comment attached to a
121
+ * rectangular area of the page, drawn as a dashed box with the thread's pin on the corner the
122
+ * drag released on. Off by default: comments attach to points and shapes only, and a drag just
123
+ * trails the composer. A region reveals its box while the pointer is inside it, moves by its
124
+ * pin, and resizes from its corners.
125
+ */
126
+ readonly enableRegions: boolean
127
+
128
+ // ── Permissions ──────────────────────────────────────────────────────────────────────────
129
+ /**
130
+ * Whether the viewer may participate in commenting: composing new threads and replies, editing
131
+ * and deleting comments, resolving threads, and moving pins or regions. Composers render when
132
+ * it returns true; when it returns false, the {@link CommentingComponents.ComposerFallback}
133
+ * slot renders in their place (or nothing, if that slot is unset) and the action affordances
134
+ * are hidden. Unset, participation is allowed exactly when `currentUserId` is set.
135
+ *
136
+ * Called during render via {@link useCanComment}, so reactive reads (signals) are tracked.
137
+ * The comment tool itself stays registered and selectable — hosts that want its toolbar button
138
+ * to do something else (e.g. open a sign-in dialog) can override the tool item's `onSelect`.
139
+ * Note posting still requires a `currentUserId` to author the records, so a callback that
140
+ * returns true for a signed-out viewer yields a composer whose send button stays disabled.
141
+ */
142
+ readonly canComment:
143
+ | ((ctx: { editor: Editor; currentUserId: string | null }) => boolean)
144
+ | undefined
58
145
 
59
146
  // ── Anchoring ────────────────────────────────────────────────────────────────────────────
60
147
  /** Normalized (0–1) spot within a shape where imprecise shape pins sit. Default top-right. */
61
148
  readonly impreciseShapeAnchor: { readonly x: number; readonly y: number }
62
-
63
- // ── Clustering tuning ─────────────────────────────────────────────────────────────────────
64
- /** Screen-pixel margin by which the viewport is inflated when culling cluster badges. */
65
- readonly clusterCullMargin: number
66
- /** How far past a cluster's split zoom to land when expanding it (1.05 = 5% overshoot). */
67
- readonly clusterSplitZoomFactor: number
149
+ /**
150
+ * Whether a comment landing on a shape anchors precisely — pinned to the exact clicked spot
151
+ * within the shape or imprecisely pinned to the shape as a whole, rendered at
152
+ * `impreciseShapeAnchor`. Called wherever a shape anchor is created (placing with the comment
153
+ * tool, dropping a dragged pin onto a shape). Always precise by default. Return `false` for
154
+ * shape-level anchoring, or decide from the context — the Alt key's state, or the shape itself,
155
+ * e.g. precise only on notes. Governs new placements only; existing anchors render as stored.
156
+ */
157
+ shouldBePrecise(editor: Editor, context: ShapeCommentPrecisionContext): boolean
68
158
 
69
159
  // ── Components ────────────────────────────────────────────────────────────────────────────
70
160
  /** Component overrides. See {@link CommentingComponents}. */
@@ -80,9 +170,12 @@ export const defaultCommentingOptions = {
80
170
  history: 'ignore',
81
171
  dragHistory: undefined,
82
172
  enableClustering: true,
173
+ allowMultipleReactions: true,
174
+ isAllowedReaction: isAllowedReactionEmoji,
175
+ enableRegions: false,
176
+ canComment: undefined,
83
177
  impreciseShapeAnchor: { x: 1, y: 0 },
84
- clusterCullMargin: 120,
85
- clusterSplitZoomFactor: 1.05,
178
+ shouldBePrecise: () => true,
86
179
  components: {},
87
180
  } as const satisfies CommentingOptions
88
181
 
@@ -109,3 +202,34 @@ export function useCommentingOptions(): CommentingOptions {
109
202
  const editor = useEditor()
110
203
  return useMemo(() => getCommentingOptions(editor), [editor])
111
204
  }
205
+
206
+ /**
207
+ * Whether the viewer may participate in commenting, per {@link CommentingOptions.canComment}
208
+ * (defaulting to `currentUserId != null` when unset). Where this is false, composers give way to
209
+ * the {@link CommentingComponents.ComposerFallback} slot and action affordances are hidden.
210
+ *
211
+ * This is a plain, untracked read — a `canComment` callback that reads signals is not observed.
212
+ * In React, use {@link useCanComment} instead.
213
+ *
214
+ * @public
215
+ */
216
+ export function getCanComment(editor: Editor, currentUserId: string | null | undefined): boolean {
217
+ const { canComment } = getCommentingOptions(editor)
218
+ return canComment
219
+ ? canComment({ editor, currentUserId: currentUserId ?? null })
220
+ : currentUserId != null
221
+ }
222
+
223
+ /**
224
+ * Reactive React hook for {@link getCanComment}: a `canComment` callback that reads signals
225
+ * re-evaluates when they change.
226
+ *
227
+ * @public
228
+ */
229
+ export function useCanComment(currentUserId: string | null | undefined): boolean {
230
+ const editor = useEditor()
231
+ return useValue('can comment', () => getCanComment(editor, currentUserId), [
232
+ editor,
233
+ currentUserId,
234
+ ])
235
+ }
@@ -0,0 +1,114 @@
1
+ import { Box, Mat, type Editor, type TLCommentAnchor, type TLCommentThread } from 'tldraw'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { computePinStacks } from './pin-stacking'
4
+
5
+ const CURRENT_PAGE = 'page:one'
6
+ const OTHER_PAGE = 'page:two'
7
+
8
+ function thread(
9
+ id: string,
10
+ anchor: TLCommentAnchor,
11
+ opts: { pageId?: string; createdAt?: number } = {}
12
+ ): TLCommentThread {
13
+ return {
14
+ id,
15
+ typeName: 'comment-thread',
16
+ pageId: opts.pageId ?? CURRENT_PAGE,
17
+ anchor,
18
+ createdBy: 'user:1',
19
+ createdAt: opts.createdAt ?? 0,
20
+ resolved: null,
21
+ meta: {},
22
+ } as unknown as TLCommentThread
23
+ }
24
+
25
+ /**
26
+ * Unrotated shapes: local geometry sized by the box, placed by a translate-only page transform.
27
+ * No comment tool registered, so the commenting options `anchorPagePoint` reads are the defaults.
28
+ */
29
+ function stubEditor(
30
+ shapes: Record<string, { minX: number; minY: number; maxX: number; maxY: number }> = {}
31
+ ): Editor {
32
+ const shapeId = (shape: string | { id: string }) => (typeof shape === 'string' ? shape : shape.id)
33
+ return {
34
+ getCurrentPageId: () => CURRENT_PAGE,
35
+ getStateDescendant: () => undefined,
36
+ getShape: (id: string) => (shapes[id] ? { id } : undefined),
37
+ getShapeGeometry: (shape: string | { id: string }) => {
38
+ const bounds = shapes[shapeId(shape)]
39
+ return { bounds: new Box(0, 0, bounds.maxX - bounds.minX, bounds.maxY - bounds.minY) }
40
+ },
41
+ getShapePageTransform: (shape: string | { id: string }) => {
42
+ const bounds = shapes[shapeId(shape)]
43
+ return bounds ? Mat.Translate(bounds.minX, bounds.minY) : undefined
44
+ },
45
+ } as unknown as Editor
46
+ }
47
+
48
+ const SHAPE = { 'shape:a': { minX: 0, minY: 0, maxX: 200, maxY: 100 } }
49
+
50
+ function impreciseAnchor(shapeId: string): TLCommentAnchor {
51
+ return { type: 'shape', shapeId, x: 0.2, y: 0.9, isPrecise: false } as TLCommentAnchor
52
+ }
53
+
54
+ describe('computePinStacks', () => {
55
+ it('groups coincident imprecise pins on one shape, oldest first', () => {
56
+ const stacks = computePinStacks(stubEditor(SHAPE), [
57
+ thread('t2', impreciseAnchor('shape:a'), { createdAt: 20 }),
58
+ thread('t1', impreciseAnchor('shape:a'), { createdAt: 10 }),
59
+ ])
60
+ expect(stacks.get('t1')).toEqual(['t1', 't2'])
61
+ expect(stacks.get('t2')).toEqual(['t1', 't2'])
62
+ })
63
+
64
+ it('leaves separated pins ungrouped', () => {
65
+ const stacks = computePinStacks(stubEditor(), [
66
+ thread('t1', { type: 'point', x: 0, y: 0 }),
67
+ thread('t2', { type: 'point', x: 50, y: 0 }),
68
+ ])
69
+ expect(stacks.size).toBe(0)
70
+ })
71
+
72
+ it('groups coincident point anchors', () => {
73
+ const stacks = computePinStacks(stubEditor(), [
74
+ thread('t1', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
75
+ thread('t2', { type: 'point', x: 5, y: 5 }, { createdAt: 2 }),
76
+ thread('t3', { type: 'point', x: 5, y: 5 }, { createdAt: 3 }),
77
+ ])
78
+ expect(stacks.get('t2')).toEqual(['t1', 't2', 't3'])
79
+ })
80
+
81
+ it('breaks creation-time ties by id so ordering is deterministic', () => {
82
+ const stacks = computePinStacks(stubEditor(), [
83
+ thread('t2', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
84
+ thread('t1', { type: 'point', x: 5, y: 5 }, { createdAt: 1 }),
85
+ ])
86
+ expect(stacks.get('t1')).toEqual(['t1', 't2'])
87
+ })
88
+
89
+ it('ignores threads on other pages and unresolvable anchors', () => {
90
+ const stacks = computePinStacks(stubEditor(SHAPE), [
91
+ thread('t1', impreciseAnchor('shape:a')),
92
+ thread('t2', impreciseAnchor('shape:a'), { pageId: OTHER_PAGE }),
93
+ thread('t3', impreciseAnchor('shape:gone')),
94
+ ])
95
+ expect(stacks.size).toBe(0)
96
+ })
97
+
98
+ it('does not group a precise pin sitting away from a coincident pair', () => {
99
+ const precise: TLCommentAnchor = {
100
+ type: 'shape',
101
+ shapeId: 'shape:a',
102
+ x: 0.5,
103
+ y: 0.5,
104
+ isPrecise: true,
105
+ } as TLCommentAnchor
106
+ const stacks = computePinStacks(stubEditor(SHAPE), [
107
+ thread('t1', impreciseAnchor('shape:a'), { createdAt: 1 }),
108
+ thread('t2', impreciseAnchor('shape:a'), { createdAt: 2 }),
109
+ thread('t3', precise),
110
+ ])
111
+ expect(stacks.get('t1')).toEqual(['t1', 't2'])
112
+ expect(stacks.has('t3')).toBe(false)
113
+ })
114
+ })
@@ -0,0 +1,57 @@
1
+ import type { Editor, TLCommentThread } from 'tldraw'
2
+ import { anchorPagePoint } from './thread-state'
3
+
4
+ /** Two anchors within this page-space distance (per axis) share a stack. Identical imprecise
5
+ * anchors on one shape resolve to the same point exactly; the tolerance only absorbs float noise. */
6
+ const PIN_STACK_QUANTUM = 0.1
7
+
8
+ /**
9
+ * The key a page point stacks under. Threads sharing a key are coincident. Stable identity for a
10
+ * stack itself: it survives losing any member (the survivors keep the same key), unlike keying by
11
+ * a particular thread id, so open-stack state stays put when the stack's oldest thread is deleted.
12
+ */
13
+ export function pinStackKey(point: { x: number; y: number }): string {
14
+ return `${Math.round(point.x / PIN_STACK_QUANTUM)}:${Math.round(point.y / PIN_STACK_QUANTUM)}`
15
+ }
16
+
17
+ /**
18
+ * Group threads whose pins land on the same page point — coincident pins (typically several
19
+ * imprecise comments on one shape) that zooming can never separate. The overlay renders each
20
+ * group as a single count-badge pin that opens the threads as a list. Every member id maps to
21
+ * its group's ordered member ids (oldest first); threads without an entry pin individually.
22
+ *
23
+ * Keyed by page-space anchor point, not screen position, so the result only changes when threads
24
+ * or their anchors move — never on camera moves.
25
+ * @internal
26
+ */
27
+ export function computePinStacks(
28
+ editor: Editor,
29
+ threads: readonly TLCommentThread[]
30
+ ): Map<string, readonly string[]> {
31
+ const pageId = editor.getCurrentPageId()
32
+ const groups = new Map<string, TLCommentThread[]>()
33
+
34
+ for (const thread of threads) {
35
+ if (thread.pageId !== pageId) continue
36
+ const point = anchorPagePoint(editor, thread.anchor)
37
+ if (!point) continue
38
+ const key = pinStackKey(point)
39
+ const group = groups.get(key)
40
+ if (group) {
41
+ group.push(thread)
42
+ } else {
43
+ groups.set(key, [thread])
44
+ }
45
+ }
46
+
47
+ const stacks = new Map<string, readonly string[]>()
48
+ for (const group of groups.values()) {
49
+ if (group.length < 2) continue
50
+ group.sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : 1))
51
+ const ids = group.map((thread) => thread.id)
52
+ for (const id of ids) {
53
+ stacks.set(id, ids)
54
+ }
55
+ }
56
+ return stacks
57
+ }
@@ -16,10 +16,11 @@ export interface SidebarFilters {
16
16
  onlyCurrentPage: boolean
17
17
  }
18
18
 
19
- /** @public */
19
+ /** The out-of-the-box view: every page's threads, resolved ones hidden until asked for.
20
+ * @public */
20
21
  export const DEFAULT_SIDEBAR_FILTERS: SidebarFilters = {
21
- showResolved: true,
22
+ showResolved: false,
22
23
  onlyMine: false,
23
24
  onlyUnread: false,
24
- onlyCurrentPage: true,
25
+ onlyCurrentPage: false,
25
26
  }
@@ -1,13 +1,5 @@
1
- import {
2
- EditorAtom,
3
- type BoxModel,
4
- type Editor,
5
- type TLHistoryBatchOptions,
6
- useEditor,
7
- useValue,
8
- } from 'tldraw'
1
+ import { EditorAtom, type BoxModel, type Editor, useEditor, useValue } from 'tldraw'
9
2
  import type { PendingComment } from './comment-tool'
10
- import { getCommentingOptions } from './options'
11
3
  import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
12
4
 
13
5
  /**
@@ -21,10 +13,77 @@ import { DEFAULT_SIDEBAR_FILTERS, type SidebarFilters } from './sidebar-filters'
21
13
  * @public */
22
14
  export const openThreadId = new EditorAtom<string | null>('openThreadId', () => null)
23
15
 
16
+ /** The coincident-pin stack whose thread list is showing (keyed by its oldest member's thread
17
+ * id), or null. Editor state rather than component state: the stack pin remounts when its owning
18
+ * render path changes (e.g. a member thread opens), and the open list must survive that.
19
+ * @internal */
20
+ export const openStackId = new EditorAtom<string | null>('openStackId', () => null)
21
+
24
22
  /** The comment currently being placed (composer open, not yet posted), or null.
25
- * @public */
23
+ * @internal */
26
24
  export const pendingComment = new EditorAtom<PendingComment | null>('pendingComment', () => null)
27
25
 
26
+ /**
27
+ * A pending request to reveal a thread: a thread or comment id to open and bring into view, or
28
+ * null when none is pending. Written by {@link revealThread}; served and cleared by
29
+ * `CanvasComments`, which owns the wait for the records to sync in and the cluster-aware reveal.
30
+ * @internal
31
+ */
32
+ export const revealThreadRequest = new EditorAtom<string | null>('revealThreadRequest', () => null)
33
+
34
+ /**
35
+ * Open a thread and bring it into view, given a thread id or the id of any comment in it. Use it
36
+ * to jump to a thread from outside the canvas — a notification, a deep link, your own list.
37
+ *
38
+ * The request is served by `CanvasComments`, so it works before the records have arrived: the
39
+ * layer waits for them to sync in, switches pages if it needs to, unhides pins, zooms in far
40
+ * enough to split the thread out of any cluster it's folded into, and then opens it. That also
41
+ * means nothing happens if `CanvasComments` isn't mounted.
42
+ *
43
+ * To open a thread you already hold and skip the wait, see {@link focusThread}.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * revealThread(editor, new URLSearchParams(location.search).get('comment')!)
48
+ * ```
49
+ *
50
+ * @public
51
+ */
52
+ export function revealThread(editor: Editor, threadOrCommentId: string): void {
53
+ revealThreadRequest.set(editor, threadOrCommentId)
54
+ }
55
+
56
+ /**
57
+ * The id passed to the most recent {@link revealThread} call that `CanvasComments` hasn't served
58
+ * yet, or null when there's nothing outstanding. A request also clears when `CanvasComments`
59
+ * unmounts, since nothing is left to serve it.
60
+ *
61
+ * This is a plain, untracked read. In React, use {@link useRevealThreadPending} — but reach for
62
+ * this one inside a timer or callback that needs the value as of *now* rather than as of the
63
+ * render it closed over.
64
+ *
65
+ * @public
66
+ */
67
+ export function getRevealThreadPending(editor: Editor): string | null {
68
+ return revealThreadRequest.get(editor)
69
+ }
70
+
71
+ /**
72
+ * Reactive React hook for {@link getRevealThreadPending}.
73
+ *
74
+ * Use it to notice a reveal that never lands — most often a deep link to a comment that has since
75
+ * been deleted. Give it a grace period before you act: a request also sits here while its records
76
+ * are still syncing in, which is the normal case on a cold load. Re-check with
77
+ * {@link getRevealThreadPending} when the grace period elapses, since the request can clear inside
78
+ * it without this hook's value having caught up yet.
79
+ *
80
+ * @public
81
+ */
82
+ export function useRevealThreadPending(): string | null {
83
+ const editor = useEditor()
84
+ return useValue('pending reveal thread', () => getRevealThreadPending(editor), [editor])
85
+ }
86
+
28
87
  /** The region rectangle being dragged out right now (page coords), or null when not dragging. The
29
88
  * comment tool writes it on each move; the overlay reads it to draw the live dashed box. */
30
89
  export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () => null)
@@ -36,6 +95,15 @@ export const regionDraft = new EditorAtom<BoxModel | null>('regionDraft', () =>
36
95
  */
37
96
  export const commentsHidden = new EditorAtom<boolean>('commentsHidden', () => false)
38
97
 
98
+ /**
99
+ * Whether the comments sidebar (the thread list) is open. Driven by an explicit control — a button
100
+ * next to Share on dotcom — rather than by which tool is active, so browsing threads is separate
101
+ * from placing them. The comment tool additionally closes it on enter, keeping placement
102
+ * canvas-focused.
103
+ * @public
104
+ */
105
+ export const commentsSidebarOpen = new EditorAtom<boolean>('commentsSidebarOpen', () => false)
106
+
39
107
  /** Which threads the comments sidebar shows.
40
108
  * @public */
41
109
  export const sidebarFilters = new EditorAtom<SidebarFilters>(
@@ -49,6 +117,12 @@ export function toggleCommentsHidden(editor: Editor): void {
49
117
  commentsHidden.update(editor, (hidden) => !hidden)
50
118
  }
51
119
 
120
+ /** Open or close the comments sidebar for an editor.
121
+ * @public */
122
+ export function toggleCommentsSidebar(editor: Editor): void {
123
+ commentsSidebarOpen.update(editor, (open) => !open)
124
+ }
125
+
52
126
  /** React hook for the open thread id.
53
127
  * @public */
54
128
  export function useOpenThreadId(): string | null {
@@ -57,7 +131,7 @@ export function useOpenThreadId(): string | null {
57
131
  }
58
132
 
59
133
  /** React hook for the pending (being-placed) comment.
60
- * @public */
134
+ * @internal */
61
135
  export function usePendingComment(): PendingComment | null {
62
136
  const editor = useEditor()
63
137
  return useValue('pending comment', () => pendingComment.get(editor), [editor])
@@ -70,33 +144,16 @@ export function useCommentsHidden(): boolean {
70
144
  return useValue('comments hidden', () => commentsHidden.get(editor), [editor])
71
145
  }
72
146
 
147
+ /** React hook for whether the comments sidebar is open.
148
+ * @public */
149
+ export function useCommentsSidebarOpen(): boolean {
150
+ const editor = useEditor()
151
+ return useValue('comments sidebar open', () => commentsSidebarOpen.get(editor), [editor])
152
+ }
153
+
73
154
  /** React hook for the current sidebar filters.
74
155
  * @public */
75
156
  export function useSidebarFilters(): SidebarFilters {
76
157
  const editor = useEditor()
77
158
  return useValue('sidebar filters', () => sidebarFilters.get(editor), [editor])
78
159
  }
79
-
80
- /**
81
- * Commit a comment mutation with the configured undo/redo behavior. All comment writes go through
82
- * here so the {@link CommentingOptions.history} option (and {@link CommentingOptions.dragHistory}
83
- * for pin re-anchors) governs whether they land on the undo stack. Defaults to `'ignore'`.
84
- * @public
85
- */
86
- export function commitCommentMutation<T>(
87
- editor: Editor,
88
- fn: () => T,
89
- kind: 'mutation' | 'drag' = 'mutation'
90
- ): T {
91
- const options = getCommentingOptions(editor)
92
- const history: TLHistoryBatchOptions['history'] =
93
- kind === 'drag' ? (options.dragHistory ?? options.history) : options.history
94
- let result: T
95
- editor.run(
96
- () => {
97
- result = fn()
98
- },
99
- { history }
100
- )
101
- return result!
102
- }
@@ -0,0 +1,91 @@
1
+ import type { TLComment, TLCommentThread } from 'tldraw'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { selectPreviewCards, sortThreadsForPreview } from './thread-preview'
4
+
5
+ function thread(id: string, createdAt: number): TLCommentThread {
6
+ return {
7
+ id,
8
+ typeName: 'comment-thread',
9
+ pageId: 'page:one',
10
+ anchor: { type: 'point', x: 0, y: 0 },
11
+ createdBy: 'user:1',
12
+ createdAt,
13
+ resolved: null,
14
+ meta: {},
15
+ } as unknown as TLCommentThread
16
+ }
17
+
18
+ function comment(threadId: string): TLComment {
19
+ return { id: `comment:${threadId}`, typeName: 'comment', threadId } as unknown as TLComment
20
+ }
21
+
22
+ /** Stands in for the store lookup: only the named threads have their opening comment yet. */
23
+ function arrived(...threadIds: string[]) {
24
+ return (t: TLCommentThread) => (threadIds.includes(t.id) ? comment(t.id) : undefined)
25
+ }
26
+
27
+ describe('sortThreadsForPreview', () => {
28
+ it('orders threads oldest first', () => {
29
+ const sorted = sortThreadsForPreview([thread('t2', 20), thread('t1', 10), thread('t3', 30)])
30
+ expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
31
+ })
32
+
33
+ it('breaks creation-time ties by id, matching the stack ordering', () => {
34
+ const sorted = sortThreadsForPreview([thread('t3', 5), thread('t1', 5), thread('t2', 5)])
35
+ expect(sorted.map((t) => t.id)).toEqual(['t1', 't2', 't3'])
36
+ })
37
+
38
+ // A cluster hands over `node.members`, which the clustering table sorts by id — the preview has
39
+ // to re-sort rather than inherit that order, or a cluster would read newest-first at random.
40
+ it('reorders an id-sorted cluster membership by creation time', () => {
41
+ const sorted = sortThreadsForPreview([thread('t1', 300), thread('t2', 200), thread('t3', 100)])
42
+ expect(sorted.map((t) => t.id)).toEqual(['t3', 't2', 't1'])
43
+ })
44
+
45
+ it('does not mutate the input', () => {
46
+ const input = [thread('t2', 20), thread('t1', 10)]
47
+ sortThreadsForPreview(input)
48
+ expect(input.map((t) => t.id)).toEqual(['t2', 't1'])
49
+ })
50
+ })
51
+
52
+ describe('selectPreviewCards', () => {
53
+ it('pairs each thread with its opening comment', () => {
54
+ const threads = [thread('t1', 10), thread('t2', 20)]
55
+ const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2'))
56
+ expect(cards.map((c) => [c.thread.id, c.first.id])).toEqual([
57
+ ['t1', 'comment:t1'],
58
+ ['t2', 'comment:t2'],
59
+ ])
60
+ expect(overflow).toBe(0)
61
+ })
62
+
63
+ // A thread record can sync ahead of its first comment. It has nothing to show, and rendering it
64
+ // anyway would paint a blank card — or, for a single-pin preview, a blank panel.
65
+ it('drops threads whose opening comment has not arrived', () => {
66
+ const threads = [thread('t1', 10), thread('t2', 20), thread('t3', 30)]
67
+ const { cards } = selectPreviewCards(threads, arrived('t1', 't3'))
68
+ expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't3'])
69
+ })
70
+
71
+ it('reports nothing to show when no comment has arrived', () => {
72
+ const { cards, overflow } = selectPreviewCards([thread('t1', 10)], arrived())
73
+ expect(cards).toEqual([])
74
+ expect(overflow).toBe(0)
75
+ })
76
+
77
+ it('caps the cards and counts the rest as overflow', () => {
78
+ const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
79
+ const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3', 't4'), 2)
80
+ expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
81
+ expect(overflow).toBe(2)
82
+ })
83
+
84
+ // "+N more" should promise N readable threads, so unarrived ones can't pad the count.
85
+ it('excludes unarrived threads from the overflow tally', () => {
86
+ const threads = ['t1', 't2', 't3', 't4'].map((id, i) => thread(id, i))
87
+ const { cards, overflow } = selectPreviewCards(threads, arrived('t1', 't2', 't3'), 2)
88
+ expect(cards.map((c) => c.thread.id)).toEqual(['t1', 't2'])
89
+ expect(overflow).toBe(1)
90
+ })
91
+ })