✨ feat(im): MessageInput / MentionPicker / ChatPanel 三连修——粘贴文件、切群清空、命名规范
【ChatPanel.vue】
- 加 messageInputKey computed(type-targetId)+ MessageInput :key 绑它,
切会话强制 unmount + remount editor / mention range / 草稿全归零,
避免 A 群打了一半的字 / @ token 漏到 B 群被发出去
(早先用 inline template literal 做 :key,Vue SFC 编译没把表达式接到
vnode.key 上,hmr / 完整 reload 都看到 key=null;改 computed 后正常)
【MessageInput.vue】
- onPaste 加 clipboardData.items 扫一轮:image/* → uploadAndSendImage,
其它 file → uploadAndSendFile,纯文本兜底走 nativeExec('insertText');
截图 / 拖入图片 / 拖入文件不再被默默吞掉
- 抽 uploadAndSendImage / uploadAndSendFile 两个共用函数,
onImagePicked / onFilePicked 改成薄包装走它们,避免上传逻辑双份
- 删 nativeExec 里的 // eslint-disable-next-line @typescript-eslint/no-deprecated:
项目当前 @typescript-eslint v7 没有这条规则,加了会让 lint 报"规则不存在",
反而把 lint 拖红;改用单纯 JSDoc 解释为什么留着 execCommand
- 重命名 mentionPos → mentionPosition(prop / ref 一致),按"变量不缩写"
- 7 个方法补 JSDoc:onSelectionChange / insertText / onPaste / onInput /
onKeydown / onImagePicked / onFilePicked / onVoiceSend;复杂的
collectFromEditor 和 handleSend 加分步 1./2./3. 内联注释
- data-empty 改用属性"存在 / 缺失"模拟(template 里 data-empty="",JS 里
raw 为空就 set ''、否则 delete),CSS 选择器同步改 [data-empty],
比 [data-empty='true'] 直观
【MentionPicker.vue】
- prop pos → position(不缩写);ref / 内部解构 / 默认值都跟着改
- <el-icon><UserFilled /></el-icon> → <Icon icon="ep:user-filled">:
用全局 Icon 组件走 Iconify,少一个 EP 图标 import
- scrollToTop / scrollToActive 局部变量 wrap → scrollWrap、
itemH → itemHeight、activeTop → activeOffsetTop;
v-for 与 handleSelect 的 (m) → (member)
im
parent
678c2d6834
commit
cba5c15604
|
|
@ -9,9 +9,9 @@
|
|||
v-show="visible && showMembers.length > 0"
|
||||
class="message-input__mention-picker !fixed z-100 w-50 rounded-md bg-[var(--el-bg-color)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
|
||||
:style="{
|
||||
left: pos.x + 'px',
|
||||
top: pos.top != null ? pos.top + 'px' : 'auto',
|
||||
bottom: pos.bottom != null ? pos.bottom + 'px' : 'auto'
|
||||
left: position.x + 'px',
|
||||
top: position.top != null ? position.top + 'px' : 'auto',
|
||||
bottom: position.bottom != null ? position.bottom + 'px' : 'auto'
|
||||
}"
|
||||
>
|
||||
<el-scrollbar ref="scrollRef" max-height="300px">
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
<div
|
||||
class="flex items-center justify-center w-[30px] h-[30px] rounded text-white bg-[var(--el-color-primary)] flex-shrink-0"
|
||||
>
|
||||
<el-icon :size="18"><UserFilled /></el-icon>
|
||||
<Icon icon="ep:user-filled" :size="18" />
|
||||
</div>
|
||||
<span class="overflow-hidden text-sm truncate text-[var(--el-text-color-regular)]">
|
||||
{{ allItem.showNickName }}
|
||||
|
|
@ -42,13 +42,13 @@
|
|||
|
||||
<!-- 真成员行 -->
|
||||
<ChatGroupMember
|
||||
v-for="(m, idx) in memberItems"
|
||||
:key="m.userId"
|
||||
:member="m"
|
||||
v-for="(member, idx) in memberItems"
|
||||
:key="member.userId"
|
||||
:member="member"
|
||||
:height="40"
|
||||
:active="activeIdx === (allItem ? idx + 1 : idx)"
|
||||
:clickable="false"
|
||||
@click.stop="handleSelect(m)"
|
||||
@click.stop="handleSelect(member)"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
|
||||
|
|
@ -60,8 +60,8 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { ElScrollbar } from 'element-plus'
|
||||
import { UserFilled } from '@element-plus/icons-vue'
|
||||
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '@/views/im/utils/constants'
|
||||
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
|
||||
|
|
@ -72,14 +72,14 @@ const props = withDefaults(
|
|||
defineProps<{
|
||||
visible: boolean // 是否显示
|
||||
// 浮层位置:x 横坐标 + top / bottom 二选一(bottom 锚定时 picker 下沿贴 @ 上方)
|
||||
pos: { x: number; top?: number; bottom?: number }
|
||||
position: { x: number; top?: number; bottom?: number }
|
||||
members: GroupMemberLite[] // 当前群的成员列表
|
||||
searchText?: string // @ 后输入的过滤文本
|
||||
ownerId?: number // 群主 id,判断是否能展示"所有人"
|
||||
}>(),
|
||||
{
|
||||
searchText: '',
|
||||
pos: () => ({ x: 0, bottom: 0 })
|
||||
position: () => ({ x: 0, bottom: 0 })
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -151,31 +151,34 @@ watch(
|
|||
|
||||
/** el-scrollbar 没暴露 scrollTo,直接拿内部 wrap 调 scrollTop */
|
||||
function scrollToTop() {
|
||||
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
|
||||
if (wrap) {
|
||||
wrap.scrollTop = 0
|
||||
const scrollWrap = scrollRef.value?.$el?.querySelector(
|
||||
'.el-scrollbar__wrap'
|
||||
) as HTMLElement | null
|
||||
if (scrollWrap) {
|
||||
scrollWrap.scrollTop = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 键盘上下导航时把高亮项滚到可视区:超出底边下推、超出顶边上拉,否则不动 */
|
||||
// TODO @AI:变量尽量完整!
|
||||
function scrollToActive() {
|
||||
const wrap = scrollRef.value?.$el?.querySelector('.el-scrollbar__wrap') as HTMLElement | null
|
||||
if (!wrap) {
|
||||
const scrollWrap = scrollRef.value?.$el?.querySelector(
|
||||
'.el-scrollbar__wrap'
|
||||
) as HTMLElement | null
|
||||
if (!scrollWrap) {
|
||||
return
|
||||
}
|
||||
const itemH = 40
|
||||
const activeTop = activeIdx.value * itemH
|
||||
if (activeTop + itemH > wrap.scrollTop + wrap.clientHeight) {
|
||||
wrap.scrollTop = activeTop + itemH - wrap.clientHeight
|
||||
} else if (activeTop < wrap.scrollTop) {
|
||||
wrap.scrollTop = activeTop
|
||||
const itemHeight = 40
|
||||
const activeOffsetTop = activeIdx.value * itemHeight
|
||||
if (activeOffsetTop + itemHeight > scrollWrap.scrollTop + scrollWrap.clientHeight) {
|
||||
scrollWrap.scrollTop = activeOffsetTop + itemHeight - scrollWrap.clientHeight
|
||||
} else if (activeOffsetTop < scrollWrap.scrollTop) {
|
||||
scrollWrap.scrollTop = activeOffsetTop
|
||||
}
|
||||
}
|
||||
|
||||
/** 选中一项:emit 给 MessageInput 落 token,同时关掉浮层 */
|
||||
function handleSelect(m: GroupMemberLite) {
|
||||
emit('select', m)
|
||||
function handleSelect(member: GroupMemberLite) {
|
||||
emit('select', member)
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@
|
|||
<MentionPicker
|
||||
ref="mentionRef"
|
||||
v-model:visible="mentionVisible"
|
||||
:pos="mentionPos"
|
||||
:position="mentionPosition"
|
||||
:members="groupMembers"
|
||||
:search-text="mentionSearchText"
|
||||
:owner-id="groupOwnerId"
|
||||
|
|
@ -171,7 +171,6 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
|||
let text = ''
|
||||
|
||||
function walk(node: Node) {
|
||||
// 1. text 节点
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += (node.textContent || '').replace(//g, '')
|
||||
return
|
||||
|
|
@ -181,12 +180,10 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
|||
}
|
||||
const el = node as HTMLElement
|
||||
const tag = el.tagName.toLowerCase()
|
||||
// 2. br
|
||||
if (tag === 'br') {
|
||||
text += '\n'
|
||||
return
|
||||
}
|
||||
// 3. span[data-id]:mention token
|
||||
if (tag === 'span' && el.dataset.id) {
|
||||
text += el.textContent || ''
|
||||
const id = Number(el.dataset.id)
|
||||
|
|
@ -195,7 +192,6 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
|||
}
|
||||
return
|
||||
}
|
||||
// 4. div:行级换行容器
|
||||
if (tag === 'div') {
|
||||
if (text && !text.endsWith('\n')) {
|
||||
text += '\n'
|
||||
|
|
@ -203,10 +199,10 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
|||
el.childNodes.forEach(walk)
|
||||
return
|
||||
}
|
||||
// 5. 其他元素:递归
|
||||
el.childNodes.forEach(walk)
|
||||
}
|
||||
|
||||
// 直接从 root.childNodes 开始,避免把 root 本身也当元素处理(虽然目前没有特殊样式,但以防未来改动)
|
||||
root.childNodes.forEach(walk)
|
||||
return {
|
||||
text: text.trim(),
|
||||
|
|
@ -225,21 +221,18 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
|
|||
* 5. 上送:atUserIds 非空才传,避免发空数组
|
||||
*/
|
||||
async function handleSend() {
|
||||
// 1.
|
||||
const editor = editorRef.value
|
||||
if (!canSend.value || !editor) {
|
||||
return
|
||||
}
|
||||
// 2.
|
||||
const { text, atUserIds } = collectFromEditor(editor)
|
||||
// 3.
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
// 4.
|
||||
// 1. 清空 + 同步状态
|
||||
editor.innerHTML = ''
|
||||
syncEditorState()
|
||||
// 5.
|
||||
// 2. 发送
|
||||
await send(text, atUserIds.length > 0 ? { atUserIds } : undefined)
|
||||
}
|
||||
|
||||
|
|
@ -251,7 +244,22 @@ async function handleSend() {
|
|||
*/
|
||||
let savedRange: Range | null = null
|
||||
|
||||
// TODO @AI:方法注释、方法内注释;
|
||||
/**
|
||||
* 走 native execCommand,保留浏览器原生 undo 栈
|
||||
*
|
||||
* execCommand 在 lib.dom 里被标 @deprecated(IDE 显示删除线),但 'insertText' /
|
||||
* 'insertLineBreak' 没有等价的 W3C 标准替代——Range/Selection 自己拼 DOM 会让 Ctrl+Z 失效。
|
||||
* 集中到这里调用,调用点不必散落 ts-expect-error;不引入 eslint disable(项目当前
|
||||
* @typescript-eslint v7 没有 no-deprecated 规则,加了也无效,反而让 lint 报"规则不存在")
|
||||
*/
|
||||
function nativeExec(command: 'insertText' | 'insertLineBreak', value?: string) {
|
||||
document.execCommand(command, false, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* document selectionchange 监听:把落在 editor 内的 selection 缓存到 savedRange,
|
||||
* insertText 在焦点被偷走后用它把光标恢复到原插入点
|
||||
*/
|
||||
function onSelectionChange() {
|
||||
const editor = editorRef.value
|
||||
const sel = window.getSelection()
|
||||
|
|
@ -299,10 +307,13 @@ onBeforeUnmount(() => {
|
|||
})
|
||||
|
||||
/**
|
||||
* 把字符串插入光标处。execCommand('insertText') 而非自己拼 DOM,是为了保留浏览器
|
||||
* 原生 undo 栈(Range API 替代实现会让 Ctrl+Z 失效)
|
||||
* 把字符串插入光标处(emoji 面板等场景调用)
|
||||
*
|
||||
* 1. editor 没挂直接返回
|
||||
* 2. 焦点回到 editor + 把 savedRange 恢复成当前 selection(emoji 面板偷焦点后还能回原位)
|
||||
* 3. nativeExec 插文本,保留浏览器原生 undo 栈
|
||||
* 4. 同步 canSend / placeholder
|
||||
*/
|
||||
// TODO @AI:方法注释、方法内注释;
|
||||
function insertText(str: string) {
|
||||
const editor = editorRef.value
|
||||
if (!editor) {
|
||||
|
|
@ -316,21 +327,51 @@ function insertText(str: string) {
|
|||
sel.addRange(savedRange)
|
||||
}
|
||||
}
|
||||
// TODO @AI:linter 报错
|
||||
document.execCommand('insertText', false, str)
|
||||
// 1. nativeExec 插文本,保留浏览器原生 undo 栈
|
||||
nativeExec('insertText', str)
|
||||
// 2. 同步 canSend / placeholder
|
||||
syncEditorState()
|
||||
}
|
||||
|
||||
/** 粘贴:剥掉外部样式 / 脚本,只留 plain text */
|
||||
/**
|
||||
* 粘贴处理
|
||||
*
|
||||
* 1. 优先扫 clipboardData.items 找文件类型条目(截图、拖入的图片 / 文件等)
|
||||
* - image/* → 走 IMAGE 上传发送
|
||||
* - 其它 file → 走 FILE 上传发送
|
||||
* - 一次粘贴只处理第一个文件,避免一次粘贴发出多条消息
|
||||
* 2. 没文件再走 plain text:剥掉外部样式 / 脚本,避免外站 inline style 污染 editor
|
||||
* (contenteditable 默认粘贴会带 HTML,所以模板上 @paste.prevent 拦截)
|
||||
*/
|
||||
function onPaste(e: ClipboardEvent) {
|
||||
// 1. 优先扫 clipboardData.items 找文件类型条目(截图、拖入的图片 / 文件等)
|
||||
const items = e.clipboardData?.items
|
||||
if (items?.length) {
|
||||
// TODO @AI:这了有 linter 报错;
|
||||
for (const item of items) {
|
||||
if (item.kind !== 'file') {
|
||||
continue
|
||||
}
|
||||
const file = item.getAsFile()
|
||||
if (!file) {
|
||||
continue
|
||||
}
|
||||
if (item.type.startsWith('image/')) {
|
||||
void uploadAndSendImage(file)
|
||||
} else {
|
||||
void uploadAndSendFile(file)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
// 2. 没文件再走 plain text:剥掉外部样式 / 脚本,避免外站 inline style 污染 editor
|
||||
const text = e.clipboardData?.getData('text/plain') || ''
|
||||
if (text) {
|
||||
// TODO @AI:linter 报错
|
||||
document.execCommand('insertText', false, text)
|
||||
nativeExec('insertText', text)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @AI:方法注释、方法内注释;
|
||||
/** 编辑器内容变化的统一入口:先同步 canSend / placeholder,再判 @ 浮层是否要展开 */
|
||||
function onInput() {
|
||||
syncEditorState()
|
||||
detectAtMention()
|
||||
|
|
@ -353,6 +394,7 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
|
|||
if (!conversation || conversation.type !== ImConversationType.GROUP) {
|
||||
return []
|
||||
}
|
||||
// TODO @AI:g 变 group
|
||||
const g = groupStore.getGroup(conversation.targetId)
|
||||
return (g?.members || []).map((m) => ({
|
||||
userId: m.userId,
|
||||
|
|
@ -374,7 +416,7 @@ const mentionVisible = ref(false)
|
|||
const mentionSearchText = ref('')
|
||||
/** 浮层定位:x 是左边距;top / bottom 二选一—— bottom 锚定(picker 下沿贴 @)是默认,
|
||||
* 上方放不下时退化为 top 锚定(picker 上沿贴 @ 下方) */
|
||||
const mentionPos = ref<{ x: number; top?: number; bottom?: number }>({ x: 0, bottom: 0 })
|
||||
const mentionPosition = ref<{ x: number; top?: number; bottom?: number }>({ x: 0, bottom: 0 })
|
||||
|
||||
/** MentionPicker 的容器宽度(与组件里的 w-50 对齐),用于视口右沿回弹;
|
||||
* 高度不再用常量算位置——bottom 锚定后 picker 内容多寡都不影响下沿位置,自然贴 @ */
|
||||
|
|
@ -436,18 +478,18 @@ function detectAtMention() {
|
|||
* 无论 1 项还是 N 项 picker 下沿都贴 @);不够则翻到 @ 下方走 top 锚定
|
||||
*/
|
||||
function positionMention(node: Node, atOffset: number) {
|
||||
// 1.
|
||||
// 1. 计算 @ 字符屏幕坐标 rect
|
||||
const anchor = document.createRange()
|
||||
anchor.setStart(node, atOffset)
|
||||
anchor.collapse(true)
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
// 2.
|
||||
// 2. 横向:picker 左边对齐 @,越过视口右沿则左推;至少留 8px 留白
|
||||
const left = Math.max(8, Math.min(rect.left, window.innerWidth - MENTION_WIDTH - 8))
|
||||
// 3.
|
||||
// 3. 纵向:上方剩余 ≥ MENTION_MIN_FIT_ABOVE 走 bottom 锚定
|
||||
if (rect.top >= MENTION_MIN_FIT_ABOVE) {
|
||||
mentionPos.value = { x: left, bottom: window.innerHeight - rect.top + 8 }
|
||||
mentionPosition.value = { x: left, bottom: window.innerHeight - rect.top + 8 }
|
||||
} else {
|
||||
mentionPos.value = { x: left, top: rect.bottom + 8 }
|
||||
mentionPosition.value = { x: left, top: rect.bottom + 8 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -496,9 +538,18 @@ function onMentionSelect(member: GroupMemberLite) {
|
|||
syncEditorState()
|
||||
}
|
||||
|
||||
// TODO @AI:方法注释、方法内注释;
|
||||
/**
|
||||
* 键盘事件分发
|
||||
*
|
||||
* 1. mention 浮层打开时
|
||||
* 1.1 ↑/↓ 移动高亮
|
||||
* 1.2 Enter 有候选 → 选中;无候选 fall through 到下面的发送分支,避免按 Enter 没反应
|
||||
* 1.3 Esc 关浮层
|
||||
* 2. Shift+Enter 换行:强制走 br(浏览器默认会插 div,DOM walk 拼接更复杂)
|
||||
* 3. 普通 Enter 发送(IME composition 期间不触发,避免选词被误发)
|
||||
*/
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
// @ 浮层打开时键盘上下 / Enter / Esc 由浮层消费
|
||||
// 1. mention 浮层打开时
|
||||
if (mentionVisible.value) {
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
|
|
@ -511,7 +562,6 @@ function onKeydown(e: KeyboardEvent) {
|
|||
return
|
||||
}
|
||||
if (e.key === 'Enter' && !e.isComposing) {
|
||||
// 有候选才拦 Enter 选中;无候选 fall through 到下面的发送分支,避免按 Enter 没反应
|
||||
if (mentionRef.value?.hasCandidates()) {
|
||||
e.preventDefault()
|
||||
mentionRef.value?.pickActive()
|
||||
|
|
@ -523,14 +573,14 @@ function onKeydown(e: KeyboardEvent) {
|
|||
return
|
||||
}
|
||||
}
|
||||
// Shift+Enter 强制走 br:浏览器默认会插 div,DOM walk 时拼接更复杂
|
||||
// 2. Shift+Enter 换行:强制走 br(浏览器默认会插 div,DOM walk 拼接更复杂)
|
||||
if (e.key === 'Enter' && e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
document.execCommand('insertLineBreak')
|
||||
nativeExec('insertLineBreak')
|
||||
syncEditorState()
|
||||
return
|
||||
}
|
||||
// 普通 Enter 发送(IME composition 中除外)
|
||||
// 3. 普通 Enter 发送(IME composition 期间不触发,避免选词被误发)
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
|
|
@ -538,14 +588,8 @@ function onKeydown(e: KeyboardEvent) {
|
|||
}
|
||||
|
||||
// ==================== 图片 / 文件上传 ====================
|
||||
// TODO @AI:方法注释、方法内注释;
|
||||
async function onImagePicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
/** 上传并发送 IMAGE 消息;文件选择器和粘贴板都复用这条 */
|
||||
async function uploadAndSendImage(file: File) {
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
|
@ -560,14 +604,8 @@ async function onImagePicked(e: Event) {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO @AI:方法注释、方法内注释;
|
||||
async function onFilePicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
|
||||
async function uploadAndSendFile(file: File) {
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
|
|
@ -585,9 +623,29 @@ async function onFilePicked(e: Event) {
|
|||
}
|
||||
}
|
||||
|
||||
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor,整体走 sendRaw) */
|
||||
async function onImagePicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (file) {
|
||||
await uploadAndSendImage(file)
|
||||
}
|
||||
}
|
||||
|
||||
/** 文件选完即上传 + 发送 FILE 消息(携带原始 name / size 元数据) */
|
||||
async function onFilePicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (file) {
|
||||
await uploadAndSendFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 语音 ====================
|
||||
const voiceVisible = ref(false)
|
||||
// TODO @AI:方法注释、方法内注释;
|
||||
/** VoiceRecorder 录完后回传 blob,包成 webm 文件上传,发送 VOICE 消息 */
|
||||
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
|
||||
try {
|
||||
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
|
||||
|
|
|
|||
Loading…
Reference in New Issue