@lark-apaas/coding-template-nestjs-react-fullstack 0.1.60-alpha.20261105131051 → 0.1.60-alpha.20261105142153

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-template-nestjs-react-fullstack",
3
- "version": "0.1.60-alpha.20261105131051",
3
+ "version": "0.1.60-alpha.20261105142153",
4
4
  "description": "NestJS + React fullstack template for miaoda coding",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,36 +1,101 @@
1
- # 通用评论入口(OpenAPI pilot)
1
+ # 评论组件
2
2
 
3
- 与 `record-comments` 共享 UI/CSS 和乐观更新逻辑。新的公开数据契约不包含 Action、插件实例或飞书数字用户 ID。旧入口继续兼容;此试接入不搬迁全部内部文件,不自动迁移应用。
3
+ 默认通过 `useComments` 接入。页面只提供业务对象、当前用户和 adapter;请求方法、认证及服务协议由应用自己实现。UI、回复/编辑/删除、乐观更新仍由同一套组件管理。
4
4
 
5
5
  ```tsx
6
- import { CommentsProvider, CommentList, CommentComposer, commentReactions } from '@/components/business-ui/comments';
7
-
8
- <CommentsProvider
9
- scopeKey="feishu:my-app:account"
10
- subjectKey={documentToken}
11
- threads={threads}
12
- currentUser={currentUser}
13
- actions={adapter.actions}
14
- input={{ userMentions: ref => ref.namespace === userNamespace, links: true }}
15
- actors={directory}
16
- extensions={[commentReactions({ allowedKeys: ['THUMBSUP'], onChange: adapter.changeReaction })]}
17
- >
18
- <div className="min-h-0 flex-1 overflow-y-auto"><CommentList /></div>
19
- <CommentComposer />
20
- </CommentsProvider>
6
+ import {
7
+ useComments,
8
+ CommentsProvider,
9
+ CommentList,
10
+ CommentComposer,
11
+ } from '@/components/business-ui/comments';
12
+
13
+ function Comments({ recordId, currentUser, adapter }) {
14
+ const comments = useComments({ resourceKey: recordId, currentUser, adapter });
15
+ return (
16
+ <CommentsProvider value={comments}>
17
+ <div className="min-h-0 flex-1 overflow-y-auto">
18
+ <CommentList />
19
+ </div>
20
+ <CommentComposer />
21
+ </CommentsProvider>
22
+ );
23
+ }
21
24
  ```
22
25
 
23
- `CommentsProvider` 不输出布局 DOM。也可用 `CommentPanel` 提供默认完整布局。类型以 `contracts.ts` 为准。
26
+ Provider 不输出布局 DOM。标题、边框、尺寸、滚动容器归页面。也可 `<CommentPanel value={comments} />` 使用默认完整布局。主输入框在同一个 Provider 内挂一次,行内编辑由列表承载。
27
+
28
+ ## 最小 adapter
29
+
30
+ ```ts
31
+ import type { CommentAdapter } from '@/components/business-ui/comments';
32
+
33
+ const adapter: CommentAdapter = {
34
+ scopeKey: 'my-api:tenant:authenticated-account',
35
+ async list({ resourceKey, cursor, signal }) {
36
+ // 调用应用自己的 API,并映射为 CommentPage;不要把后端原始结构直接返回。
37
+ return api.listComments({ recordId: resourceKey, cursor, signal });
38
+ },
39
+ async create(
40
+ { resourceKey, threadId, replyToMessageId, content, attachments },
41
+ context,
42
+ ) {
43
+ return api.createComment(
44
+ {
45
+ recordId: resourceKey,
46
+ threadId,
47
+ replyToMessageId,
48
+ content,
49
+ attachments,
50
+ },
51
+ context,
52
+ );
53
+ },
54
+ };
55
+ ```
56
+
57
+ `api` 是应用自身代码,不是组件内置客户端。只有 list 就是只读;提供 create 才能新增/回复,update/remove 配合每条消息权限启用编辑/删除。不要用组件参数替代后端鉴权。
58
+
59
+ - scopeKey 隔离提供方、租户和认证上下文,不含凭证;切换连接或权限身份时变化。hook 额外根据 resourceKey 和 currentUser 的完整引用隔离会话。推荐用 useMemo 或模块变量保持 adapter 稳定;同一 scope 的新对象本身不会反复加载。
60
+ - list 返回 `{threads,nextCursor?}`;讨论包含 id/messages,第一条为正文。messages 使用字符串 ID 和毫秒时间。nextCursor 省略表示没有下一页,不自行生成游标。
61
+ - 单个讨论未读全时返回 nextReplyCursor,并提供 listReplies,返回 `{messages,nextCursor?}`。hook 接管两层分页、去重、游标保护,以及刷新后保留已加载页数。
62
+ - create/update 返回 `{threadId,messageId,message?}`,message 是可选权威回读。create 不传 threadId 表示新讨论;回复同时带 threadId 和精确 replyToMessageId,后端只支持线性讨论时可以忽略后者。
63
+ - remove 接收选中消息 ID,包括首条。删除后第二条自然提升;返回 `{threadId,threadDeleted?}`,不能确定讨论是否为空就省略该字段,不凭局部列表推断。
64
+ - hook 自动首屏读取;写入前丢弃旧读取,等并发写入完成再静默刷新。正常写后回读不触发整体 loading,刷新失败不反转已成功写入。`comments.refresh()` 是主动刷新,`comments.refreshing` 可供宿主显示后台读取状态。
65
+ - 每次写操作收到 `{operationId,signal}`;同一次重试保持 operationId。确定未写入可抛 Error 并设置 outcome:'not-applied';超时/回包丢失为 outcome:'unknown',普通未标注异常也按未知处理,不能盲重发。
66
+ - 服务支持客户端幂等标识时,adapter 将 context.operationId 写入并在读取消息上返回 clientOperationId,组件据此核对未知新增结果。没有服务端关联证据时不按正文去重,也不宣称能自动恢复所有未知结果。
67
+
68
+ ## 可选能力都放 adapter
69
+
70
+ | 字段 | 作用 |
71
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
72
+ | people.source | search/recommend/resolve;search 或 recommend 可用时启用 @;只有 resolve 时只补展示资料 |
73
+ | people.canMention / onClick | 可选人员插入策略、人员卡片回调 |
74
+ | documents | `{source,canInsert?}`,注册后默认可插入;canInsert:false 只提供元数据解析 |
75
+ | reactions | `{catalog?,allowedKeys?,onChange?}`,无 onChange 为只读,onChange 接收 resourceKey、threadId、messageId、key、add/remove |
76
+ | images | 设 true 才启用文件选择;create/update 必须能上传并持久化完整消息,注册不会自动上传 |
77
+ | links | 是否允许插入结构化普通链接,默认 false |
78
+ | validateDraft | 服务特有正文限制;返回错误文案或 undefined |
79
+ | getMessageLink | 提供消息链接时显示复制链接菜单 |
80
+
81
+ catalog 项为 `{key,label,text?,url?,group?}`;不传时沿用现有表情资产。allowedKeys 同时约束快捷点击和选择列表。回应数据放 message.reactions,undefined 表示未读取、[] 表示权威为空、mine 省略表示本人状态未知,不根据截断人员名单推断 false。
82
+
83
+ 正文是 CommentContent,不是 Tiptap JSON 或服务 wire schema。读取遇到不支持的节点要保留为 unsupported 并禁用编辑。不能丢图片或未知节点后保存。禁用插入新引用时,编辑可原样保留已有引用,最终仍由 validateDraft/后端确认支持。
84
+
85
+ 用户引用当前为 `{kind:'user',namespace,id}`。不能把 open_id 当妙搭 userId;需要对接平台人员体系时在服务端 adapter 转换,统一目录、作者和 currentUser 的身份域。组件没有内置身份转换或 owner 权限兜底。
86
+
87
+ ## 高级受控方式继续可用
88
+
89
+ 已有 React Query、自有缓存或实时订阅时,可以继续传 `scopeKey/subjectKey/threads/actions/loading/error/pagination/actors/input/extensions` 等原始 props,详见 contracts.ts。该模式由宿主维护读取、分页和写入屏障,组件仍管理乐观覆盖。不要同时传 value 和受控 props,不再另建一套临时消息队列。
90
+
91
+ 原 `business-ui/record-comments` 的多维表格合同与数字 ID/秒级时间入口保留兼容;不能与新入口的类型混用。新通用入口仍共享原 UI 内部实现,未移动 CSS/assets。
92
+
93
+ ## 样式与验收
94
+
95
+ 项目入口需要模板自身的全局 `index.css`(Tailwind、主题与基础重置),组件会引入原内部 CSS。使用 hook 不改变任何视觉 token。
96
+
97
+ 真实 OpenAPI 验收示例位于仓库 `scripts/__tests__/fixtures/record-comments/openapi/`,使用同一个 useComments 入口。它是开发工具,不随模板运行时携带;运行中的应用不能 shell 执行 lark-cli 或把 token 放到浏览器。
24
98
 
25
- - `threads[].messages[]` 中第一条为正文。删除只针对选中消息;剩余消息原位提升,讨论 ID 不变。`canReply:false` 隐藏回复入口(例如 OpenAPI 整篇文档评论)。
26
- - ID 一律非空字符串;人员用 `{kind:'user',namespace,id}`,namespace 应包含提供方、应用及 ID 类型。不能把 `ou_...` 转数字。时间为毫秒,缺 `updatedAt` 可以正常展示,不伪造编辑时间。
27
- - 读取、权限、分页、缓存和认证在宿主。加载更多通过外部 `pagination.onLoadMore` / `repliesPagination` 请求。权限未知就不显示修改入口;后端仍须鉴权。
28
- - `actions.create/update` 返回真实 `{threadId,messageId,message?}`。可选 message 为权威回读,用于接管乐观内容。`remove` 返回 `{threadId,threadDeleted?}`,缺少讨论状态时等待外部回读,不凭局部列表为空认定整串删除。
29
- - 写成功就 resolve;后续刷新失败不能把成功写入 reject。确定未写入的失败可抛 `{outcome:'not-applied'}`;超时/响应丢失等抛 `{outcome:'unknown'}`,不能盲目重发。`context.operationId` 在一次重试中稳定,但宿主只有下游确实支持时才能把它当幂等保证。
30
- - 外部读取需要请求序号和写入屏障:忽略写入前开始的过期读取。组件无法判断任意新数组是否更新,也不能独自解决远端最终一致性。
31
- - 作者快照可只有 ref;姓名缺失交 `actors.resolve`。开启输入 @ 要同时提供目录及 `input.userMentions` 策略;自己回复不自动插入本人 @。
32
- - 文档用 `commentDocuments({source,canInsert:true})`;source 负责解析 URL、搜索和权限状态。普通链接受 `input.links` 控制。无法解析、未授权、无权限是不同结果,不能把解析失败标成无权限。
33
- - 回应用 `commentReactions({allowedKeys,onChange})`。`reactions:undefined` 表示未加载,`[]` 表示已知为空,`mine:undefined` 表示本人状态未知,不根据截断名单推断 false。此次 pilot 在任意回应 membership 未知时保守禁用该消息的回应修改;写入结果未知时保留本地状态并锁定,等待权威回读确认。
34
- - `commentImages()` 只启用选择 UI,不提供上传。宿主自行完成上传、持久化和授权,再在 create/update 返回完整消息。此 OpenAPI 试接入没有图片功能;通用文件附件暂不编辑,避免丢失内容。
99
+ 本地完整能力示例在同级测试目录 `memory/`:用独立 adapter 验收人员选择、混合图片回复、文档、表情、确定失败重试和未知结果核对;图片仅在本地持久化,不代表 OpenAPI 图片接入。
35
100
 
36
- 真实 OpenAPI 示例和验收工具在 `scripts/__tests__/fixtures/record-comments/openapi/`(仓库测试资产,不随模板运行时携带)。不要让部署后的应用执行 lark-cli,也不要把 token/app secret 放到浏览器。生产应用用自己的后端 OAuth/OpenAPI SDK 替换示例 request。
101
+ 多维表格插件的真实应用 adapter 示例位于 `scripts/__tests__/fixtures/record-comments/bitable/`,使用与 OpenAPI 相同的 useComments 接口,保留服务自身身份域和权限。
@@ -1,5 +1,7 @@
1
1
  import type {
2
2
  CommentsProviderProps,
3
+ CommentsProviderInput,
4
+ CommentPanelInput,
3
5
  CommentThread,
4
6
  CommentContent,
5
7
  CommentDraft,
@@ -85,7 +87,11 @@ const reactionState = (items: CommentReaction[]) =>
85
87
  );
86
88
  const key = (ref: EntityRef) =>
87
89
  JSON.stringify([ref.kind, ref.namespace, ref.id]);
88
- export function CommentsProvider(props: CommentsProviderProps) {
90
+ export function CommentsProvider(input: CommentsProviderInput) {
91
+ const props: CommentsProviderProps =
92
+ input.value !== undefined
93
+ ? { ...input.value.props, children: input.children }
94
+ : (input as CommentsProviderProps);
89
95
  return (
90
96
  <Session
91
97
  key={JSON.stringify([props.scopeKey, props.subjectKey])}
@@ -267,6 +273,7 @@ function Session(props: CommentsProviderProps) {
267
273
  );
268
274
  return {
269
275
  replyID: m.id,
276
+ clientOperationId: m.clientOperationId,
270
277
  author: m.author ? toRef(m.author.ref) : null,
271
278
  createTime: m.createdAt / 1000,
272
279
  updateTime: m.updatedAt === undefined ? undefined : m.updatedAt / 1000,
@@ -359,13 +366,33 @@ function Session(props: CommentsProviderProps) {
359
366
  },
360
367
  ),
361
368
  };
369
+ const signature = (n: import('./contracts').CommentNode) =>
370
+ n.type === 'link'
371
+ ? JSON.stringify(['link', n.url])
372
+ : n.type === 'mention'
373
+ ? JSON.stringify(['mention', key(n.ref), n.href ?? ''])
374
+ : '';
375
+ const retained = new Map<string, number>();
376
+ if (mode === 'update')
377
+ for (const n of latest.current.threads
378
+ .find((t) => t.id === target.threadId)
379
+ ?.messages.find((m) => m.id === target.messageId)?.content.nodes ??
380
+ []) {
381
+ const k = signature(n);
382
+ if (k) retained.set(k, (retained.get(k) ?? 0) + 1);
383
+ }
362
384
  for (const n of result.content.nodes) {
363
- if (n.type === 'link' && !latest.current.input?.links)
385
+ const k = signature(n),
386
+ remaining = retained.get(k) ?? 0;
387
+ const existing = remaining > 0;
388
+ if (existing) retained.set(k, remaining - 1);
389
+ if (n.type === 'link' && !latest.current.input?.links && !existing)
364
390
  throw fail('当前接口不支持结构化链接');
365
391
  if (
366
392
  n.type === 'mention' &&
367
393
  n.ref.kind === 'document' &&
368
- !documents?.canInsert
394
+ !documents?.canInsert &&
395
+ !existing
369
396
  )
370
397
  throw fail('当前接口不支持插入文档引用');
371
398
  if (n.type === 'unresolved-mention' || n.type === 'unsupported')
@@ -373,7 +400,8 @@ function Session(props: CommentsProviderProps) {
373
400
  if (
374
401
  n.type === 'mention' &&
375
402
  n.ref.kind === 'user' &&
376
- !latest.current.input?.userMentions?.(n.ref as UserRef)
403
+ !latest.current.input?.userMentions?.(n.ref as UserRef) &&
404
+ !existing
377
405
  )
378
406
  throw fail('当前接口不支持此人员引用');
379
407
  }
@@ -413,13 +441,16 @@ function Session(props: CommentsProviderProps) {
413
441
  canMentionUser: (r) =>
414
442
  r.kind === 'user' &&
415
443
  Boolean(latest.current.input?.userMentions?.(fromRef(r) as UserRef)),
416
- reactionEmojis: reactions?.options.allowedKeys
417
- ? DEFAULT_REACTION_EMOJIS.filter((e) =>
418
- reactions.options.allowedKeys!.includes(e.key),
444
+ reactionEmojis: reactions
445
+ ? (reactions.options.catalog ?? DEFAULT_REACTION_EMOJIS).filter(
446
+ (e) =>
447
+ !reactions.options.allowedKeys ||
448
+ reactions.options.allowedKeys.includes(e.key),
419
449
  )
420
450
  : undefined,
421
451
  showReactions: !!reactions,
422
452
  allowReact: !!reactions?.options.onChange,
453
+ enablePrivateComments: false,
423
454
  enableImages: images && !!props.actions?.create,
424
455
  enableEditImages: images && !!props.actions?.update,
425
456
  enableDocuments: documents?.canInsert ?? false,
@@ -477,11 +508,19 @@ function Session(props: CommentsProviderProps) {
477
508
  await latest.current.actions!.create!(
478
509
  {
479
510
  ...input,
480
- ...(draft.commentID ? { threadId: draft.commentID } : {}),
511
+ ...(draft.commentID
512
+ ? {
513
+ threadId: draft.commentID,
514
+ ...(draft.replyToMessageId
515
+ ? { replyToMessageId: draft.replyToMessageId }
516
+ : {}),
517
+ }
518
+ : {}),
481
519
  },
482
520
  ctx,
483
521
  ),
484
522
  ),
523
+ ctx.operationId,
485
524
  );
486
525
  setSubmitted(id);
487
526
  },
@@ -540,6 +579,11 @@ function Session(props: CommentsProviderProps) {
540
579
  !reactions?.options.onChange
541
580
  )
542
581
  throw fail('回应状态或权限未知');
582
+ if (
583
+ reactions.options.allowedKeys &&
584
+ !reactions.options.allowedKeys.includes(emoji)
585
+ )
586
+ throw fail('该表情未启用');
543
587
  const before = reactionOverlays.current.get(k)?.data ?? m.reactions;
544
588
  const old = before.find((r) => r.key === emoji);
545
589
  const action = old?.mine ? 'remove' : 'add';
@@ -615,7 +659,7 @@ function Session(props: CommentsProviderProps) {
615
659
  </DiscussionProvider>
616
660
  );
617
661
  }
618
- export function CommentPanel(props: Omit<CommentsProviderProps, 'children'>) {
662
+ export function CommentPanel(props: CommentPanelInput) {
619
663
  return (
620
664
  <CommentsProvider {...props}>
621
665
  <div className="replica-comment-panel">
@@ -40,6 +40,8 @@ export interface CommentReaction {
40
40
  previewUsers?: CommentAuthor[];
41
41
  }
42
42
  export interface CommentMessage {
43
+ /** Optional server-echoed idempotency correlation; never synthesize from matching text. */
44
+ clientOperationId?: string;
43
45
  id: string;
44
46
  author: CommentAuthor | null;
45
47
  content: CommentContent;
@@ -80,7 +82,7 @@ export interface CommentWriteResult extends CommentTarget {
80
82
  }
81
83
  export interface CommentActions {
82
84
  create?(
83
- input: CommentDraft & { threadId?: string },
85
+ input: CommentDraft & { threadId?: string; replyToMessageId?: string },
84
86
  context: CommentMutationContext,
85
87
  ): Promise<CommentWriteResult>;
86
88
  update?(
@@ -119,7 +121,15 @@ export interface CommentEntitySource {
119
121
  ): Promise<CommentEntityResult[]>;
120
122
  parseUrl?(url: string): EntityRef | undefined;
121
123
  }
124
+ export interface CommentEmoji {
125
+ key: string;
126
+ label: string;
127
+ text?: string;
128
+ url?: string;
129
+ group?: string;
130
+ }
122
131
  export interface CommentReactionFeature {
132
+ catalog?: readonly CommentEmoji[];
123
133
  allowedKeys?: readonly string[];
124
134
  onChange?(
125
135
  input: CommentTarget & { key: string; action: 'add' | 'remove' },
@@ -163,3 +173,84 @@ export type CommentComposerProps = CommentListProps & {
163
173
  placeholder?: string;
164
174
  disabled?: boolean;
165
175
  };
176
+
177
+ /** Adapter reads return data and cursors, never React callbacks or loading state. */
178
+ export type CommentPageThread = Omit<CommentThread, 'repliesPagination'> & {
179
+ nextReplyCursor?: string;
180
+ };
181
+ export interface CommentPage {
182
+ threads: CommentPageThread[];
183
+ nextCursor?: string;
184
+ }
185
+ export interface CommentRepliesPage {
186
+ messages: CommentMessage[];
187
+ nextCursor?: string;
188
+ }
189
+ export interface CommentResource {
190
+ resourceKey: string;
191
+ }
192
+ export interface CommentListRequest extends CommentResource {
193
+ cursor?: string;
194
+ signal: AbortSignal;
195
+ }
196
+ export interface CommentAdapter {
197
+ /** Stable backend/tenant/auth scope. Current-user identity is additionally isolated by the hook. */
198
+ scopeKey: string;
199
+ list(input: CommentListRequest): Promise<CommentPage>;
200
+ listReplies?(
201
+ input: CommentListRequest & { threadId: string },
202
+ ): Promise<CommentRepliesPage>;
203
+ create?(
204
+ input: CommentDraft &
205
+ CommentResource & { threadId?: string; replyToMessageId?: string },
206
+ context: CommentMutationContext,
207
+ ): Promise<CommentWriteResult>;
208
+ update?(
209
+ input: CommentDraft & CommentTarget & CommentResource,
210
+ context: CommentMutationContext,
211
+ ): Promise<CommentWriteResult>;
212
+ remove?(
213
+ input: CommentTarget & CommentResource,
214
+ context: CommentMutationContext,
215
+ ): Promise<{ threadId: string; threadDeleted?: boolean }>;
216
+ people?: {
217
+ source: CommentEntitySource;
218
+ canMention?: (ref: UserRef) => boolean;
219
+ onClick?: CommentsProviderProps['onActorClick'];
220
+ };
221
+ documents?: { source: CommentEntitySource; canInsert?: boolean };
222
+ reactions?: Omit<CommentReactionFeature, 'onChange'> & {
223
+ onChange?(
224
+ input: CommentTarget &
225
+ CommentResource & { key: string; action: 'add' | 'remove' },
226
+ context: CommentMutationContext,
227
+ ): Promise<{ reactions?: CommentReaction[] }>;
228
+ };
229
+ /** Enable only when create/update can persist the complete image-bearing message. */
230
+ images?: boolean;
231
+ links?: boolean;
232
+ getMessageLink?(target: CommentTarget & CommentResource): string | undefined;
233
+ validateDraft?: CommentsProviderProps['validateDraft'];
234
+ }
235
+ export interface UseCommentsOptions {
236
+ resourceKey: string;
237
+ currentUser?: CommentAuthor;
238
+ adapter: CommentAdapter;
239
+ }
240
+ export interface CommentsController {
241
+ readonly props: Omit<CommentsProviderProps, 'children'>;
242
+ readonly refreshing: boolean;
243
+ refresh(): Promise<void>;
244
+ loadMore(): Promise<void>;
245
+ }
246
+ type ManagedInput<Controlled> = { value: CommentsController } & {
247
+ [K in keyof Controlled]?: never;
248
+ };
249
+ export type CommentsProviderInput =
250
+ | (CommentsProviderProps & { value?: never })
251
+ | (ManagedInput<Omit<CommentsProviderProps, 'children'>> & {
252
+ children: ReactNode;
253
+ });
254
+ export type CommentPanelInput =
255
+ | (Omit<CommentsProviderProps, 'children'> & { value?: never })
256
+ | ManagedInput<Omit<CommentsProviderProps, 'children'>>;
@@ -1,3 +1,4 @@
1
+ export { useComments } from './use-comments';
1
2
  export { CommentsProvider, CommentPanel } from './comments';
2
3
  export { CommentList, CommentComposer } from '../record-comments/comment-panel';
3
4
  export type * from './contracts';
@@ -0,0 +1,492 @@
1
+ import { useEffect, useMemo, useSyncExternalStore } from 'react';
2
+ import type {
3
+ CommentAdapter,
4
+ CommentActions,
5
+ CommentsController,
6
+ CommentExtension,
7
+ CommentMessage,
8
+ CommentPageThread,
9
+ CommentPagination,
10
+ CommentMutationContext,
11
+ UseCommentsOptions,
12
+ } from './contracts';
13
+
14
+ type ReadState = {
15
+ threads: CommentPageThread[];
16
+ loading: boolean;
17
+ refreshing: boolean;
18
+ error?: string;
19
+ nextCursor?: string;
20
+ moreLoading: boolean;
21
+ moreError?: string;
22
+ replyBusy: Set<string>;
23
+ replyErrors: Map<string, string>;
24
+ };
25
+ const message = (e: unknown) =>
26
+ e instanceof Error ? e.message : '评论读取失败';
27
+ const failure = (text: string) =>
28
+ Object.assign(new Error(text), { outcome: 'not-applied' });
29
+ const mergeMessages = (a: CommentMessage[], b: CommentMessage[]) => [
30
+ ...new Map([...a, ...b].map((m) => [m.id, m])).values(),
31
+ ];
32
+ const mergeThreads = (
33
+ a: CommentPageThread[],
34
+ b: CommentPageThread[],
35
+ keepReplyProgress = false,
36
+ ) => {
37
+ const map = new Map(a.map((t) => [t.id, t]));
38
+ for (const t of b) {
39
+ const old = map.get(t.id);
40
+ map.set(
41
+ t.id,
42
+ old
43
+ ? {
44
+ ...t,
45
+ messages: mergeMessages(old.messages, t.messages),
46
+ ...(keepReplyProgress
47
+ ? { nextReplyCursor: old.nextReplyCursor }
48
+ : {}),
49
+ }
50
+ : t,
51
+ );
52
+ }
53
+ return [...map.values()];
54
+ };
55
+ /** Read orchestration only. The Provider remains the sole optimistic-mutation owner. */
56
+ class ReadSession {
57
+ adapter: CommentAdapter;
58
+ private state: ReadState = {
59
+ threads: [],
60
+ loading: true,
61
+ refreshing: false,
62
+ moreLoading: false,
63
+ replyBusy: new Set(),
64
+ replyErrors: new Map(),
65
+ };
66
+ private listeners = new Set<() => void>();
67
+ private readers = new Set<AbortController>();
68
+ private lifecycle = new AbortController();
69
+ private generation = 0;
70
+ private active = true;
71
+ private hasSnapshot = false;
72
+ private pages = 1;
73
+ private pageCursors = new Set<string>();
74
+ private replyCursors = new Map<string, Set<string>>();
75
+ private replyPages = new Map<string, number>();
76
+ private writes = 0;
77
+ private waiters = new Set<() => void>();
78
+ constructor(
79
+ adapter: CommentAdapter,
80
+ readonly resourceKey: string,
81
+ ) {
82
+ this.adapter = adapter;
83
+ }
84
+ subscribe = (fn: () => void) => {
85
+ this.listeners.add(fn);
86
+ return () => {
87
+ this.listeners.delete(fn);
88
+ };
89
+ };
90
+ snapshot = () => this.state;
91
+ private patch(p: Partial<ReadState>) {
92
+ if (!this.active) return;
93
+ this.state = { ...this.state, ...p };
94
+ this.listeners.forEach((fn) => fn());
95
+ }
96
+ activate() {
97
+ this.active = true;
98
+ this.lifecycle = new AbortController();
99
+ }
100
+ dispose() {
101
+ this.active = false;
102
+ this.lifecycle.abort();
103
+ this.invalidate();
104
+ this.waiters.forEach((fn) => fn());
105
+ this.waiters.clear();
106
+ }
107
+ private invalidate() {
108
+ this.generation++;
109
+ this.readers.forEach((c) => c.abort());
110
+ this.readers.clear();
111
+ }
112
+ private reader() {
113
+ const c = new AbortController();
114
+ this.readers.add(c);
115
+ return c;
116
+ }
117
+ private valid(version: number) {
118
+ return this.active && version === this.generation && this.writes === 0;
119
+ }
120
+ private cursor(
121
+ next: string | undefined,
122
+ previous: string | undefined,
123
+ seen: Set<string>,
124
+ ) {
125
+ if (next && (next === previous || seen.has(next)))
126
+ throw new Error('评论分页游标未前进');
127
+ if (next) seen.add(next);
128
+ }
129
+ refresh = async (options: { background?: boolean } = {}) => {
130
+ while (this.active && this.writes) {
131
+ await new Promise<void>((resolve) => this.waiters.add(resolve));
132
+ }
133
+ if (!this.active) return;
134
+ this.invalidate();
135
+ const version = this.generation;
136
+ const c = this.reader();
137
+ this.patch({
138
+ loading: !options.background,
139
+ refreshing: this.hasSnapshot,
140
+ error: undefined,
141
+ moreLoading: false,
142
+ replyBusy: new Set(),
143
+ });
144
+ try {
145
+ const adapter = this.adapter;
146
+ let cursor: string | undefined;
147
+ let rows: CommentPageThread[] = [];
148
+ const seen = new Set<string>();
149
+ let count = 0;
150
+ do {
151
+ const page = await adapter.list({
152
+ resourceKey: this.resourceKey,
153
+ cursor,
154
+ signal: c.signal,
155
+ });
156
+ if (!this.valid(version)) return;
157
+ this.cursor(page.nextCursor, cursor, seen);
158
+ rows = mergeThreads(rows, page.threads);
159
+ cursor = page.nextCursor;
160
+ count++;
161
+ } while (cursor && count < this.pages);
162
+ const replyCursors = new Map<string, Set<string>>();
163
+ for (let i = 0; i < rows.length; i++) {
164
+ let t = rows[i];
165
+ let next = t.nextReplyCursor;
166
+ const seenReplies = new Set<string>();
167
+ if (next) seenReplies.add(next);
168
+ for (let n = 1; n < (this.replyPages.get(t.id) ?? 1) && next; n++) {
169
+ if (!adapter.listReplies)
170
+ throw new Error('adapter 返回回复游标但未提供 listReplies');
171
+ const p = await adapter.listReplies({
172
+ resourceKey: this.resourceKey,
173
+ threadId: t.id,
174
+ cursor: next,
175
+ signal: c.signal,
176
+ });
177
+ if (!this.valid(version)) return;
178
+ this.cursor(p.nextCursor, next, seenReplies);
179
+ t = {
180
+ ...t,
181
+ messages: mergeMessages(t.messages, p.messages),
182
+ nextReplyCursor: p.nextCursor,
183
+ };
184
+ next = p.nextCursor;
185
+ }
186
+ replyCursors.set(t.id, seenReplies);
187
+ rows[i] = t;
188
+ }
189
+ if (this.valid(version)) {
190
+ this.replyCursors = replyCursors;
191
+ this.pages = count;
192
+ this.pageCursors = seen;
193
+ this.hasSnapshot = true;
194
+ this.patch({
195
+ threads: rows,
196
+ nextCursor: cursor,
197
+ moreError: undefined,
198
+ replyErrors: new Map(),
199
+ });
200
+ }
201
+ } catch (e) {
202
+ if (this.valid(version)) {
203
+ this.patch({ error: message(e) });
204
+ throw e;
205
+ }
206
+ } finally {
207
+ this.readers.delete(c);
208
+ if (this.valid(version))
209
+ this.patch({ loading: false, refreshing: false });
210
+ }
211
+ };
212
+ loadMore = async () => {
213
+ if (
214
+ !this.active ||
215
+ this.writes ||
216
+ this.state.refreshing ||
217
+ this.state.loading ||
218
+ this.state.moreLoading ||
219
+ !this.state.nextCursor
220
+ )
221
+ return;
222
+ const cursor = this.state.nextCursor,
223
+ version = this.generation,
224
+ c = this.reader();
225
+ this.patch({ moreLoading: true, moreError: undefined });
226
+ try {
227
+ const page = await this.adapter.list({
228
+ resourceKey: this.resourceKey,
229
+ cursor,
230
+ signal: c.signal,
231
+ });
232
+ if (!this.valid(version)) return;
233
+ this.cursor(page.nextCursor, cursor, this.pageCursors);
234
+ this.pages++;
235
+ this.patch({
236
+ threads: mergeThreads(this.state.threads, page.threads, true),
237
+ nextCursor: page.nextCursor,
238
+ });
239
+ } catch (e) {
240
+ if (this.valid(version)) {
241
+ this.patch({ moreError: message(e) });
242
+ throw e;
243
+ }
244
+ } finally {
245
+ this.readers.delete(c);
246
+ if (this.valid(version)) this.patch({ moreLoading: false });
247
+ }
248
+ };
249
+ loadReplies = async (threadId: string) => {
250
+ const thread = this.state.threads.find((t) => t.id === threadId),
251
+ cursor = thread?.nextReplyCursor;
252
+ if (
253
+ !this.active ||
254
+ this.writes ||
255
+ this.state.loading ||
256
+ this.state.refreshing ||
257
+ this.state.replyBusy.has(threadId) ||
258
+ !cursor
259
+ )
260
+ return;
261
+ if (!this.adapter.listReplies)
262
+ throw new Error('adapter 返回回复游标但未提供 listReplies');
263
+ const version = this.generation,
264
+ c = this.reader(),
265
+ busy = new Set(this.state.replyBusy),
266
+ errors = new Map(this.state.replyErrors);
267
+ busy.add(threadId);
268
+ errors.delete(threadId);
269
+ this.patch({ replyBusy: busy, replyErrors: errors });
270
+ try {
271
+ const page = await this.adapter.listReplies({
272
+ resourceKey: this.resourceKey,
273
+ threadId,
274
+ cursor,
275
+ signal: c.signal,
276
+ });
277
+ if (!this.valid(version)) return;
278
+ const seen = new Set(this.replyCursors.get(threadId) ?? [cursor]);
279
+ this.cursor(page.nextCursor, cursor, seen);
280
+ this.replyCursors.set(threadId, seen);
281
+ this.replyPages.set(threadId, (this.replyPages.get(threadId) ?? 1) + 1);
282
+ this.patch({
283
+ threads: this.state.threads.map((t) =>
284
+ t.id === threadId
285
+ ? {
286
+ ...t,
287
+ messages: mergeMessages(t.messages, page.messages),
288
+ nextReplyCursor: page.nextCursor,
289
+ }
290
+ : t,
291
+ ),
292
+ });
293
+ } catch (e) {
294
+ if (this.valid(version)) {
295
+ const errors = new Map(this.state.replyErrors);
296
+ errors.set(threadId, message(e));
297
+ this.patch({ replyErrors: errors });
298
+ throw e;
299
+ }
300
+ } finally {
301
+ this.readers.delete(c);
302
+ if (this.valid(version)) {
303
+ const busy = new Set(this.state.replyBusy);
304
+ busy.delete(threadId);
305
+ this.patch({ replyBusy: busy });
306
+ }
307
+ }
308
+ };
309
+ async write<T>(
310
+ execute: (context: CommentMutationContext) => Promise<T>,
311
+ context: CommentMutationContext,
312
+ ): Promise<T> {
313
+ if (!this.active || context.signal.aborted) throw failure('评论会话已关闭');
314
+ this.writes++;
315
+ this.invalidate();
316
+ this.patch({
317
+ loading: false,
318
+ refreshing: false,
319
+ moreLoading: false,
320
+ replyBusy: new Set(),
321
+ });
322
+ const c = new AbortController(),
323
+ cancel = () => c.abort();
324
+ context.signal.addEventListener('abort', cancel, { once: true });
325
+ this.lifecycle.signal.addEventListener('abort', cancel, { once: true });
326
+ try {
327
+ return await execute({ ...context, signal: c.signal });
328
+ } finally {
329
+ context.signal.removeEventListener('abort', cancel);
330
+ this.lifecycle.signal.removeEventListener('abort', cancel);
331
+ this.writes--;
332
+ if (!this.writes) {
333
+ this.waiters.forEach((fn) => fn());
334
+ this.waiters.clear();
335
+ if (this.active)
336
+ void this.refresh({ background: true }).catch(() => {});
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ export function useComments({
343
+ resourceKey,
344
+ currentUser,
345
+ adapter,
346
+ }: UseCommentsOptions): CommentsController {
347
+ if (!resourceKey || !adapter.scopeKey)
348
+ throw new Error('评论 resourceKey 和 adapter.scopeKey 不能为空');
349
+ const identity = currentUser
350
+ ? JSON.stringify([
351
+ currentUser.ref.kind,
352
+ currentUser.ref.namespace,
353
+ currentUser.ref.id,
354
+ ])
355
+ : '';
356
+ const session = useMemo(
357
+ () => new ReadSession(adapter, resourceKey),
358
+ [adapter.scopeKey, resourceKey, identity],
359
+ );
360
+ session.adapter = adapter;
361
+ const state = useSyncExternalStore(
362
+ session.subscribe,
363
+ session.snapshot,
364
+ session.snapshot,
365
+ );
366
+ useEffect(() => {
367
+ session.activate();
368
+ void session.refresh().catch(() => {});
369
+ return () => session.dispose();
370
+ }, [session]);
371
+ const actions: CommentActions = useMemo(
372
+ () => ({
373
+ ...(adapter.create
374
+ ? {
375
+ create: (input, ctx) =>
376
+ session.write(
377
+ (c) => session.adapter.create!({ ...input, resourceKey }, c),
378
+ ctx,
379
+ ),
380
+ }
381
+ : {}),
382
+ ...(adapter.update
383
+ ? {
384
+ update: (input, ctx) =>
385
+ session.write(
386
+ (c) => session.adapter.update!({ ...input, resourceKey }, c),
387
+ ctx,
388
+ ),
389
+ }
390
+ : {}),
391
+ ...(adapter.remove
392
+ ? {
393
+ remove: (input, ctx) =>
394
+ session.write(
395
+ (c) => session.adapter.remove!({ ...input, resourceKey }, c),
396
+ ctx,
397
+ ),
398
+ }
399
+ : {}),
400
+ }),
401
+ [
402
+ session,
403
+ Boolean(adapter.create),
404
+ Boolean(adapter.update),
405
+ Boolean(adapter.remove),
406
+ ],
407
+ );
408
+ const props = useMemo<CommentsController['props']>(() => {
409
+ const extensions: CommentExtension[] = [];
410
+ if (adapter.documents)
411
+ extensions.push({
412
+ type: 'documents',
413
+ source: adapter.documents.source,
414
+ canInsert: adapter.documents.canInsert ?? true,
415
+ });
416
+ if (adapter.images) extensions.push({ type: 'images' });
417
+ if (adapter.reactions)
418
+ extensions.push({
419
+ type: 'reactions',
420
+ options: {
421
+ ...adapter.reactions,
422
+ ...(adapter.reactions.onChange
423
+ ? {
424
+ onChange: (input, ctx) =>
425
+ session.write(
426
+ (c) =>
427
+ session.adapter.reactions!.onChange!(
428
+ { ...input, resourceKey },
429
+ c,
430
+ ),
431
+ ctx,
432
+ ),
433
+ }
434
+ : {}),
435
+ },
436
+ });
437
+ const people = adapter.people;
438
+ return {
439
+ scopeKey: JSON.stringify([adapter.scopeKey, identity]),
440
+ subjectKey: resourceKey,
441
+ currentUser,
442
+ threads: state.threads.map((t) => ({
443
+ ...t,
444
+ ...(t.nextReplyCursor
445
+ ? {
446
+ repliesPagination: {
447
+ hasMore: true,
448
+ loading: state.replyBusy.has(t.id),
449
+ error: state.replyErrors.get(t.id),
450
+ onLoadMore: () => session.loadReplies(t.id),
451
+ },
452
+ }
453
+ : {}),
454
+ })),
455
+ actions,
456
+ loading: state.loading,
457
+ error: state.error,
458
+ onRetry: session.refresh,
459
+ pagination: state.nextCursor
460
+ ? {
461
+ hasMore: true,
462
+ loading: state.moreLoading,
463
+ error: state.moreError,
464
+ onLoadMore: session.loadMore,
465
+ }
466
+ : undefined,
467
+ actors: people?.source,
468
+ input: {
469
+ links: adapter.links ?? false,
470
+ ...(people && (people.source.search || people.source.recommend)
471
+ ? { userMentions: people.canMention ?? (() => true) }
472
+ : {}),
473
+ },
474
+ extensions,
475
+ onActorClick: people?.onClick,
476
+ getMessageLink: adapter.getMessageLink
477
+ ? (target) =>
478
+ session.adapter.getMessageLink!({ ...target, resourceKey })
479
+ : undefined,
480
+ validateDraft: adapter.validateDraft,
481
+ };
482
+ }, [session, state, adapter, currentUser, identity, actions, resourceKey]);
483
+ return useMemo(
484
+ () => ({
485
+ props,
486
+ refreshing: state.refreshing,
487
+ refresh: session.refresh,
488
+ loadMore: session.loadMore,
489
+ }),
490
+ [props, state.refreshing, session],
491
+ );
492
+ }
@@ -187,6 +187,7 @@ export function DiscussionComposer({
187
187
  replyMention,
188
188
  replyIntentKey,
189
189
  commentID,
190
+ replyToMessageId,
190
191
  onCancel,
191
192
  onSubmit,
192
193
  disabled,
@@ -202,6 +203,7 @@ export function DiscussionComposer({
202
203
  replyName?: string;
203
204
  replyIntentKey?: string;
204
205
  commentID?: string;
206
+ replyToMessageId?: string;
205
207
  replyMention?: Extract<CommentValue['content'][number], { type: 'mention' }>;
206
208
  onCancel?: () => void;
207
209
  onSubmit: (draft: DiscussionDraft) => Promise<void> | void;
@@ -280,7 +282,7 @@ export function DiscussionComposer({
280
282
  ownedUrls.current.delete(image.url),
281
283
  );
282
284
  try {
283
- await onSubmit({ value, images, private: privateComment, commentID });
285
+ await onSubmit({ value, images, private: privateComment, commentID, replyToMessageId });
284
286
  if (!alive.current) return;
285
287
  setImages([]);
286
288
  setValue(emptyComment());
@@ -329,6 +329,7 @@ function ReplyItem({
329
329
  )}
330
330
  <div className="replica-reactions">
331
331
  <CommentReactionList
332
+ emojis={panel.reactionEmojis}
332
333
  replyId={comment.replyID}
333
334
  reactions={comment.reactions}
334
335
  onToggle={
@@ -500,7 +501,7 @@ export function DiscussionComposerView({ placeholder, disabled }: { placeholder?
500
501
  const name = replyTarget ? replyPerson?.status === 'resolved' ? replyPerson.label : '评论者' : undefined;
501
502
  const author = replyTarget?.reply.author;
502
503
  const mentionAuthor = author && (!panel.currentUser || mentionKey(author) !== mentionKey(panel.currentUser)) && (panel.canMentionUser?.(author) ?? true);
503
- return <DiscussionComposer sessionKey="new" commentID={replyTarget?.commentID}
504
+ return <DiscussionComposer sessionKey="new" commentID={replyTarget?.commentID} replyToMessageId={replyTarget?.reply.replyID}
504
505
  replyIntentKey={replyTarget ? `${replyTarget.commentID}:${replyTarget.reply.replyID}:${replyRevision}` : undefined}
505
506
  replyMention={mentionAuthor && author ? { type: 'mention', ref: author,
506
507
  label: replyPerson?.status === 'resolved' ? replyPerson.label : undefined } : undefined}
@@ -15,6 +15,7 @@ interface Operation extends LocalCommentMutation {
15
15
  replyID: string;
16
16
  draft?: DiscussionDraft;
17
17
  author?: MentionRef;
18
+ operationId?: string;
18
19
  createTime: number;
19
20
  wholeThread?: boolean;
20
21
  deletionUnknown?: boolean;
@@ -61,13 +62,13 @@ export class CommentMutations {
61
62
  void this.run(operation);
62
63
  return operation.id;
63
64
  }
64
- add(thread: DiscussionThread | undefined, draft: DiscussionDraft, author: MentionRef | undefined, execute: () => Promise<CommentIDs>) {
65
+ add(thread: DiscussionThread | undefined, draft: DiscussionDraft, author: MentionRef | undefined, execute: () => Promise<CommentIDs>, operationId?: string) {
65
66
  if (thread) this.assertAvailable(thread.commentID);
66
67
  else if (!this.active) throw new Error('评论会话已关闭');
67
68
  const id = `local:${crypto.randomUUID()}`;
68
69
  const createTime = Math.floor(Date.now() / 1000);
69
70
  const commentID = thread?.commentID ?? `local:thread:${crypto.randomUUID()}`;
70
- return this.start({ id, kind: 'add', phase: 'sending', commentID, replyID: id,
71
+ return this.start({ id, operationId, kind: 'add', phase: 'sending', commentID, replyID: id,
71
72
  thread: thread ?? { commentID, creator: author ?? null, createTime, isSolved: false, replies: [] },
72
73
  draft: { ...draft, commentID: thread?.commentID, value: normalizeValue(draft.value), images: [...draft.images] }, author, createTime, execute });
73
74
  }
@@ -126,6 +127,19 @@ export class CommentMutations {
126
127
  this.release(operation); this.changed();
127
128
  }
128
129
  private acknowledged(operation: Operation, base: readonly DiscussionThread[]) {
130
+ // Only a server-echoed operation ID can correlate a write whose response was lost.
131
+ if (operation.kind === 'add' && operation.operationId) {
132
+ const matches = base.flatMap(thread => thread.replies.filter(reply =>
133
+ reply.clientOperationId === operation.operationId &&
134
+ (!operation.draft?.commentID || thread.commentID === operation.draft.commentID) &&
135
+ (!operation.author || (reply.author && mentionKey(reply.author) === mentionKey(operation.author)))
136
+ ).map(reply => ({ thread, reply })));
137
+ if (matches.length === 1) {
138
+ operation.commentID = matches[0].thread.commentID;
139
+ operation.replyID = matches[0].reply.replyID;
140
+ return true;
141
+ }
142
+ }
129
143
  const thread = base.find((item) => item.commentID === operation.commentID);
130
144
  const reply = thread?.replies.find((item) => item.replyID === operation.replyID);
131
145
  if (operation.kind === 'delete') return operation.wholeThread ? !thread : !reply;
@@ -145,7 +159,7 @@ export class CommentMutations {
145
159
  }
146
160
  reconcile(base: readonly DiscussionThread[]) {
147
161
  const finished = this.operations.filter((operation) => (operation.phase === 'confirmed' ||
148
- (operation.phase === 'uncertain' && operation.kind !== 'add')) && this.acknowledged(operation, base));
162
+ (operation.phase === 'uncertain' && (operation.kind !== 'add' || Boolean(operation.operationId)))) && this.acknowledged(operation, base));
149
163
  if (!finished.length) return;
150
164
  this.operations = this.operations.filter((operation) => !finished.includes(operation));
151
165
  finished.forEach((operation) => this.release(operation)); this.changed();
@@ -21,6 +21,7 @@ export interface CommentReaction {
21
21
  }
22
22
 
23
23
  export interface CommentEmoji {
24
+ text?: string;
24
25
  key: string;
25
26
  label: string;
26
27
  group?: string;
@@ -55,10 +56,10 @@ export function CommentReactionEmoji({
55
56
  style={{ height: size, width: 'auto' }}
56
57
  />
57
58
  );
58
- if (!entry)
59
+ if (emoji?.text || !entry)
59
60
  return (
60
61
  <span className="text-xs" role="img" aria-label={label}>
61
- [{label}]
62
+ {emoji?.text || `[${label}]`}
62
63
  </span>
63
64
  );
64
65
  const [, , x, y, width, height] = entry;
@@ -273,6 +274,7 @@ export function CommentReactionEntry({
273
274
  const { store } = useMentionContext();
274
275
  const hover = useReactionHover();
275
276
  const keyboardOpen = useRef(false);
277
+ const quick = emojis.find(e=>e.key==='THUMBSUP') ?? emojis[0];
276
278
  useEffect(() => {
277
279
  onOpenChange?.(hover.open);
278
280
  }, [hover.open, onOpenChange]);
@@ -290,7 +292,7 @@ export function CommentReactionEntry({
290
292
  type="button"
291
293
  aria-label="表情回复"
292
294
  title="表情回复(点击点赞,悬停选择)"
293
- disabled={disabled}
295
+ disabled={disabled || !quick}
294
296
  className={cn(
295
297
  COMMENT_THEME_CLASS,
296
298
  'inline-flex size-[14px] items-center justify-center text-[var(--subtle-foreground)] hover:text-foreground disabled:text-muted-foreground',
@@ -311,7 +313,7 @@ export function CommentReactionEntry({
311
313
  event.preventDefault();
312
314
  event.stopPropagation();
313
315
  hover.close();
314
- onToggle('THUMBSUP');
316
+ if(quick)onToggle(quick.key);
315
317
  }}
316
318
  >
317
319
  <ReactionIcon />
@@ -16,6 +16,7 @@ export interface DiscussionReply {
16
16
  mutation?: LocalCommentMutation;
17
17
  mutationBlocked?: boolean;
18
18
  replyID: string;
19
+ clientOperationId?: string;
19
20
  author: MentionRef | null;
20
21
  link?: string;
21
22
  /** Explicit capabilities supplied by the host/backend, independent of display names. */
@@ -41,6 +42,7 @@ export interface DiscussionThread {
41
42
  replies: DiscussionReply[];
42
43
  }
43
44
  export interface DiscussionDraft {
45
+ replyToMessageId?: string;
44
46
  commentID?: string;
45
47
  value: CommentValue;
46
48
  images: DiscussionImage[];