Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub d18decf8b3 Merge branch 'develop' into feat/save-recorded-audio 2025-03-26 09:04:09 +05:30
PettersonandGitHub 157ec00994 fix(i18n): correct settings name translation in pt-BR (#11172)
# Pull Request Template

## Description

In pt_BR language, some strings are being mispelled, the correct word
would be "Configurações" but instead it is being showed as
"Confirgurações", which is wrong.
Fixes: https://github.com/chatwoot/chatwoot/issues/11149

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)


## How Has This Been Tested?

I've compiled the new translations and I compared the problematic
strings in PT_BR to check if it was still with the "Confirgurações" word
instead of "Configurações"

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
2025-03-25 20:27:41 -07:00
Sivin VargheseandGitHub d797fe34fb fix: Support Business hours when downloading the agent reports.
# Pull Request Template

## Description

This PR fixes an issue where downloaded agent conversation reports from
the Conversations page under Reports do not respect business hours.

Fixes
https://linear.app/chatwoot/issue/CW-4139/downloaded-agent-reports-do-not-respect-business-hours
https://github.com/chatwoot/chatwoot/issues/11057

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/43b94494647b48c3855476a227b02acb?sid=d6072725-11e5-487c-8aa5-8ecfae6dc818


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-03-25 20:26:18 -07:00
iamsivin 4be4def0a6 chore: Clean up 2025-03-25 23:59:11 +05:30
iamsivin f46d7e581f feat: Save recorded audio in local storage 2025-03-25 23:29:57 +05:30
10 changed files with 305 additions and 32 deletions
@@ -21,13 +21,45 @@ const recordedAudioAttachments = computed(() =>
props.attachments.filter(attachment => attachment.isRecordedAudio)
);
const onRemoveAttachment = itemIndex => {
emit(
'removeAttachment',
nonRecordedAudioAttachments.value
.filter((_, index) => index !== itemIndex)
.concat(recordedAudioAttachments.value)
// Create unified list of attachments with metadata for rendering
const allAttachments = computed(() => {
const audioAttachments = recordedAudioAttachments.value.map(
(attachment, index) => ({
attachment,
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 => {
@@ -46,32 +78,32 @@ const fileName = file => {
</script>
<template>
<div class="flex overflow-auto max-h-[12.5rem]">
<div class="flex flex-col overflow-auto max-h-[12.5rem]">
<div
v-for="(attachment, index) in nonRecordedAudioAttachments"
:key="attachment.id"
class="preview-item flex items-center p-1 bg-slate-50 dark:bg-slate-800 gap-1 rounded-md w-[15rem] mb-1"
v-for="item in allAttachments"
:key="item.key"
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">
<img
v-if="isTypeImage(attachment.resource)"
v-if="!item.isRecordedAudio && isTypeImage(item.attachment.resource)"
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">
📄
{{ item.isRecordedAudio ? '🎤' : '📄' }}
</span>
</div>
<div class="max-w-3/5 min-w-[50%] overflow-hidden text-ellipsis">
<span
class="h-4 overflow-hidden text-sm font-medium text-ellipsis whitespace-nowrap"
>
{{ fileName(attachment.resource) }}
{{ fileName(item.attachment.resource) }}
</span>
</div>
<div class="w-[30%] justify-center">
<span class="overflow-hidden text-xs text-ellipsis whitespace-nowrap">
{{ formatFileSize(attachment.resource) }}
{{ formatFileSize(item.attachment.resource) }}
</span>
</div>
<div class="flex items-center justify-center">
@@ -80,7 +112,7 @@ const fileName = file => {
slate
xs
icon="i-lucide-x"
@click="onRemoveAttachment(index)"
@click="removeAttachment(item.isRecordedAudio, item.index)"
/>
</div>
</div>
@@ -40,7 +40,11 @@ import {
replaceSignature,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import {
saveAudioToDraft,
getAudioFromDraft,
removeAudioDraft,
} from 'dashboard/helpers/audioDraftHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
@@ -215,7 +219,11 @@ export default {
},
isReplyButtonDisabled() {
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 (
this.isMessageEmpty ||
@@ -418,7 +426,9 @@ export default {
},
conversationIdByRoute(conversationId, oldConversationId) {
if (conversationId !== oldConversationId) {
this.saveAudioDraft(oldConversationId);
this.setToDraft(oldConversationId, this.replyType);
this.resetAudioRecorderUIState();
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) {
if (this.message || this.message === '') {
const key = `draft-${conversationId}-${replyType}`;
@@ -552,6 +611,8 @@ export default {
// ensure that the message has signature set based on the ui setting
this.message = this.toggleSignatureForDraft(messageFromStore);
// get audio recording from draft
this.getAudioRecordingFromDraft(this.conversationIdByRoute);
}
},
toggleSignatureForDraft(message) {
@@ -816,6 +877,12 @@ export default {
// if signature is enabled, append it to the message
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.isRecordingAudio = false;
this.resetReplyToMessage();
@@ -873,6 +940,15 @@ export default {
this.recordingAudioDurationText = duration;
},
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.hasRecordedAudio = true;
// Added a new key isRecordedAudio to the file to find it's and recorded audio
@@ -913,6 +989,16 @@ export default {
},
removeAttachment(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) {
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",
"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",
"CHANGE_STATUS": "Conversation status changed",
@@ -100,7 +100,7 @@
"NO": "cancelar"
}
},
"SETTINGS": "Confirgurações",
"SETTINGS": "Configurações",
"FORM": {
"UPDATE": "Atualizar a equipa",
"CREATE": "Criar uma equipa",
@@ -9,7 +9,7 @@
"FILTER": "Filtrar por",
"SORT": "Classificar por",
"LOCALE": "Localidade",
"SETTINGS_BUTTON": "Confirgurações",
"SETTINGS_BUTTON": "Configurações",
"NEW_BUTTON": "Novo artigo",
"DROPDOWN_OPTIONS": {
"PUBLISHED": "Publicado",
@@ -121,7 +121,7 @@
"COUNT_LABEL": "artigos",
"ADD": "Adicionar localidade",
"VISIT": "Visitar site",
"SETTINGS": "Confirgurações",
"SETTINGS": "Configurações",
"DELETE": "Excluir"
},
"PORTAL_CONFIG": {
@@ -473,7 +473,7 @@
"WIDGET_BUILDER": "Construtor de Widget",
"BOT_CONFIGURATION": "Configuração do Bot"
},
"SETTINGS": "Confirgurações",
"SETTINGS": "Configurações",
"FEATURES": {
"LABEL": "Funcionalidades",
"DISPLAY_FILE_PICKER": "Exibir seletor de arquivos no widget",
@@ -333,7 +333,7 @@
"ARTICLES": "Artigos",
"CATEGORIES": "Categorias",
"LOCALES": "Localidades",
"SETTINGS": "Confirgurações"
"SETTINGS": "Configurações"
},
"CHANNELS": "Canais",
"SET_AUTO_OFFLINE": {
@@ -100,7 +100,7 @@
"NO": "Cancelar"
}
},
"SETTINGS": "Confirgurações",
"SETTINGS": "Configurações",
"FORM": {
"UPDATE": "Atualizar equipe",
"CREATE": "Criar nova equipe",
@@ -1,11 +1,10 @@
<script>
import V4Button from 'dashboard/components-next/button/Button.vue';
import { useAlert, useTrack } from 'dashboard/composables';
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import ReportFilterSelector from './components/FilterSelector.vue';
import { GROUP_BY_FILTER } from './constants';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { generateFileName } from 'dashboard/helper/downloadHelper';
import ReportContainer from './ReportContainer.vue';
import ReportHeader from './components/ReportHeader.vue';
@@ -79,11 +78,17 @@ export default {
},
downloadAgentReports() {
const { from, to } = this;
const fileName = `agent-report-${format(
fromUnixTime(to),
'dd-MM-yyyy'
)}.csv`;
this.$store.dispatch('downloadAgentReports', { from, to, fileName });
const fileName = generateFileName({
type: 'agent',
to,
businessHours: this.businessHours,
});
this.$store.dispatch('downloadAgentReports', {
from,
to,
fileName,
businessHours: this.businessHours,
});
},
onFilterChange({ from, to, groupBy, businessHours }) {
this.from = from;