@lark-apaas/coding-template-nestjs-react-fullstack 0.1.59 → 0.1.60-alpha.20261105123111

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 (30) hide show
  1. package/package.json +1 -1
  2. package/template/client/src/components/business-ui/README.md +2 -0
  3. package/template/client/src/components/business-ui/comments/README.md +36 -0
  4. package/template/client/src/components/business-ui/comments/comments.tsx +626 -0
  5. package/template/client/src/components/business-ui/comments/contracts.ts +165 -0
  6. package/template/client/src/components/business-ui/comments/index.ts +19 -0
  7. package/template/client/src/components/business-ui/record-comments/README.md +141 -0
  8. package/template/client/src/components/business-ui/record-comments/app-source.ts +93 -0
  9. package/template/client/src/components/business-ui/record-comments/assets/reaction-sprite.png +0 -0
  10. package/template/client/src/components/business-ui/record-comments/comment-panel.contract.ts +240 -0
  11. package/template/client/src/components/business-ui/record-comments/comment-panel.tsx +231 -0
  12. package/template/client/src/components/business-ui/record-comments/extensions.ts +130 -0
  13. package/template/client/src/components/business-ui/record-comments/index.ts +9 -0
  14. package/template/client/src/components/business-ui/record-comments/internal/composer.tsx +529 -0
  15. package/template/client/src/components/business-ui/record-comments/internal/content.tsx +213 -0
  16. package/template/client/src/components/business-ui/record-comments/internal/discussion.tsx +522 -0
  17. package/template/client/src/components/business-ui/record-comments/internal/editor.tsx +681 -0
  18. package/template/client/src/components/business-ui/record-comments/internal/icons.tsx +148 -0
  19. package/template/client/src/components/business-ui/record-comments/internal/images.tsx +155 -0
  20. package/template/client/src/components/business-ui/record-comments/internal/mentions.tsx +163 -0
  21. package/template/client/src/components/business-ui/record-comments/internal/model.ts +581 -0
  22. package/template/client/src/components/business-ui/record-comments/internal/mutations.ts +184 -0
  23. package/template/client/src/components/business-ui/record-comments/internal/reaction-catalog.ts +196 -0
  24. package/template/client/src/components/business-ui/record-comments/internal/reactions.tsx +681 -0
  25. package/template/client/src/components/business-ui/record-comments/internal/styles.css +1109 -0
  26. package/template/client/src/components/business-ui/record-comments/internal/suggestions.tsx +456 -0
  27. package/template/client/src/components/business-ui/record-comments/internal/view-types.ts +89 -0
  28. package/template/client/src/components/business-ui/record-comments/types.ts +121 -0
  29. package/template/package-lock.json +1449 -216
  30. package/template/package.json +16 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-template-nestjs-react-fullstack",
3
- "version": "0.1.59",
3
+ "version": "0.1.60-alpha.20261105123111",
4
4
  "description": "NestJS + React fullstack template for miaoda coding",
5
5
  "type": "module",
6
6
  "files": [
@@ -1,5 +1,7 @@
1
1
  # 业务组件文档
2
2
 
3
+ 记录评论组件入口:`@/components/business-ui/record-comments`。使用前阅读 [组件接口与接入说明](record-comments/README.md)。评论请求与分页游标留在宿主,组件负责回复、编辑删除、人员提及、乐观更新及可选表情/文档引用。
4
+
3
5
  本文档包含用户选择、部门选择、群组选择、用户展示等业务组件的使用说明。
4
6
 
5
7
  ---
@@ -0,0 +1,36 @@
1
+ # 通用评论入口(OpenAPI pilot)
2
+
3
+ 与 `record-comments` 共享 UI/CSS 和乐观更新逻辑。新的公开数据契约不包含 Action、插件实例或飞书数字用户 ID。旧入口继续兼容;此试接入不搬迁全部内部文件,不自动迁移应用。
4
+
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>
21
+ ```
22
+
23
+ `CommentsProvider` 不输出布局 DOM。也可用 `CommentPanel` 提供默认完整布局。类型以 `contracts.ts` 为准。
24
+
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 试接入没有图片功能;通用文件附件暂不编辑,避免丢失内容。
35
+
36
+ 真实 OpenAPI 示例和验收工具在 `scripts/__tests__/fixtures/record-comments/openapi/`(仓库测试资产,不随模板运行时携带)。不要让部署后的应用执行 lark-cli,也不要把 token/app secret 放到浏览器。生产应用用自己的后端 OAuth/OpenAPI SDK 替换示例 request。
@@ -0,0 +1,626 @@
1
+ import type {
2
+ CommentsProviderProps,
3
+ CommentThread,
4
+ CommentContent,
5
+ CommentDraft,
6
+ EntityRef,
7
+ UserRef,
8
+ CommentEntitySource,
9
+ CommentReaction,
10
+ CommentTarget,
11
+ CommentWriteResult,
12
+ CommentMessage,
13
+ } from './contracts';
14
+ import type {
15
+ CommentDataSource,
16
+ CommentValue,
17
+ MentionRef,
18
+ MentionOption,
19
+ } from '../record-comments/types';
20
+ import type {
21
+ DiscussionThread,
22
+ DiscussionDraft,
23
+ DiscussionReply,
24
+ } from '../record-comments/internal/view-types';
25
+ import {
26
+ useEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ useSyncExternalStore,
31
+ } from 'react';
32
+ import { CommentMutations } from '../record-comments/internal/mutations';
33
+ import {
34
+ DiscussionProvider,
35
+ DiscussionLayout,
36
+ } from '../record-comments/internal/discussion';
37
+ import { ReadStatus, PaginationFooter } from '../record-comments/comment-panel';
38
+ import { withDocumentSource } from '../record-comments/extensions';
39
+ import { DEFAULT_REACTION_EMOJIS } from '../record-comments/internal/reactions';
40
+ import { normalizeValue } from '../record-comments/internal/model';
41
+
42
+ // Encoding is private UI identity, not a backend ID conversion. Namespace stays lossless.
43
+ const toRef = (ref: EntityRef): MentionRef => {
44
+ if (!ref.id || !ref.namespace) throw new Error('引用标识不能为空');
45
+ return ref.kind === 'user'
46
+ ? {
47
+ kind: 'user',
48
+ idType: 'generic',
49
+ id: JSON.stringify([ref.namespace, ref.id]),
50
+ }
51
+ : { kind: 'document', resourceType: ref.namespace, id: ref.id };
52
+ };
53
+ const fromRef = (ref: MentionRef): EntityRef => {
54
+ if (ref.kind === 'document')
55
+ return { kind: 'document', namespace: ref.resourceType, id: ref.id };
56
+ if (ref.idType !== 'generic')
57
+ return { kind: 'user', namespace: ref.idType, id: ref.id };
58
+ const [namespace, id] = JSON.parse(ref.id);
59
+ return { kind: 'user', namespace, id };
60
+ };
61
+ const value = (content: CommentContent): CommentValue =>
62
+ normalizeValue({
63
+ version: 1,
64
+ content: content.nodes.map((n) =>
65
+ n.type === 'mention'
66
+ ? { ...n, ref: toRef(n.ref) }
67
+ : n.type === 'unsupported'
68
+ ? { type: 'text', text: n.label }
69
+ : n,
70
+ ),
71
+ });
72
+ const content = (v: CommentValue): CommentContent => ({
73
+ version: 1,
74
+ nodes: normalizeValue(v).content.map((n) =>
75
+ n.type === 'mention' ? { ...n, ref: fromRef(n.ref) } : n,
76
+ ),
77
+ });
78
+ const fail = (message: string) =>
79
+ Object.assign(new Error(message), { outcome: 'not-applied' });
80
+ const reactionState = (items: CommentReaction[]) =>
81
+ JSON.stringify(
82
+ items
83
+ .map((r) => [r.key, r.count, r.mine])
84
+ .sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
85
+ );
86
+ const key = (ref: EntityRef) =>
87
+ JSON.stringify([ref.kind, ref.namespace, ref.id]);
88
+ export function CommentsProvider(props: CommentsProviderProps) {
89
+ return (
90
+ <Session
91
+ key={JSON.stringify([props.scopeKey, props.subjectKey])}
92
+ {...props}
93
+ />
94
+ );
95
+ }
96
+ function Session(props: CommentsProviderProps) {
97
+ const latest = useRef(props);
98
+ latest.current = props;
99
+ const [mutations] = useState(
100
+ () =>
101
+ new CommentMutations({
102
+ opaqueIDs: true,
103
+ unknownDeletion: true,
104
+ unknownErrors: true,
105
+ removePending: true,
106
+ }),
107
+ );
108
+ useSyncExternalStore(
109
+ mutations.subscribe,
110
+ mutations.getVersion,
111
+ mutations.getVersion,
112
+ );
113
+ useEffect(() => {
114
+ mutations.activate();
115
+ return () => mutations.dispose();
116
+ }, [mutations]);
117
+ const abort = useRef(new AbortController());
118
+ useEffect(() => {
119
+ const controller = new AbortController();
120
+ abort.current = controller;
121
+ return () => controller.abort();
122
+ }, []);
123
+ const context = () => ({
124
+ operationId: crypto.randomUUID(),
125
+ signal: abort.current.signal,
126
+ });
127
+ const extensions = new Map(props.extensions?.map((x) => [x.type, x]));
128
+ if (extensions.size !== (props.extensions?.length ?? 0))
129
+ throw new Error('同种评论扩展只能注册一次');
130
+ const documents = props.extensions?.find((x) => x.type === 'documents');
131
+ const reactions = props.extensions?.find((x) => x.type === 'reactions');
132
+ const images = extensions.has('images');
133
+ const names = useRef(new Map<string, MentionOption>());
134
+ names.current = new Map();
135
+ for (const author of [
136
+ props.currentUser,
137
+ ...props.threads.flatMap((t) =>
138
+ t.messages.flatMap((m) => [
139
+ m.author,
140
+ ...(m.reactions ?? []).flatMap((r) => r.previewUsers ?? []),
141
+ ]),
142
+ ),
143
+ ])
144
+ if (author?.name)
145
+ names.current.set(key(author.ref), {
146
+ ref: toRef(author.ref),
147
+ label: author.name || '用户',
148
+ avatarUrl: author.avatarUrl,
149
+ });
150
+ const source = useMemo(() => {
151
+ function wrap(
152
+ external: CommentEntitySource | undefined,
153
+ user: boolean,
154
+ ): CommentDataSource {
155
+ const option = (o: import('./contracts').CommentEntityOption) => ({
156
+ ...o,
157
+ ref: toRef(o.ref),
158
+ });
159
+ const allowed = (o: import('./contracts').CommentEntityOption) =>
160
+ o.ref.kind === 'user'
161
+ ? Boolean(latest.current.input?.userMentions?.(o.ref as UserRef))
162
+ : true;
163
+ return {
164
+ scopeKey: props.scopeKey + (user ? ':actors' : ':documents'),
165
+ allowLinks: props.input?.links ?? false,
166
+ search: async (args) => {
167
+ if (user && !latest.current.input?.userMentions) return { items: [] };
168
+ const result = await external?.search?.(args);
169
+ return {
170
+ items: (result?.items ?? []).filter(allowed).map(option),
171
+ nextCursor: result?.nextCursor,
172
+ };
173
+ },
174
+ ...(external?.recommend
175
+ ? {
176
+ recommend: async (args) => {
177
+ if (user && !latest.current.input?.userMentions)
178
+ return { items: [] };
179
+ const r = await external.recommend!(args);
180
+ return {
181
+ items: r.items.filter(allowed).map(option),
182
+ nextCursor: r.nextCursor,
183
+ };
184
+ },
185
+ }
186
+ : {}),
187
+ parseLink: (url) => {
188
+ const ref = external?.parseUrl?.(url);
189
+ return ref
190
+ ? { type: 'mention', ref: toRef(ref), href: url }
191
+ : undefined;
192
+ },
193
+ resolve: async (refs, signal) => {
194
+ const found = new Map(
195
+ refs.flatMap((ref) => {
196
+ const r = names.current.get(key(fromRef(ref)));
197
+ return r
198
+ ? [
199
+ [
200
+ key(fromRef(ref)),
201
+ { ...r, status: 'resolved' as const },
202
+ ] as const,
203
+ ]
204
+ : [];
205
+ }),
206
+ );
207
+ const missing = refs.filter((r) => !found.has(key(fromRef(r))));
208
+ if (missing.length && external)
209
+ for (const r of await external.resolve(
210
+ missing.map(fromRef),
211
+ signal,
212
+ ))
213
+ found.set(key(r.ref), { ...r, ref: toRef(r.ref) } as never);
214
+ signal.throwIfAborted();
215
+ return refs.map(
216
+ (ref) =>
217
+ found.get(key(fromRef(ref))) ?? { ref, status: 'error' as const },
218
+ );
219
+ },
220
+ };
221
+ }
222
+ const people = wrap(props.actors, true);
223
+ return documents
224
+ ? {
225
+ ...withDocumentSource(people, wrap(documents.source, false)),
226
+ allowLinks: props.input?.links ?? false,
227
+ }
228
+ : people;
229
+ }, [props.actors, props.scopeKey, props.input?.links, documents?.source]);
230
+ const [submitted, setSubmitted] = useState<string>();
231
+ const [reactionVersion, setReactionVersion] = useState(0);
232
+ const reactionOverlays = useRef(
233
+ new Map<
234
+ string,
235
+ {
236
+ data: CommentReaction[];
237
+ before: CommentReaction[];
238
+ confirmed: boolean;
239
+ uncertain?: boolean;
240
+ key: string;
241
+ action: 'add' | 'remove';
242
+ }
243
+ >(),
244
+ );
245
+ const reactionBusy = useRef(new Set<string>());
246
+ const reactKey = (target: CommentTarget) =>
247
+ JSON.stringify([target.threadId, target.messageId]);
248
+ const dataToReaction = (data: CommentReaction[]) =>
249
+ data
250
+ .filter((r) => r.count > 0)
251
+ .map((r) => ({
252
+ key: r.key,
253
+ count: r.count,
254
+ mine: r.mine === true,
255
+ users: (r.previewUsers ?? []).map((u) => toRef(u.ref).id),
256
+ }));
257
+ const toMessage = (m: CommentMessage, threadId: string): DiscussionReply => {
258
+ const target = { threadId, messageId: m.id };
259
+ const overlay = reactionOverlays.current.get(reactKey(target));
260
+ const canEdit = Boolean(
261
+ m.permissions?.edit &&
262
+ props.actions?.update &&
263
+ !m.content.nodes.some((n) => n.type === 'unsupported') &&
264
+ (!m.attachments?.length ||
265
+ (images &&
266
+ m.attachments.every((a) => a.kind === 'image' && Boolean(a.url)))),
267
+ );
268
+ return {
269
+ replyID: m.id,
270
+ author: m.author ? toRef(m.author.ref) : null,
271
+ createTime: m.createdAt / 1000,
272
+ updateTime: m.updatedAt === undefined ? undefined : m.updatedAt / 1000,
273
+ edited: m.edited || Boolean(m.updatedAt && m.updatedAt > m.createdAt),
274
+ value: value({
275
+ version: 1,
276
+ nodes: [
277
+ ...m.content.nodes,
278
+ ...(m.attachments ?? [])
279
+ .filter((a) => a.kind !== 'image' || !a.url)
280
+ .map((a) => ({
281
+ type: 'unsupported' as const,
282
+ label: `[附件暂未接入:${a.name}]`,
283
+ })),
284
+ ],
285
+ }),
286
+ images: (m.attachments ?? [])
287
+ .filter((a) => a.kind === 'image' && a.url)
288
+ .map((a) => ({ ...a, url: a.url! })),
289
+ reactions: dataToReaction(overlay?.data ?? m.reactions ?? []),
290
+ permissions: {
291
+ canEdit,
292
+ canDelete: Boolean(m.permissions?.delete && props.actions?.remove),
293
+ canCopyLink: Boolean(props.getMessageLink?.(target)),
294
+ canReact: Boolean(
295
+ reactions?.options.onChange &&
296
+ m.permissions?.react &&
297
+ !overlay?.uncertain &&
298
+ m.reactions !== undefined &&
299
+ !m.reactions.some((r) => r.mine === undefined),
300
+ ),
301
+ },
302
+ };
303
+ };
304
+ const base: DiscussionThread[] = props.threads.map((t) => ({
305
+ commentID: t.id,
306
+ canReply: t.canReply,
307
+ creator: t.messages[0]?.author ? toRef(t.messages[0].author.ref) : null,
308
+ createTime: (t.messages[0]?.createdAt ?? 0) / 1000,
309
+ isSolved: !!t.resolved,
310
+ listFooter: t.repliesPagination?.hasMore ? (
311
+ <PaginationFooter pagination={t.repliesPagination} />
312
+ ) : undefined,
313
+ replies: t.messages.map((m) => toMessage(m, t.id)),
314
+ }));
315
+ useEffect(() => {
316
+ mutations.reconcile(base);
317
+ });
318
+ useEffect(() => {
319
+ for (const [k, o] of reactionOverlays.current)
320
+ if (o.confirmed) {
321
+ const [tid, mid] = JSON.parse(k);
322
+ const data = props.threads
323
+ .find((t) => t.id === tid)
324
+ ?.messages.find((m) => m.id === mid)?.reactions;
325
+ if (
326
+ data &&
327
+ (reactionState(data) === reactionState(o.data) ||
328
+ (o.action === 'add'
329
+ ? data.find((r) => r.key === o.key)?.mine === true
330
+ : !data.some((r) => r.key === o.key) ||
331
+ data.find((r) => r.key === o.key)?.mine === false))
332
+ ) {
333
+ reactionOverlays.current.delete(k);
334
+ reactionBusy.current.delete(k);
335
+ setReactionVersion((v) => v + 1);
336
+ }
337
+ }
338
+ }, [props.threads, reactionVersion]);
339
+ const readDraft = (
340
+ draft: DiscussionDraft,
341
+ mode: 'create' | 'update',
342
+ target: Partial<CommentTarget>,
343
+ ): CommentDraft => {
344
+ const result: CommentDraft = {
345
+ content: content(draft.value),
346
+ attachments: draft.images.map((i) =>
347
+ i.file
348
+ ? { kind: 'local', localId: i.id, file: i.file }
349
+ : {
350
+ kind: 'existing',
351
+ attachment: {
352
+ id: i.id,
353
+ kind: 'image',
354
+ name: i.name,
355
+ url: i.url,
356
+ width: i.width,
357
+ height: i.height,
358
+ },
359
+ },
360
+ ),
361
+ };
362
+ for (const n of result.content.nodes) {
363
+ if (n.type === 'link' && !latest.current.input?.links)
364
+ throw fail('当前接口不支持结构化链接');
365
+ if (
366
+ n.type === 'mention' &&
367
+ n.ref.kind === 'document' &&
368
+ !documents?.canInsert
369
+ )
370
+ throw fail('当前接口不支持插入文档引用');
371
+ if (n.type === 'unresolved-mention' || n.type === 'unsupported')
372
+ throw fail('请重新选择无法识别的引用');
373
+ if (
374
+ n.type === 'mention' &&
375
+ n.ref.kind === 'user' &&
376
+ !latest.current.input?.userMentions?.(n.ref as UserRef)
377
+ )
378
+ throw fail('当前接口不支持此人员引用');
379
+ }
380
+ const error = latest.current.validateDraft?.(result, { mode, ...target });
381
+ if (error) throw fail(error);
382
+ return result;
383
+ };
384
+ const accept = async (result: CommentWriteResult, target?: CommentTarget) => {
385
+ if (
386
+ target &&
387
+ (result.threadId !== target.threadId ||
388
+ result.messageId !== target.messageId)
389
+ )
390
+ throw Object.assign(new Error('写入目标不匹配'), { outcome: 'unknown' });
391
+ if (result.message && result.message.id !== result.messageId)
392
+ throw Object.assign(new Error('回读消息 ID 不匹配'), {
393
+ outcome: 'unknown',
394
+ });
395
+ return {
396
+ commentID: result.threadId,
397
+ replyID: result.messageId,
398
+ ...(result.message
399
+ ? { authoritativeReply: toMessage(result.message, result.threadId) }
400
+ : {}),
401
+ };
402
+ };
403
+ return (
404
+ <DiscussionProvider
405
+ panel={{
406
+ source,
407
+ currentUser: props.currentUser
408
+ ? toRef(props.currentUser.ref)
409
+ : undefined,
410
+ comments: mutations.view(base),
411
+ allowCompose: !!props.actions?.create,
412
+ optimisticMutations: true,
413
+ canMentionUser: (r) =>
414
+ r.kind === 'user' &&
415
+ 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),
419
+ )
420
+ : undefined,
421
+ showReactions: !!reactions,
422
+ allowReact: !!reactions?.options.onChange,
423
+ enableImages: images && !!props.actions?.create,
424
+ enableEditImages: images && !!props.actions?.update,
425
+ enableDocuments: documents?.canInsert ?? false,
426
+ submittedReplyID: submitted ? mutations.replyID(submitted) : undefined,
427
+ onScrolledToReply: () => setSubmitted(undefined),
428
+ onRetryMutation: (id) => mutations.retry(id),
429
+ autoScrollNewReplies: false,
430
+ listHeader: (
431
+ <ReadStatus
432
+ loading={props.loading}
433
+ error={props.error}
434
+ onRetry={props.onRetry}
435
+ empty={
436
+ !base.some((t) => t.replies.length) &&
437
+ !mutations.view(base).length
438
+ }
439
+ hasMore={props.pagination?.hasMore}
440
+ />
441
+ ),
442
+ listFooter: props.pagination ? (
443
+ <PaginationFooter
444
+ pagination={props.pagination}
445
+ loading={props.loading || !!props.error}
446
+ />
447
+ ) : undefined,
448
+ emptyState: null,
449
+ onMentionClick: (ref, anchor) => {
450
+ if (ref.kind === 'user')
451
+ props.onActorClick?.(fromRef(ref) as UserRef, anchor);
452
+ },
453
+ getCommentLink: (threadId, m) =>
454
+ props.getMessageLink?.({ threadId, messageId: m.replyID }) ?? '',
455
+ resolveUserRef: (id) => ({ kind: 'user', idType: 'generic', id }),
456
+ onSubmit: (draft) => {
457
+ if (
458
+ draft.commentID &&
459
+ !latest.current.threads.some(
460
+ (t) =>
461
+ t.id === draft.commentID && t.canReply !== false && !t.resolved,
462
+ )
463
+ )
464
+ throw fail('该讨论当前不可回复');
465
+ const input = readDraft(
466
+ draft,
467
+ 'create',
468
+ draft.commentID ? { threadId: draft.commentID } : {},
469
+ );
470
+ const ctx = context();
471
+ const id = mutations.add(
472
+ base.find((t) => t.commentID === draft.commentID),
473
+ draft,
474
+ props.currentUser ? toRef(props.currentUser.ref) : undefined,
475
+ async () =>
476
+ accept(
477
+ await latest.current.actions!.create!(
478
+ {
479
+ ...input,
480
+ ...(draft.commentID ? { threadId: draft.commentID } : {}),
481
+ },
482
+ ctx,
483
+ ),
484
+ ),
485
+ );
486
+ setSubmitted(id);
487
+ },
488
+ onEdit: (threadId, messageId, v, attachments) => {
489
+ const input = readDraft(
490
+ { value: v, images: attachments, private: false },
491
+ 'update',
492
+ { threadId, messageId },
493
+ );
494
+ const ctx = context();
495
+ mutations.edit(
496
+ base.find((t) => t.commentID === threadId)!,
497
+ messageId,
498
+ { value: v, images: attachments, private: false },
499
+ async () =>
500
+ accept(
501
+ await latest.current.actions!.update!(
502
+ { ...input, threadId, messageId },
503
+ ctx,
504
+ ),
505
+ { threadId, messageId },
506
+ ),
507
+ );
508
+ },
509
+ onDelete: (threadId, messageId) => {
510
+ if (!messageId) throw fail('未选择消息');
511
+ const ctx = context();
512
+ mutations.delete(
513
+ base.find((t) => t.commentID === threadId)!,
514
+ messageId,
515
+ async () => {
516
+ const r = await latest.current.actions!.remove!(
517
+ { threadId, messageId },
518
+ ctx,
519
+ );
520
+ return {
521
+ commentID: r.threadId,
522
+ ...(r.threadDeleted === undefined
523
+ ? {}
524
+ : { commentDeleted: r.threadDeleted }),
525
+ } as never;
526
+ },
527
+ );
528
+ },
529
+ onToggleReaction: async (threadId, messageId, emoji) => {
530
+ const target = { threadId, messageId };
531
+ const k = reactKey(target);
532
+ if (reactionBusy.current.has(k)) return;
533
+ const m = latest.current.threads
534
+ .find((t) => t.id === threadId)
535
+ ?.messages.find((m) => m.id === messageId);
536
+ if (
537
+ !m?.permissions?.react ||
538
+ !m.reactions ||
539
+ m.reactions.some((r) => r.mine === undefined) ||
540
+ !reactions?.options.onChange
541
+ )
542
+ throw fail('回应状态或权限未知');
543
+ const before = reactionOverlays.current.get(k)?.data ?? m.reactions;
544
+ const old = before.find((r) => r.key === emoji);
545
+ const action = old?.mine ? 'remove' : 'add';
546
+ const next = before.filter((r) => r.key !== emoji);
547
+ const count = (old?.count ?? 0) + (action === 'add' ? 1 : -1);
548
+ if (count > 0)
549
+ next.push({
550
+ key: emoji,
551
+ count,
552
+ mine: action === 'add',
553
+ previewUsers:
554
+ action === 'add' && props.currentUser
555
+ ? [
556
+ props.currentUser,
557
+ ...(old?.previewUsers ?? []).filter(
558
+ (u) => key(u.ref) !== key(props.currentUser!.ref),
559
+ ),
560
+ ]
561
+ : (old?.previewUsers ?? []).filter(
562
+ (u) =>
563
+ !props.currentUser ||
564
+ key(u.ref) !== key(props.currentUser.ref),
565
+ ),
566
+ });
567
+ reactionBusy.current.add(k);
568
+ reactionOverlays.current.set(k, {
569
+ data: next,
570
+ before,
571
+ confirmed: false,
572
+ key: emoji,
573
+ action,
574
+ });
575
+ setReactionVersion((v) => v + 1);
576
+ try {
577
+ const r = await reactions.options.onChange(
578
+ { ...target, key: emoji, action },
579
+ context(),
580
+ );
581
+ if (!abort.current.signal.aborted) {
582
+ reactionOverlays.current.set(k, {
583
+ data: r.reactions ?? next,
584
+ before,
585
+ confirmed: true,
586
+ key: emoji,
587
+ action,
588
+ });
589
+ setReactionVersion((v) => v + 1);
590
+ }
591
+ } catch (e) {
592
+ if (!abort.current.signal.aborted) {
593
+ if ((e as { outcome?: string })?.outcome === 'not-applied')
594
+ reactionOverlays.current.delete(k);
595
+ else
596
+ reactionOverlays.current.set(k, {
597
+ data: next,
598
+ before,
599
+ confirmed: true,
600
+ uncertain: true,
601
+ key: emoji,
602
+ action,
603
+ });
604
+ setReactionVersion((v) => v + 1);
605
+ }
606
+ throw e;
607
+ } finally {
608
+ if (!reactionOverlays.current.get(k)?.uncertain)
609
+ reactionBusy.current.delete(k);
610
+ }
611
+ },
612
+ }}
613
+ >
614
+ {props.children}
615
+ </DiscussionProvider>
616
+ );
617
+ }
618
+ export function CommentPanel(props: Omit<CommentsProviderProps, 'children'>) {
619
+ return (
620
+ <CommentsProvider {...props}>
621
+ <div className="replica-comment-panel">
622
+ <DiscussionLayout />
623
+ </div>
624
+ </CommentsProvider>
625
+ );
626
+ }