koishi-plugin-chat-patch 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,678 +1,683 @@
1
- import { ref, computed, onMounted, nextTick, onUnmounted, reactive, watch } from 'vue'
2
- import { receive, send } from '@koishijs/client'
3
- import { useChatData } from './composables/useChatData'
4
- import { useChatActions } from './composables/useChatActions'
5
- import { useImageCache } from './composables/useImageCache'
6
- import { ElMessage } from 'element-plus'
7
-
8
- export function useChatLogic() {
9
- const {
10
- bots, pinnedBots, pinnedChannels, getChannels, getMessages,
11
- loadInitialData, loadConfig, addMessage, loadHistory, getPagination
12
- } = useChatData()
13
-
14
- const {
15
- isSending, sendMessage, deleteBotData, deleteChannelData,
16
- togglePinBot, togglePinChannel
17
- } = useChatActions()
18
-
19
- const { getCachedImageUrl, cacheImage } = useImageCache()
20
-
21
- // 状态管理
22
- const menu = ref({ show: false, x: 0, y: 0, type: '', id: '', isPinned: false, hasMedia: false })
23
- const selectedBot = ref('')
24
- const selectedChannel = ref('')
25
- const inputText = ref('')
26
- const uploadedImages = ref<any[]>([])
27
- const scrollRef = ref<any>(null)
28
- const inputRef = ref<any>(null)
29
- const isMobile = ref(false)
30
- const mobileView = ref<'bots' | 'channels' | 'messages' | 'forward' | 'image' | 'profile' | 'raw'>('bots')
31
- const isLoadingHistory = ref(false)
32
- const keyboardHeight = ref(0) // 键盘高度
33
-
34
- // 合并转发详情状态
35
- const forwardData = reactive({
36
- messages: [] as any[]
37
- })
38
- const forwardDialogVisible = ref(false)
39
-
40
- // 图片查看器状态
41
- const imageViewer = reactive({
42
- url: ''
43
- })
44
- const imageViewerVisible = ref(false)
45
- const imageZoom = ref(1)
46
-
47
- // 原始消息查看状态
48
- const rawMessage = reactive({
49
- content: ''
50
- })
51
- const rawMessageVisible = ref(false)
52
-
53
- // 引用/回复状态
54
- const replyingTo = ref<any>(null)
55
-
56
- // 用户资料状态
57
- const userProfile = reactive({
58
- data: null as any
59
- })
60
- const userProfileVisible = ref(false)
61
-
62
- // 计算属性
63
- const currentChannels = computed(() => getChannels(selectedBot.value))
64
- const currentMessages = computed(() => getMessages(selectedBot.value, selectedChannel.value))
65
- const currentChannelName = computed(() => {
66
- const c = currentChannels.value.find(i => i.id === selectedChannel.value)
67
- return c ? c.name : ''
68
- })
69
- const selectedBotPlatform = computed(() => {
70
- const bot = bots.value.find(b => b.selfId === selectedBot.value)
71
- return bot?.platform || 'unknown'
72
- })
73
-
74
- // 方法
75
- const selectBot = (id: string) => {
76
- selectedBot.value = id
77
- selectedChannel.value = ''
78
- if (isMobile.value) mobileView.value = 'channels'
79
- }
80
-
81
- const selectChannel = async (id: string) => {
82
- selectedChannel.value = id
83
- if (isMobile.value) mobileView.value = 'messages'
84
-
85
- // 如果没有消息,加载第一页
86
- if (currentMessages.value.length === 0) {
87
- await loadHistory(selectedBot.value, id)
88
- }
89
-
90
- await nextTick()
91
- scrollToBottom()
92
-
93
- // 针对图片加载导致的滚动偏移,在 300ms 和 800ms 后再次校准底部
94
- setTimeout(scrollToBottom, 300)
95
- setTimeout(scrollToBottom, 800)
96
- }
97
-
98
- const goBack = () => {
99
- if (isMobile.value && mobileView.value !== 'bots') {
100
- // 手机端使用 history.back(),由 popstate 监听器处理视图切换
101
- window.history.back()
102
- } else {
103
- // PC 端或初始页面的逻辑
104
- if (mobileView.value === 'image') {
105
- mobileView.value = 'messages'
106
- imageZoom.value = 1
107
- }
108
- else if (mobileView.value === 'forward') mobileView.value = 'messages'
109
- else if (mobileView.value === 'profile') mobileView.value = 'messages'
110
- else if (mobileView.value === 'raw') mobileView.value = 'messages'
111
- else if (mobileView.value === 'messages') mobileView.value = 'channels'
112
- else if (mobileView.value === 'channels') mobileView.value = 'bots'
113
- }
114
- }
115
-
116
- // 处理手机物理返回键
117
- const handlePopState = (e: PopStateEvent) => {
118
- if (!isMobile.value) return
119
- if (e.state && e.state.view) {
120
- mobileView.value = e.state.view
121
- } else {
122
- mobileView.value = 'bots'
123
- }
124
- }
125
-
126
- // 监听视图变化,同步到 history state
127
- watch(mobileView, (newView, oldView) => {
128
- if (!isMobile.value) return
129
- // 只有在非 popstate 导致的改变时才 pushState (简单判断:如果当前 state 不匹配则 push)
130
- if (window.history.state?.view !== newView) {
131
- window.history.pushState({ view: newView }, '')
132
- }
133
- })
134
-
135
- const showForward = (elements: any[]) => {
136
- forwardData.messages = elements.filter(e => e.type === 'message')
137
- if (isMobile.value) {
138
- mobileView.value = 'forward'
139
- } else {
140
- forwardDialogVisible.value = true
141
- }
142
- }
143
-
144
- const openImageViewer = (url: string) => {
145
- imageViewer.url = url
146
- imageZoom.value = 1
147
- if (isMobile.value) {
148
- mobileView.value = 'image'
149
- } else {
150
- imageViewerVisible.value = true
151
- }
152
- }
153
-
154
- const handleImageWheel = (e: WheelEvent) => {
155
- if (e.deltaY < 0) {
156
- imageZoom.value = Math.min(imageZoom.value + 0.1, 3)
157
- } else {
158
- imageZoom.value = Math.max(imageZoom.value - 0.1, 0.5)
159
- }
160
- }
161
-
162
- const downloadImage = (url: string) => {
163
- const a = document.createElement('a')
164
- a.href = url
165
- a.download = `chat-image-${Date.now()}.png`
166
- a.click()
167
- }
168
-
169
- const scrollToBottom = () => {
170
- if (scrollRef.value) {
171
- const wrap = scrollRef.value.wrapRef
172
- if (wrap) wrap.scrollTop = wrap.scrollHeight
173
- }
174
- }
175
-
176
- const handleScroll = async ({ scrollTop }: { scrollTop: number }) => {
177
- if (scrollTop <= 10 && !isLoadingHistory.value && selectedBot.value && selectedChannel.value) {
178
- const pagination = getPagination(selectedBot.value, selectedChannel.value)
179
- if (pagination.hasMore) {
180
- isLoadingHistory.value = true
181
- const wrap = scrollRef.value?.wrapRef
182
- const oldHeight = wrap?.scrollHeight || 0
183
-
184
- await loadHistory(selectedBot.value, selectedChannel.value)
185
-
186
- await nextTick()
187
- // 保持滚动位置
188
- if (wrap) {
189
- wrap.scrollTop = wrap.scrollHeight - oldHeight
190
- }
191
- isLoadingHistory.value = false
192
- }
193
- }
194
- }
195
-
196
- const handleSend = async () => {
197
- if (!selectedBot.value || !selectedChannel.value) return
198
- if (!inputText.value.trim() && !uploadedImages.value.length) return
199
-
200
- let content = inputText.value
201
- if (replyingTo.value) {
202
- // 优先使用真实 ID 进行引用
203
- const quoteId = replyingTo.value.realId || replyingTo.value.id
204
- content = `<quote id="${quoteId}"/>${content}`
205
- }
206
-
207
- const res = await sendMessage(selectedBot.value, selectedChannel.value, content, uploadedImages.value)
208
- if (res?.success) {
209
- inputText.value = ''
210
- uploadedImages.value = []
211
- replyingTo.value = null
212
- scrollToBottom()
213
- } else {
214
- ElMessage.error(res?.error || '发送失败')
215
- }
216
- }
217
-
218
- // 复读消息 (+1)
219
- const repeatMessage = async (msg: any) => {
220
- if (!selectedBot.value || !selectedChannel.value) return
221
-
222
- // 复读消息内容
223
- let content = msg.content
224
-
225
- // 处理引用逻辑
226
- if (msg.quote) {
227
- // 如果原消息有引用,保留引用
228
- const quoteId = msg.quote.id
229
- // 检查引用ID是否为临时ID
230
- if (quoteId && !quoteId.startsWith('bot-msg-')) {
231
- content = `<quote id="${quoteId}"/>${content}`
232
- } else {
233
- // 引用ID是临时ID,不添加引用,只发送内容
234
- content = msg.content
235
- }
236
- } else if (msg.isBot || msg.userId === selectedBot.value) {
237
- // 如果是复读机器人自己的消息
238
- const messageId = msg.realId || msg.id
239
-
240
- // 检查消息ID是否为临时ID(以bot-msg-开头)
241
- if (messageId && messageId.startsWith('bot-msg-')) {
242
- // 临时ID,不使用引用,直接发送内容
243
- content = msg.content
244
- } else if (messageId) {
245
- // 真实ID,可以使用引用
246
- content = `<quote id="${messageId}"/>${content}`
247
- } else {
248
- // 没有ID,只发送内容
249
- content = msg.content
250
- }
251
- }
252
-
253
- const res = await sendMessage(selectedBot.value, selectedChannel.value, content, [])
254
- if (res?.success) {
255
- scrollToBottom()
256
- } else {
257
- ElMessage.error(res?.error || '复读失败')
258
- }
259
- }
260
-
261
- // 机器人右键菜单
262
- const onBotMenu = (e: MouseEvent, bot: any) => {
263
- e.preventDefault()
264
- e.stopPropagation()
265
- menu.value = {
266
- show: true,
267
- x: e.clientX,
268
- y: e.clientY,
269
- type: 'bot',
270
- id: bot.selfId,
271
- isPinned: pinnedBots.value.has(bot.selfId),
272
- hasMedia: false
273
- }
274
- }
275
-
276
- // 频道右键菜单
277
- const onChannelMenu = (e: MouseEvent, channel: any) => {
278
- e.preventDefault()
279
- e.stopPropagation()
280
- menu.value = {
281
- show: true,
282
- x: e.clientX,
283
- y: e.clientY,
284
- type: 'channel',
285
- id: channel.id,
286
- isPinned: pinnedChannels.value.has(`${selectedBot.value}:${channel.id}`),
287
- hasMedia: false
288
- }
289
- }
290
-
291
- // 消息右键菜单
292
- const onMessageMenu = (e: MouseEvent, msg: any) => {
293
- // 如果菜单已经显示,则关闭它并允许原生菜单弹出(第二次右键逻辑)
294
- if (menu.value.show && menu.value.type === 'message' && menu.value.id === msg.id) {
295
- menu.value.show = false
296
- return
297
- }
298
-
299
- e.preventDefault()
300
- e.stopPropagation()
301
-
302
- const hasMedia = msg.elements?.some((el: any) => ['image', 'img', 'mface', 'audio', 'video'].includes(el.type))
303
- menu.value = {
304
- show: true,
305
- x: e.clientX,
306
- y: e.clientY,
307
- type: 'message',
308
- id: msg.id,
309
- isPinned: false,
310
- data: msg,
311
- hasMedia
312
- } as any
313
- }
314
-
315
- // 统一处理菜单动作
316
- const handleMenuAction = async (action: string) => {
317
- const type = menu.value.type
318
- const id = menu.value.id
319
- const isPinned = menu.value.isPinned
320
- menu.value.show = false
321
-
322
- if (action === 'pin') {
323
- if (type === 'bot') await togglePinBot(id, isPinned, pinnedBots.value)
324
- else await togglePinChannel(selectedBot.value, id, isPinned, pinnedChannels.value)
325
- } else if (action === 'delete') {
326
- // 删除逻辑保持在 index.vue 中通过 ElMessageBox 确认,或者这里直接处理
327
- if (type === 'bot') await deleteBotData(id)
328
- else await deleteChannelData(selectedBot.value, id)
329
- location.reload()
330
- }
331
- }
332
-
333
- // 兼容手机端的复制函数
334
- const copyToClipboard = (text: string) => {
335
- if (!text) {
336
- ElMessage.warning('未复制任何内容')
337
- return Promise.resolve()
338
- }
339
- if (navigator.clipboard && window.isSecureContext) {
340
- return navigator.clipboard.writeText(text)
341
- } else {
342
- // 回退方案
343
- const textArea = document.createElement("textarea")
344
- textArea.value = text
345
- textArea.style.position = "fixed"
346
- textArea.style.left = "-999999px"
347
- textArea.style.top = "-999999px"
348
- document.body.appendChild(textArea)
349
- textArea.focus()
350
- textArea.select()
351
- return new Promise<void>((res, rej) => {
352
- document.execCommand('copy') ? res() : rej()
353
- textArea.remove()
354
- })
355
- }
356
- }
357
-
358
- const handleMessageAction = async (action: string) => {
359
- const msg = (menu.value as any).data
360
- menu.value.show = false
361
- if (!msg) return
362
-
363
- if (action === 'copy') {
364
- // 移除 HTML 标签
365
- const text = (msg.content || '').replace(/<[^>]+>/g, '')
366
- if (!text) {
367
- ElMessage.warning('未复制任何内容')
368
- } else {
369
- copyToClipboard(text).then(() => ElMessage.success('已复制到剪贴板'))
370
- }
371
- } else if (action === 'copy-raw') {
372
- // 查看原始消息,包含引用标签
373
- let raw = msg.content || ''
374
- if (msg.quote) {
375
- raw = `<quote id="${msg.quote.id}"/>${raw}`
376
- }
377
- rawMessage.content = raw
378
- if (isMobile.value) {
379
- mobileView.value = 'raw'
380
- } else {
381
- rawMessageVisible.value = true
382
- }
383
- } else if (action === 'plus1') {
384
- await repeatMessage(msg)
385
- } else if (action === 'reply') {
386
- replyingTo.value = msg
387
- // 自动聚焦输入框
388
- nextTick(() => {
389
- // Element Plus 的 el-input 需要访问其内部的 textarea
390
- const inputEl = inputRef.value?.$el?.querySelector('textarea') || inputRef.value?.ref
391
- if (inputEl) {
392
- inputEl.focus()
393
- } else {
394
- inputRef.value?.focus?.()
395
- }
396
- })
397
- } else if (action === 'download') {
398
- const media = msg.elements?.find((el: any) => ['image', 'img', 'mface', 'audio', 'video'].includes(el.type))
399
- const url = media?.attrs?.src || media?.attrs?.url || media?.attrs?.file
400
- if (url) downloadImage(url)
401
- }
402
- }
403
-
404
- // 显示用户资料
405
- const showUserProfile = async (msg: any) => {
406
- if (!selectedBot.value) return
407
-
408
- const res = await (send as any)('get-user-info', {
409
- selfId: selectedBot.value,
410
- userId: msg.userId,
411
- guildId: msg.guildId
412
- })
413
-
414
- if (res.success) {
415
- userProfile.data = res.data
416
- if (isMobile.value) {
417
- mobileView.value = 'profile'
418
- } else {
419
- userProfileVisible.value = true
420
- }
421
- } else {
422
- ElMessage.error(res.error || '获取用户信息失败')
423
- }
424
- }
425
-
426
- // 定位消息并高亮
427
- const scrollToMessage = async (id: string) => {
428
- // 尝试在当前列表中查找,优先匹配 data-id
429
- let el = document.querySelector(`[data-id="${id}"]`) || document.getElementById(id)
430
-
431
- if (!el) {
432
- // 如果没找到,尝试向上加载历史记录
433
- if (isLoadingHistory.value) return
434
-
435
- ElMessage.info('正在向上查找历史消息...')
436
-
437
- // 最多尝试向上查找 3 次
438
- for (let i = 0; i < 3; i++) {
439
- await loadHistory(selectedBot.value, selectedChannel.value)
440
- // 等待 DOM 更新
441
- await new Promise(resolve => setTimeout(resolve, 150))
442
- el = document.querySelector(`[data-id="${id}"]`) || document.getElementById(id)
443
- if (el) break
444
- }
445
- }
446
-
447
- if (el) {
448
- el.scrollIntoView({ behavior: 'smooth', block: 'center' })
449
- // 找到消息内容容器进行高亮
450
- const contentEl = el.querySelector('.cursor-context-menu') || el
451
- contentEl.classList.add('message-highlight')
452
- setTimeout(() => contentEl.classList.remove('message-highlight'), 1500)
453
- } else {
454
- ElMessage.warning('消息太久远,已不在当前列表中')
455
- }
456
- }
457
-
458
- // 处理粘贴图片
459
- const handlePaste = async (event: ClipboardEvent) => {
460
- const items = event.clipboardData?.items
461
- if (!items) return
462
-
463
- for (let i = 0; i < items.length; i++) {
464
- if (items[i].type.indexOf('image') !== -1) {
465
- const file = items[i].getAsFile()
466
- if (file) {
467
- // 模拟文件上传逻辑
468
- const reader = new FileReader()
469
- reader.onload = async (e) => {
470
- const base64 = e.target?.result as string
471
- const res = await (send as any)('upload-image', {
472
- file: base64,
473
- filename: `pasted_image_${Date.now()}.png`,
474
- mimeType: file.type
475
- })
476
- if (res.success) {
477
- uploadedImages.value.push({
478
- tempId: res.tempId,
479
- preview: URL.createObjectURL(file),
480
- filename: `pasted_image_${Date.now()}.png`
481
- })
482
- }
483
- }
484
- reader.readAsDataURL(file)
485
- }
486
- }
487
- }
488
- }
489
-
490
- const checkMobile = () => {
491
- isMobile.value = window.innerWidth <= 768
492
- }
493
-
494
- // 监听键盘弹出(通过 visualViewport 或 window resize)
495
- const updateKeyboardHeight = () => {
496
- if (!isMobile.value) {
497
- keyboardHeight.value = 0
498
- return
499
- }
500
-
501
- // 使用 visualViewport API(现代浏览器)
502
- if (window.visualViewport) {
503
- const viewportHeight = window.visualViewport.height
504
- const windowHeight = window.innerHeight
505
- const calculatedHeight = Math.max(0, windowHeight - viewportHeight)
506
-
507
- // 只有当键盘高度变化超过50px时才更新(避免小幅抖动)
508
- if (Math.abs(calculatedHeight - keyboardHeight.value) > 50) {
509
- keyboardHeight.value = calculatedHeight
510
- }
511
- } else {
512
- keyboardHeight.value = 0
513
- }
514
- }
515
-
516
- // 生命周期
517
- let dispose: any[] = []
518
-
519
- onMounted(async () => {
520
- checkMobile()
521
- window.addEventListener('resize', checkMobile)
522
- window.addEventListener('click', () => menu.value.show = false)
523
- window.addEventListener('popstate', handlePopState)
524
-
525
- // 监听键盘弹出
526
- if (window.visualViewport) {
527
- window.visualViewport.addEventListener('resize', updateKeyboardHeight)
528
- window.visualViewport.addEventListener('scroll', updateKeyboardHeight)
529
- }
530
- window.addEventListener('resize', updateKeyboardHeight)
531
-
532
- // 监听输入框聚焦和失焦
533
- const handleFocus = () => {
534
- // 延迟更新,等待键盘完全弹出
535
- setTimeout(updateKeyboardHeight, 300)
536
- }
537
- const handleBlur = () => {
538
- // 延迟更新,等待键盘完全收起
539
- setTimeout(() => {
540
- keyboardHeight.value = 0
541
- }, 100)
542
- }
543
-
544
- // 为输入框添加事件监听
545
- nextTick(() => {
546
- const textarea = inputRef.value?.$el?.querySelector('textarea')
547
- if (textarea) {
548
- textarea.addEventListener('focus', handleFocus)
549
- textarea.addEventListener('blur', handleBlur)
550
- dispose.push(() => {
551
- textarea.removeEventListener('focus', handleFocus)
552
- textarea.removeEventListener('blur', handleBlur)
553
- })
554
- }
555
- })
556
-
557
- // 初始化 history state
558
- if (isMobile.value) {
559
- window.history.replaceState({ view: mobileView.value }, '')
560
- }
561
-
562
- await loadConfig()
563
- await loadInitialData()
564
-
565
- const d1 = receive('chat-message-event', (ev: any) => {
566
- addMessage(ev)
567
- if (ev.selfId === selectedBot.value && ev.channelId === selectedChannel.value) {
568
- // 只有在底部附近才自动滚动
569
- const wrap = scrollRef.value?.wrapRef
570
- if (wrap && wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 100) {
571
- nextTick(scrollToBottom)
572
- }
573
- }
574
- })
575
- if (typeof d1 === 'function') dispose.push(d1)
576
-
577
- const d2 = receive('bot-message-sent-event', (ev: any) => {
578
- addMessage(ev)
579
- if (ev.selfId === selectedBot.value && ev.channelId === selectedChannel.value) {
580
- nextTick(scrollToBottom)
581
- }
582
- })
583
- if (typeof d2 === 'function') dispose.push(d2)
584
-
585
- const d3 = receive('chat-bot-message-event', (ev: any) => {
586
- addMessage(ev)
587
- if (ev.selfId === selectedBot.value && ev.channelId === selectedChannel.value) {
588
- nextTick(scrollToBottom)
589
- }
590
- })
591
- if (typeof d3 === 'function') dispose.push(d3)
592
-
593
- // 监听机器人消息更新(发送成功后从虚拟 ID 转为真实 ID)
594
- const d4 = receive('bot-message-updated', (data: any) => {
595
- const channelKey = `${selectedBot.value}:${selectedChannel.value}`
596
- if (data.channelKey !== channelKey) return
597
-
598
- const msg = currentMessages.value.find(m => m.id === data.tempId)
599
- if (msg) {
600
- msg.sending = false
601
- msg.realId = data.realId
602
- }
603
- })
604
- if (typeof d4 === 'function') dispose.push(d4)
605
- })
606
-
607
- onUnmounted(() => {
608
- window.removeEventListener('resize', checkMobile)
609
- window.removeEventListener('popstate', handlePopState)
610
- window.removeEventListener('resize', updateKeyboardHeight)
611
- if (window.visualViewport) {
612
- window.visualViewport.removeEventListener('resize', updateKeyboardHeight)
613
- window.visualViewport.removeEventListener('scroll', updateKeyboardHeight)
614
- }
615
- dispose.forEach(d => d?.())
616
- })
617
-
618
- return {
619
- // 数据
620
- bots,
621
- selectedBot,
622
- selectedChannel,
623
- currentChannels,
624
- currentMessages,
625
- currentChannelName,
626
- inputText,
627
- uploadedImages,
628
- isSending,
629
- pinnedBots,
630
- pinnedChannels,
631
- scrollRef,
632
- isMobile,
633
- mobileView,
634
- forwardData,
635
- imageViewer,
636
- imageZoom,
637
- rawMessage,
638
- isLoadingHistory,
639
- forwardDialogVisible,
640
- imageViewerVisible,
641
- rawMessageVisible,
642
- replyingTo,
643
- userProfile,
644
- userProfileVisible,
645
- selectedBotPlatform,
646
- menu,
647
- keyboardHeight,
648
-
649
- // 方法
650
- selectBot,
651
- selectChannel,
652
- handleSend,
653
- togglePinBot,
654
- togglePinChannel,
655
- deleteBotData,
656
- deleteChannelData,
657
- getCachedImageUrl,
658
- cacheImage,
659
- getMessages,
660
- goBack,
661
- showForward,
662
- openImageViewer,
663
- handleImageWheel,
664
- downloadImage,
665
- handleScroll,
666
- repeatMessage,
667
- handlePaste,
668
- copyToClipboard,
669
- onBotMenu,
670
- onChannelMenu,
671
- onMessageMenu,
672
- handleMenuAction,
673
- handleMessageAction,
674
- showUserProfile,
675
- inputRef,
676
- scrollToMessage
677
- }
678
- }
1
+ import { ref, computed, onMounted, nextTick, onUnmounted, reactive, watch } from 'vue'
2
+ import { receive, send } from '@koishijs/client'
3
+ import { useChatData } from './composables/useChatData'
4
+ import { useChatActions } from './composables/useChatActions'
5
+ import { useImageCache } from './composables/useImageCache'
6
+ import { useVideoCache } from './composables/useVideoCache'
7
+ import { ElMessage } from 'element-plus'
8
+
9
+ export function useChatLogic() {
10
+ const {
11
+ bots, pinnedBots, pinnedChannels, getChannels, getMessages,
12
+ loadInitialData, loadConfig, addMessage, loadHistory, getPagination
13
+ } = useChatData()
14
+
15
+ const {
16
+ isSending, sendMessage, deleteBotData, deleteChannelData,
17
+ togglePinBot, togglePinChannel
18
+ } = useChatActions()
19
+
20
+ const { getCachedImageUrl, cacheImage } = useImageCache()
21
+ const { loadVideo, isVideoLoading, isVideoLoaded } = useVideoCache()
22
+
23
+ // 状态管理
24
+ const menu = ref({ show: false, x: 0, y: 0, type: '', id: '', isPinned: false, hasMedia: false })
25
+ const selectedBot = ref('')
26
+ const selectedChannel = ref('')
27
+ const inputText = ref('')
28
+ const uploadedImages = ref<any[]>([])
29
+ const scrollRef = ref<any>(null)
30
+ const inputRef = ref<any>(null)
31
+ const isMobile = ref(false)
32
+ const mobileView = ref<'bots' | 'channels' | 'messages' | 'forward' | 'image' | 'profile' | 'raw'>('bots')
33
+ const isLoadingHistory = ref(false)
34
+ const keyboardHeight = ref(0) // 键盘高度
35
+
36
+ // 合并转发详情状态
37
+ const forwardData = reactive({
38
+ messages: [] as any[]
39
+ })
40
+ const forwardDialogVisible = ref(false)
41
+
42
+ // 图片查看器状态
43
+ const imageViewer = reactive({
44
+ url: ''
45
+ })
46
+ const imageViewerVisible = ref(false)
47
+ const imageZoom = ref(1)
48
+
49
+ // 原始消息查看状态
50
+ const rawMessage = reactive({
51
+ content: ''
52
+ })
53
+ const rawMessageVisible = ref(false)
54
+
55
+ // 引用/回复状态
56
+ const replyingTo = ref<any>(null)
57
+
58
+ // 用户资料状态
59
+ const userProfile = reactive({
60
+ data: null as any
61
+ })
62
+ const userProfileVisible = ref(false)
63
+
64
+ // 计算属性
65
+ const currentChannels = computed(() => getChannels(selectedBot.value))
66
+ const currentMessages = computed(() => getMessages(selectedBot.value, selectedChannel.value))
67
+ const currentChannelName = computed(() => {
68
+ const c = currentChannels.value.find(i => i.id === selectedChannel.value)
69
+ return c ? c.name : ''
70
+ })
71
+ const selectedBotPlatform = computed(() => {
72
+ const bot = bots.value.find(b => b.selfId === selectedBot.value)
73
+ return bot?.platform || 'unknown'
74
+ })
75
+
76
+ // 方法
77
+ const selectBot = (id: string) => {
78
+ selectedBot.value = id
79
+ selectedChannel.value = ''
80
+ if (isMobile.value) mobileView.value = 'channels'
81
+ }
82
+
83
+ const selectChannel = async (id: string) => {
84
+ selectedChannel.value = id
85
+ if (isMobile.value) mobileView.value = 'messages'
86
+
87
+ // 如果没有消息,加载第一页
88
+ if (currentMessages.value.length === 0) {
89
+ await loadHistory(selectedBot.value, id)
90
+ }
91
+
92
+ await nextTick()
93
+ scrollToBottom()
94
+
95
+ // 针对图片加载导致的滚动偏移,在 300ms 和 800ms 后再次校准底部
96
+ setTimeout(scrollToBottom, 300)
97
+ setTimeout(scrollToBottom, 800)
98
+ }
99
+
100
+ const goBack = () => {
101
+ if (isMobile.value && mobileView.value !== 'bots') {
102
+ // 手机端使用 history.back(),由 popstate 监听器处理视图切换
103
+ window.history.back()
104
+ } else {
105
+ // PC 端或初始页面的逻辑
106
+ if (mobileView.value === 'image') {
107
+ mobileView.value = 'messages'
108
+ imageZoom.value = 1
109
+ }
110
+ else if (mobileView.value === 'forward') mobileView.value = 'messages'
111
+ else if (mobileView.value === 'profile') mobileView.value = 'messages'
112
+ else if (mobileView.value === 'raw') mobileView.value = 'messages'
113
+ else if (mobileView.value === 'messages') mobileView.value = 'channels'
114
+ else if (mobileView.value === 'channels') mobileView.value = 'bots'
115
+ }
116
+ }
117
+
118
+ // 处理手机物理返回键
119
+ const handlePopState = (e: PopStateEvent) => {
120
+ if (!isMobile.value) return
121
+ if (e.state && e.state.view) {
122
+ mobileView.value = e.state.view
123
+ } else {
124
+ mobileView.value = 'bots'
125
+ }
126
+ }
127
+
128
+ // 监听视图变化,同步到 history state
129
+ watch(mobileView, (newView, oldView) => {
130
+ if (!isMobile.value) return
131
+ // 只有在非 popstate 导致的改变时才 pushState (简单判断:如果当前 state 不匹配则 push)
132
+ if (window.history.state?.view !== newView) {
133
+ window.history.pushState({ view: newView }, '')
134
+ }
135
+ })
136
+
137
+ const showForward = (elements: any[]) => {
138
+ forwardData.messages = elements.filter(e => e.type === 'message')
139
+ if (isMobile.value) {
140
+ mobileView.value = 'forward'
141
+ } else {
142
+ forwardDialogVisible.value = true
143
+ }
144
+ }
145
+
146
+ const openImageViewer = (url: string) => {
147
+ imageViewer.url = url
148
+ imageZoom.value = 1
149
+ if (isMobile.value) {
150
+ mobileView.value = 'image'
151
+ } else {
152
+ imageViewerVisible.value = true
153
+ }
154
+ }
155
+
156
+ const handleImageWheel = (e: WheelEvent) => {
157
+ if (e.deltaY < 0) {
158
+ imageZoom.value = Math.min(imageZoom.value + 0.1, 3)
159
+ } else {
160
+ imageZoom.value = Math.max(imageZoom.value - 0.1, 0.5)
161
+ }
162
+ }
163
+
164
+ const downloadImage = (url: string) => {
165
+ const a = document.createElement('a')
166
+ a.href = url
167
+ a.download = `chat-image-${Date.now()}.png`
168
+ a.click()
169
+ }
170
+
171
+ const scrollToBottom = () => {
172
+ if (scrollRef.value) {
173
+ const wrap = scrollRef.value.wrapRef
174
+ if (wrap) wrap.scrollTop = wrap.scrollHeight
175
+ }
176
+ }
177
+
178
+ const handleScroll = async ({ scrollTop }: { scrollTop: number }) => {
179
+ if (scrollTop <= 10 && !isLoadingHistory.value && selectedBot.value && selectedChannel.value) {
180
+ const pagination = getPagination(selectedBot.value, selectedChannel.value)
181
+ if (pagination.hasMore) {
182
+ isLoadingHistory.value = true
183
+ const wrap = scrollRef.value?.wrapRef
184
+ const oldHeight = wrap?.scrollHeight || 0
185
+
186
+ await loadHistory(selectedBot.value, selectedChannel.value)
187
+
188
+ await nextTick()
189
+ // 保持滚动位置
190
+ if (wrap) {
191
+ wrap.scrollTop = wrap.scrollHeight - oldHeight
192
+ }
193
+ isLoadingHistory.value = false
194
+ }
195
+ }
196
+ }
197
+
198
+ const handleSend = async () => {
199
+ if (!selectedBot.value || !selectedChannel.value) return
200
+ if (!inputText.value.trim() && !uploadedImages.value.length) return
201
+
202
+ let content = inputText.value
203
+ if (replyingTo.value) {
204
+ // 优先使用真实 ID 进行引用
205
+ const quoteId = replyingTo.value.realId || replyingTo.value.id
206
+ content = `<quote id="${quoteId}"/>${content}`
207
+ }
208
+
209
+ const res = await sendMessage(selectedBot.value, selectedChannel.value, content, uploadedImages.value)
210
+ if (res?.success) {
211
+ inputText.value = ''
212
+ uploadedImages.value = []
213
+ replyingTo.value = null
214
+ scrollToBottom()
215
+ } else {
216
+ ElMessage.error(res?.error || '发送失败')
217
+ }
218
+ }
219
+
220
+ // 复读消息 (+1)
221
+ const repeatMessage = async (msg: any) => {
222
+ if (!selectedBot.value || !selectedChannel.value) return
223
+
224
+ // 复读消息内容
225
+ let content = msg.content
226
+
227
+ // 处理引用逻辑
228
+ if (msg.quote) {
229
+ // 如果原消息有引用,保留引用
230
+ const quoteId = msg.quote.id
231
+ // 检查引用ID是否为临时ID
232
+ if (quoteId && !quoteId.startsWith('bot-msg-')) {
233
+ content = `<quote id="${quoteId}"/>${content}`
234
+ } else {
235
+ // 引用ID是临时ID,不添加引用,只发送内容
236
+ content = msg.content
237
+ }
238
+ } else if (msg.isBot || msg.userId === selectedBot.value) {
239
+ // 如果是复读机器人自己的消息
240
+ const messageId = msg.realId || msg.id
241
+
242
+ // 检查消息ID是否为临时ID(以bot-msg-开头)
243
+ if (messageId && messageId.startsWith('bot-msg-')) {
244
+ // 临时ID,不使用引用,直接发送内容
245
+ content = msg.content
246
+ } else if (messageId) {
247
+ // 真实ID,可以使用引用
248
+ content = `<quote id="${messageId}"/>${content}`
249
+ } else {
250
+ // 没有ID,只发送内容
251
+ content = msg.content
252
+ }
253
+ }
254
+
255
+ const res = await sendMessage(selectedBot.value, selectedChannel.value, content, [])
256
+ if (res?.success) {
257
+ scrollToBottom()
258
+ } else {
259
+ ElMessage.error(res?.error || '复读失败')
260
+ }
261
+ }
262
+
263
+ // 机器人右键菜单
264
+ const onBotMenu = (e: MouseEvent, bot: any) => {
265
+ e.preventDefault()
266
+ e.stopPropagation()
267
+ menu.value = {
268
+ show: true,
269
+ x: e.clientX,
270
+ y: e.clientY,
271
+ type: 'bot',
272
+ id: bot.selfId,
273
+ isPinned: pinnedBots.value.has(bot.selfId),
274
+ hasMedia: false
275
+ }
276
+ }
277
+
278
+ // 频道右键菜单
279
+ const onChannelMenu = (e: MouseEvent, channel: any) => {
280
+ e.preventDefault()
281
+ e.stopPropagation()
282
+ menu.value = {
283
+ show: true,
284
+ x: e.clientX,
285
+ y: e.clientY,
286
+ type: 'channel',
287
+ id: channel.id,
288
+ isPinned: pinnedChannels.value.has(`${selectedBot.value}:${channel.id}`),
289
+ hasMedia: false
290
+ }
291
+ }
292
+
293
+ // 消息右键菜单
294
+ const onMessageMenu = (e: MouseEvent, msg: any) => {
295
+ // 如果菜单已经显示,则关闭它并允许原生菜单弹出(第二次右键逻辑)
296
+ if (menu.value.show && menu.value.type === 'message' && menu.value.id === msg.id) {
297
+ menu.value.show = false
298
+ return
299
+ }
300
+
301
+ e.preventDefault()
302
+ e.stopPropagation()
303
+
304
+ const hasMedia = msg.elements?.some((el: any) => ['image', 'img', 'mface', 'audio', 'video'].includes(el.type))
305
+ menu.value = {
306
+ show: true,
307
+ x: e.clientX,
308
+ y: e.clientY,
309
+ type: 'message',
310
+ id: msg.id,
311
+ isPinned: false,
312
+ data: msg,
313
+ hasMedia
314
+ } as any
315
+ }
316
+
317
+ // 统一处理菜单动作
318
+ const handleMenuAction = async (action: string) => {
319
+ const type = menu.value.type
320
+ const id = menu.value.id
321
+ const isPinned = menu.value.isPinned
322
+ menu.value.show = false
323
+
324
+ if (action === 'pin') {
325
+ if (type === 'bot') await togglePinBot(id, isPinned, pinnedBots.value)
326
+ else await togglePinChannel(selectedBot.value, id, isPinned, pinnedChannels.value)
327
+ } else if (action === 'delete') {
328
+ // 删除逻辑保持在 index.vue 中通过 ElMessageBox 确认,或者这里直接处理
329
+ if (type === 'bot') await deleteBotData(id)
330
+ else await deleteChannelData(selectedBot.value, id)
331
+ location.reload()
332
+ }
333
+ }
334
+
335
+ // 兼容手机端的复制函数
336
+ const copyToClipboard = (text: string) => {
337
+ if (!text) {
338
+ ElMessage.warning('未复制任何内容')
339
+ return Promise.resolve()
340
+ }
341
+ if (navigator.clipboard && window.isSecureContext) {
342
+ return navigator.clipboard.writeText(text)
343
+ } else {
344
+ // 回退方案
345
+ const textArea = document.createElement("textarea")
346
+ textArea.value = text
347
+ textArea.style.position = "fixed"
348
+ textArea.style.left = "-999999px"
349
+ textArea.style.top = "-999999px"
350
+ document.body.appendChild(textArea)
351
+ textArea.focus()
352
+ textArea.select()
353
+ return new Promise<void>((res, rej) => {
354
+ document.execCommand('copy') ? res() : rej()
355
+ textArea.remove()
356
+ })
357
+ }
358
+ }
359
+
360
+ const handleMessageAction = async (action: string) => {
361
+ const msg = (menu.value as any).data
362
+ menu.value.show = false
363
+ if (!msg) return
364
+
365
+ if (action === 'copy') {
366
+ // 移除 HTML 标签
367
+ const text = (msg.content || '').replace(/<[^>]+>/g, '')
368
+ if (!text) {
369
+ ElMessage.warning('未复制任何内容')
370
+ } else {
371
+ copyToClipboard(text).then(() => ElMessage.success('已复制到剪贴板'))
372
+ }
373
+ } else if (action === 'copy-raw') {
374
+ // 查看原始消息,包含引用标签
375
+ let raw = msg.content || ''
376
+ if (msg.quote) {
377
+ raw = `<quote id="${msg.quote.id}"/>${raw}`
378
+ }
379
+ rawMessage.content = raw
380
+ if (isMobile.value) {
381
+ mobileView.value = 'raw'
382
+ } else {
383
+ rawMessageVisible.value = true
384
+ }
385
+ } else if (action === 'plus1') {
386
+ await repeatMessage(msg)
387
+ } else if (action === 'reply') {
388
+ replyingTo.value = msg
389
+ // 自动聚焦输入框
390
+ nextTick(() => {
391
+ // Element Plus 的 el-input 需要访问其内部的 textarea
392
+ const inputEl = inputRef.value?.$el?.querySelector('textarea') || inputRef.value?.ref
393
+ if (inputEl) {
394
+ inputEl.focus()
395
+ } else {
396
+ inputRef.value?.focus?.()
397
+ }
398
+ })
399
+ } else if (action === 'download') {
400
+ const media = msg.elements?.find((el: any) => ['image', 'img', 'mface', 'audio', 'video'].includes(el.type))
401
+ const url = media?.attrs?.src || media?.attrs?.url || media?.attrs?.file
402
+ if (url) downloadImage(url)
403
+ }
404
+ }
405
+
406
+ // 显示用户资料
407
+ const showUserProfile = async (msg: any) => {
408
+ if (!selectedBot.value) return
409
+
410
+ const res = await (send as any)('get-user-info', {
411
+ selfId: selectedBot.value,
412
+ userId: msg.userId,
413
+ guildId: msg.guildId
414
+ })
415
+
416
+ if (res.success) {
417
+ userProfile.data = res.data
418
+ if (isMobile.value) {
419
+ mobileView.value = 'profile'
420
+ } else {
421
+ userProfileVisible.value = true
422
+ }
423
+ } else {
424
+ ElMessage.error(res.error || '获取用户信息失败')
425
+ }
426
+ }
427
+
428
+ // 定位消息并高亮
429
+ const scrollToMessage = async (id: string) => {
430
+ // 尝试在当前列表中查找,优先匹配 data-id
431
+ let el = document.querySelector(`[data-id="${id}"]`) || document.getElementById(id)
432
+
433
+ if (!el) {
434
+ // 如果没找到,尝试向上加载历史记录
435
+ if (isLoadingHistory.value) return
436
+
437
+ ElMessage.info('正在向上查找历史消息...')
438
+
439
+ // 最多尝试向上查找 3 次
440
+ for (let i = 0; i < 3; i++) {
441
+ await loadHistory(selectedBot.value, selectedChannel.value)
442
+ // 等待 DOM 更新
443
+ await new Promise(resolve => setTimeout(resolve, 150))
444
+ el = document.querySelector(`[data-id="${id}"]`) || document.getElementById(id)
445
+ if (el) break
446
+ }
447
+ }
448
+
449
+ if (el) {
450
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
451
+ // 找到消息内容容器进行高亮
452
+ const contentEl = el.querySelector('.cursor-context-menu') || el
453
+ contentEl.classList.add('message-highlight')
454
+ setTimeout(() => contentEl.classList.remove('message-highlight'), 1500)
455
+ } else {
456
+ ElMessage.warning('消息太久远,已不在当前列表中')
457
+ }
458
+ }
459
+
460
+ // 处理粘贴图片
461
+ const handlePaste = async (event: ClipboardEvent) => {
462
+ const items = event.clipboardData?.items
463
+ if (!items) return
464
+
465
+ for (let i = 0; i < items.length; i++) {
466
+ if (items[i].type.indexOf('image') !== -1) {
467
+ const file = items[i].getAsFile()
468
+ if (file) {
469
+ // 模拟文件上传逻辑
470
+ const reader = new FileReader()
471
+ reader.onload = async (e) => {
472
+ const base64 = e.target?.result as string
473
+ const res = await (send as any)('upload-image', {
474
+ file: base64,
475
+ filename: `pasted_image_${Date.now()}.png`,
476
+ mimeType: file.type
477
+ })
478
+ if (res.success) {
479
+ uploadedImages.value.push({
480
+ tempId: res.tempId,
481
+ preview: URL.createObjectURL(file),
482
+ filename: `pasted_image_${Date.now()}.png`
483
+ })
484
+ }
485
+ }
486
+ reader.readAsDataURL(file)
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ const checkMobile = () => {
493
+ isMobile.value = window.innerWidth <= 768
494
+ }
495
+
496
+ // 监听键盘弹出(通过 visualViewport 或 window resize)
497
+ const updateKeyboardHeight = () => {
498
+ if (!isMobile.value) {
499
+ keyboardHeight.value = 0
500
+ return
501
+ }
502
+
503
+ // 使用 visualViewport API(现代浏览器)
504
+ if (window.visualViewport) {
505
+ const viewportHeight = window.visualViewport.height
506
+ const windowHeight = window.innerHeight
507
+ const calculatedHeight = Math.max(0, windowHeight - viewportHeight)
508
+
509
+ // 只有当键盘高度变化超过50px时才更新(避免小幅抖动)
510
+ if (Math.abs(calculatedHeight - keyboardHeight.value) > 50) {
511
+ keyboardHeight.value = calculatedHeight
512
+ }
513
+ } else {
514
+ keyboardHeight.value = 0
515
+ }
516
+ }
517
+
518
+ // 生命周期
519
+ let dispose: any[] = []
520
+
521
+ onMounted(async () => {
522
+ checkMobile()
523
+ window.addEventListener('resize', checkMobile)
524
+ window.addEventListener('click', () => menu.value.show = false)
525
+ window.addEventListener('popstate', handlePopState)
526
+
527
+ // 监听键盘弹出
528
+ if (window.visualViewport) {
529
+ window.visualViewport.addEventListener('resize', updateKeyboardHeight)
530
+ window.visualViewport.addEventListener('scroll', updateKeyboardHeight)
531
+ }
532
+ window.addEventListener('resize', updateKeyboardHeight)
533
+
534
+ // 监听输入框聚焦和失焦
535
+ const handleFocus = () => {
536
+ // 延迟更新,等待键盘完全弹出
537
+ setTimeout(updateKeyboardHeight, 300)
538
+ }
539
+ const handleBlur = () => {
540
+ // 延迟更新,等待键盘完全收起
541
+ setTimeout(() => {
542
+ keyboardHeight.value = 0
543
+ }, 100)
544
+ }
545
+
546
+ // 为输入框添加事件监听
547
+ nextTick(() => {
548
+ const textarea = inputRef.value?.$el?.querySelector('textarea')
549
+ if (textarea) {
550
+ textarea.addEventListener('focus', handleFocus)
551
+ textarea.addEventListener('blur', handleBlur)
552
+ dispose.push(() => {
553
+ textarea.removeEventListener('focus', handleFocus)
554
+ textarea.removeEventListener('blur', handleBlur)
555
+ })
556
+ }
557
+ })
558
+
559
+ // 初始化 history state
560
+ if (isMobile.value) {
561
+ window.history.replaceState({ view: mobileView.value }, '')
562
+ }
563
+
564
+ await loadConfig()
565
+ await loadInitialData()
566
+
567
+ const d1 = receive('chat-message-event', (ev: any) => {
568
+ addMessage(ev)
569
+ if (ev.selfId === selectedBot.value && ev.channelId === selectedChannel.value) {
570
+ // 只有在底部附近才自动滚动
571
+ const wrap = scrollRef.value?.wrapRef
572
+ if (wrap && wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 100) {
573
+ nextTick(scrollToBottom)
574
+ }
575
+ }
576
+ })
577
+ if (typeof d1 === 'function') dispose.push(d1)
578
+
579
+ const d2 = receive('bot-message-sent-event', (ev: any) => {
580
+ addMessage(ev)
581
+ if (ev.selfId === selectedBot.value && ev.channelId === selectedChannel.value) {
582
+ nextTick(scrollToBottom)
583
+ }
584
+ })
585
+ if (typeof d2 === 'function') dispose.push(d2)
586
+
587
+ const d3 = receive('chat-bot-message-event', (ev: any) => {
588
+ addMessage(ev)
589
+ if (ev.selfId === selectedBot.value && ev.channelId === selectedChannel.value) {
590
+ nextTick(scrollToBottom)
591
+ }
592
+ })
593
+ if (typeof d3 === 'function') dispose.push(d3)
594
+
595
+ // 监听机器人消息更新(发送成功后从虚拟 ID 转为真实 ID)
596
+ const d4 = receive('bot-message-updated', (data: any) => {
597
+ const channelKey = `${selectedBot.value}:${selectedChannel.value}`
598
+ if (data.channelKey !== channelKey) return
599
+
600
+ const msg = currentMessages.value.find(m => m.id === data.tempId)
601
+ if (msg) {
602
+ msg.sending = false
603
+ msg.realId = data.realId
604
+ }
605
+ })
606
+ if (typeof d4 === 'function') dispose.push(d4)
607
+ })
608
+
609
+ onUnmounted(() => {
610
+ window.removeEventListener('resize', checkMobile)
611
+ window.removeEventListener('popstate', handlePopState)
612
+ window.removeEventListener('resize', updateKeyboardHeight)
613
+ if (window.visualViewport) {
614
+ window.visualViewport.removeEventListener('resize', updateKeyboardHeight)
615
+ window.visualViewport.removeEventListener('scroll', updateKeyboardHeight)
616
+ }
617
+ dispose.forEach(d => d?.())
618
+ })
619
+
620
+ return {
621
+ // 数据
622
+ bots,
623
+ selectedBot,
624
+ selectedChannel,
625
+ currentChannels,
626
+ currentMessages,
627
+ currentChannelName,
628
+ inputText,
629
+ uploadedImages,
630
+ isSending,
631
+ pinnedBots,
632
+ pinnedChannels,
633
+ scrollRef,
634
+ isMobile,
635
+ mobileView,
636
+ forwardData,
637
+ imageViewer,
638
+ imageZoom,
639
+ rawMessage,
640
+ isLoadingHistory,
641
+ forwardDialogVisible,
642
+ imageViewerVisible,
643
+ rawMessageVisible,
644
+ replyingTo,
645
+ userProfile,
646
+ userProfileVisible,
647
+ selectedBotPlatform,
648
+ menu,
649
+ keyboardHeight,
650
+
651
+ // 方法
652
+ selectBot,
653
+ selectChannel,
654
+ handleSend,
655
+ togglePinBot,
656
+ togglePinChannel,
657
+ deleteBotData,
658
+ deleteChannelData,
659
+ getCachedImageUrl,
660
+ cacheImage,
661
+ loadVideo,
662
+ isVideoLoading,
663
+ isVideoLoaded,
664
+ getMessages,
665
+ goBack,
666
+ showForward,
667
+ openImageViewer,
668
+ handleImageWheel,
669
+ downloadImage,
670
+ handleScroll,
671
+ repeatMessage,
672
+ handlePaste,
673
+ copyToClipboard,
674
+ onBotMenu,
675
+ onChannelMenu,
676
+ onMessageMenu,
677
+ handleMenuAction,
678
+ handleMessageAction,
679
+ showUserProfile,
680
+ inputRef,
681
+ scrollToMessage
682
+ }
683
+ }