Merge branch 'develop' into feat/add_last_snoozed_option
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
<script setup>
|
||||
import axios from 'axios';
|
||||
import { ref, computed, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { handleOtpPaste } from 'shared/helpers/clipboard';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import FormInput from 'v3/components/Form/Input.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mfaToken: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['verified', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const OTP = 'otp';
|
||||
const BACKUP = 'backup';
|
||||
|
||||
// State
|
||||
const verificationMethod = ref(OTP);
|
||||
const otpDigits = ref(['', '', '', '', '', '']);
|
||||
const backupCode = ref('');
|
||||
const isVerifying = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const helpModalRef = ref(null);
|
||||
const otpInputRefs = ref([]);
|
||||
|
||||
// Computed
|
||||
const otpCode = computed(() => otpDigits.value.join(''));
|
||||
const canSubmit = computed(() =>
|
||||
verificationMethod.value === OTP
|
||||
? otpCode.value.length === 6
|
||||
: backupCode.value.length === 8
|
||||
);
|
||||
|
||||
const contactDescKey = computed(() =>
|
||||
isOnChatwootCloud.value ? 'CONTACT_DESC_CLOUD' : 'CONTACT_DESC_SELF_HOSTED'
|
||||
);
|
||||
|
||||
const focusInput = i => otpInputRefs.value[i]?.focus();
|
||||
|
||||
// Verification
|
||||
const handleVerification = async () => {
|
||||
if (!canSubmit.value || isVerifying.value) return;
|
||||
|
||||
isVerifying.value = true;
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
mfa_token: props.mfaToken,
|
||||
};
|
||||
|
||||
if (verificationMethod.value === OTP) {
|
||||
payload.otp_code = otpCode.value;
|
||||
} else {
|
||||
payload.backup_code = backupCode.value;
|
||||
}
|
||||
|
||||
const response = await axios.post('/auth/sign_in', payload);
|
||||
|
||||
// Set auth credentials and redirect
|
||||
if (response.data && response.headers) {
|
||||
// Store auth credentials in cookies
|
||||
const authData = {
|
||||
'access-token': response.headers['access-token'],
|
||||
'token-type': response.headers['token-type'],
|
||||
client: response.headers.client,
|
||||
expiry: response.headers.expiry,
|
||||
uid: response.headers.uid,
|
||||
};
|
||||
|
||||
// Store in cookies for auth
|
||||
document.cookie = `cw_d_session_info=${encodeURIComponent(JSON.stringify(authData))}; path=/; SameSite=Lax`;
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/app/';
|
||||
} else {
|
||||
emit('verified', response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value =
|
||||
parseAPIErrorResponse(error) || t('MFA_VERIFICATION.VERIFICATION_FAILED');
|
||||
|
||||
// Clear inputs on error
|
||||
if (verificationMethod.value === OTP) {
|
||||
otpDigits.value.fill('');
|
||||
await nextTick();
|
||||
focusInput(0);
|
||||
} else {
|
||||
backupCode.value = '';
|
||||
}
|
||||
} finally {
|
||||
isVerifying.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// OTP Input Handling
|
||||
const handleOtpInput = async i => {
|
||||
const v = otpDigits.value[i];
|
||||
|
||||
// Only allow numbers
|
||||
if (!/^\d*$/.test(v)) {
|
||||
otpDigits.value[i] = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Move to next input if value entered
|
||||
if (v && i < 5) {
|
||||
await nextTick();
|
||||
focusInput(i + 1);
|
||||
}
|
||||
|
||||
// Auto-submit if all digits entered
|
||||
if (otpCode.value.length === 6) {
|
||||
handleVerification();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackspace = (e, i) => {
|
||||
if (!otpDigits.value[i] && i > 0) {
|
||||
e.preventDefault();
|
||||
focusInput(i - 1);
|
||||
otpDigits.value[i - 1] = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpCodePaste = e => {
|
||||
e.preventDefault();
|
||||
const code = handleOtpPaste(e, 6);
|
||||
|
||||
if (code) {
|
||||
otpDigits.value = code.split('');
|
||||
handleVerification();
|
||||
}
|
||||
};
|
||||
|
||||
// Alternative Actions
|
||||
const handleTryAnotherMethod = () => {
|
||||
// Toggle between methods
|
||||
verificationMethod.value = verificationMethod.value === OTP ? BACKUP : OTP;
|
||||
otpDigits.value.fill('');
|
||||
backupCode.value = '';
|
||||
errorMessage.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full max-w-md mx-auto">
|
||||
<div
|
||||
class="bg-white shadow sm:mx-auto sm:w-full sm:max-w-lg dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-6">
|
||||
<div
|
||||
class="inline-flex items-center justify-center size-14 bg-n-solid-1 outline outline-n-weak rounded-full mb-4"
|
||||
>
|
||||
<Icon icon="i-lucide-lock-keyhole" class="size-6 text-n-slate-10" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-n-slate-12">
|
||||
{{ $t('MFA_VERIFICATION.TITLE') }}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11 mt-2">
|
||||
{{ $t('MFA_VERIFICATION.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tab Selection -->
|
||||
<div class="flex rounded-lg bg-n-alpha-black2 p-1 mb-6">
|
||||
<button
|
||||
v-for="method in [OTP, BACKUP]"
|
||||
:key="method"
|
||||
class="flex-1 py-2 px-4 text-sm font-medium rounded-md transition-colors"
|
||||
:class="
|
||||
verificationMethod === method
|
||||
? 'bg-n-solid-active text-n-slate-12 shadow-sm'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
@click="verificationMethod = method"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
`MFA_VERIFICATION.${method === OTP ? 'AUTHENTICATOR_APP' : 'BACKUP_CODE'}`
|
||||
)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Verification Form -->
|
||||
<form class="space-y-4" @submit.prevent="handleVerification">
|
||||
<!-- OTP Code Input -->
|
||||
<div v-if="verificationMethod === OTP">
|
||||
<label class="block text-sm font-medium text-n-slate-12 mb-2">
|
||||
{{ $t('MFA_VERIFICATION.ENTER_OTP_CODE') }}
|
||||
</label>
|
||||
<div class="flex justify-between gap-2">
|
||||
<input
|
||||
v-for="(_, i) in otpDigits"
|
||||
:key="i"
|
||||
ref="otpInputRefs"
|
||||
v-model="otpDigits[i]"
|
||||
type="text"
|
||||
maxlength="1"
|
||||
pattern="[0-9]"
|
||||
inputmode="numeric"
|
||||
class="w-12 h-12 text-center text-lg font-semibold border-2 border-n-weak hover:border-n-strong rounded-lg focus:border-n-brand bg-n-alpha-black2 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
@input="handleOtpInput(i)"
|
||||
@keydown.left.prevent="focusInput(i - 1)"
|
||||
@keydown.right.prevent="focusInput(i + 1)"
|
||||
@keydown.backspace="handleBackspace($event, i)"
|
||||
@paste="handleOtpCodePaste"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Code Input -->
|
||||
<div v-if="verificationMethod === BACKUP">
|
||||
<FormInput
|
||||
v-model="backupCode"
|
||||
name="backup_code"
|
||||
type="text"
|
||||
data-testid="backup_code_input"
|
||||
:tabindex="1"
|
||||
required
|
||||
:label="$t('MFA_VERIFICATION.ENTER_BACKUP_CODE')"
|
||||
:placeholder="
|
||||
$t('MFA_VERIFICATION.BACKUP_CODE_PLACEHOLDER') || '000000'
|
||||
"
|
||||
@keyup.enter="handleVerification"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="p-3 bg-n-ruby-3 outline outline-n-ruby-5 outline-1 rounded-lg"
|
||||
>
|
||||
<p class="text-sm text-n-ruby-9">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<NextButton
|
||||
lg
|
||||
type="submit"
|
||||
data-testid="submit_button"
|
||||
class="w-full"
|
||||
:tabindex="2"
|
||||
:label="$t('MFA_VERIFICATION.VERIFY_BUTTON')"
|
||||
:disabled="!canSubmit || isVerifying"
|
||||
:is-loading="isVerifying"
|
||||
/>
|
||||
|
||||
<!-- Alternative Actions -->
|
||||
<div class="text-center flex items-center flex-col gap-2 pt-4">
|
||||
<NextButton
|
||||
sm
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:tabindex="2"
|
||||
:label="$t('MFA_VERIFICATION.TRY_ANOTHER_METHOD')"
|
||||
@click="handleTryAnotherMethod"
|
||||
/>
|
||||
<NextButton
|
||||
sm
|
||||
slate
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:tabindex="3"
|
||||
:label="$t('MFA_VERIFICATION.CANCEL_LOGIN')"
|
||||
@click="() => emit('cancel')"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('MFA_VERIFICATION.HELP_TEXT') }}
|
||||
</p>
|
||||
<NextButton
|
||||
sm
|
||||
link
|
||||
type="button"
|
||||
class="w-full hover:!no-underline"
|
||||
:tabindex="4"
|
||||
:label="$t('MFA_VERIFICATION.LEARN_MORE')"
|
||||
@click="helpModalRef?.open()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Help Modal -->
|
||||
<Dialog
|
||||
ref="helpModalRef"
|
||||
:title="$t('MFA_VERIFICATION.HELP_MODAL.TITLE')"
|
||||
:show-confirm-button="false"
|
||||
class="[&>dialog>div]:bg-n-alpha-3 [&>dialog>div]:rounded-lg"
|
||||
@confirm="helpModalRef?.close()"
|
||||
>
|
||||
<div class="space-y-4 text-sm text-n-slate-11">
|
||||
<div v-for="section in ['AUTHENTICATOR', 'BACKUP']" :key="section">
|
||||
<h4 class="font-medium text-n-slate-12 mb-2">
|
||||
{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_TITLE`) }}
|
||||
</h4>
|
||||
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${section}_DESC`) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium text-n-slate-12 mb-2">
|
||||
{{ $t('MFA_VERIFICATION.HELP_MODAL.CONTACT_TITLE') }}
|
||||
</h4>
|
||||
<p>{{ $t(`MFA_VERIFICATION.HELP_MODAL.${contactDescKey}`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
@@ -12,62 +13,67 @@ const props = defineProps({
|
||||
const route = useRoute();
|
||||
|
||||
const activeIndex = computed(() => {
|
||||
return props.items.findIndex(i => i.route === route.name);
|
||||
const index = props.items.findIndex(i => i.route === route.name);
|
||||
return index === -1 ? 0 : index;
|
||||
});
|
||||
|
||||
const isActive = item => {
|
||||
return props.items.indexOf(item) === activeIndex.value;
|
||||
};
|
||||
|
||||
const isOver = item => {
|
||||
return props.items.indexOf(item) < activeIndex.value;
|
||||
};
|
||||
const steps = computed(() =>
|
||||
props.items.map((item, index) => {
|
||||
const isActive = index === activeIndex.value;
|
||||
const isOver = index < activeIndex.value;
|
||||
return {
|
||||
...item,
|
||||
index,
|
||||
isActive,
|
||||
isOver,
|
||||
};
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition-group tag="div">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.route"
|
||||
class="cursor-pointer flex items-start gap-6 relative after:content-[''] after:absolute after:w-0.5 after:h-full after:top-5 ltr:after:left-4 rtl:after:right-4 before:content-[''] before:absolute before:w-0.5 before:h-4 before:top-0 before:left-4 rtl:before:right-4 last:after:hidden last:before:hidden"
|
||||
:class="
|
||||
isOver(item)
|
||||
? 'after:bg-n-blue-9 before:bg-n-blue-9'
|
||||
: 'after:bg-n-weak before:bg-n-weak'
|
||||
"
|
||||
v-for="step in steps"
|
||||
:key="step.route"
|
||||
class="cursor-pointer flex items-start gap-6 relative after:content-[''] after:absolute after:w-0.5 after:h-full after:top-5 ltr:after:left-4 rtl:after:right-4 before:content-[''] before:absolute before:w-0.5 before:h-4 before:top-0 before:left-4 rtl:before:right-4 last:after:hidden last:before:hidden after:bg-n-slate-3 before:bg-n-slate-3"
|
||||
>
|
||||
<!-- Circle -->
|
||||
<div
|
||||
class="rounded-2xl flex-shrink-0 size-8 flex items-center justify-center left-2 outline outline-2 leading-4 z-10 top-5 bg-n-background"
|
||||
:class="
|
||||
isActive(item) || isOver(item) ? 'outline-n-blue-9' : 'outline-n-weak'
|
||||
"
|
||||
class="rounded-2xl flex-shrink-0 size-8 border-2 border-n-slate-3 flex items-center justify-center left-2 leading-4 z-10 top-5 transition-all duration-300 ease-in-out"
|
||||
:class="{
|
||||
'bg-n-slate-3': step.isActive || step.isOver,
|
||||
'bg-n-background': !step.isActive && !step.isOver,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold"
|
||||
:class="
|
||||
isActive(item) || isOver(item)
|
||||
? 'text-n-blue-11'
|
||||
: 'text-n-slate-11'
|
||||
"
|
||||
v-if="!step.isOver"
|
||||
:key="'num-' + step.index"
|
||||
class="text-xs font-bold transition-colors duration-300"
|
||||
:class="step.isActive ? 'text-n-blue-11' : 'text-n-slate-11'"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
{{ step.index + 1 }}
|
||||
</span>
|
||||
<Icon
|
||||
v-else
|
||||
:key="'check-' + step.index"
|
||||
icon="i-lucide-check"
|
||||
class="text-n-slate-11 size-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col items-start gap-1.5 pb-10 pt-1">
|
||||
<div class="flex items-center">
|
||||
<h3
|
||||
class="text-sm font-medium overflow-hidden whitespace-nowrap mt-0.5 text-ellipsis leading-tight"
|
||||
:class="
|
||||
isActive(item) || isOver(item)
|
||||
? 'text-n-blue-11'
|
||||
: 'text-n-slate-12'
|
||||
"
|
||||
:class="step.isActive ? 'text-n-blue-11' : 'text-n-slate-12'"
|
||||
>
|
||||
{{ item.title }}
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="m-0 mt-1.5 text-sm text-n-slate-11">
|
||||
{{ item.body }}
|
||||
{{ step.body }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: REPLY_EDITOR_MODES.REPLY,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['toggleMode']);
|
||||
@@ -20,9 +24,12 @@ const privateModeSize = useElementSize(wootEditorPrivateMode);
|
||||
|
||||
/**
|
||||
* Computed boolean indicating if the editor is in private note mode
|
||||
* When disabled, always show NOTE mode regardless of actual mode prop
|
||||
* @type {ComputedRef<boolean>}
|
||||
*/
|
||||
const isPrivate = computed(() => props.mode === REPLY_EDITOR_MODES.NOTE);
|
||||
const isPrivate = computed(() => {
|
||||
return props.disabled || props.mode === REPLY_EDITOR_MODES.NOTE;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computes the width of the sliding background chip in pixels
|
||||
@@ -53,6 +60,10 @@ const translateValue = computed(() => {
|
||||
<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"
|
||||
:disabled="disabled"
|
||||
:class="{
|
||||
'cursor-not-allowed': disabled,
|
||||
}"
|
||||
@click="$emit('toggleMode')"
|
||||
>
|
||||
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
|
||||
@@ -62,7 +73,10 @@ const translateValue = computed(() => {
|
||||
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
||||
</div>
|
||||
<div
|
||||
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] transition-all duration-300 ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
|
||||
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,
|
||||
}"
|
||||
:style="{
|
||||
'--chip-width': width,
|
||||
'--translate-x': translateValue,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -20,9 +19,9 @@ export default {
|
||||
components: { NextButton, FileUpload, VideoCallButton, AIAssistanceButton },
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: REPLY_EDITOR_MODES.REPLY,
|
||||
isNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onSend: {
|
||||
type: Function,
|
||||
@@ -168,9 +167,6 @@ export default {
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
uiFlags: 'integrations/getUIFlags',
|
||||
}),
|
||||
isNote() {
|
||||
return this.mode === REPLY_EDITOR_MODES.NOTE;
|
||||
},
|
||||
wrapClass() {
|
||||
return {
|
||||
'is-note-mode': this.isNote,
|
||||
|
||||
@@ -15,6 +15,10 @@ export default {
|
||||
type: String,
|
||||
default: REPLY_EDITOR_MODES.REPLY,
|
||||
},
|
||||
isReplyRestricted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isMessageLengthReachingThreshold: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
@@ -30,6 +34,7 @@ export default {
|
||||
emit('setReplyMode', mode);
|
||||
};
|
||||
const handleReplyClick = () => {
|
||||
if (props.isReplyRestricted) return;
|
||||
setReplyMode(REPLY_EDITOR_MODES.REPLY);
|
||||
};
|
||||
const handleNoteClick = () => {
|
||||
@@ -88,6 +93,7 @@ export default {
|
||||
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
|
||||
<EditorModeToggle
|
||||
:mode="mode"
|
||||
:disabled="isReplyRestricted"
|
||||
class="mt-3"
|
||||
@toggle-mode="handleModeToggle"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
@@ -82,6 +83,16 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const callStatus = computed(
|
||||
() => props.chat.additional_attributes?.call_status
|
||||
);
|
||||
const callDirection = computed(
|
||||
() => props.chat.additional_attributes?.call_direction
|
||||
);
|
||||
|
||||
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
||||
useVoiceCallStatus(callStatus, callDirection);
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
const inbox = computed(() => {
|
||||
@@ -306,14 +317,30 @@ const deleteConversation = () => {
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<div
|
||||
v-if="callStatus"
|
||||
key="voice-status-row"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<span
|
||||
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
|
||||
:class="[voiceIconColor]"
|
||||
/>
|
||||
<span class="mx-1">
|
||||
{{ $t(voiceLabelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
<MessagePreview
|
||||
v-if="lastMessageInChat"
|
||||
v-else-if="lastMessageInChat"
|
||||
key="message-preview"
|
||||
:message="lastMessageInChat"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
||||
:class="messagePreviewClass"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ref, provide } from 'vue';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
@@ -437,6 +438,11 @@ export default {
|
||||
makeMessagesRead() {
|
||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||
},
|
||||
async handleMessageRetry(message) {
|
||||
if (!message) return;
|
||||
const payload = useSnakeCase(message);
|
||||
await this.$store.dispatch('sendMessageWithData', payload);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -465,6 +471,7 @@ export default {
|
||||
:is-an-email-channel="isAnEmailChannel"
|
||||
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||
:messages="getMessages"
|
||||
@retry="handleMessageRetry"
|
||||
>
|
||||
<template #beforeAll>
|
||||
<transition name="slide-up">
|
||||
|
||||
@@ -170,6 +170,9 @@ export default {
|
||||
}
|
||||
return true;
|
||||
},
|
||||
isReplyRestricted() {
|
||||
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
|
||||
},
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
@@ -1070,6 +1073,7 @@ export default {
|
||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
:is-reply-restricted="isReplyRestricted"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:popout-reply-box="popOutReplyBox"
|
||||
@@ -1180,7 +1184,7 @@ export default {
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
:is-send-disabled="isReplyButtonDisabled"
|
||||
:mode="replyType"
|
||||
:is-note="isPrivate"
|
||||
:on-file-upload="onFileUpload"
|
||||
:on-send="onSendReply"
|
||||
:conversation-type="conversationType"
|
||||
|
||||
Reference in New Issue
Block a user