@tldraw/commenting 0.0.0-bootstrap → 5.3.0-canary.04044ed9e96d

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 (338) hide show
  1. package/README.md +0 -2
  2. package/commenting.css +1152 -350
  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/{ui/avatar.js → canvas/canvas-events.js} +14 -22
  6. package/dist-cjs/canvas/canvas-events.js.map +7 -0
  7. package/dist-cjs/canvas/cluster-badge.js +121 -0
  8. package/dist-cjs/canvas/cluster-badge.js.map +7 -0
  9. package/dist-cjs/canvas/cluster-fade.js +85 -0
  10. package/dist-cjs/canvas/cluster-fade.js.map +7 -0
  11. package/dist-cjs/canvas/cluster-input.js +53 -3
  12. package/dist-cjs/canvas/cluster-input.js.map +2 -2
  13. package/dist-cjs/canvas/cluster-model.js +252 -0
  14. package/dist-cjs/canvas/cluster-model.js.map +7 -0
  15. package/dist-cjs/canvas/comment-body.js +1 -1
  16. package/dist-cjs/canvas/comment-body.js.map +2 -2
  17. package/dist-cjs/canvas/comment-drafts.js +54 -0
  18. package/dist-cjs/canvas/comment-drafts.js.map +7 -0
  19. package/dist-cjs/canvas/comment-mutations.js +152 -0
  20. package/dist-cjs/canvas/comment-mutations.js.map +7 -0
  21. package/dist-cjs/canvas/comment-reactions.js +160 -0
  22. package/dist-cjs/canvas/comment-reactions.js.map +7 -0
  23. package/dist-cjs/canvas/comment-render.js +9 -6
  24. package/dist-cjs/canvas/comment-render.js.map +2 -2
  25. package/dist-cjs/canvas/comment-store.js +19 -9
  26. package/dist-cjs/canvas/comment-store.js.map +2 -2
  27. package/dist-cjs/canvas/comment-tool.js +63 -14
  28. package/dist-cjs/canvas/comment-tool.js.map +2 -2
  29. package/dist-cjs/canvas/comments-filter-menu.js +17 -37
  30. package/dist-cjs/canvas/comments-filter-menu.js.map +2 -2
  31. package/dist-cjs/canvas/comments-overflow-menu.js +25 -22
  32. package/dist-cjs/canvas/comments-overflow-menu.js.map +2 -2
  33. package/dist-cjs/canvas/comments-overlay.js +151 -989
  34. package/dist-cjs/canvas/comments-overlay.js.map +3 -3
  35. package/dist-cjs/canvas/comments-sidebar.js +49 -41
  36. package/dist-cjs/canvas/comments-sidebar.js.map +2 -2
  37. package/dist-cjs/canvas/comments-visibility-toggle.js +45 -0
  38. package/dist-cjs/canvas/comments-visibility-toggle.js.map +7 -0
  39. package/dist-cjs/canvas/context.js +17 -0
  40. package/dist-cjs/canvas/context.js.map +7 -0
  41. package/dist-cjs/canvas/hooks.js +28 -3
  42. package/dist-cjs/canvas/hooks.js.map +2 -2
  43. package/dist-cjs/canvas/license.js.map +1 -1
  44. package/dist-cjs/canvas/mobile-placement.js +91 -0
  45. package/dist-cjs/canvas/mobile-placement.js.map +7 -0
  46. package/dist-cjs/canvas/options.js +57 -2
  47. package/dist-cjs/canvas/options.js.map +2 -2
  48. package/dist-cjs/canvas/pending-composer.js +134 -0
  49. package/dist-cjs/canvas/pending-composer.js.map +7 -0
  50. package/dist-cjs/canvas/pin-stacking.js +81 -0
  51. package/dist-cjs/canvas/pin-stacking.js.map +7 -0
  52. package/dist-cjs/canvas/region-box.js +113 -0
  53. package/dist-cjs/canvas/region-box.js.map +7 -0
  54. package/dist-cjs/canvas/sidebar-filters.js +2 -2
  55. package/dist-cjs/canvas/sidebar-filters.js.map +2 -2
  56. package/dist-cjs/canvas/state.js +28 -14
  57. package/dist-cjs/canvas/state.js.map +2 -2
  58. package/dist-cjs/canvas/thread-pin.js +304 -0
  59. package/dist-cjs/canvas/thread-pin.js.map +7 -0
  60. package/dist-cjs/canvas/thread-preview.js +177 -0
  61. package/dist-cjs/canvas/thread-preview.js.map +7 -0
  62. package/dist-cjs/canvas/thread-stack.js +199 -0
  63. package/dist-cjs/canvas/thread-stack.js.map +7 -0
  64. package/dist-cjs/canvas/thread-state.js +82 -21
  65. package/dist-cjs/canvas/thread-state.js.map +2 -2
  66. package/dist-cjs/canvas/thread-view.js +450 -0
  67. package/dist-cjs/canvas/thread-view.js.map +7 -0
  68. package/dist-cjs/clustering/computeClusterTable.js +6 -6
  69. package/dist-cjs/clustering/computeClusterTable.js.map +2 -2
  70. package/dist-cjs/clustering/replay.js +89 -22
  71. package/dist-cjs/clustering/replay.js.map +3 -3
  72. package/dist-cjs/clustering/runtime.js +126 -7
  73. package/dist-cjs/clustering/runtime.js.map +2 -2
  74. package/dist-cjs/clustering/types.js.map +1 -1
  75. package/dist-cjs/index.d.ts +904 -353
  76. package/dist-cjs/index.js +52 -28
  77. package/dist-cjs/index.js.map +2 -2
  78. package/dist-cjs/ui/byline.js +10 -6
  79. package/dist-cjs/ui/byline.js.map +2 -2
  80. package/dist-cjs/ui/comment-card.js +15 -8
  81. package/dist-cjs/ui/comment-card.js.map +2 -2
  82. package/dist-cjs/ui/comment-composer.js +124 -39
  83. package/dist-cjs/ui/comment-composer.js.map +2 -2
  84. package/dist-cjs/ui/comment-pin.js +11 -2
  85. package/dist-cjs/ui/comment-pin.js.map +2 -2
  86. package/dist-cjs/ui/comment-thread.js +9 -7
  87. package/dist-cjs/ui/comment-thread.js.map +2 -2
  88. package/dist-cjs/ui/comments-list.js +39 -40
  89. package/dist-cjs/ui/comments-list.js.map +2 -2
  90. package/dist-cjs/ui/count-badge.js +3 -2
  91. package/dist-cjs/ui/count-badge.js.map +2 -2
  92. package/dist-cjs/ui/emoji-picker.js +54 -0
  93. package/dist-cjs/ui/emoji-picker.js.map +7 -0
  94. package/dist-cjs/ui/empty-state.js +3 -3
  95. package/dist-cjs/ui/empty-state.js.map +2 -2
  96. package/dist-cjs/ui/format-time.js +16 -1
  97. package/dist-cjs/ui/format-time.js.map +2 -2
  98. package/dist-cjs/ui/icons.js +115 -0
  99. package/dist-cjs/ui/icons.js.map +7 -0
  100. package/dist-cjs/ui/reaction-picker.js +71 -0
  101. package/dist-cjs/ui/reaction-picker.js.map +7 -0
  102. package/dist-cjs/ui/reaction.js +58 -6
  103. package/dist-cjs/ui/reaction.js.map +2 -2
  104. package/dist-cjs/ui/reactions.js +20 -7
  105. package/dist-cjs/ui/reactions.js.map +2 -2
  106. package/dist-cjs/ui/{mention.js → reply-count.js} +9 -11
  107. package/dist-cjs/ui/reply-count.js.map +7 -0
  108. package/dist-cjs/ui/send-button.js +13 -1
  109. package/dist-cjs/ui/send-button.js.map +2 -2
  110. package/dist-cjs/ui/{comment-text.js → visual-viewport.js} +12 -9
  111. package/dist-cjs/ui/visual-viewport.js.map +7 -0
  112. package/dist-esm/canvas/anchor-lifecycle.mjs +128 -0
  113. package/dist-esm/canvas/anchor-lifecycle.mjs.map +7 -0
  114. package/dist-esm/canvas/canvas-events.mjs +15 -0
  115. package/dist-esm/canvas/canvas-events.mjs.map +7 -0
  116. package/dist-esm/canvas/cluster-badge.mjs +106 -0
  117. package/dist-esm/canvas/cluster-badge.mjs.map +7 -0
  118. package/dist-esm/canvas/cluster-fade.mjs +65 -0
  119. package/dist-esm/canvas/cluster-fade.mjs.map +7 -0
  120. package/dist-esm/canvas/cluster-input.mjs +54 -4
  121. package/dist-esm/canvas/cluster-input.mjs.map +2 -2
  122. package/dist-esm/canvas/cluster-model.mjs +236 -0
  123. package/dist-esm/canvas/cluster-model.mjs.map +7 -0
  124. package/dist-esm/canvas/comment-body.mjs +1 -1
  125. package/dist-esm/canvas/comment-body.mjs.map +2 -2
  126. package/dist-esm/canvas/comment-drafts.mjs +34 -0
  127. package/dist-esm/canvas/comment-drafts.mjs.map +7 -0
  128. package/dist-esm/canvas/comment-mutations.mjs +132 -0
  129. package/dist-esm/canvas/comment-mutations.mjs.map +7 -0
  130. package/dist-esm/canvas/comment-reactions.mjs +145 -0
  131. package/dist-esm/canvas/comment-reactions.mjs.map +7 -0
  132. package/dist-esm/canvas/comment-render.mjs +10 -7
  133. package/dist-esm/canvas/comment-render.mjs.map +2 -2
  134. package/dist-esm/canvas/comment-store.mjs +19 -9
  135. package/dist-esm/canvas/comment-store.mjs.map +2 -2
  136. package/dist-esm/canvas/comment-tool.mjs +66 -17
  137. package/dist-esm/canvas/comment-tool.mjs.map +2 -2
  138. package/dist-esm/canvas/comments-filter-menu.mjs +17 -36
  139. package/dist-esm/canvas/comments-filter-menu.mjs.map +2 -2
  140. package/dist-esm/canvas/comments-overflow-menu.mjs +27 -22
  141. package/dist-esm/canvas/comments-overflow-menu.mjs.map +2 -2
  142. package/dist-esm/canvas/comments-overlay.mjs +157 -1010
  143. package/dist-esm/canvas/comments-overlay.mjs.map +3 -3
  144. package/dist-esm/canvas/comments-sidebar.mjs +53 -46
  145. package/dist-esm/canvas/comments-sidebar.mjs.map +2 -2
  146. package/dist-esm/canvas/comments-visibility-toggle.mjs +25 -0
  147. package/dist-esm/canvas/comments-visibility-toggle.mjs.map +7 -0
  148. package/dist-esm/canvas/context.mjs +1 -0
  149. package/dist-esm/canvas/context.mjs.map +7 -0
  150. package/dist-esm/canvas/hooks.mjs +35 -5
  151. package/dist-esm/canvas/hooks.mjs.map +2 -2
  152. package/dist-esm/canvas/license.mjs.map +1 -1
  153. package/dist-esm/canvas/mobile-placement.mjs +71 -0
  154. package/dist-esm/canvas/mobile-placement.mjs.map +7 -0
  155. package/dist-esm/canvas/options.mjs +59 -3
  156. package/dist-esm/canvas/options.mjs.map +2 -2
  157. package/dist-esm/canvas/pending-composer.mjs +126 -0
  158. package/dist-esm/canvas/pending-composer.mjs.map +7 -0
  159. package/dist-esm/canvas/pin-stacking.mjs +61 -0
  160. package/dist-esm/canvas/pin-stacking.mjs.map +7 -0
  161. package/dist-esm/canvas/region-box.mjs +93 -0
  162. package/dist-esm/canvas/region-box.mjs.map +7 -0
  163. package/dist-esm/canvas/sidebar-filters.mjs +2 -2
  164. package/dist-esm/canvas/sidebar-filters.mjs.map +2 -2
  165. package/dist-esm/canvas/state.mjs +29 -19
  166. package/dist-esm/canvas/state.mjs.map +2 -2
  167. package/dist-esm/canvas/thread-pin.mjs +310 -0
  168. package/dist-esm/canvas/thread-pin.mjs.map +7 -0
  169. package/dist-esm/canvas/thread-preview.mjs +163 -0
  170. package/dist-esm/canvas/thread-preview.mjs.map +7 -0
  171. package/dist-esm/canvas/thread-stack.mjs +189 -0
  172. package/dist-esm/canvas/thread-stack.mjs.map +7 -0
  173. package/dist-esm/canvas/thread-state.mjs +86 -22
  174. package/dist-esm/canvas/thread-state.mjs.map +2 -2
  175. package/dist-esm/canvas/thread-view.mjs +461 -0
  176. package/dist-esm/canvas/thread-view.mjs.map +7 -0
  177. package/dist-esm/clustering/computeClusterTable.mjs +6 -6
  178. package/dist-esm/clustering/computeClusterTable.mjs.map +2 -2
  179. package/dist-esm/clustering/replay.mjs +89 -22
  180. package/dist-esm/clustering/replay.mjs.map +3 -3
  181. package/dist-esm/clustering/runtime.mjs +126 -7
  182. package/dist-esm/clustering/runtime.mjs.map +2 -2
  183. package/dist-esm/index.d.mts +904 -353
  184. package/dist-esm/index.mjs +102 -43
  185. package/dist-esm/index.mjs.map +2 -2
  186. package/dist-esm/ui/byline.mjs +11 -7
  187. package/dist-esm/ui/byline.mjs.map +2 -2
  188. package/dist-esm/ui/comment-card.mjs +15 -8
  189. package/dist-esm/ui/comment-card.mjs.map +2 -2
  190. package/dist-esm/ui/comment-composer.mjs +135 -39
  191. package/dist-esm/ui/comment-composer.mjs.map +2 -2
  192. package/dist-esm/ui/comment-pin.mjs +11 -2
  193. package/dist-esm/ui/comment-pin.mjs.map +2 -2
  194. package/dist-esm/ui/comment-thread.mjs +9 -7
  195. package/dist-esm/ui/comment-thread.mjs.map +2 -2
  196. package/dist-esm/ui/comments-list.mjs +51 -50
  197. package/dist-esm/ui/comments-list.mjs.map +2 -2
  198. package/dist-esm/ui/count-badge.mjs +3 -2
  199. package/dist-esm/ui/count-badge.mjs.map +2 -2
  200. package/dist-esm/ui/emoji-picker.mjs +34 -0
  201. package/dist-esm/ui/emoji-picker.mjs.map +7 -0
  202. package/dist-esm/ui/empty-state.mjs +3 -3
  203. package/dist-esm/ui/empty-state.mjs.map +2 -2
  204. package/dist-esm/ui/format-time.mjs +16 -1
  205. package/dist-esm/ui/format-time.mjs.map +2 -2
  206. package/dist-esm/ui/icons.mjs +95 -0
  207. package/dist-esm/ui/icons.mjs.map +7 -0
  208. package/dist-esm/ui/reaction-picker.mjs +59 -0
  209. package/dist-esm/ui/reaction-picker.mjs.map +7 -0
  210. package/dist-esm/ui/reaction.mjs +59 -7
  211. package/dist-esm/ui/reaction.mjs.map +2 -2
  212. package/dist-esm/ui/reactions.mjs +21 -8
  213. package/dist-esm/ui/reactions.mjs.map +2 -2
  214. package/dist-esm/ui/reply-count.mjs +9 -0
  215. package/dist-esm/ui/reply-count.mjs.map +7 -0
  216. package/dist-esm/ui/send-button.mjs +13 -1
  217. package/dist-esm/ui/send-button.mjs.map +2 -2
  218. package/dist-esm/ui/visual-viewport.mjs +12 -0
  219. package/dist-esm/ui/visual-viewport.mjs.map +7 -0
  220. package/package.json +6 -6
  221. package/src/canvas/anchor-lifecycle.test.ts +290 -0
  222. package/src/canvas/anchor-lifecycle.ts +191 -0
  223. package/src/canvas/canvas-events.ts +18 -0
  224. package/src/canvas/canvas.css +464 -55
  225. package/src/canvas/cluster-badge.tsx +133 -0
  226. package/src/canvas/cluster-fade.ts +102 -0
  227. package/src/canvas/cluster-input.test.ts +178 -36
  228. package/src/canvas/cluster-input.ts +95 -9
  229. package/src/canvas/cluster-model.ts +378 -0
  230. package/src/canvas/comment-body.tsx +1 -1
  231. package/src/canvas/comment-drafts.test.ts +55 -0
  232. package/src/canvas/comment-drafts.ts +46 -0
  233. package/src/canvas/comment-mutations.test.ts +452 -0
  234. package/src/canvas/comment-mutations.ts +299 -0
  235. package/src/canvas/comment-reactions.test.ts +154 -0
  236. package/src/canvas/comment-reactions.tsx +246 -0
  237. package/src/canvas/comment-render.test.ts +1 -1
  238. package/src/canvas/comment-render.ts +17 -13
  239. package/src/canvas/comment-store.ts +68 -33
  240. package/src/canvas/comment-tool.test.ts +162 -0
  241. package/src/canvas/comment-tool.tsx +103 -25
  242. package/src/canvas/comments-filter-menu.tsx +20 -36
  243. package/src/canvas/comments-overflow-menu.tsx +17 -19
  244. package/src/canvas/comments-overlay.tsx +252 -1369
  245. package/src/canvas/comments-sidebar.test.ts +45 -0
  246. package/src/canvas/comments-sidebar.tsx +112 -73
  247. package/src/canvas/comments-visibility-toggle.tsx +25 -0
  248. package/src/canvas/context.ts +48 -0
  249. package/src/canvas/hooks.test.ts +94 -0
  250. package/src/canvas/hooks.ts +66 -11
  251. package/src/canvas/license.ts +1 -1
  252. package/src/canvas/mobile-placement.ts +115 -0
  253. package/src/canvas/options.test.ts +176 -4
  254. package/src/canvas/options.ts +276 -18
  255. package/src/canvas/pending-composer.tsx +157 -0
  256. package/src/canvas/pin-stacking.test.ts +224 -0
  257. package/src/canvas/pin-stacking.ts +103 -0
  258. package/src/canvas/region-box.tsx +124 -0
  259. package/src/canvas/sidebar-filters.ts +4 -3
  260. package/src/canvas/state.ts +88 -39
  261. package/src/canvas/thread-pin.tsx +418 -0
  262. package/src/canvas/thread-preview.test.ts +91 -0
  263. package/src/canvas/thread-preview.tsx +294 -0
  264. package/src/canvas/thread-stack.tsx +242 -0
  265. package/src/canvas/thread-state.test.ts +260 -0
  266. package/src/canvas/thread-state.ts +171 -37
  267. package/src/canvas/thread-view.test.ts +72 -0
  268. package/src/canvas/thread-view.tsx +614 -0
  269. package/src/clustering/computeClusterTable.test.ts +24 -23
  270. package/src/clustering/computeClusterTable.ts +17 -8
  271. package/src/clustering/replay.test.ts +0 -7
  272. package/src/clustering/replay.ts +131 -32
  273. package/src/clustering/runtime.test.ts +267 -13
  274. package/src/clustering/runtime.ts +176 -19
  275. package/src/clustering/schedule.test.ts +0 -6
  276. package/src/clustering/screen-offsets.test.ts +171 -0
  277. package/src/clustering/types.ts +19 -9
  278. package/src/index.ts +91 -42
  279. package/src/ui/byline.tsx +21 -9
  280. package/src/ui/comment-card.tsx +20 -10
  281. package/src/ui/comment-composer.tsx +178 -56
  282. package/src/ui/comment-pin.tsx +13 -2
  283. package/src/ui/comment-thread.tsx +13 -6
  284. package/src/ui/comments-list.test.ts +31 -0
  285. package/src/ui/comments-list.tsx +82 -47
  286. package/src/ui/comments.css +577 -294
  287. package/src/ui/count-badge.tsx +9 -2
  288. package/src/ui/emoji-picker.test.ts +25 -0
  289. package/src/ui/emoji-picker.tsx +76 -0
  290. package/src/ui/empty-state.tsx +3 -3
  291. package/src/ui/format-time.test.ts +69 -0
  292. package/src/ui/format-time.ts +27 -2
  293. package/src/ui/icons.tsx +116 -0
  294. package/src/ui/reaction-picker.tsx +102 -0
  295. package/src/ui/reaction.tsx +125 -6
  296. package/src/ui/reactions.tsx +69 -10
  297. package/src/ui/reply-count.ts +16 -0
  298. package/src/ui/send-button.tsx +13 -3
  299. package/src/ui/visual-viewport.test.ts +36 -0
  300. package/src/ui/visual-viewport.ts +27 -0
  301. package/dist-cjs/canvas/region-options.js +0 -49
  302. package/dist-cjs/canvas/region-options.js.map +0 -7
  303. package/dist-cjs/ui/avatar.js.map +0 -7
  304. package/dist-cjs/ui/comment-mention.js +0 -42
  305. package/dist-cjs/ui/comment-mention.js.map +0 -7
  306. package/dist-cjs/ui/comment-text.js.map +0 -7
  307. package/dist-cjs/ui/mention-list.js +0 -70
  308. package/dist-cjs/ui/mention-list.js.map +0 -7
  309. package/dist-cjs/ui/mention-suggestion.js +0 -206
  310. package/dist-cjs/ui/mention-suggestion.js.map +0 -7
  311. package/dist-cjs/ui/mention.js.map +0 -7
  312. package/dist-cjs/ui/render-markdown.js +0 -63
  313. package/dist-cjs/ui/render-markdown.js.map +0 -7
  314. package/dist-esm/canvas/region-options.mjs +0 -29
  315. package/dist-esm/canvas/region-options.mjs.map +0 -7
  316. package/dist-esm/ui/avatar.mjs +0 -23
  317. package/dist-esm/ui/avatar.mjs.map +0 -7
  318. package/dist-esm/ui/comment-mention.mjs +0 -22
  319. package/dist-esm/ui/comment-mention.mjs.map +0 -7
  320. package/dist-esm/ui/comment-text.mjs +0 -9
  321. package/dist-esm/ui/comment-text.mjs.map +0 -7
  322. package/dist-esm/ui/mention-list.mjs +0 -50
  323. package/dist-esm/ui/mention-list.mjs.map +0 -7
  324. package/dist-esm/ui/mention-suggestion.mjs +0 -186
  325. package/dist-esm/ui/mention-suggestion.mjs.map +0 -7
  326. package/dist-esm/ui/mention.mjs +0 -11
  327. package/dist-esm/ui/mention.mjs.map +0 -7
  328. package/dist-esm/ui/render-markdown.mjs +0 -45
  329. package/dist-esm/ui/render-markdown.mjs.map +0 -7
  330. package/src/canvas/region-options.ts +0 -57
  331. package/src/ui/avatar.tsx +0 -31
  332. package/src/ui/comment-mention.ts +0 -47
  333. package/src/ui/comment-text.tsx +0 -12
  334. package/src/ui/mention-list.tsx +0 -106
  335. package/src/ui/mention-suggestion.test.ts +0 -18
  336. package/src/ui/mention-suggestion.tsx +0 -285
  337. package/src/ui/mention.tsx +0 -9
  338. package/src/ui/render-markdown.tsx +0 -72
@@ -0,0 +1,614 @@
1
+ import { memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import {
3
+ createComment,
4
+ Editor,
5
+ EditorPortal,
6
+ TLComment,
7
+ TLCommentThread,
8
+ TLRichText,
9
+ TldrawUiButton,
10
+ TldrawUiDropdownMenuContent,
11
+ TldrawUiDropdownMenuGroup,
12
+ TldrawUiDropdownMenuItem,
13
+ TldrawUiDropdownMenuRoot,
14
+ TldrawUiDropdownMenuTrigger,
15
+ TldrawUiIcon,
16
+ useContainer,
17
+ usePassThroughWheelEvents,
18
+ useTranslation,
19
+ useValue,
20
+ } from 'tldraw'
21
+ import { CommentCard, CommentCardProps } from '../ui/comment-card'
22
+ import { CommentComposer } from '../ui/comment-composer'
23
+ import { EMPTY_COMMENT, isCommentEmpty } from '../ui/comment-extensions'
24
+ import { CommentThread } from '../ui/comment-thread'
25
+ import { CommentBody } from './comment-body'
26
+ import {
27
+ clearCommentDraft,
28
+ getCommentDraft,
29
+ replyDraftSlot,
30
+ saveCommentDraft,
31
+ } from './comment-drafts'
32
+ import {
33
+ commitCommentMutation,
34
+ deleteComment,
35
+ deleteThread,
36
+ editComment,
37
+ reopenThread,
38
+ resolveThread,
39
+ } from './comment-mutations'
40
+ import { CommentReactionPicker, CommentReactions } from './comment-reactions'
41
+ import { UNKNOWN_AUTHOR, UNKNOWN_COMMENT_AUTHOR } from './comment-render'
42
+ import { type CommentingContext } from './context'
43
+ import { useThreadComments } from './hooks'
44
+ import { useIsMobileCommenting, useMobilePlacement } from './mobile-placement'
45
+ import {
46
+ type CommentingComponents,
47
+ getCanModifyComment,
48
+ useCanComment,
49
+ useCanModifyComment,
50
+ useCommentingOptions,
51
+ } from './options'
52
+ import { openThreadId } from './state'
53
+
54
+ const stop = (e: { stopPropagation(): void }) => e.stopPropagation()
55
+
56
+ /**
57
+ * A name-only view of an author resolver, for the mention/rich-text paths. Stable identity, so
58
+ * `CommentBody`'s memoized render doesn't recompute on every render of its host.
59
+ */
60
+ export function useResolveName(resolveAuthor: CommentingContext['resolveAuthor']) {
61
+ return useCallback((id: string) => resolveAuthor(id)?.name, [resolveAuthor])
62
+ }
63
+
64
+ /** How long the copy-link item reads "Link copied" before reverting. */
65
+ const LINK_COPIED_MS = 2000
66
+
67
+ /**
68
+ * What a thread's `getThreadHref` should put on the clipboard. Hosts may hand back a relative href,
69
+ * which is meaningless once pasted elsewhere, so it's resolved against the current document first.
70
+ * An href the URL parser can't make sense of is copied as-is rather than dropped.
71
+ *
72
+ * @internal
73
+ */
74
+ export function absoluteThreadLink(href: string, base = window.location.href): string {
75
+ try {
76
+ return new URL(href, base).toString()
77
+ } catch {
78
+ return href
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Copy a thread's link, with a flag that stays set briefly afterwards so the control can confirm.
84
+ * Only set once the write resolves, so a withheld clipboard doesn't claim a copy that didn't happen.
85
+ */
86
+ function useCopyLink(href: string | undefined) {
87
+ const [copied, setCopied] = useState(false)
88
+ useEffect(() => {
89
+ if (!copied) return
90
+ const timeout = window.setTimeout(() => setCopied(false), LINK_COPIED_MS)
91
+ return () => window.clearTimeout(timeout)
92
+ }, [copied])
93
+ const copy = useCallback(() => {
94
+ if (href === undefined) return
95
+ navigator.clipboard?.writeText(absoluteThreadLink(href)).then(
96
+ () => setCopied(true),
97
+ () => setCopied(false)
98
+ )
99
+ }, [href])
100
+ return [copied, copy] as const
101
+ }
102
+
103
+ export function toCardProps(
104
+ comment: TLComment,
105
+ props: Pick<CommentingContext, 'currentUserId' | 'resolveAuthor'>,
106
+ components: CommentingComponents,
107
+ resolveName: (id: string) => string | undefined
108
+ ): CommentCardProps {
109
+ const Body = components.CommentBody
110
+ // The `CommentBody` component slot overrides the built-in rich-text default (which resolves
111
+ // mention ids to names).
112
+ const body = Body ? (
113
+ <Body comment={comment} />
114
+ ) : (
115
+ <CommentBody richText={comment.body} resolveName={resolveName} />
116
+ )
117
+ return {
118
+ author: props.resolveAuthor(comment.authorId) ?? UNKNOWN_COMMENT_AUTHOR,
119
+ body,
120
+ date: new Date(comment.createdAt).toISOString(),
121
+ you: comment.authorId === props.currentUserId,
122
+ edited: comment.editedAt != null,
123
+ }
124
+ }
125
+
126
+ /** A pin is this square (mirrors `--tlui-cmt-pin-size`). The two marker kinds differ in size *and*
127
+ * anchor point, so lining their previews up means correcting for both. Keep in sync with the CSS. */
128
+ const PIN_SIZE = 28
129
+
130
+ /** A cluster/stack badge is this square (`--tlui-cmt-marker-size`) — pin-sized, so the marker
131
+ * kinds read as one family. Keep in sync with the stylesheet. */
132
+ const MARKER_SIZE = PIN_SIZE
133
+
134
+ /** Where a popover's top card sits vertically, measured from the marker visual's middle. */
135
+ const CARD_TOP_Y = -28
136
+
137
+ /** The horizontal gap between a marker visual's right edge and its popover/preview panel. The
138
+ * preview's hover bridge spans exactly this (see `--tlui-cmt-preview-bridge`). */
139
+ const PREVIEW_GAP = 6
140
+
141
+ /**
142
+ * Where a marker's popover sits relative to the marker's anchor point. The hover preview uses the
143
+ * same origins, so moving a popover here moves its preview with it.
144
+ *
145
+ * Both marker kinds hang off their point the same way (`translate(0, -100%)`), differing only in
146
+ * size — so each offset clears its own width plus the shared gap, and re-bases the shared card-top
147
+ * measure from its bottom anchor to its middle.
148
+ */
149
+ export const POPOVER_OFFSET = {
150
+ thread: { x: PIN_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - PIN_SIZE / 2 },
151
+ list: { x: MARKER_SIZE + PREVIEW_GAP, y: CARD_TOP_Y - MARKER_SIZE / 2 },
152
+ } as const
153
+
154
+ /** The open thread's popover container, portaled above the UI panels. A wheel over it passes
155
+ * through to the canvas (unless it scrolls its own content), like tldraw's panels. */
156
+ export function ThreadPopover({
157
+ base,
158
+ children,
159
+ }: {
160
+ base: { x: number; y: number }
161
+ children: ReactNode
162
+ }) {
163
+ const ref = useRef<HTMLDivElement>(null)
164
+ usePassThroughWheelEvents(ref)
165
+ // On mobile the popover slides off its fixed offset to stay above the software keyboard and
166
+ // on-screen; desktop keeps the fixed offset (base returned unchanged).
167
+ const isMobile = useIsMobileCommenting()
168
+ const placed = useMobilePlacement(ref, base, isMobile)
169
+ return (
170
+ <EditorPortal>
171
+ {/* contextmenu also stops here: portals bubble React events to the canvas's context-menu
172
+ trigger (the layer mounts inside it), which would open the canvas menu over this panel. */}
173
+ <div
174
+ ref={ref}
175
+ className="tlui-cmt-canvas-popover"
176
+ style={{ left: placed.left, top: placed.top }}
177
+ onPointerDown={stop}
178
+ onContextMenu={stop}
179
+ >
180
+ {children}
181
+ </div>
182
+ </EditorPortal>
183
+ )
184
+ }
185
+
186
+ /**
187
+ * One thread's interactive view: its comments, the reply composer, edit-in-place on your own
188
+ * comments, and the resolve/delete actions. Read receipts are reported for every unread comment
189
+ * while mounted, so only mount it where the thread is actually being shown.
190
+ *
191
+ * Memoized because the popover position rides the pin's per-frame render point, so the pin
192
+ * re-renders on every camera frame while a thread is open.
193
+ */
194
+ export const ThreadView = memo(function ThreadView({
195
+ editor,
196
+ thread,
197
+ ...props
198
+ }: CommentingContext & { editor: Editor; thread: TLCommentThread }) {
199
+ const {
200
+ currentUserId,
201
+ resolveAuthor,
202
+ onPostComment,
203
+ isCommentUnread,
204
+ onCommentsRead,
205
+ getMentionSuggestions,
206
+ renderMentionSuggestion,
207
+ getThreadHref,
208
+ } = props
209
+ const options = useCommentingOptions()
210
+ const comments = useThreadComments(editor, thread.id)
211
+ const msg = useTranslation()
212
+ const resolveName = useResolveName(resolveAuthor)
213
+ // Rebuilt only when the comments change, not on every render — each of which would otherwise
214
+ // re-allocate a date string and body element per comment.
215
+ const cards = useMemo(
216
+ () =>
217
+ comments.map((c) =>
218
+ toCardProps(c, { currentUserId, resolveAuthor }, options.components, resolveName)
219
+ ),
220
+ [comments, currentUserId, resolveAuthor, options.components, resolveName]
221
+ )
222
+ const me = currentUserId ? resolveAuthor(currentUserId) : undefined
223
+ // Composing, editing, deleting, and resolving are all gated on the viewer's permission. Where it's
224
+ // withheld the composer gives way to the ComposerFallback slot and the affordances are hidden.
225
+ const canComment = useCanComment(currentUserId)
226
+ // Editing and deleting are further per-record: the author's to do by default, wider or narrower
227
+ // under a `canModifyComment` callback. Computed for the thread's comments in one pass, since
228
+ // `renderComment` is a callback rather than a component and can't call a hook per comment.
229
+ const commentPermissions = useValue(
230
+ 'comment permissions',
231
+ () =>
232
+ new Map(
233
+ comments.map((comment) => [
234
+ comment.id,
235
+ {
236
+ edit: getCanModifyComment(editor, currentUserId, { action: 'edit-comment', comment }),
237
+ delete: getCanModifyComment(editor, currentUserId, {
238
+ action: 'delete-comment',
239
+ comment,
240
+ }),
241
+ },
242
+ ])
243
+ ),
244
+ [editor, currentUserId, comments]
245
+ )
246
+ // Deleting a thread is its creator's by default (and server-enforced); `canModifyComment` is what
247
+ // widens that. Still a commenting write, so `canComment` stays the outer gate.
248
+ const canModifyThread = useCanModifyComment(currentUserId, { action: 'delete-thread', thread })
249
+ const canDeleteThread = canComment && canModifyThread
250
+ const ComposerFallback = options.components.ComposerFallback
251
+ // An unsent reply survives closing the thread (saved on every change, keyed by thread id) —
252
+ // the flip side of dismissing without a discard warning.
253
+ const [reply, setReply] = useState<TLRichText>(
254
+ () => getCommentDraft(replyDraftSlot(thread.id)) ?? EMPTY_COMMENT
255
+ )
256
+ const [editingId, setEditingId] = useState<string | null>(null)
257
+ const [editText, setEditText] = useState<TLRichText>(EMPTY_COMMENT)
258
+ const canReply = canComment && !thread.resolved
259
+ const container = useContainer()
260
+ // Resolved when the composer closes rather than held as an element: the card unmounts while the
261
+ // composer stands in its place, so the node captured on the way in is gone by the way out.
262
+ const editReturnFocus = useRef<(() => HTMLElement | null) | null>(null)
263
+
264
+ // Tab from the canvas drops the caret in the reply box: a pin click leaves focus on the editor
265
+ // container, so the first Tab would otherwise walk the app's UI. Capture phase, once per open thread.
266
+ const [focusReply, setFocusReply] = useState(false)
267
+ const tabTaken = useRef(false)
268
+ const swallowTabUp = useRef(false)
269
+ useEffect(() => {
270
+ if (!canReply) {
271
+ // Resolving unmounts the reply box under an open thread. Reopening it should feel like a
272
+ // freshly opened thread: no autoFocus left armed from the last Tab, and Tab available again.
273
+ setFocusReply(false)
274
+ tabTaken.current = false
275
+ return
276
+ }
277
+ const doc = container.ownerDocument
278
+ const onKeyDown = (e: KeyboardEvent) => {
279
+ if (e.key !== 'Tab' || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
280
+ if (e.defaultPrevented || tabTaken.current) return
281
+ // Focus rests on the container (or nothing) after a pin click. Anywhere else is deliberate, and
282
+ // Tab belongs to whatever holds it.
283
+ if (e.target !== container && e.target !== doc.body) return
284
+ tabTaken.current = true
285
+ swallowTabUp.current = true
286
+ setFocusReply(true)
287
+ e.preventDefault()
288
+ e.stopPropagation()
289
+ }
290
+ // The select tool navigates shapes on Tab's *keyup*, so a quick tap would both focus the reply and
291
+ // step the selection. Swallow the release of the press we took, and only that one.
292
+ const onKeyUp = (e: KeyboardEvent) => {
293
+ if (e.key !== 'Tab' || !swallowTabUp.current) return
294
+ swallowTabUp.current = false
295
+ e.preventDefault()
296
+ e.stopPropagation()
297
+ }
298
+ doc.addEventListener('keydown', onKeyDown, true)
299
+ doc.addEventListener('keyup', onKeyUp, true)
300
+ return () => {
301
+ doc.removeEventListener('keydown', onKeyDown, true)
302
+ doc.removeEventListener('keyup', onKeyUp, true)
303
+ }
304
+ }, [canReply, container])
305
+
306
+ // Leaving the edit composer unmounts it while it holds focus, dropping focus to the editor
307
+ // container — outside the thread, so a Tab from there walks the app's UI. Hand focus back to
308
+ // whatever opened the composer instead.
309
+ useEffect(() => {
310
+ if (editingId !== null) return
311
+ const resolve = editReturnFocus.current
312
+ editReturnFocus.current = null
313
+ // Runs after React has swapped the card back in, so the target is mounted again by now.
314
+ resolve?.()?.focus()
315
+ }, [editingId])
316
+
317
+ // Reported as one batch so the host can record the receipts in a single write. The write flips
318
+ // isCommentUnread to false, so re-runs find nothing to report.
319
+ useEffect(() => {
320
+ if (!isCommentUnread || !onCommentsRead) return
321
+ const unreadIds = comments.filter((comment) => isCommentUnread(comment.id)).map((c) => c.id)
322
+ if (unreadIds.length > 0) {
323
+ onCommentsRead(unreadIds)
324
+ }
325
+ }, [comments, isCommentUnread, onCommentsRead])
326
+
327
+ const postReply = () => {
328
+ if (isCommentEmpty(reply) || !currentUserId) return
329
+ const comment = commitCommentMutation(editor, ({ put }) => {
330
+ const comment = createComment({
331
+ threadId: thread.id,
332
+ pageId: thread.pageId,
333
+ authorId: currentUserId,
334
+ body: reply,
335
+ })
336
+ put([comment])
337
+ return comment
338
+ })
339
+ setReply(EMPTY_COMMENT)
340
+ clearCommentDraft(replyDraftSlot(thread.id))
341
+ // The host's callback is its own operation, not part of the post's history scope. It runs
342
+ // last so a throwing host can't strand the composer holding a draft of a posted reply.
343
+ onPostComment?.(comment)
344
+ }
345
+
346
+ const toggleResolve = () => {
347
+ if (!currentUserId) return
348
+ if (thread.resolved) reopenThread(editor, thread)
349
+ else resolveThread(editor, thread, currentUserId)
350
+ }
351
+
352
+ // No `currentUserId` check: unlike a resolve, a delete stamps nothing on the record, so who may
353
+ // make it is `canModifyComment`'s call alone (which withholds it from an unidentified viewer by
354
+ // default).
355
+ const removeThread = () => {
356
+ deleteThread(editor, thread)
357
+ }
358
+
359
+ const ThreadActions = options.components.ThreadActions
360
+ // The host's URL for this thread. Its presence is what puts "copy link" in the header menu.
361
+ const threadHref = getThreadHref?.(thread.id)
362
+ const [linkCopied, copyThreadLink] = useCopyLink(threadHref)
363
+
364
+ const startEdit = (comment: TLComment, { fromMoreMenu = false } = {}) => {
365
+ // Edit from the ⋯ menu comes back to that button, looked up again on the way out since the card
366
+ // unmounts. Otherwise return to whatever held focus, ignoring the body and the editor container.
367
+ const active = container.ownerDocument.activeElement
368
+ editReturnFocus.current = fromMoreMenu
369
+ ? () => container.querySelector<HTMLElement>(`[data-cmt-more-for="${comment.id}"]`)
370
+ : active instanceof HTMLElement && active !== container && active !== document.body
371
+ ? () => (active.isConnected ? active : null)
372
+ : null
373
+ setEditingId(comment.id)
374
+ setEditText(comment.body)
375
+ }
376
+
377
+ const saveEdit = () => {
378
+ const comment = comments.find((c) => c.id === editingId)
379
+ if (!comment || isCommentEmpty(editText)) return
380
+ editComment(editor, comment, editText)
381
+ setEditingId(null)
382
+ }
383
+
384
+ // Swap a comment for a pre-filled composer while it's being edited; otherwise show the card,
385
+ // with the affordances the viewer is allowed on it.
386
+ const renderComment = (card: CommentCardProps, index: number): ReactNode => {
387
+ const comment = comments[index]
388
+ const permissions = commentPermissions.get(comment.id)
389
+ if (editingId === comment.id) {
390
+ return (
391
+ <div
392
+ className="tlui-cmt-editing"
393
+ onKeyDown={(e) => {
394
+ if (e.key === 'Escape') {
395
+ setEditingId(null)
396
+ e.stopPropagation()
397
+ }
398
+ }}
399
+ >
400
+ <CommentComposer
401
+ author={card.author}
402
+ placeholder={msg('comments.edit-placeholder')}
403
+ value={editText}
404
+ onChange={setEditText}
405
+ onSubmit={saveEdit}
406
+ sendLabel={msg('comments.save')}
407
+ disabled={isCommentEmpty(editText)}
408
+ getMentionSuggestions={getMentionSuggestions}
409
+ renderMentionSuggestion={renderMentionSuggestion}
410
+ autoFocus
411
+ />
412
+ </div>
413
+ )
414
+ }
415
+ return (
416
+ <CommentCard
417
+ {...card}
418
+ footer={
419
+ <CommentReactions
420
+ comment={comment}
421
+ // Reacting is a commenting write: without `canComment` the tally renders
422
+ // read-only (no identity → pills aren't clickable).
423
+ currentUserId={canComment ? currentUserId : null}
424
+ resolveName={resolveName}
425
+ />
426
+ }
427
+ actions={
428
+ canComment && (
429
+ <>
430
+ {(permissions?.edit || permissions?.delete) && (
431
+ <TldrawUiDropdownMenuRoot id={`comment-actions-${comment.id}`}>
432
+ <TldrawUiDropdownMenuTrigger>
433
+ <TldrawUiButton
434
+ type="icon"
435
+ tooltip={msg('comments.more-options')}
436
+ title={msg('comments.more-options')}
437
+ className="tlui-cmt-thread__action"
438
+ data-cmt-more-for={comment.id}
439
+ >
440
+ <TldrawUiIcon
441
+ icon="dots-vertical"
442
+ label={msg('comments.more-options')}
443
+ small
444
+ />
445
+ </TldrawUiButton>
446
+ </TldrawUiDropdownMenuTrigger>
447
+ <TldrawUiDropdownMenuContent
448
+ className="tlui-cmt-menu"
449
+ side="bottom"
450
+ align="start"
451
+ alignOffset={0}
452
+ sideOffset={4}
453
+ >
454
+ <TldrawUiDropdownMenuGroup>
455
+ {permissions?.edit && (
456
+ <TldrawUiDropdownMenuItem>
457
+ <button
458
+ type="button"
459
+ className="tlui-cmt-menu-item"
460
+ onClick={() => startEdit(comment, { fromMoreMenu: true })}
461
+ >
462
+ <span>{msg('comments.edit')}</span>
463
+ </button>
464
+ </TldrawUiDropdownMenuItem>
465
+ )}
466
+ {permissions?.delete && (
467
+ <TldrawUiDropdownMenuItem>
468
+ <button
469
+ type="button"
470
+ className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
471
+ onClick={() => deleteComment(editor, comment)}
472
+ >
473
+ <span>{msg('action.delete')}</span>
474
+ </button>
475
+ </TldrawUiDropdownMenuItem>
476
+ )}
477
+ </TldrawUiDropdownMenuGroup>
478
+ </TldrawUiDropdownMenuContent>
479
+ </TldrawUiDropdownMenuRoot>
480
+ )}
481
+ {/* Last so react is always the rightmost pill. */}
482
+ <CommentReactionPicker comment={comment} currentUserId={currentUserId} />
483
+ </>
484
+ )
485
+ }
486
+ />
487
+ )
488
+ }
489
+
490
+ // Resolve and delete are commenting writes: behind `canComment`, plus the `currentUserId` a
491
+ // resolve stamps into `resolved.by`.
492
+ const resolveLabel = msg(thread.resolved ? 'comments.reopen' : 'comments.resolve')
493
+ const headerActions = (
494
+ <>
495
+ {/* Host verbs — assign, link a ticket — sit ahead of the built-in actions. */}
496
+ {ThreadActions && <ThreadActions thread={thread} comments={comments} />}
497
+ {(threadHref !== undefined || canDeleteThread) && (
498
+ <TldrawUiDropdownMenuRoot id={`comment-thread-actions-${thread.id}`}>
499
+ <TldrawUiDropdownMenuTrigger>
500
+ <TldrawUiButton
501
+ type="icon"
502
+ tooltip={msg('comments.more-options')}
503
+ title={msg('comments.more-options')}
504
+ className="tlui-cmt-thread__action"
505
+ >
506
+ <TldrawUiIcon icon="dots-vertical" label={msg('comments.more-options')} small />
507
+ </TldrawUiButton>
508
+ </TldrawUiDropdownMenuTrigger>
509
+ <TldrawUiDropdownMenuContent
510
+ className="tlui-cmt-menu"
511
+ side="bottom"
512
+ align="start"
513
+ alignOffset={0}
514
+ sideOffset={4}
515
+ >
516
+ <TldrawUiDropdownMenuGroup>
517
+ {/* A link is a read affordance: offered to anyone who can see the thread,
518
+ including a viewer who can't comment. `noClose` keeps the menu up so the
519
+ item can confirm the copy in place. */}
520
+ {threadHref !== undefined && (
521
+ <TldrawUiDropdownMenuItem noClose>
522
+ <button type="button" className="tlui-cmt-menu-item" onClick={copyThreadLink}>
523
+ <span>{msg(linkCopied ? 'comments.link-copied' : 'comments.copy-link')}</span>
524
+ </button>
525
+ </TldrawUiDropdownMenuItem>
526
+ )}
527
+ {canDeleteThread && (
528
+ <TldrawUiDropdownMenuItem>
529
+ <button
530
+ type="button"
531
+ className="tlui-cmt-menu-item tlui-cmt-menu-item--danger"
532
+ onClick={removeThread}
533
+ >
534
+ <span>{msg('comments.delete')}</span>
535
+ </button>
536
+ </TldrawUiDropdownMenuItem>
537
+ )}
538
+ </TldrawUiDropdownMenuGroup>
539
+ </TldrawUiDropdownMenuContent>
540
+ </TldrawUiDropdownMenuRoot>
541
+ )}
542
+ {canComment && currentUserId && (
543
+ <TldrawUiButton
544
+ type="icon"
545
+ tooltip={resolveLabel}
546
+ title={resolveLabel}
547
+ className="tlui-cmt-thread__action"
548
+ onClick={toggleResolve}
549
+ >
550
+ <TldrawUiIcon icon="check" label={resolveLabel} small />
551
+ </TldrawUiButton>
552
+ )}
553
+ <TldrawUiButton
554
+ type="icon"
555
+ tooltip={msg('comments.dismiss')}
556
+ title={msg('comments.dismiss')}
557
+ className="tlui-cmt-thread__action"
558
+ onClick={() => openThreadId.set(editor, null)}
559
+ >
560
+ <TldrawUiIcon icon="cross-2" label={msg('comments.dismiss')} small />
561
+ </TldrawUiButton>
562
+ </>
563
+ )
564
+
565
+ return (
566
+ <CommentThread
567
+ header={msg('comments.thread-title')}
568
+ headerActions={headerActions}
569
+ renderComment={renderComment}
570
+ comments={cards}
571
+ resolvedBanner={
572
+ thread.resolved
573
+ ? msg('comments.resolved-by').replace(
574
+ '{name}',
575
+ resolveAuthor(thread.resolved.by)?.name ?? UNKNOWN_AUTHOR
576
+ )
577
+ : undefined
578
+ }
579
+ composer={
580
+ canReply
581
+ ? {
582
+ author: me ?? UNKNOWN_COMMENT_AUTHOR,
583
+ placeholder: msg('comments.reply-placeholder'),
584
+ sendLabel: msg('comments.send'),
585
+ value: reply,
586
+ onChange: (value: TLRichText) => {
587
+ setReply(value)
588
+ saveCommentDraft(replyDraftSlot(thread.id), value)
589
+ },
590
+ onSubmit: postReply,
591
+ // Up in the empty reply box edits the comment directly above it, chat-style —
592
+ // only when you're allowed to edit it (the same gate as the Edit link).
593
+ onArrowUpWhenEmpty: () => {
594
+ const last = comments[comments.length - 1]
595
+ if (last && commentPermissions.get(last.id)?.edit) startEdit(last)
596
+ },
597
+ // No user, no author for the record — dead send button.
598
+ disabled: isCommentEmpty(reply) || !currentUserId,
599
+ getMentionSuggestions,
600
+ renderMentionSuggestion,
601
+ // Reuses the composer's own focus path, so the caret lands at the end of a
602
+ // restored draft rather than in front of it.
603
+ autoFocus: focusReply,
604
+ }
605
+ : undefined
606
+ }
607
+ footer={
608
+ !canComment && !thread.resolved && ComposerFallback ? (
609
+ <ComposerFallback context="thread" />
610
+ ) : undefined
611
+ }
612
+ />
613
+ )
614
+ })