Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
<script setup>
|
||||
import getUuid from 'widget/helpers/uuid';
|
||||
import { ref, onMounted, onUnmounted, defineEmits, defineExpose } from 'vue';
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import RecordPlugin from 'wavesurfer.js/dist/plugins/record.js';
|
||||
import { format, intervalToDuration } from 'date-fns';
|
||||
import { convertAudio } from './utils/mp3ConversionUtils';
|
||||
|
||||
const props = defineProps({
|
||||
audioRecordFormat: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'recorderProgressChanged',
|
||||
'finishRecord',
|
||||
'pause',
|
||||
'play',
|
||||
]);
|
||||
|
||||
const waveformContainer = ref(null);
|
||||
const wavesurfer = ref(null);
|
||||
const record = ref(null);
|
||||
const isRecording = ref(false);
|
||||
const isPlaying = ref(false);
|
||||
const hasRecording = ref(false);
|
||||
|
||||
const formatTimeProgress = time => {
|
||||
const duration = intervalToDuration({ start: 0, end: time });
|
||||
return format(
|
||||
new Date(0, 0, 0, 0, duration.minutes, duration.seconds),
|
||||
'mm:ss'
|
||||
);
|
||||
};
|
||||
|
||||
const initWaveSurfer = () => {
|
||||
wavesurfer.value = WaveSurfer.create({
|
||||
container: waveformContainer.value,
|
||||
waveColor: '#1F93FF',
|
||||
progressColor: '#6E6F73',
|
||||
height: 100,
|
||||
barWidth: 2,
|
||||
barGap: 1,
|
||||
barRadius: 2,
|
||||
plugins: [
|
||||
RecordPlugin.create({
|
||||
scrollingWaveform: true,
|
||||
renderRecordedAudio: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
wavesurfer.value.on('pause', () => emit('pause'));
|
||||
wavesurfer.value.on('play', () => emit('play'));
|
||||
|
||||
record.value = wavesurfer.value.plugins[0];
|
||||
|
||||
wavesurfer.value.on('finish', () => {
|
||||
isPlaying.value = false;
|
||||
});
|
||||
|
||||
record.value.on('record-end', async blob => {
|
||||
const audioUrl = URL.createObjectURL(blob);
|
||||
const audioBlob = await convertAudio(blob, props.audioRecordFormat);
|
||||
const fileName = `${getUuid()}.mp3`;
|
||||
const file = new File([audioBlob], fileName, {
|
||||
type: props.audioRecordFormat,
|
||||
});
|
||||
wavesurfer.value.load(audioUrl);
|
||||
emit('finishRecord', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
file,
|
||||
});
|
||||
hasRecording.value = true;
|
||||
isRecording.value = false;
|
||||
});
|
||||
|
||||
record.value.on('record-progress', time => {
|
||||
emit('recorderProgressChanged', formatTimeProgress(time));
|
||||
});
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (isRecording.value) {
|
||||
record.value.stopRecording();
|
||||
isRecording.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = () => {
|
||||
record.value.startRecording();
|
||||
isRecording.value = true;
|
||||
};
|
||||
|
||||
const playPause = () => {
|
||||
if (hasRecording.value) {
|
||||
wavesurfer.value.playPause();
|
||||
isPlaying.value = !isPlaying.value;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initWaveSurfer();
|
||||
startRecording();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (wavesurfer.value) {
|
||||
wavesurfer.value.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ playPause, stopRecording, record });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="waveformContainer" class="w-full p-1" />
|
||||
</template>
|
||||
@@ -0,0 +1,253 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, useTemplateRef } from 'vue';
|
||||
|
||||
import {
|
||||
buildMessageSchema,
|
||||
buildEditor,
|
||||
EditorView,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
EditorState,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Give copilot additional prompts, or ask anything else...',
|
||||
},
|
||||
generatedContent: { type: String, default: '' },
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isPopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'blur',
|
||||
'input',
|
||||
'update:modelValue',
|
||||
'keyup',
|
||||
'focus',
|
||||
'keydown',
|
||||
'send',
|
||||
]);
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
// Minimal schema with no marks or nodes for copilot input
|
||||
const copilotSchema = buildMessageSchema([], []);
|
||||
|
||||
const handleSubmit = () => emit('send');
|
||||
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
plugins = [],
|
||||
enabledMenuOptions = []
|
||||
) => {
|
||||
return EditorState.create({
|
||||
doc: new MessageMarkdownTransformer(copilotSchema).parse(content),
|
||||
plugins: buildEditor({
|
||||
schema: copilotSchema,
|
||||
placeholder,
|
||||
plugins,
|
||||
enabledMenuOptions,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// we don't need them to be reactive
|
||||
// It cases weird issues where the objects are proxied
|
||||
// and then the editor doesn't work as expected
|
||||
let editorView = null;
|
||||
let state = null;
|
||||
|
||||
// reactive data
|
||||
const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
|
||||
// element refs
|
||||
const editor = useTemplateRef('editor');
|
||||
|
||||
function contentFromEditor() {
|
||||
if (editorView) {
|
||||
return MessageMarkdownSerializer.serialize(editorView.state.doc);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function focusEditorInputField() {
|
||||
const { tr } = editorView.state;
|
||||
const selection = Selection.atEnd(tr.doc);
|
||||
|
||||
editorView.dispatch(tr.setSelection(selection));
|
||||
editorView.focus();
|
||||
}
|
||||
|
||||
function emitOnChange() {
|
||||
emit('update:modelValue', contentFromEditor());
|
||||
emit('input', contentFromEditor());
|
||||
}
|
||||
|
||||
function onKeyup() {
|
||||
emit('keyup');
|
||||
}
|
||||
|
||||
function onKeydown(view, event) {
|
||||
emit('keydown');
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
}
|
||||
|
||||
return false; // Allow other keys to work normally
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
emit('blur');
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
emit('focus');
|
||||
}
|
||||
|
||||
function checkSelection(editorState) {
|
||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||
if (hasSelection === isTextSelected.value) return;
|
||||
isTextSelected.value = hasSelection;
|
||||
}
|
||||
|
||||
// computed properties
|
||||
const plugins = computed(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
const enabledMenuOptions = computed(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
function reloadState() {
|
||||
state = createState(
|
||||
props.modelValue,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
enabledMenuOptions.value
|
||||
);
|
||||
editorView.updateState(state);
|
||||
focusEditorInputField();
|
||||
}
|
||||
|
||||
function createEditorView() {
|
||||
editorView = new EditorView(editor.value, {
|
||||
state: state,
|
||||
dispatchTransaction: tx => {
|
||||
state = state.apply(tx);
|
||||
editorView.updateState(state);
|
||||
if (tx.docChanged) {
|
||||
emitOnChange();
|
||||
}
|
||||
checkSelection(state);
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keyup: onKeyup,
|
||||
focus: onFocus,
|
||||
blur: onBlur,
|
||||
keydown: onKeydown,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// watchers
|
||||
watch(
|
||||
computed(() => props.modelValue),
|
||||
(newValue = '') => {
|
||||
if (newValue !== contentFromEditor()) {
|
||||
reloadState();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
computed(() => props.editorId),
|
||||
() => {
|
||||
reloadState();
|
||||
}
|
||||
);
|
||||
|
||||
// lifecycle
|
||||
onMounted(() => {
|
||||
state = createState(
|
||||
props.modelValue,
|
||||
props.placeholder,
|
||||
plugins.value,
|
||||
enabledMenuOptions.value
|
||||
);
|
||||
|
||||
createEditorView();
|
||||
editorView.updateState(state);
|
||||
|
||||
if (props.autofocus) {
|
||||
focusEditorInputField();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2 mb-4">
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
|
||||
>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(generatedContent, false)"
|
||||
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"
|
||||
/>
|
||||
</div>
|
||||
<div class="editor-root relative editor--copilot space-x-2">
|
||||
<div ref="editor" />
|
||||
<div class="flex items-center justify-end absolute right-2 bottom-2">
|
||||
<NextButton
|
||||
class="bg-n-iris-9 text-white !rounded-full"
|
||||
icon="i-lucide-arrow-up"
|
||||
solid
|
||||
sm
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@chatwoot/prosemirror-schema/src/styles/base.scss';
|
||||
|
||||
.editor--copilot {
|
||||
@apply bg-n-iris-5 rounded;
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
min-height: 5rem;
|
||||
max-height: 7.5rem !important;
|
||||
overflow: auto;
|
||||
@apply px-2 !important;
|
||||
|
||||
.empty-node {
|
||||
&::before {
|
||||
@apply text-n-iris-9 dark:text-n-iris-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useElementSize, useWindowSize } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
hasSelection: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['executeCopilotAction']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { draftMessage } = useCaptain();
|
||||
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
// Selection-based menu items (when text is selected)
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
// for now, we don't allow improving just aprt of the selection
|
||||
// we will add this feature later. Once we do, we can revert the change
|
||||
const hasSelection = false;
|
||||
// const hasSelection = props.hasSelection
|
||||
|
||||
if (hasSelection) {
|
||||
items.push({
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
|
||||
),
|
||||
key: 'improve_selection',
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
} else if (
|
||||
replyMode.value === REPLY_EDITOR_MODES.REPLY &&
|
||||
draftMessage.value
|
||||
) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY'),
|
||||
key: 'improve',
|
||||
icon: 'i-fluent-pen-sparkle-24-regular',
|
||||
});
|
||||
}
|
||||
|
||||
if (draftMessage.value) {
|
||||
items.push(
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.TITLE'
|
||||
),
|
||||
key: 'change_tone',
|
||||
icon: 'i-fluent-sound-wave-circle-sparkle-24-regular',
|
||||
subMenuItems: [
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.PROFESSIONAL'
|
||||
),
|
||||
key: 'professional',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CASUAL'
|
||||
),
|
||||
key: 'casual',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.STRAIGHTFORWARD'
|
||||
),
|
||||
key: 'straightforward',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.CONFIDENT'
|
||||
),
|
||||
key: 'confident',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.CHANGE_TONE.OPTIONS.FRIENDLY'
|
||||
),
|
||||
key: 'friendly',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.GRAMMAR'),
|
||||
key: 'fix_spelling_grammar',
|
||||
icon: 'i-fluent-flow-sparkle-24-regular',
|
||||
}
|
||||
);
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
const generalMenuItems = computed(() => {
|
||||
const items = [];
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.REPLY) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUGGESTION'),
|
||||
key: 'reply_suggestion',
|
||||
icon: 'i-fluent-chat-sparkle-16-regular',
|
||||
});
|
||||
}
|
||||
|
||||
if (replyMode.value === REPLY_EDITOR_MODES.NOTE || true) {
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.SUMMARIZE'),
|
||||
key: 'summarize',
|
||||
icon: 'i-fluent-text-bullet-list-square-sparkle-32-regular',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.ASK_COPILOT'),
|
||||
key: 'ask_copilot',
|
||||
icon: 'i-fluent-circle-sparkle-24-regular',
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const menuRef = useTemplateRef('menuRef');
|
||||
const { height: menuHeight } = useElementSize(menuRef);
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
// Smart submenu positioning based on available space
|
||||
const submenuPosition = computed(() => {
|
||||
const el = menuRef.value?.$el;
|
||||
if (!el) return 'ltr:right-full rtl:left-full';
|
||||
|
||||
const { left, right } = el.getBoundingClientRect();
|
||||
const SUBMENU_WIDTH = 200;
|
||||
const spaceRight = (windowWidth.value ?? window.innerWidth) - right;
|
||||
const spaceLeft = left;
|
||||
|
||||
// Prefer right, fallback to side with more space
|
||||
const showRight = spaceRight >= SUBMENU_WIDTH || spaceRight >= spaceLeft;
|
||||
|
||||
return showRight ? 'left-full' : 'right-full';
|
||||
});
|
||||
|
||||
// Computed style for selection menu positioning (only dynamic top offset)
|
||||
const selectionMenuStyle = computed(() => {
|
||||
// Dynamically calculate offset based on actual menu height + 10px gap
|
||||
const dynamicOffset = menuHeight.value > 0 ? menuHeight.value + 10 : 60;
|
||||
|
||||
return {
|
||||
top: `calc(var(--selection-top) - ${dynamicOffset}px)`,
|
||||
};
|
||||
});
|
||||
|
||||
const handleMenuItemClick = item => {
|
||||
// For items with submenus, do nothing on click (hover will show submenu)
|
||||
if (!item.subMenuItems) {
|
||||
emit('executeCopilotAction', item.key);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubMenuItemClick = (parentItem, subItem) => {
|
||||
emit('executeCopilotAction', subItem.key);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownBody
|
||||
ref="menuRef"
|
||||
class="min-w-56 [&>ul]:gap-3 z-50 [&>ul]:px-4 [&>ul]:py-3.5"
|
||||
:class="{ 'selection-menu': hasSelection }"
|
||||
:style="hasSelection ? selectionMenuStyle : {}"
|
||||
>
|
||||
<div v-if="menuItems.length > 0" class="flex flex-col items-start gap-2.5">
|
||||
<div
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
class="w-full relative group/submenu"
|
||||
>
|
||||
<Button
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
slate
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
|
||||
@click="handleMenuItemClick(item)"
|
||||
>
|
||||
<template v-if="item.subMenuItems" #default>
|
||||
<div class="flex items-center gap-1 justify-between w-full">
|
||||
<span class="min-w-0 truncate">{{ item.label }}</span>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-right"
|
||||
class="text-n-slate-10 size-3"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<!-- Hover Submenu -->
|
||||
<DropdownBody
|
||||
v-if="item.subMenuItems"
|
||||
class="group-hover/submenu:block hidden [&>ul]:gap-2 [&>ul]:px-3 [&>ul]:py-2.5 [&>ul]:dark:!border-n-strong max-h-[15rem] min-w-32 z-10 top-0"
|
||||
:class="submenuPosition"
|
||||
>
|
||||
<Button
|
||||
v-for="subItem in item.subMenuItems"
|
||||
:key="subItem.key + subItem.label"
|
||||
:label="subItem.label"
|
||||
slate
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start mb-1"
|
||||
@click="handleSubMenuItemClick(item, subItem)"
|
||||
/>
|
||||
</DropdownBody>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="menuItems.length > 0" class="h-px w-full bg-n-strong" />
|
||||
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<Button
|
||||
v-for="(item, index) in generalMenuItems"
|
||||
:key="index"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
slate
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline text-n-slate-12 font-normal text-xs w-full !justify-start"
|
||||
@click="handleMenuItemClick(item)"
|
||||
/>
|
||||
</div>
|
||||
</DropdownBody>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.selection-menu {
|
||||
position: absolute !important;
|
||||
|
||||
// Default/LTR: position from left
|
||||
left: var(--selection-left);
|
||||
|
||||
// RTL: position from right instead
|
||||
[dir='rtl'] & {
|
||||
left: auto;
|
||||
right: var(--selection-right);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
|
||||
defineProps({
|
||||
isGeneratingContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
const { t } = useI18n();
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
const acceptLabel = computed(() => {
|
||||
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between items-center p-3 pt-0">
|
||||
<NextButton
|
||||
:label="t('GENERAL.DISCARD')"
|
||||
slate
|
||||
link
|
||||
class="!px-1 hover:!no-underline"
|
||||
sm
|
||||
:disabled="isGeneratingContent"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<NextButton
|
||||
:label="acceptLabel"
|
||||
class="bg-n-iris-9 text-white"
|
||||
solid
|
||||
sm
|
||||
:disabled="isGeneratingContent"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: REPLY_EDITOR_MODES.REPLY,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isReplyRestricted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['toggleMode']);
|
||||
|
||||
const wootEditorReplyMode = useTemplateRef('wootEditorReplyMode');
|
||||
const wootEditorPrivateMode = useTemplateRef('wootEditorPrivateMode');
|
||||
|
||||
const replyModeSize = useElementSize(wootEditorReplyMode);
|
||||
const privateModeSize = useElementSize(wootEditorPrivateMode);
|
||||
|
||||
/**
|
||||
* Computed boolean indicating if the editor is in private note mode
|
||||
* When isReplyRestricted is true, force switch to private note
|
||||
* Otherwise, respect the current mode prop
|
||||
* @type {ComputedRef<boolean>}
|
||||
*/
|
||||
const isPrivate = computed(() => {
|
||||
if (props.isReplyRestricted) {
|
||||
// Force switch to private note when replies are restricted
|
||||
return true;
|
||||
}
|
||||
// Otherwise respect the current mode
|
||||
return props.mode === REPLY_EDITOR_MODES.NOTE;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the width of the sliding background chip in pixels
|
||||
* Includes 16px of padding in the calculation
|
||||
* @type {ComputedRef<string>}
|
||||
*/
|
||||
const width = computed(() => {
|
||||
const widthToUse = isPrivate.value
|
||||
? privateModeSize.width.value
|
||||
: replyModeSize.width.value;
|
||||
|
||||
const widthWithPadding = widthToUse + 16;
|
||||
return `${widthWithPadding}px`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the X translation value for the sliding background chip
|
||||
* Translates by the width of reply mode + padding when in private mode
|
||||
* @type {ComputedRef<string>}
|
||||
*/
|
||||
const translateValue = computed(() => {
|
||||
const xTranslate = isPrivate.value ? replyModeSize.width.value + 16 : 0;
|
||||
|
||||
return `${xTranslate}px`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
|
||||
:disabled="disabled || isReplyRestricted"
|
||||
:class="{
|
||||
'cursor-not-allowed': disabled || isReplyRestricted,
|
||||
}"
|
||||
@click="$emit('toggleMode')"
|
||||
>
|
||||
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
|
||||
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
|
||||
</div>
|
||||
<div ref="wootEditorPrivateMode" class="flex items-center gap-1 px-2 z-20">
|
||||
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
||||
</div>
|
||||
<div
|
||||
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
|
||||
:class="{
|
||||
'transition-all duration-300': !disabled && !isReplyRestricted,
|
||||
}"
|
||||
:style="{
|
||||
'--chip-width': width,
|
||||
'--translate-x': translateValue,
|
||||
'--rtl-translate-x': `calc(-1 * var(--translate-x))`,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,351 @@
|
||||
<script>
|
||||
import {
|
||||
fullSchema,
|
||||
buildEditor,
|
||||
EditorView,
|
||||
ArticleMarkdownSerializer,
|
||||
ArticleMarkdownTransformer,
|
||||
EditorState,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
// eslint-disable-next-line default-param-last
|
||||
plugins = [],
|
||||
// eslint-disable-next-line default-param-last
|
||||
methods = {},
|
||||
enabledMenuOptions
|
||||
) => {
|
||||
return EditorState.create({
|
||||
doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
|
||||
plugins: buildEditor({
|
||||
schema: fullSchema,
|
||||
placeholder,
|
||||
methods,
|
||||
plugins,
|
||||
enabledMenuOptions,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
let editorView = null;
|
||||
let state;
|
||||
|
||||
export default {
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
modelValue: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['blur', 'input', 'update:modelValue', 'keyup', 'focus', 'keydown'],
|
||||
setup() {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
plugins: [imagePastePlugin(this.handleImageUpload)],
|
||||
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelValue(newValue = '') {
|
||||
if (newValue !== this.contentFromEditor()) {
|
||||
this.reloadState();
|
||||
}
|
||||
},
|
||||
editorId() {
|
||||
this.reloadState();
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
state = createState(
|
||||
this.modelValue,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
this.enabledMenuOptions
|
||||
);
|
||||
},
|
||||
mounted() {
|
||||
this.createEditorView();
|
||||
|
||||
editorView.updateState(state);
|
||||
if (this.autofocus) {
|
||||
this.focusEditorInputField();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
contentFromEditor() {
|
||||
if (editorView) {
|
||||
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
|
||||
}
|
||||
return '';
|
||||
},
|
||||
openFileBrowser() {
|
||||
this.$refs.imageUploadInput.click();
|
||||
},
|
||||
async handleImageUpload(url) {
|
||||
try {
|
||||
const fileUrl = await this.$store.dispatch(
|
||||
'articles/uploadExternalImage',
|
||||
{
|
||||
portalSlug: this.$route.params.portalSlug,
|
||||
url,
|
||||
}
|
||||
);
|
||||
|
||||
return fileUrl;
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR')
|
||||
);
|
||||
return '';
|
||||
}
|
||||
},
|
||||
onFileChange() {
|
||||
const file = this.$refs.imageUploadInput.files[0];
|
||||
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
this.uploadImageToStorage(file);
|
||||
} else {
|
||||
useAlert(
|
||||
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR_FILE_SIZE', {
|
||||
size: MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.$refs.imageUploadInput.value = '';
|
||||
},
|
||||
async uploadImageToStorage(file) {
|
||||
try {
|
||||
const fileUrl = await this.$store.dispatch('articles/attachImage', {
|
||||
portalSlug: this.$route.params.portalSlug,
|
||||
file,
|
||||
});
|
||||
|
||||
if (fileUrl) {
|
||||
this.onImageUploadStart(fileUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
|
||||
}
|
||||
},
|
||||
onImageUploadStart(fileUrl) {
|
||||
const { selection } = editorView.state;
|
||||
const from = selection.from;
|
||||
const node = editorView.state.schema.nodes.image.create({
|
||||
src: fileUrl,
|
||||
});
|
||||
const paragraphNode = editorView.state.schema.node('paragraph');
|
||||
if (node) {
|
||||
// Insert the image and the caption wrapped inside a paragraph
|
||||
const tr = editorView.state.tr
|
||||
.replaceSelectionWith(paragraphNode)
|
||||
.insert(from + 1, node);
|
||||
|
||||
editorView.dispatch(tr.scrollIntoView());
|
||||
this.focusEditorInputField();
|
||||
}
|
||||
},
|
||||
reloadState() {
|
||||
state = createState(
|
||||
this.modelValue,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
this.enabledMenuOptions
|
||||
);
|
||||
editorView.updateState(state);
|
||||
this.focusEditorInputField();
|
||||
},
|
||||
createEditorView() {
|
||||
editorView = new EditorView(this.$refs.editor, {
|
||||
state: state,
|
||||
dispatchTransaction: tx => {
|
||||
state = state.apply(tx);
|
||||
editorView.updateState(state);
|
||||
if (tx.docChanged) {
|
||||
this.emitOnChange();
|
||||
}
|
||||
this.checkSelection(state);
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keyup: this.onKeyup,
|
||||
focus: this.onFocus,
|
||||
blur: this.onBlur,
|
||||
keydown: this.onKeydown,
|
||||
paste: (view, event) => {
|
||||
const data = event.clipboardData.files;
|
||||
if (data.length > 0) {
|
||||
data.forEach(file => {
|
||||
// Check if the file is an image
|
||||
if (file.type.includes('image')) {
|
||||
this.uploadImageToStorage(file);
|
||||
}
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
handleKeyEvents() {},
|
||||
focusEditorInputField() {
|
||||
const { tr } = editorView.state;
|
||||
const selection = Selection.atEnd(tr.doc);
|
||||
|
||||
editorView.dispatch(tr.setSelection(selection));
|
||||
editorView.focus();
|
||||
},
|
||||
emitOnChange() {
|
||||
this.$emit('update:modelValue', this.contentFromEditor());
|
||||
this.$emit('input', this.contentFromEditor());
|
||||
},
|
||||
onKeyup() {
|
||||
this.$emit('keyup');
|
||||
},
|
||||
onKeydown() {
|
||||
this.$emit('keydown');
|
||||
},
|
||||
onBlur() {
|
||||
this.$emit('blur');
|
||||
},
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
checkSelection(editorState) {
|
||||
const { from, to } = editorState.selection;
|
||||
// Check if there's a selection (from and to are different)
|
||||
const hasSelection = from !== to;
|
||||
// If the selection state is the same as the previous state, do nothing
|
||||
if (hasSelection === this.isTextSelected) return;
|
||||
// Update the selection state
|
||||
this.isTextSelected = hasSelection;
|
||||
|
||||
const { editor } = this.$refs;
|
||||
|
||||
// Toggle the 'has-selection' class based on whether there's a selection
|
||||
editor.classList.toggle('has-selection', hasSelection);
|
||||
// If there's a selection, update the menubar position
|
||||
if (hasSelection) this.setMenubarPosition(editorState);
|
||||
},
|
||||
setMenubarPosition(editorState) {
|
||||
if (!editorState.selection) return;
|
||||
|
||||
// Get the start and end positions of the selection
|
||||
const { from, to } = editorState.selection;
|
||||
const { editor } = this.$refs;
|
||||
// Get the editor's position relative to the viewport
|
||||
const { left: editorLeft, top: editorTop } =
|
||||
editor.getBoundingClientRect();
|
||||
|
||||
// Get the editor's width
|
||||
const editorWidth = editor.offsetWidth;
|
||||
const menubarWidth = 480; // Menubar width (adjust as needed (px))
|
||||
|
||||
// Get the end position of the selection
|
||||
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
|
||||
// Get the start position of the selection
|
||||
const { left: startLeft } = editorView.coordsAtPos(from);
|
||||
|
||||
// Calculate the top position for the menubar (10px below the selection)
|
||||
const top = endBottom - editorTop + 10;
|
||||
// Calculate the left position for the menubar
|
||||
// This centers the menubar on the selection while keeping it within the editor's bounds
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
(startLeft + endRight) / 2 - editorLeft,
|
||||
editorWidth - menubarWidth
|
||||
)
|
||||
);
|
||||
// Set the CSS custom properties for positioning the menubar
|
||||
editor.style.setProperty('--selection-top', `${top}px`);
|
||||
editor.style.setProperty('--selection-left', `${left}px`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="editor-root editor--article">
|
||||
<input
|
||||
ref="imageUploadInput"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
hidden
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div ref="editor" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import '@chatwoot/prosemirror-schema/src/styles/article.scss';
|
||||
|
||||
.ProseMirror-menubar-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .ProseMirror {
|
||||
padding: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ProseMirror-woot-style {
|
||||
min-height: 5rem;
|
||||
max-height: 7.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply z-[9999] bg-n-alpha-3 min-w-80 backdrop-blur-[100px] border border-n-strong p-6 shadow-xl rounded-xl;
|
||||
|
||||
h5 {
|
||||
@apply text-n-slate-12 mb-1.5;
|
||||
}
|
||||
|
||||
.ProseMirror-prompt-buttons {
|
||||
button {
|
||||
@apply h-8 px-3;
|
||||
|
||||
&[type='submit'] {
|
||||
@apply bg-n-brand text-white hover:bg-n-brand/90;
|
||||
}
|
||||
|
||||
&[type='button'] {
|
||||
@apply bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,439 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import * as ActiveStorage from 'activestorage';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { NextButton, FileUpload, VideoCallButton },
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
isNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onSend: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
sendButtonText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
recordingAudioDurationText: {
|
||||
type: String,
|
||||
default: '00:00',
|
||||
},
|
||||
// inbox prop is used in /mixins/inboxMixin,
|
||||
// remove this props when refactoring to composable if not needed
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showFileUpload: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showAudioRecorder: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onFileUpload: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
toggleEmojiPicker: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
toggleAudioRecorder: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
toggleAudioRecorderPlayPause: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
isRecordingAudio: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recordingAudioState: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isSendDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isOnPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableMultipleFileUpload: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
enableWhatsAppTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableContentTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
newConversationModalActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
portalSlug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showQuotedReplyToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
quotedReplyEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEditorDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'replaceText',
|
||||
'toggleInsertArticle',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
'toggleQuotedReply',
|
||||
],
|
||||
setup(props) {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
useUISettings();
|
||||
|
||||
const uploadRef = ref(false);
|
||||
|
||||
const keyboardEvents = {
|
||||
'$mod+Alt+KeyA': {
|
||||
action: () => {
|
||||
// Skip if editor is disabled (e.g., WhatsApp 24-hour window expired)
|
||||
if (props.isEditorDisabled) return;
|
||||
|
||||
// TODO: This is really hacky, we need to replace the file picker component with
|
||||
// a custom one, where the logic and the component markup is isolated.
|
||||
// Once we have the custom component, we can remove the hacky logic below.
|
||||
|
||||
const uploadTriggerButton = document.querySelector(
|
||||
'#conversationAttachment'
|
||||
);
|
||||
if (uploadTriggerButton) uploadTriggerButton.click();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
return {
|
||||
setSignatureFlagForInbox,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
uploadRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ALLOWED_FILE_TYPES,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
uiFlags: 'integrations/getUIFlags',
|
||||
}),
|
||||
wrapClass() {
|
||||
return {
|
||||
'is-note-mode': this.isNote,
|
||||
};
|
||||
},
|
||||
showAttachButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
return this.showFileUpload || this.isNote;
|
||||
},
|
||||
showAudioRecorderButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
if (this.isALineChannel) {
|
||||
return false;
|
||||
}
|
||||
// Disable audio recorder for safari browser as recording is not supported
|
||||
// const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
|
||||
// navigator.userAgent
|
||||
// );
|
||||
|
||||
return (
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.VOICE_RECORDER
|
||||
) && this.showAudioRecorder
|
||||
// !isSafari
|
||||
);
|
||||
},
|
||||
showAudioPlayStopButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
return this.showAudioRecorder && this.isRecordingAudio;
|
||||
},
|
||||
isInstagramDM() {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
allowedFileTypes() {
|
||||
// Use default file types for private notes
|
||||
if (this.isOnPrivateNote) {
|
||||
return this.ALLOWED_FILE_TYPES;
|
||||
}
|
||||
|
||||
let channelType = this.channelType || this.inbox?.channel_type;
|
||||
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
channelType = INBOX_TYPES.INSTAGRAM;
|
||||
}
|
||||
|
||||
return getAllowedFileTypesByChannel({
|
||||
channelType,
|
||||
medium: this.inbox?.medium,
|
||||
});
|
||||
},
|
||||
enableDragAndDrop() {
|
||||
return !this.newConversationModalActive;
|
||||
},
|
||||
audioRecorderPlayStopIcon() {
|
||||
switch (this.recordingAudioState) {
|
||||
// playing paused recording stopped inactive destroyed
|
||||
case 'playing':
|
||||
return 'i-ph-pause';
|
||||
case 'paused':
|
||||
return 'i-ph-play';
|
||||
case 'stopped':
|
||||
return 'i-ph-play';
|
||||
default:
|
||||
return 'i-ph-stop';
|
||||
}
|
||||
},
|
||||
showMessageSignatureButton() {
|
||||
if (this.isEditorDisabled) return false;
|
||||
return !this.isOnPrivateNote;
|
||||
},
|
||||
sendWithSignature() {
|
||||
// channelType is sourced from inboxMixin
|
||||
return this.fetchSignatureFlagFromUISettings(this.channelType);
|
||||
},
|
||||
signatureToggleTooltip() {
|
||||
return this.sendWithSignature
|
||||
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
|
||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||
},
|
||||
enableInsertArticleInReply() {
|
||||
return this.portalSlug;
|
||||
},
|
||||
isFetchingAppIntegrations() {
|
||||
return this.uiFlags.isFetching;
|
||||
},
|
||||
quotedReplyToggleTooltip() {
|
||||
return this.quotedReplyEnabled
|
||||
? this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.DISABLE_TOOLTIP')
|
||||
: this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.ENABLE_TOOLTIP');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
ActiveStorage.start();
|
||||
},
|
||||
methods: {
|
||||
toggleMessageSignature() {
|
||||
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
|
||||
},
|
||||
replaceText(text) {
|
||||
this.$emit('replaceText', text);
|
||||
},
|
||||
toggleInsertArticle() {
|
||||
this.$emit('toggleInsertArticle');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between p-3" :class="wrapClass">
|
||||
<div class="left-wrap">
|
||||
<NextButton
|
||||
v-if="!isEditorDisabled"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
||||
icon="i-ph-smiley-sticker"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
<FileUpload
|
||||
v-if="showAttachButton"
|
||||
ref="uploadRef"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
input-id="conversationAttachment"
|
||||
:size="4096 * 4096"
|
||||
:accept="allowedFileTypes"
|
||||
:multiple="enableMultipleFileUpload"
|
||||
:drop="enableDragAndDrop"
|
||||
:drop-directory="false"
|
||||
:data="{
|
||||
direct_upload_url: '/rails/active_storage/direct_uploads',
|
||||
direct_upload: true,
|
||||
}"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<NextButton
|
||||
v-if="showAttachButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
icon="i-ph-paperclip"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
/>
|
||||
</FileUpload>
|
||||
<NextButton
|
||||
v-if="showAudioRecorderButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleAudioRecorder"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showAudioPlayStopButton"
|
||||
:icon="audioRecorderPlayStopIcon"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:label="recordingAudioDurationText"
|
||||
@click="toggleAudioRecorderPlayPause"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showMessageSignatureButton"
|
||||
v-tooltip.top-end="signatureToggleTooltip"
|
||||
icon="i-ph-signature"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleMessageSignature"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showQuotedReplyToggle"
|
||||
v-tooltip.top-end="quotedReplyToggleTooltip"
|
||||
icon="i-ph-quotes"
|
||||
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
|
||||
color="slate"
|
||||
sm
|
||||
:aria-pressed="quotedReplyEnabled"
|
||||
@click="$emit('toggleQuotedReply')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableWhatsAppTemplates"
|
||||
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableContentTemplates"
|
||||
v-tooltip.top-end="'Content Templates'"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('selectContentTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="uploadRef && uploadRef.dropActive"
|
||||
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
>
|
||||
<fluent-icon icon="cloud-backup" size="40" />
|
||||
<h4 class="text-2xl break-words text-n-slate-12">
|
||||
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
|
||||
</h4>
|
||||
</div>
|
||||
</transition>
|
||||
<NextButton
|
||||
v-if="enableInsertArticleInReply"
|
||||
v-tooltip.top-end="$t('HELP_CENTER.ARTICLE_SEARCH.OPEN_ARTICLE_SEARCH')"
|
||||
icon="i-ph-article-ny-times"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleInsertArticle"
|
||||
/>
|
||||
</div>
|
||||
<div class="right-wrap">
|
||||
<NextButton
|
||||
:label="sendButtonText"
|
||||
type="submit"
|
||||
sm
|
||||
:color="isNote ? 'amber' : 'blue'"
|
||||
:disabled="isSendDisabled"
|
||||
class="flex-shrink-0"
|
||||
@click="onSend"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.left-wrap {
|
||||
@apply items-center flex gap-2;
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
::v-deep .file-uploads {
|
||||
label {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
@apply enabled:bg-n-slate-9/20;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useCaptain } from 'dashboard/composables/useCaptain';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import EditorModeToggle from './EditorModeToggle.vue';
|
||||
import CopilotMenuBar from './CopilotMenuBar.vue';
|
||||
|
||||
export default {
|
||||
name: 'ReplyTopPanel',
|
||||
components: {
|
||||
NextButton,
|
||||
EditorModeToggle,
|
||||
CopilotMenuBar,
|
||||
},
|
||||
directives: {
|
||||
OnClickOutside: vOnClickOutside,
|
||||
},
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: REPLY_EDITOR_MODES.REPLY,
|
||||
},
|
||||
isReplyRestricted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEditorDisabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
isMessageLengthReachingThreshold: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
charactersRemaining: {
|
||||
type: Number,
|
||||
default: () => 0,
|
||||
},
|
||||
},
|
||||
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
|
||||
setup(props, { emit }) {
|
||||
const setReplyMode = mode => {
|
||||
emit('setReplyMode', mode);
|
||||
};
|
||||
const handleReplyClick = () => {
|
||||
if (props.isReplyRestricted) return;
|
||||
setReplyMode(REPLY_EDITOR_MODES.REPLY);
|
||||
};
|
||||
const handleNoteClick = () => {
|
||||
setReplyMode(REPLY_EDITOR_MODES.NOTE);
|
||||
};
|
||||
const handleModeToggle = () => {
|
||||
const newMode =
|
||||
props.mode === REPLY_EDITOR_MODES.REPLY
|
||||
? REPLY_EDITOR_MODES.NOTE
|
||||
: REPLY_EDITOR_MODES.REPLY;
|
||||
setReplyMode(newMode);
|
||||
};
|
||||
|
||||
const { captainTasksEnabled } = useCaptain();
|
||||
const showCopilotMenu = ref(false);
|
||||
|
||||
const handleCopilotAction = actionKey => {
|
||||
emit('executeCopilotAction', actionKey);
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
const toggleCopilotMenu = () => {
|
||||
const isOpening = !showCopilotMenu.value;
|
||||
if (isOpening) {
|
||||
useTrack(CAPTAIN_EVENTS.EDITOR_AI_MENU_OPENED, {
|
||||
conversationId: props.conversationId,
|
||||
entryPoint: 'top_panel',
|
||||
});
|
||||
}
|
||||
showCopilotMenu.value = isOpening;
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
showCopilotMenu.value = false;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyP': {
|
||||
action: () => handleNoteClick(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: () => handleReplyClick(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
return {
|
||||
handleModeToggle,
|
||||
handleReplyClick,
|
||||
handleNoteClick,
|
||||
REPLY_EDITOR_MODES,
|
||||
captainTasksEnabled,
|
||||
handleCopilotAction,
|
||||
showCopilotMenu,
|
||||
toggleCopilotMenu,
|
||||
handleClickOutside,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
replyButtonClass() {
|
||||
return {
|
||||
'is-active': this.mode === REPLY_EDITOR_MODES.REPLY,
|
||||
};
|
||||
},
|
||||
noteButtonClass() {
|
||||
return {
|
||||
'is-active': this.mode === REPLY_EDITOR_MODES.NOTE,
|
||||
};
|
||||
},
|
||||
charLengthClass() {
|
||||
return this.charactersRemaining < 0 ? 'text-n-ruby-9' : 'text-n-slate-11';
|
||||
},
|
||||
characterLengthWarning() {
|
||||
return this.charactersRemaining < 0
|
||||
? `${-this.charactersRemaining} ${CHAR_LENGTH_WARNING.NEGATIVE}`
|
||||
: `${this.charactersRemaining} ${CHAR_LENGTH_WARNING.UNDER_50}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between gap-2 h-[3.25rem] items-center ltr:pl-3 ltr:pr-2 rtl:pr-3 rtl:pl-2"
|
||||
>
|
||||
<EditorModeToggle
|
||||
:mode="mode"
|
||||
:disabled="disabled"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
@toggle-mode="handleModeToggle"
|
||||
/>
|
||||
<div class="flex items-center mx-4 my-0">
|
||||
<div v-if="isMessageLengthReachingThreshold" class="text-xs">
|
||||
<span :class="charLengthClass">
|
||||
{{ characterLengthWarning }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="captainTasksEnabled" class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<NextButton
|
||||
ghost
|
||||
:disabled="disabled || isEditorDisabled"
|
||||
:class="{
|
||||
'text-n-violet-9 hover:enabled:!bg-n-violet-3': !showCopilotMenu,
|
||||
'text-n-violet-9 bg-n-violet-3': showCopilotMenu,
|
||||
}"
|
||||
sm
|
||||
icon="i-ph-sparkle-fill"
|
||||
@click="toggleCopilotMenu"
|
||||
/>
|
||||
<CopilotMenuBar
|
||||
v-if="showCopilotMenu"
|
||||
v-on-click-outside="handleClickOutside"
|
||||
:has-selection="false"
|
||||
class="ltr:right-0 rtl:left-0 bottom-full mb-2"
|
||||
@execute-copilot-action="handleCopilotAction"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
ghost
|
||||
class="text-n-slate-11"
|
||||
sm
|
||||
icon="i-lucide-maximize-2"
|
||||
@click="$emit('togglePopout')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
export const REPLY_EDITOR_MODES = {
|
||||
REPLY: 'REPLY',
|
||||
NOTE: 'NOTE',
|
||||
};
|
||||
|
||||
export const CHAR_LENGTH_WARNING = {
|
||||
UNDER_50: 'characters remaining',
|
||||
NEGATIVE: 'characters over',
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { shallowRef, computed, onMounted } from 'vue';
|
||||
import emojiGroups from 'shared/components/emoji/emojisGroup.json';
|
||||
import MentionBox from '../mentions/MentionBox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectEmoji']);
|
||||
|
||||
const allEmojis = shallowRef([]);
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.searchKey) return [];
|
||||
const searchTerm = props.searchKey.toLowerCase();
|
||||
return allEmojis.value.filter(emoji =>
|
||||
emoji.searchString.includes(searchTerm)
|
||||
);
|
||||
});
|
||||
|
||||
function loadEmojis() {
|
||||
allEmojis.value = emojiGroups.flatMap(({ emojis }) =>
|
||||
emojis.map(({ name, slug, ...rest }) => ({
|
||||
...rest,
|
||||
name,
|
||||
slug,
|
||||
searchString: `${name.replace(/\s+/g, '')} ${slug}`.toLowerCase(), // Remove all whitespace and convert to lowercase
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function handleMentionClick(item = {}) {
|
||||
emit('selectEmoji', item.emoji);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadEmojis();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<MentionBox
|
||||
v-if="items.length"
|
||||
type="emoji"
|
||||
:items="items"
|
||||
@mention-select="handleMentionClick"
|
||||
>
|
||||
<template #default="{ item, selected }">
|
||||
<span
|
||||
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-n-slate-12 group-hover:text-n-brand truncate"
|
||||
>
|
||||
{{ item.emoji }}
|
||||
<p
|
||||
class="relative mb-0 truncate bottom-px"
|
||||
:class="{
|
||||
'text-n-brand': selected,
|
||||
'font-normal': !selected,
|
||||
}"
|
||||
>
|
||||
:{{ item.name }}
|
||||
</p>
|
||||
</span>
|
||||
</template>
|
||||
</MentionBox>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
import lamejs from '@breezystack/lamejs';
|
||||
|
||||
const writeString = (view, offset, string) => {
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
const bufferToWav = async (buffer, numChannels, sampleRate) => {
|
||||
const length = buffer.length * numChannels * 2;
|
||||
const wav = new ArrayBuffer(44 + length);
|
||||
const view = new DataView(wav);
|
||||
|
||||
// WAV Header
|
||||
writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + length, true);
|
||||
writeString(view, 8, 'WAVE');
|
||||
writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * numChannels * 2, true);
|
||||
view.setUint16(32, numChannels * 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(view, 36, 'data');
|
||||
view.setUint32(40, length, true);
|
||||
|
||||
// WAV Data
|
||||
const offset = 44;
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let channel = 0; channel < numChannels; channel++) {
|
||||
const sample = Math.max(
|
||||
-1,
|
||||
Math.min(1, buffer.getChannelData(channel)[i])
|
||||
);
|
||||
view.setInt16(
|
||||
offset + (i * numChannels + channel) * 2,
|
||||
sample * 0x7fff,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([wav], { type: 'audio/wav' });
|
||||
};
|
||||
|
||||
const decodeAudioData = async audioBlob => {
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
const audioData = await audioContext.decodeAudioData(arrayBuffer);
|
||||
return audioData;
|
||||
};
|
||||
|
||||
export const convertToWav = async audioBlob => {
|
||||
const audioBuffer = await decodeAudioData(audioBlob);
|
||||
return bufferToWav(
|
||||
audioBuffer,
|
||||
audioBuffer.numberOfChannels,
|
||||
audioBuffer.sampleRate
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Encodes audio samples to MP3 format.
|
||||
* @param {number} channels - Number of audio channels.
|
||||
* @param {number} sampleRate - Sample rate in Hz.
|
||||
* @param {Int16Array} samples - Audio samples to be encoded.
|
||||
* @param {number} bitrate - MP3 bitrate (default: 128)
|
||||
* @returns {Blob} - The MP3 encoded audio as a Blob.
|
||||
*/
|
||||
export const encodeToMP3 = (channels, sampleRate, samples, bitrate = 128) => {
|
||||
const outputBuffer = [];
|
||||
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, bitrate);
|
||||
const maxSamplesPerFrame = 1152;
|
||||
|
||||
for (let offset = 0; offset < samples.length; offset += maxSamplesPerFrame) {
|
||||
const sliceEnd = Math.min(offset + maxSamplesPerFrame, samples.length);
|
||||
const sampleSlice = samples.subarray(offset, sliceEnd);
|
||||
const mp3Buffer = encoder.encodeBuffer(sampleSlice);
|
||||
|
||||
if (mp3Buffer.length > 0) {
|
||||
outputBuffer.push(new Int8Array(mp3Buffer));
|
||||
}
|
||||
}
|
||||
|
||||
const remainingData = encoder.flush();
|
||||
if (remainingData.length > 0) {
|
||||
outputBuffer.push(new Int8Array(remainingData));
|
||||
}
|
||||
|
||||
return new Blob(outputBuffer, { type: 'audio/mp3' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an audio Blob to an MP3 format Blob.
|
||||
* @param {Blob} audioBlob - The audio data as a Blob.
|
||||
* @param {number} bitrate - MP3 bitrate (default: 128)
|
||||
* @returns {Promise<Blob>} - A Blob containing the MP3 encoded audio.
|
||||
*/
|
||||
export const convertToMp3 = async (audioBlob, bitrate = 128) => {
|
||||
try {
|
||||
const audioBuffer = await decodeAudioData(audioBlob);
|
||||
const samples = new Int16Array(
|
||||
audioBuffer.length * audioBuffer.numberOfChannels
|
||||
);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < audioBuffer.length; i += 1) {
|
||||
for (
|
||||
let channel = 0;
|
||||
channel < audioBuffer.numberOfChannels;
|
||||
channel += 1
|
||||
) {
|
||||
const sample = Math.max(
|
||||
-1,
|
||||
Math.min(1, audioBuffer.getChannelData(channel)[i])
|
||||
);
|
||||
samples[offset] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
return encodeToMP3(
|
||||
audioBuffer.numberOfChannels,
|
||||
audioBuffer.sampleRate,
|
||||
samples,
|
||||
bitrate
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error('Conversion to MP3 failed.');
|
||||
}
|
||||
};
|
||||
|
||||
export const convertAudio = async (inputBlob, outputFormat, bitrate = 128) => {
|
||||
let audio;
|
||||
if (outputFormat === 'audio/wav') {
|
||||
audio = await convertToWav(inputBlob);
|
||||
} else if (outputFormat === 'audio/mp3') {
|
||||
audio = await convertToMp3(inputBlob, bitrate);
|
||||
} else {
|
||||
throw new Error('Unsupported output format');
|
||||
}
|
||||
return audio;
|
||||
};
|
||||
Reference in New Issue
Block a user