chore: Replace Thumbnail with Avatar in conversation card (#12112)

This commit is contained in:
Sivin Varghese
2025-08-07 09:50:24 +05:30
committed by GitHub
parent 304c938260
commit ca13664ef9
8 changed files with 176 additions and 73 deletions

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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';
}); });

View File

@@ -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' };

View File

@@ -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 }}

View File

@@ -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"

View File

@@ -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>