mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-03 20:48:07 +00:00
chore: Replace Thumbnail with Avatar in conversation card (#12112)
This commit is contained in:
@@ -98,6 +98,7 @@ const onClickViewDetails = () => emit('showContact', props.id);
|
|||||||
:src="thumbnail"
|
:src="thumbnail"
|
||||||
:size="48"
|
:size="48"
|
||||||
:status="availabilityStatus"
|
:status="availabilityStatus"
|
||||||
|
hide-offline-status
|
||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-0.5 flex-1">
|
<div class="flex flex-col gap-0.5 flex-1">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { removeEmoji } from 'shared/helpers/emoji';
|
import { removeEmoji } from 'shared/helpers/emoji';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -33,10 +34,18 @@ const props = defineProps({
|
|||||||
validator: value =>
|
validator: value =>
|
||||||
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
!value || wootConstants.AVAILABILITY_STATUS_KEYS.includes(value),
|
||||||
},
|
},
|
||||||
|
inbox: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
iconName: {
|
iconName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
hideOfflineStatus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['upload', 'delete']);
|
const emit = defineEmits(['upload', 'delete']);
|
||||||
@@ -66,11 +75,11 @@ const AVATAR_COLORS = {
|
|||||||
default: { bg: '#E8E8E8', text: '#60646C' },
|
default: { bg: '#E8E8E8', text: '#60646C' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_CLASSES = {
|
const STATUS_CLASSES = computed(() => ({
|
||||||
online: 'bg-n-teal-10',
|
online: 'bg-n-teal-10',
|
||||||
busy: 'bg-n-amber-10',
|
busy: 'bg-n-amber-10',
|
||||||
offline: 'bg-n-slate-10',
|
...(props.hideOfflineStatus ? {} : { offline: 'bg-n-slate-10' }),
|
||||||
};
|
}));
|
||||||
|
|
||||||
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
const showDefaultAvatar = computed(() => !props.src && !props.name);
|
||||||
|
|
||||||
@@ -178,11 +187,18 @@ watch(
|
|||||||
<!-- Status Badge -->
|
<!-- Status Badge -->
|
||||||
<slot name="badge" :size="size">
|
<slot name="badge" :size="size">
|
||||||
<div
|
<div
|
||||||
v-if="status"
|
v-if="status && STATUS_CLASSES[status]"
|
||||||
class="absolute z-20 border rounded-full border-n-slate-3"
|
class="absolute z-20 border rounded-full border-n-slate-3"
|
||||||
:style="badgeStyles"
|
:style="badgeStyles"
|
||||||
:class="STATUS_CLASSES[status]"
|
:class="STATUS_CLASSES[status]"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
v-if="inbox && !(status && STATUS_CLASSES[status])"
|
||||||
|
:style="badgeStyles"
|
||||||
|
class="absolute z-20 flex items-center justify-center rounded-full bg-n-solid-1 border border-transparent flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ChannelIcon :inbox="inbox" class="w-full h-full text-n-slate-11" />
|
||||||
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<!-- Delete Avatar Button -->
|
<!-- Delete Avatar Button -->
|
||||||
@@ -239,24 +255,33 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Upload Overlay and Input -->
|
<!-- Upload Overlay and Input -->
|
||||||
<div
|
<slot
|
||||||
v-if="allowUpload"
|
v-if="allowUpload || $slots.overlay"
|
||||||
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
name="overlay"
|
||||||
@click="handleUploadAvatar"
|
:size="size"
|
||||||
|
:handle-upload="handleUploadAvatar"
|
||||||
|
:file-input-ref="fileInput"
|
||||||
|
:handle-image-upload="handleImageUpload"
|
||||||
>
|
>
|
||||||
<Icon
|
<div
|
||||||
icon="i-lucide-upload"
|
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
|
||||||
class="text-white"
|
@click="handleUploadAvatar"
|
||||||
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
|
>
|
||||||
/>
|
<Icon
|
||||||
<input
|
icon="i-lucide-upload"
|
||||||
ref="fileInput"
|
class="text-white"
|
||||||
type="file"
|
:style="{ width: `${size / 2}px`, height: `${size / 2}px` }"
|
||||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
/>
|
||||||
class="hidden"
|
<input
|
||||||
@change="handleImageUpload"
|
v-if="allowUpload"
|
||||||
/>
|
ref="fileInput"
|
||||||
</div>
|
type="file"
|
||||||
|
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleImageUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { toRef } from 'vue';
|
||||||
import { useChannelIcon } from './provider';
|
import { useChannelIcon } from './provider';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const channelIcon = useChannelIcon(props.inbox);
|
const channelIcon = useChannelIcon(toRef(props, 'inbox'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -22,15 +22,21 @@ export function useChannelIcon(inbox) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const channelIcon = computed(() => {
|
const channelIcon = computed(() => {
|
||||||
const type = inbox.channel_type;
|
const inboxDetails = inbox.value || inbox;
|
||||||
|
const type = inboxDetails.channel_type;
|
||||||
let icon = channelTypeIconMap[type];
|
let icon = channelTypeIconMap[type];
|
||||||
|
|
||||||
if (type === 'Channel::Email' && inbox.provider) {
|
if (type === 'Channel::Email' && inboxDetails.provider) {
|
||||||
if (Object.keys(providerIconMap).includes(inbox.provider)) {
|
if (Object.keys(providerIconMap).includes(inboxDetails.provider)) {
|
||||||
icon = providerIconMap[inbox.provider];
|
icon = providerIconMap[inboxDetails.provider];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special case for Twilio whatsapp
|
||||||
|
if (type === 'Channel::TwilioSms' && inboxDetails.medium === 'whatsapp') {
|
||||||
|
icon = 'i-ri-whatsapp-fill';
|
||||||
|
}
|
||||||
|
|
||||||
return icon ?? 'i-ri-global-fill';
|
return icon ?? 'i-ri-global-fill';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,77 @@ describe('useChannelIcon', () => {
|
|||||||
expect(icon).toBe('i-ri-phone-fill');
|
expect(icon).toBe('i-ri-phone-fill');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for Line channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::Line' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-line-fill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for SMS channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::Sms' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-chat-1-fill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for Telegram channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::Telegram' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-telegram-fill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for Twitter channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::TwitterProfile' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-twitter-x-fill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for WebWidget channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::WebWidget' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-global-fill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct icon for Instagram channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::Instagram' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-instagram-fill');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwilioSms channel', () => {
|
||||||
|
it('returns chat icon for regular Twilio SMS channel', () => {
|
||||||
|
const inbox = { channel_type: 'Channel::TwilioSms' };
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-chat-1-fill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns WhatsApp icon for Twilio SMS with WhatsApp medium', () => {
|
||||||
|
const inbox = {
|
||||||
|
channel_type: 'Channel::TwilioSms',
|
||||||
|
medium: 'whatsapp',
|
||||||
|
};
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-whatsapp-fill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns chat icon for Twilio SMS with non-WhatsApp medium', () => {
|
||||||
|
const inbox = {
|
||||||
|
channel_type: 'Channel::TwilioSms',
|
||||||
|
medium: 'sms',
|
||||||
|
};
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-chat-1-fill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns chat icon for Twilio SMS with undefined medium', () => {
|
||||||
|
const inbox = {
|
||||||
|
channel_type: 'Channel::TwilioSms',
|
||||||
|
medium: undefined,
|
||||||
|
};
|
||||||
|
const { value: icon } = useChannelIcon(inbox);
|
||||||
|
expect(icon).toBe('i-ri-chat-1-fill');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Email channel', () => {
|
describe('Email channel', () => {
|
||||||
it('returns mail icon for generic email channel', () => {
|
it('returns mail icon for generic email channel', () => {
|
||||||
const inbox = { channel_type: 'Channel::Email' };
|
const inbox = { channel_type: 'Channel::Email' };
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import { getInboxClassByType } from 'dashboard/helper/inbox';
|
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
props: {
|
inbox: {
|
||||||
inbox: {
|
type: Object,
|
||||||
type: Object,
|
default: () => {},
|
||||||
default: () => {},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
computed: {
|
});
|
||||||
computedInboxClass() {
|
|
||||||
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
|
|
||||||
const classByType = getInboxClassByType(type, phoneNumber);
|
|
||||||
return classByType;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
|
<div class="flex items-center text-n-slate-11 text-xs min-w-0">
|
||||||
<fluent-icon
|
<ChannelIcon
|
||||||
class="ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
|
:inbox="inbox"
|
||||||
:icon="computedInboxClass"
|
class="size-3 ltr:mr-0.5 rtl:ml-0.5 flex-shrink-0"
|
||||||
size="12"
|
|
||||||
/>
|
/>
|
||||||
<span class="truncate">
|
<span class="truncate">
|
||||||
{{ inbox.name }}
|
{{ inbox.name }}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import Thumbnail from '../Thumbnail.vue';
|
import Avatar from 'next/avatar/Avatar.vue';
|
||||||
import MessagePreview from './MessagePreview.vue';
|
import MessagePreview from './MessagePreview.vue';
|
||||||
import InboxName from '../InboxName.vue';
|
import InboxName from '../InboxName.vue';
|
||||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||||
@@ -44,6 +44,7 @@ const emit = defineEmits([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
const hovered = ref(false);
|
const hovered = ref(false);
|
||||||
const showContextMenu = ref(false);
|
const showContextMenu = ref(false);
|
||||||
@@ -56,15 +57,17 @@ const currentChat = useMapGetter('getSelectedChat');
|
|||||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
const activeInbox = useMapGetter('getSelectedInbox');
|
const activeInbox = useMapGetter('getSelectedInbox');
|
||||||
const accountId = useMapGetter('getCurrentAccountId');
|
const accountId = useMapGetter('getCurrentAccountId');
|
||||||
const contactById = useMapGetter('contacts/getContact');
|
|
||||||
const inboxById = useMapGetter('inboxes/getInbox');
|
|
||||||
|
|
||||||
const chatMetadata = computed(() => props.chat.meta || {});
|
const chatMetadata = computed(() => props.chat.meta || {});
|
||||||
|
|
||||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||||
|
|
||||||
|
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||||
|
|
||||||
const currentContact = computed(() => {
|
const currentContact = computed(() => {
|
||||||
return contactById.value(chatMetadata.value.sender.id);
|
return senderId.value
|
||||||
|
? store.getters['contacts/getContact'](senderId.value)
|
||||||
|
: {};
|
||||||
});
|
});
|
||||||
|
|
||||||
const isActiveChat = computed(() => {
|
const isActiveChat = computed(() => {
|
||||||
@@ -79,10 +82,10 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
|||||||
|
|
||||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||||
|
|
||||||
|
const inboxId = computed(() => props.chat.inbox_id);
|
||||||
|
|
||||||
const inbox = computed(() => {
|
const inbox = computed(() => {
|
||||||
const { inbox_id: inboxId } = props.chat;
|
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
|
||||||
const stateInbox = inboxById.value(inboxId);
|
|
||||||
return stateInbox;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const showInboxName = computed(() => {
|
const showInboxName = computed(() => {
|
||||||
@@ -241,28 +244,34 @@ const deleteConversation = () => {
|
|||||||
@mouseenter="onThumbnailHover"
|
@mouseenter="onThumbnailHover"
|
||||||
@mouseleave="onThumbnailLeave"
|
@mouseleave="onThumbnailLeave"
|
||||||
>
|
>
|
||||||
<label
|
<Avatar
|
||||||
v-if="hovered || selected"
|
|
||||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-20 backdrop-blur-[2px]"
|
|
||||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
:value="selected"
|
|
||||||
:checked="selected"
|
|
||||||
class="!m-0 cursor-pointer"
|
|
||||||
type="checkbox"
|
|
||||||
@change="onSelectConversation($event.target.checked)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Thumbnail
|
|
||||||
v-if="!hideThumbnail"
|
v-if="!hideThumbnail"
|
||||||
|
:name="currentContact.name"
|
||||||
:src="currentContact.thumbnail"
|
:src="currentContact.thumbnail"
|
||||||
:username="currentContact.name"
|
:size="32"
|
||||||
:status="currentContact.availability_status"
|
:status="currentContact.availability_status"
|
||||||
size="32px"
|
:inbox="inbox"
|
||||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||||
/>
|
hide-offline-status
|
||||||
|
rounded-full
|
||||||
|
>
|
||||||
|
<template #overlay="{ size }">
|
||||||
|
<label
|
||||||
|
v-if="hovered || selected"
|
||||||
|
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
|
||||||
|
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="selected"
|
||||||
|
:checked="selected"
|
||||||
|
class="!m-0 cursor-pointer"
|
||||||
|
type="checkbox"
|
||||||
|
@change="onSelectConversation($event.target.checked)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
|
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const visibleInfoItems = computed(() =>
|
|||||||
>
|
>
|
||||||
<InboxName
|
<InboxName
|
||||||
:inbox="inbox"
|
:inbox="inbox"
|
||||||
class="mr-2 rtl:mr-0 rtl:ml-2 bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-11 dark:text-n-slate-11"
|
class="mx-2 bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-11 dark:text-n-slate-11"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user