Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d18decf8b3 | ||
|
|
4be4def0a6 | ||
|
|
f46d7e581f |
@@ -21,13 +21,45 @@ const recordedAudioAttachments = computed(() =>
|
|||||||
props.attachments.filter(attachment => attachment.isRecordedAudio)
|
props.attachments.filter(attachment => attachment.isRecordedAudio)
|
||||||
);
|
);
|
||||||
|
|
||||||
const onRemoveAttachment = itemIndex => {
|
// Create unified list of attachments with metadata for rendering
|
||||||
emit(
|
const allAttachments = computed(() => {
|
||||||
'removeAttachment',
|
const audioAttachments = recordedAudioAttachments.value.map(
|
||||||
nonRecordedAudioAttachments.value
|
(attachment, index) => ({
|
||||||
.filter((_, index) => index !== itemIndex)
|
attachment,
|
||||||
.concat(recordedAudioAttachments.value)
|
isRecordedAudio: true,
|
||||||
|
index,
|
||||||
|
key: `audio-${index}`,
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const otherAttachments = nonRecordedAudioAttachments.value.map(
|
||||||
|
(attachment, index) => ({
|
||||||
|
attachment,
|
||||||
|
isRecordedAudio: false,
|
||||||
|
index,
|
||||||
|
key: attachment.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...audioAttachments, ...otherAttachments];
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeAttachment = (isRecordedAudio, itemIndex) => {
|
||||||
|
if (isRecordedAudio) {
|
||||||
|
emit(
|
||||||
|
'removeAttachment',
|
||||||
|
nonRecordedAudioAttachments.value.concat(
|
||||||
|
recordedAudioAttachments.value.filter((_, index) => index !== itemIndex)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
emit(
|
||||||
|
'removeAttachment',
|
||||||
|
nonRecordedAudioAttachments.value
|
||||||
|
.filter((_, index) => index !== itemIndex)
|
||||||
|
.concat(recordedAudioAttachments.value)
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatFileSize = file => {
|
const formatFileSize = file => {
|
||||||
@@ -46,32 +78,32 @@ const fileName = file => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex overflow-auto max-h-[12.5rem]">
|
<div class="flex flex-col overflow-auto max-h-[12.5rem]">
|
||||||
<div
|
<div
|
||||||
v-for="(attachment, index) in nonRecordedAudioAttachments"
|
v-for="item in allAttachments"
|
||||||
:key="attachment.id"
|
:key="item.key"
|
||||||
class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
|
class="preview-item flex items-center p-1 bg-n-slate-9/10 gap-1 rounded-md w-[15rem] mb-1"
|
||||||
>
|
>
|
||||||
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
|
<div class="max-w-[4rem] flex-shrink-0 w-6 flex items-center">
|
||||||
<img
|
<img
|
||||||
v-if="isTypeImage(attachment.resource)"
|
v-if="!item.isRecordedAudio && isTypeImage(item.attachment.resource)"
|
||||||
class="object-cover w-6 h-6 rounded-sm"
|
class="object-cover w-6 h-6 rounded-sm"
|
||||||
:src="attachment.thumb"
|
:src="item.attachment.thumb"
|
||||||
/>
|
/>
|
||||||
<span v-else class="relative w-6 h-6 text-lg text-left -top-px">
|
<span v-else class="relative w-6 h-6 text-lg text-left -top-px">
|
||||||
📄
|
{{ item.isRecordedAudio ? '🎤' : '📄' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-w-3/5 min-w-[50%] overflow-hidden text-ellipsis">
|
<div class="max-w-3/5 min-w-[50%] overflow-hidden text-ellipsis">
|
||||||
<span
|
<span
|
||||||
class="h-4 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap"
|
class="h-4 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{{ fileName(attachment.resource) }}
|
{{ fileName(item.attachment.resource) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-[30%] justify-center">
|
<div class="w-[30%] justify-center">
|
||||||
<span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap">
|
<span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap">
|
||||||
{{ formatFileSize(attachment.resource) }}
|
{{ formatFileSize(item.attachment.resource) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
@@ -80,7 +112,7 @@ const fileName = file => {
|
|||||||
slate
|
slate
|
||||||
xs
|
xs
|
||||||
icon="i-lucide-x"
|
icon="i-lucide-x"
|
||||||
@click="onRemoveAttachment(index)"
|
@click="removeAttachment(item.isRecordedAudio, item.index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ import {
|
|||||||
replaceSignature,
|
replaceSignature,
|
||||||
extractTextFromMarkdown,
|
extractTextFromMarkdown,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
import {
|
||||||
|
saveAudioToDraft,
|
||||||
|
getAudioFromDraft,
|
||||||
|
removeAudioDraft,
|
||||||
|
} from 'dashboard/helpers/audioDraftHelper';
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
@@ -215,7 +219,11 @@ export default {
|
|||||||
},
|
},
|
||||||
isReplyButtonDisabled() {
|
isReplyButtonDisabled() {
|
||||||
if (this.isATwitterInbox) return true;
|
if (this.isATwitterInbox) return true;
|
||||||
if (this.hasAttachments || this.hasRecordedAudio) return false;
|
|
||||||
|
const hasActualAttachments =
|
||||||
|
this.attachedFiles && this.attachedFiles.length > 0;
|
||||||
|
|
||||||
|
if (hasActualAttachments) return false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.isMessageEmpty ||
|
this.isMessageEmpty ||
|
||||||
@@ -418,7 +426,9 @@ export default {
|
|||||||
},
|
},
|
||||||
conversationIdByRoute(conversationId, oldConversationId) {
|
conversationIdByRoute(conversationId, oldConversationId) {
|
||||||
if (conversationId !== oldConversationId) {
|
if (conversationId !== oldConversationId) {
|
||||||
|
this.saveAudioDraft(oldConversationId);
|
||||||
this.setToDraft(oldConversationId, this.replyType);
|
this.setToDraft(oldConversationId, this.replyType);
|
||||||
|
this.resetAudioRecorderUIState();
|
||||||
this.getFromDraft();
|
this.getFromDraft();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -529,6 +539,55 @@ export default {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
resetAudioRecorderUIState() {
|
||||||
|
this.recordingAudioDurationText = '00:00';
|
||||||
|
this.isRecordingAudio = false;
|
||||||
|
this.recordingAudioState = '';
|
||||||
|
},
|
||||||
|
async getAudioRecordingFromDraft(
|
||||||
|
conversationId,
|
||||||
|
replyType = this.replyType
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// First remove any existing audio recordings to prevent duplicates
|
||||||
|
this.attachedFiles = this.attachedFiles.filter(
|
||||||
|
file => !file.isRecordedAudio
|
||||||
|
);
|
||||||
|
const audioFile = await getAudioFromDraft(conversationId, replyType);
|
||||||
|
if (audioFile) {
|
||||||
|
// Add to attachedFiles
|
||||||
|
this.attachedFiles.push({
|
||||||
|
currentChatId: conversationId,
|
||||||
|
...audioFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update UI state
|
||||||
|
this.hasRecordedAudio = true;
|
||||||
|
this.recordingAudioState = 'stopped';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If there's an error, cleanup to prevent UI issues
|
||||||
|
this.resetAudioRecorderInput();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
saveAudioDraft(oldConversationId) {
|
||||||
|
// Save audio draft for the old conversation if any
|
||||||
|
// When switching between conversations, save the audio to draft for the old conversation
|
||||||
|
if (this.hasRecordedAudio && this.attachedFiles.length) {
|
||||||
|
const audioFile = this.attachedFiles.find(file => file.isRecordedAudio);
|
||||||
|
if (audioFile) {
|
||||||
|
// Check if the audio file size exceeds the 5MB limit for localStorage
|
||||||
|
// If it does, show an error message and do not save
|
||||||
|
if (audioFile.resource.file.size > 5 * 1024 * 1024) {
|
||||||
|
useAlert(
|
||||||
|
this.$t('CONVERSATION.REPLYBOX.DRAFT_AUDIO_SIZE_LIMIT_ERROR')
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveAudioToDraft(oldConversationId, this.replyType, audioFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
saveDraft(conversationId, replyType) {
|
saveDraft(conversationId, replyType) {
|
||||||
if (this.message || this.message === '') {
|
if (this.message || this.message === '') {
|
||||||
const key = `draft-${conversationId}-${replyType}`;
|
const key = `draft-${conversationId}-${replyType}`;
|
||||||
@@ -552,6 +611,8 @@ export default {
|
|||||||
|
|
||||||
// ensure that the message has signature set based on the ui setting
|
// ensure that the message has signature set based on the ui setting
|
||||||
this.message = this.toggleSignatureForDraft(messageFromStore);
|
this.message = this.toggleSignatureForDraft(messageFromStore);
|
||||||
|
// get audio recording from draft
|
||||||
|
this.getAudioRecordingFromDraft(this.conversationIdByRoute);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
toggleSignatureForDraft(message) {
|
toggleSignatureForDraft(message) {
|
||||||
@@ -816,6 +877,12 @@ export default {
|
|||||||
// if signature is enabled, append it to the message
|
// if signature is enabled, append it to the message
|
||||||
this.message = appendSignature(this.message, this.signatureToApply);
|
this.message = appendSignature(this.message, this.signatureToApply);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove audio draft on clear
|
||||||
|
if (this.hasRecordedAudio && this.conversationIdByRoute) {
|
||||||
|
removeAudioDraft(this.conversationIdByRoute, this.replyType);
|
||||||
|
}
|
||||||
|
|
||||||
this.attachedFiles = [];
|
this.attachedFiles = [];
|
||||||
this.isRecordingAudio = false;
|
this.isRecordingAudio = false;
|
||||||
this.resetReplyToMessage();
|
this.resetReplyToMessage();
|
||||||
@@ -873,6 +940,15 @@ export default {
|
|||||||
this.recordingAudioDurationText = duration;
|
this.recordingAudioDurationText = duration;
|
||||||
},
|
},
|
||||||
onFinishRecorder(file) {
|
onFinishRecorder(file) {
|
||||||
|
// Remove audio from draft on new recording
|
||||||
|
if (file && this.conversationIdByRoute) {
|
||||||
|
removeAudioDraft(this.conversationIdByRoute, this.replyType);
|
||||||
|
// remove from attached files and isRecordedAudio
|
||||||
|
this.attachedFiles = this.attachedFiles.filter(
|
||||||
|
f => f.id !== file.id && !f.isRecordedAudio
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.recordingAudioState = 'stopped';
|
this.recordingAudioState = 'stopped';
|
||||||
this.hasRecordedAudio = true;
|
this.hasRecordedAudio = true;
|
||||||
// Added a new key isRecordedAudio to the file to find it's and recorded audio
|
// Added a new key isRecordedAudio to the file to find it's and recorded audio
|
||||||
@@ -913,6 +989,16 @@ export default {
|
|||||||
},
|
},
|
||||||
removeAttachment(attachments) {
|
removeAttachment(attachments) {
|
||||||
this.attachedFiles = attachments;
|
this.attachedFiles = attachments;
|
||||||
|
// If there are no attachments or if there are recorded audio attachments
|
||||||
|
// then remove the audio draft
|
||||||
|
if (
|
||||||
|
attachments?.some(attachment => attachment.isRecordedAudio) ||
|
||||||
|
!attachments.length
|
||||||
|
) {
|
||||||
|
if (this.conversationIdByRoute) {
|
||||||
|
removeAudioDraft(this.conversationIdByRoute, this.replyType);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setReplyToInPayload(payload) {
|
setReplyToInPayload(payload) {
|
||||||
if (this.inReplyTo?.id) {
|
if (this.inReplyTo?.id) {
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
|
|
||||||
|
const AUDIO_STORAGE_KEY = 'draftRecordedAudio';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Blob to a Base64 string
|
||||||
|
* @param {Blob} blob - The blob to convert
|
||||||
|
* @returns {Promise<string>} - A promise that resolves with the Base64 string
|
||||||
|
*/
|
||||||
|
export const blobToBase64 = blob => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Base64 string back to a Blob
|
||||||
|
* @param {string} base64String - The Base64 string to convert
|
||||||
|
* @returns {Blob} - The resulting Blob
|
||||||
|
*/
|
||||||
|
export const base64ToBlob = base64String => {
|
||||||
|
const [header, base64] = base64String.split(',');
|
||||||
|
const matches = header.match(/^data:([A-Za-z-+/]+);base64$/);
|
||||||
|
|
||||||
|
if (!matches || matches.length !== 2) {
|
||||||
|
throw new Error('Invalid base64 string format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = matches[1];
|
||||||
|
const binaryString = atob(base64);
|
||||||
|
const bytes = new Uint8Array(
|
||||||
|
[...binaryString].map(char => char.charCodeAt(0))
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Blob([bytes], { type: contentType });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a storage key for a conversation's audio draft
|
||||||
|
* @param {string} conversationId - The conversation ID
|
||||||
|
* @param {string} replyType - The reply type (REPLY or NOTE)
|
||||||
|
* @returns {string} - The storage key
|
||||||
|
*/
|
||||||
|
export const getAudioStorageKey = (conversationId, replyType) =>
|
||||||
|
`audio-${conversationId}-${replyType}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the recorded audio to the draft storage
|
||||||
|
* @param {string} conversationId - The conversation ID
|
||||||
|
* @param {string} replyType - The reply type (REPLY or NOTE)
|
||||||
|
* @param {Object} audioFile - The audio file object to save
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const saveAudioToDraft = async (
|
||||||
|
conversationId,
|
||||||
|
replyType,
|
||||||
|
audioFile
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
if (!audioFile?.resource?.file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check if the audio file size exceeds the 5MB limit for localStorage
|
||||||
|
// If it does, show an error message and do not save
|
||||||
|
if (audioFile.resource.file.size > 5 * 1024 * 1024) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getAudioStorageKey(conversationId, replyType);
|
||||||
|
|
||||||
|
// Convert audio blob to Base64
|
||||||
|
const base64Audio = await blobToBase64(audioFile.resource.file);
|
||||||
|
|
||||||
|
// Save audio metadata
|
||||||
|
const audioData = {
|
||||||
|
base64: base64Audio,
|
||||||
|
thumb: audioFile.thumb,
|
||||||
|
name: audioFile.resource.name,
|
||||||
|
type: audioFile.resource.type,
|
||||||
|
size: audioFile.resource.size,
|
||||||
|
isPrivate: audioFile.isPrivate,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
LocalStorage.updateJsonStore(AUDIO_STORAGE_KEY, key, audioData);
|
||||||
|
} catch (error) {
|
||||||
|
// Error saving audio draft
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves audio from draft storage
|
||||||
|
* @param {string} conversationId - The conversation ID
|
||||||
|
* @param {string} replyType - The reply type (REPLY or NOTE)
|
||||||
|
* @returns {Promise<Object|null>} - The audio file object or null if not found
|
||||||
|
*/
|
||||||
|
export const getAudioFromDraft = (conversationId, replyType) => {
|
||||||
|
try {
|
||||||
|
const key = getAudioStorageKey(conversationId, replyType);
|
||||||
|
const audioData = LocalStorage.getFromJsonStore(AUDIO_STORAGE_KEY, key);
|
||||||
|
|
||||||
|
if (!audioData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Base64 back to Blob
|
||||||
|
const blob = base64ToBlob(audioData.base64);
|
||||||
|
|
||||||
|
// Create a file from the blob
|
||||||
|
const file = new File([blob], audioData.name, {
|
||||||
|
type: audioData.type,
|
||||||
|
lastModified: audioData.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the complete audio file object
|
||||||
|
return {
|
||||||
|
resource: {
|
||||||
|
file,
|
||||||
|
name: audioData.name,
|
||||||
|
type: audioData.type,
|
||||||
|
size: audioData.size,
|
||||||
|
},
|
||||||
|
isPrivate: audioData.isPrivate,
|
||||||
|
thumb: audioData.thumb,
|
||||||
|
isRecordedAudio: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Error retrieving audio draft
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes audio draft from storage
|
||||||
|
* @param {string} conversationId - The conversation ID
|
||||||
|
* @param {string} replyType - The reply type (REPLY or NOTE)
|
||||||
|
*/
|
||||||
|
export const removeAudioDraft = (conversationId, replyType) => {
|
||||||
|
try {
|
||||||
|
const key = getAudioStorageKey(conversationId, replyType);
|
||||||
|
LocalStorage.deleteFromJsonStore(AUDIO_STORAGE_KEY, key);
|
||||||
|
} catch (error) {
|
||||||
|
// Error removing audio draft
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -195,7 +195,8 @@
|
|||||||
"YES": "Send",
|
"YES": "Send",
|
||||||
"CANCEL": "Cancel"
|
"CANCEL": "Cancel"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"DRAFT_AUDIO_SIZE_LIMIT_ERROR": "Audio recording exceeds the 5MB size limit and cannot be saved as a draft"
|
||||||
},
|
},
|
||||||
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
|
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
|
||||||
"CHANGE_STATUS": "Conversation status changed",
|
"CHANGE_STATUS": "Conversation status changed",
|
||||||
|
|||||||
Reference in New Issue
Block a user