feat: Replace the use of keyboardEventListener mixin to a composable (Part -2) (#9892)

This commit is contained in:
Sivin Varghese
2024-08-07 15:43:11 +05:30
committed by GitHub
parent b03a839809
commit 89acbd8d09
8 changed files with 539 additions and 525 deletions

View File

@@ -1,149 +1,147 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import { useToggle } from '@vueuse/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { import {
CMD_REOPEN_CONVERSATION, CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION, CMD_RESOLVE_CONVERSATION,
} from '../../routes/dashboard/commands/commandBarBusEvents'; } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
export default { const store = useStore();
components: { const getters = useStoreGetters();
WootDropdownItem, const { t } = useI18n();
WootDropdownMenu,
const resolveActionsRef = ref(null);
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
const [showActionsDropdown, toggleDropdown] = useToggle();
const closeDropdown = () => toggleDropdown(false);
const openDropdown = () => toggleDropdown(true);
const currentChat = computed(() => getters.getSelectedChat.value);
const isOpen = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.OPEN
);
const isPending = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.PENDING
);
const isResolved = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.RESOLVED
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
const buttonClass = computed(() => {
if (isPending.value) return 'primary';
if (isOpen.value) return 'success';
if (isResolved.value) return 'warning';
return '';
});
const showAdditionalActions = computed(
() => !isPending.value && !isSnoozed.value
);
const showOpenButton = computed(() => {
return isResolved.value || isSnoozed.value;
});
const getConversationParams = () => {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
};
const openSnoozeModal = () => {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
};
const toggleStatus = (status, snoozedUntil) => {
closeDropdown();
isLoading.value = true;
store
.dispatch('toggleStatus', {
conversationId: currentChat.value.id,
status,
snoozedUntil,
})
.then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
isLoading.value = false;
});
};
const onCmdOpenConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.OPEN);
};
const onCmdResolveConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
};
const keyboardEvents = {
'Alt+KeyM': {
action: () => arrowDownButtonRef.value?.$el.click(),
allowOnFocusedInput: true,
}, },
mixins: [keyboardEventListenerMixins], 'Alt+KeyE': {
data() { action: async () => {
return { await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
isLoading: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
};
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
isOpen() {
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
},
isPending() {
return this.currentChat.status === wootConstants.STATUS_TYPE.PENDING;
},
isResolved() {
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
buttonClass() {
if (this.isPending) return 'primary';
if (this.isOpen) return 'success';
if (this.isResolved) return 'warning';
return '';
},
showAdditionalActions() {
return !this.isPending && !this.isSnoozed;
}, },
}, },
mounted() { '$mod+Alt+KeyE': {
this.$emitter.on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation); action: async event => {
this.$emitter.on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation); const { all, activeIndex, lastIndex } = getConversationParams();
}, await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
destroyed() {
this.$emitter.off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyM': {
action: () => this.$refs.arrowDownButton?.$el.click(),
allowOnFocusedInput: true,
},
'Alt+KeyE': this.resolveOrToast,
'$mod+Alt+KeyE': async event => {
const { all, activeIndex, lastIndex } = this.getConversationParams();
await this.resolveOrToast();
if (activeIndex < lastIndex) { if (activeIndex < lastIndex) {
all[activeIndex + 1].click(); all[activeIndex + 1].click();
} else if (all.length > 1) { } else if (all.length > 1) {
all[0].click(); all[0].click();
document.querySelector('.conversations-list').scrollTop = 0; document.querySelector('.conversations-list').scrollTop = 0;
}
event.preventDefault();
},
};
},
getConversationParams() {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
},
async resolveOrToast() {
try {
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
// error
} }
}, event.preventDefault();
onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN);
},
onCmdResolveConversation() {
this.toggleStatus(this.STATUS_TYPE.RESOLVED);
},
showOpenButton() {
return this.isResolved || this.isSnoozed;
},
closeDropdown() {
this.showActionsDropdown = false;
},
openDropdown() {
this.showActionsDropdown = true;
},
toggleStatus(status, snoozedUntil) {
this.closeDropdown();
this.isLoading = true;
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat.id,
status,
snoozedUntil,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
},
openSnoozeModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
}, },
}, },
}; };
useKeyboardEvents(keyboardEvents, resolveActionsRef);
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script> </script>
<template> <template>
<div class="relative flex items-center justify-end resolve-actions"> <div
ref="resolveActionsRef"
class="relative flex items-center justify-end resolve-actions"
>
<div class="button-group"> <div class="button-group">
<woot-button <woot-button
v-if="isOpen" v-if="isOpen"
@@ -165,7 +163,7 @@ export default {
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
> >
{{ $t('CONVERSATION.HEADER.REOPEN_ACTION') }} {{ t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-else-if="showOpenButton" v-else-if="showOpenButton"
@@ -175,11 +173,11 @@ export default {
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
> >
{{ $t('CONVERSATION.HEADER.OPEN_ACTION') }} {{ t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button> </woot-button>
<woot-button <woot-button
v-if="showAdditionalActions" v-if="showAdditionalActions"
ref="arrowDownButton" ref="arrowDownButtonRef"
:color-scheme="buttonClass" :color-scheme="buttonClass"
:disabled="isLoading" :disabled="isLoading"
icon="chevron-down" icon="chevron-down"
@@ -190,7 +188,7 @@ export default {
<div <div
v-if="showActionsDropdown" v-if="showActionsDropdown"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open" class="dropdown-pane dropdown-pane--open left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem]"
> >
<WootDropdownMenu class="mb-0"> <WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending"> <WootDropdownItem v-if="!isPending">
@@ -201,7 +199,7 @@ export default {
icon="snooze" icon="snooze"
@click="() => openSnoozeModal()" @click="() => openSnoozeModal()"
> >
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }} {{ t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
</woot-button> </woot-button>
</WootDropdownItem> </WootDropdownItem>
<WootDropdownItem v-if="!isPending"> <WootDropdownItem v-if="!isPending">
@@ -212,7 +210,7 @@ export default {
icon="book-clock" icon="book-clock"
@click="() => toggleStatus(STATUS_TYPE.PENDING)" @click="() => toggleStatus(STATUS_TYPE.PENDING)"
> >
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }} {{ t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button> </woot-button>
</WootDropdownItem> </WootDropdownItem>
</WootDropdownMenu> </WootDropdownMenu>
@@ -222,8 +220,6 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.dropdown-pane { .dropdown-pane {
@apply left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem];
.dropdown-menu__item { .dropdown-menu__item {
@apply mb-0; @apply mb-0;
} }

View File

@@ -1,12 +1,13 @@
<script> <script>
import { ref } from 'vue';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin'; import { useAdmin } from 'dashboard/composables/useAdmin';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import AICTAModal from './AICTAModal.vue'; import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue'; import AIAssistanceModal from './AIAssistanceModal.vue';
import aiMixin from 'dashboard/mixins/aiMixin'; import aiMixin from 'dashboard/mixins/aiMixin';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents'; import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue'; import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
export default { export default {
@@ -15,22 +16,44 @@ export default {
AICTAModal, AICTAModal,
AIAssistanceCTAButton, AIAssistanceCTAButton,
}, },
mixins: [aiMixin, keyboardEventListenerMixins], mixins: [aiMixin],
setup() { setup(props, { emit }) {
const { uiSettings, updateUISettings } = useUISettings(); const { uiSettings, updateUISettings } = useUISettings();
const { isAdmin } = useAdmin(); const { isAdmin } = useAdmin();
const aiAssistanceButtonRef = ref(null);
const initialMessage = ref('');
const initializeMessage = draftMessage => {
initialMessage.value = draftMessage;
};
const keyboardEvents = {
'$mod+KeyZ': {
action: () => {
if (initialMessage.value) {
emit('replaceText', initialMessage.value);
initialMessage.value = '';
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, aiAssistanceButtonRef);
return { return {
uiSettings, uiSettings,
updateUISettings, updateUISettings,
isAdmin, isAdmin,
aiAssistanceButtonRef,
initialMessage,
initializeMessage,
}; };
}, },
data: () => ({ data: () => ({
showAIAssistanceModal: false, showAIAssistanceModal: false,
showAICtaModal: false, showAICtaModal: false,
aiOption: '', aiOption: '',
initialMessage: '',
}), }),
computed: { computed: {
...mapGetters({ ...mapGetters({
@@ -56,22 +79,10 @@ export default {
mounted() { mounted() {
this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist); this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initialMessage = this.draftMessage; this.initializeMessage(this.draftMessage);
}, },
methods: { methods: {
getKeyboardEvents() {
return {
'$mod+KeyZ': {
action: () => {
if (this.initialMessage) {
this.$emit('replaceText', this.initialMessage);
this.initialMessage = '';
}
},
},
};
},
hideAIAssistanceModal() { hideAIAssistanceModal() {
this.recordAnalytics('DISMISS_AI_SUGGESTION', { this.recordAnalytics('DISMISS_AI_SUGGESTION', {
aiOption: this.aiOption, aiOption: this.aiOption,
@@ -85,7 +96,7 @@ export default {
is_open_ai_cta_modal_dismissed: true, is_open_ai_cta_modal_dismissed: true,
}); });
} }
this.initialMessage = this.draftMessage; this.initializeMessage(this.draftMessage);
const ninja = document.querySelector('ninja-keys'); const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'ai_assist' }); ninja.open({ parent: 'ai_assist' });
}, },
@@ -107,7 +118,7 @@ export default {
</script> </script>
<template> <template>
<div v-if="!isFetchingAppIntegrations"> <div ref="aiAssistanceButtonRef">
<div v-if="isAIIntegrationEnabled" class="relative"> <div v-if="isAIIntegrationEnabled" class="relative">
<AIAssistanceCTAButton <AIAssistanceCTAButton
v-if="shouldShowAIAssistCTAButton" v-if="shouldShowAIAssistCTAButton"

View File

@@ -1,54 +1,56 @@
<script> <script setup>
import { ref, computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default { const props = defineProps({
mixins: [keyboardEventListenerMixins], items: {
props: { type: Array,
items: { default: () => [],
type: Array,
default: () => [],
},
activeTab: {
type: String,
default: wootConstants.ASSIGNEE_TYPE.ME,
},
}, },
computed: { activeTab: {
activeTabIndex() { type: String,
return this.items.findIndex(item => item.key === this.activeTab); default: wootConstants.ASSIGNEE_TYPE.ME,
},
}, },
methods: { });
getKeyboardEvents() {
return { const emit = defineEmits(['chatTabChange']);
'Alt+KeyN': {
action: () => { const chatTypeTabsRef = ref(null);
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
this.onTabChange(0); const activeTabIndex = computed(() => {
} else { return props.items.findIndex(item => item.key === props.activeTab);
this.onTabChange(this.activeTabIndex + 1); });
}
}, const onTabChange = selectedTabIndex => {
}, if (props.items[selectedTabIndex].key !== props.activeTab) {
}; emit('chatTabChange', props.items[selectedTabIndex].key);
}, }
onTabChange(selectedTabIndex) { };
if (this.items[selectedTabIndex].key !== this.activeTab) {
this.$emit('chatTabChange', this.items[selectedTabIndex].key); const keyboardEvents = {
'Alt+KeyN': {
action: () => {
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
onTabChange(0);
} else {
onTabChange(activeTabIndex.value + 1);
} }
}, },
}, },
}; };
useKeyboardEvents(keyboardEvents, chatTypeTabsRef);
</script> </script>
<template> <template>
<woot-tabs :index="activeTabIndex" @change="onTabChange"> <div ref="chatTypeTabsRef">
<woot-tabs-item <woot-tabs :index="activeTabIndex" @change="onTabChange">
v-for="item in items" <woot-tabs-item
:key="item.key" v-for="item in items"
:name="item.name" :key="item.key"
:count="item.count" :name="item.name"
/> :count="item.count"
</woot-tabs> />
</woot-tabs>
</div>
</template> </template>

View File

@@ -1,83 +1,71 @@
<script> <script setup>
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue'; import { ref, computed } from 'vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import { useAdmin } from 'dashboard/composables/useAdmin'; import { useAdmin } from 'dashboard/composables/useAdmin';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
export default { const props = defineProps({
components: { allLabels: {
AddLabel, type: Array,
LabelDropdown, default: () => [],
}, },
savedLabels: {
type: Array,
default: () => [],
},
});
mixins: [keyboardEventListenerMixins], const emit = defineEmits(['add', 'remove']);
props: { const labelSelectorWrapRef = ref(null);
allLabels: {
type: Array, const { isAdmin } = useAdmin();
default: () => [],
}, const showSearchDropdownLabel = ref(false);
savedLabels: {
type: Array, const selectedLabels = computed(() => {
default: () => [], return props.savedLabels.map(label => label.title);
});
const addItem = label => {
emit('add', label);
};
const removeItem = label => {
emit('remove', label);
};
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
toggleLabels();
e.preventDefault();
}, },
}, },
Escape: {
setup() { action: () => closeDropdownLabel(),
const { isAdmin } = useAdmin(); allowOnFocusedInput: true,
return {
isAdmin,
};
},
data() {
return {
showSearchDropdownLabel: false,
};
},
computed: {
selectedLabels() {
return this.savedLabels.map(label => label.title);
},
},
methods: {
addItem(label) {
this.$emit('add', label);
},
removeItem(label) {
this.$emit('remove', label);
},
toggleLabels() {
this.showSearchDropdownLabel = !this.showSearchDropdownLabel;
},
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
getKeyboardEvents() {
return {
KeyL: {
action: e => {
this.toggleLabels();
e.preventDefault();
},
},
Escape: {
action: () => this.closeDropdownLabel(),
allowOnFocusedInput: true,
},
};
},
}, },
}; };
useKeyboardEvents(keyboardEvents, labelSelectorWrapRef);
</script> </script>
<template> <template>
<div v-on-clickaway="closeDropdownLabel" class="label-wrap"> <div
ref="labelSelectorWrapRef"
v-on-clickaway="closeDropdownLabel"
class="relative leading-6"
>
<AddLabel @add="toggleLabels" /> <AddLabel @add="toggleLabels" />
<woot-label <woot-label
v-for="label in savedLabels" v-for="label in savedLabels"
@@ -89,10 +77,10 @@ export default {
variant="smooth" variant="smooth"
@click="removeItem" @click="removeItem"
/> />
<div class="dropdown-wrap"> <div class="absolute w-full top-7">
<div <div
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }" :class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
class="dropdown-pane" class="!box-border !w-full dropdown-pane"
> >
<LabelDropdown <LabelDropdown
v-if="showSearchDropdownLabel" v-if="showSearchDropdownLabel"
@@ -106,28 +94,3 @@ export default {
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.title-icon {
margin-right: var(--space-smaller);
}
.label-wrap {
position: relative;
line-height: var(--space-medium);
.dropdown-wrap {
display: flex;
position: absolute;
margin-right: var(--space-medium);
top: var(--space-medium);
width: 100%;
left: -1px;
.dropdown-pane {
width: 100%;
box-sizing: border-box;
}
}
}
</style>

View File

@@ -1,8 +1,9 @@
<script> <script>
import { ref, watchEffect, computed } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component'; import FileUpload from 'vue-upload-component';
import * as ActiveStorage from 'activestorage'; import * as ActiveStorage from 'activestorage';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { import {
@@ -18,7 +19,7 @@ import { mapGetters } from 'vuex';
export default { export default {
name: 'ReplyBottomPanel', name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton }, components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [keyboardEventListenerMixins, inboxMixin], mixins: [inboxMixin],
props: { props: {
mode: { mode: {
type: String, type: String,
@@ -115,16 +116,42 @@ export default {
setup() { setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } = const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
useUISettings(); useUISettings();
const uploadRef = ref(null);
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
// Once we have the custom component, we can remove the hacky logic below.
const uploadTriggerButton = computed(() => {
if (uploadRef.value) {
return uploadRef.value.$children[1].$el;
}
return null;
});
const keyboardEvents = {
'Alt+KeyA': {
action: () => {
uploadTriggerButton.value.click();
},
allowOnFocusedInput: true,
},
};
watchEffect(() => {
useKeyboardEvents(keyboardEvents, uploadTriggerButton);
});
return { return {
setSignatureFlagForInbox, setSignatureFlagForInbox,
fetchSignatureFlagFromUISettings, fetchSignatureFlagFromUISettings,
uploadRef,
}; };
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
uiFlags: 'integrations/getUIFlags',
}), }),
isNote() { isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE; return this.mode === REPLY_EDITOR_MODES.NOTE;
@@ -207,21 +234,14 @@ export default {
); );
return isFeatEnabled && this.portalSlug; return isFeatEnabled && this.portalSlug;
}, },
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
}, },
mounted() { mounted() {
ActiveStorage.start(); ActiveStorage.start();
}, },
methods: { methods: {
getKeyboardEvents() {
return {
'Alt+KeyA': {
action: () => {
this.$refs.upload.$children[1].$el.click();
},
allowOnFocusedInput: true,
},
};
},
toggleMessageSignature() { toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature); this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
}, },
@@ -249,7 +269,7 @@ export default {
@click="toggleEmojiPicker" @click="toggleEmojiPicker"
/> />
<FileUpload <FileUpload
ref="upload" ref="uploadRef"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')" v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
input-id="conversationAttachment" input-id="conversationAttachment"
:size="4096 * 4096" :size="4096 * 4096"
@@ -330,6 +350,7 @@ export default {
:conversation-id="conversationId" :conversation-id="conversationId"
/> />
<AIAssistanceButton <AIAssistanceButton
v-if="!isFetchingAppIntegrations"
:conversation-id="conversationId" :conversation-id="conversationId"
:is-private-note="isOnPrivateNote" :is-private-note="isOnPrivateNote"
:message="message" :message="message"
@@ -337,7 +358,7 @@ export default {
/> />
<transition name="modal-fade"> <transition name="modal-fade">
<div <div
v-show="$refs.upload && $refs.upload.dropActive" v-show="$refs.uploadRef && $refs.uploadRef.dropActive"
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark" class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
> >
<fluent-icon icon="cloud-backup" size="40" /> <fluent-icon icon="cloud-backup" size="40" />

View File

@@ -1,8 +1,9 @@
<script> <script>
import { ref } from 'vue';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import agentMixin from '../../../mixins/agentMixin.js'; import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton.vue'; import BackButton from '../BackButton.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName.vue'; import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue'; import MoreActions from './MoreActions.vue';
@@ -23,7 +24,7 @@ export default {
SLACardLabel, SLACardLabel,
Linear, Linear,
}, },
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins], mixins: [inboxMixin, agentMixin],
props: { props: {
chat: { chat: {
type: Object, type: Object,
@@ -42,6 +43,20 @@ export default {
default: false, default: false,
}, },
}, },
setup(props, { emit }) {
const conversationHeaderActionsRef = ref(null);
const keyboardEvents = {
'Alt+KeyO': {
action: () => emit('contactPanelToggle'),
},
};
useKeyboardEvents(keyboardEvents, conversationHeaderActionsRef);
return {
conversationHeaderActionsRef,
};
},
computed: { computed: {
...mapGetters({ ...mapGetters({
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
@@ -117,16 +132,6 @@ export default {
); );
}, },
}, },
methods: {
getKeyboardEvents() {
return {
'Alt+KeyO': {
action: () => this.$emit('contactPanelToggle'),
},
};
},
},
}; };
</script> </script>
@@ -178,6 +183,7 @@ export default {
</div> </div>
<div <div
ref="conversationHeaderActionsRef"
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap" class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
> >
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" /> <InboxName v-if="hasMultipleInboxes" :inbox="inbox" />

View File

@@ -1,4 +1,8 @@
<script> <script>
import { ref } from 'vue';
// composable
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
// components // components
import ReplyBox from './ReplyBox.vue'; import ReplyBox from './ReplyBox.vue';
import Message from './Message.vue'; import Message from './Message.vue';
@@ -14,7 +18,6 @@ import conversationMixin, {
} from '../../../mixins/conversations'; } from '../../../mixins/conversations';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import configMixin from 'shared/mixins/configMixin'; import configMixin from 'shared/mixins/configMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import aiMixin from 'dashboard/mixins/aiMixin'; import aiMixin from 'dashboard/mixins/aiMixin';
// utils // utils
@@ -35,13 +38,7 @@ export default {
Banner, Banner,
ConversationLabelSuggestion, ConversationLabelSuggestion,
}, },
mixins: [ mixins: [conversationMixin, inboxMixin, configMixin, aiMixin],
conversationMixin,
inboxMixin,
keyboardEventListenerMixins,
configMixin,
aiMixin,
],
props: { props: {
isContactPanelOpen: { isContactPanelOpen: {
type: Boolean, type: Boolean,
@@ -52,7 +49,33 @@ export default {
default: false, default: false,
}, },
}, },
setup() {
const conversationFooterRef = ref(null);
const isPopOutReplyBox = ref(false);
const closePopOutReplyBox = () => {
isPopOutReplyBox.value = false;
};
const showPopOutReplyBox = () => {
isPopOutReplyBox.value = !isPopOutReplyBox.value;
};
const keyboardEvents = {
Escape: {
action: closePopOutReplyBox,
},
};
useKeyboardEvents(keyboardEvents, conversationFooterRef);
return {
conversationFooterRef,
isPopOutReplyBox,
closePopOutReplyBox,
showPopOutReplyBox,
};
},
data() { data() {
return { return {
isLoadingPrevious: true, isLoadingPrevious: true,
@@ -60,7 +83,6 @@ export default {
conversationPanel: null, conversationPanel: null,
hasUserScrolled: false, hasUserScrolled: false,
isProgrammaticScroll: false, isProgrammaticScroll: false,
isPopoutReplyBox: false,
messageSentSinceOpened: false, messageSentSinceOpened: false,
labelSuggestions: [], labelSuggestions: [],
}; };
@@ -303,19 +325,6 @@ export default {
}); });
this.makeMessagesRead(); this.makeMessagesRead();
}, },
showPopoutReplyBox() {
this.isPopoutReplyBox = !this.isPopoutReplyBox;
},
closePopoutReplyBox() {
this.isPopoutReplyBox = false;
},
getKeyboardEvents() {
return {
Escape: {
action: () => this.closePopoutReplyBox(),
},
};
},
addScrollListener() { addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel'); this.conversationPanel = this.$el.querySelector('.conversation-panel');
this.setScrollParams(); this.setScrollParams();
@@ -505,8 +514,9 @@ export default {
/> />
</ul> </ul>
<div <div
ref="conversationFooterRef"
class="conversation-footer" class="conversation-footer"
:class="{ 'modal-mask': isPopoutReplyBox }" :class="{ 'modal-mask': isPopOutReplyBox }"
> >
<div <div
v-if="isAnyoneTyping" v-if="isAnyoneTyping"
@@ -525,8 +535,8 @@ export default {
</div> </div>
<ReplyBox <ReplyBox
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:popout-reply-box.sync="isPopoutReplyBox" :popout-reply-box.sync="isPopOutReplyBox"
@click="showPopoutReplyBox" @click="showPopOutReplyBox"
/> />
</div> </div>
</div> </div>

View File

@@ -1,10 +1,30 @@
<script> <script setup>
import { mapGetters } from 'vuex'; import { ref, computed, onMounted } from 'vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import { useStoreGetters } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { messageTimestamp } from 'shared/helpers/timeHelper'; import { messageTimestamp } from 'shared/helpers/timeHelper';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue'; import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
const props = defineProps({
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['close']);
const getters = useStoreGetters();
const ALLOWED_FILE_TYPES = { const ALLOWED_FILE_TYPES = {
IMAGE: 'image', IMAGE: 'image',
VIDEO: 'video', VIDEO: 'video',
@@ -15,194 +35,178 @@ const ALLOWED_FILE_TYPES = {
const MAX_ZOOM_LEVEL = 2; const MAX_ZOOM_LEVEL = 2;
const MIN_ZOOM_LEVEL = 1; const MIN_ZOOM_LEVEL = 1;
export default { const galleryViewRef = ref(null);
components: { const zoomScale = ref(1);
Thumbnail, const activeAttachment = ref({});
}, const activeFileType = ref('');
mixins: [keyboardEventListenerMixins], const activeImageIndex = ref(
props: { props.allAttachments.findIndex(
show: { attachment => attachment.message_id === props.attachment.message_id
type: Boolean, ) || 0
required: true, );
}, const activeImageRotation = ref(0);
attachment: {
type: Object, const currentUser = computed(() => getters.getCurrentUser.value);
required: true,
}, const hasMoreThanOneAttachment = computed(
allAttachments: { () => props.allAttachments.length > 1
type: Array, );
required: true,
const readableTime = computed(() => {
const { created_at: createdAt } = activeAttachment.value;
if (!createdAt) return '';
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
});
const isImage = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
);
const isVideo = computed(
() =>
activeFileType.value === ALLOWED_FILE_TYPES.VIDEO ||
activeFileType.value === ALLOWED_FILE_TYPES.IG_REEL
);
const isAudio = computed(
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
);
const senderDetails = computed(() => {
const {
name,
available_name: availableName,
avatar_url,
thumbnail,
id,
} = activeAttachment.value?.sender || props.attachment?.sender || {};
const currentUserID = currentUser.value?.id;
return {
name: currentUserID === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
});
const fileNameFromDataUrl = computed(() => {
const { data_url: dataUrl } = activeAttachment.value;
if (!dataUrl) return '';
const fileName = dataUrl?.split('/').pop();
return decodeURIComponent(fileName || '');
});
const imageRotationStyle = computed(() => ({
transform: `rotate(${activeImageRotation.value}deg) scale(${zoomScale.value})`,
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
}));
const onClose = () => {
emit('close');
};
const setImageAndVideoSrc = attachment => {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
activeAttachment.value = attachment;
activeFileType.value = type;
};
const onClickChangeAttachment = (attachment, index) => {
if (!attachment) {
return;
}
activeImageIndex.value = index;
setImageAndVideoSrc(attachment);
activeImageRotation.value = 0;
zoomScale.value = 1;
};
const onClickDownload = () => {
const { file_type: type, data_url: url } = activeAttachment.value;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
const link = document.createElement('a');
link.href = url;
link.download = `attachment.${type}`;
link.click();
};
const onRotate = type => {
if (!isImage.value) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(activeImageRotation.value) === 360) {
activeImageRotation.value = rotation;
} else {
activeImageRotation.value += rotation;
}
};
const onZoom = scale => {
if (!isImage.value) {
return;
}
const newZoomScale = zoomScale.value + scale;
// Check if the new zoom scale is within the allowed range
if (newZoomScale > MAX_ZOOM_LEVEL) {
// Set zoom to max but do not reset to default
zoomScale.value = MAX_ZOOM_LEVEL;
return;
}
if (newZoomScale < MIN_ZOOM_LEVEL) {
// Set zoom to min but do not reset to default
zoomScale.value = MIN_ZOOM_LEVEL;
return;
}
// If within bounds, update the zoom scale
zoomScale.value = newZoomScale;
};
const onClickZoomImage = () => {
onZoom(0.1);
};
const onWheelImageZoom = e => {
if (!isImage.value) {
return;
}
const scale = e.deltaY > 0 ? -0.1 : 0.1;
onZoom(scale);
};
const keyboardEvents = {
Escape: {
action: () => {
onClose();
}, },
}, },
data() { ArrowLeft: {
return { action: () => {
zoomScale: 1, onClickChangeAttachment(
activeAttachment: {}, props.allAttachments[activeImageIndex.value - 1],
activeFileType: '', activeImageIndex.value - 1
activeImageIndex:
this.allAttachments.findIndex(
attachment => attachment.message_id === this.attachment.message_id
) || 0,
activeImageRotation: 0,
};
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
}),
hasMoreThanOneAttachment() {
return this.allAttachments.length > 1;
},
readableTime() {
const { created_at: createdAt } = this.activeAttachment;
if (!createdAt) return '';
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
},
isImage() {
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return (
this.activeFileType === ALLOWED_FILE_TYPES.VIDEO ||
this.activeFileType === ALLOWED_FILE_TYPES.IG_REEL
); );
}, },
isAudio() {
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
},
senderDetails() {
const {
name,
available_name: availableName,
avatar_url,
thumbnail,
id,
} = this.activeAttachment?.sender || this.attachment?.sender || {};
const currentUserID = this.currentUser?.id;
return {
name: currentUserID === id ? 'You' : name || availableName || '',
avatar: thumbnail || avatar_url || '',
};
},
fileNameFromDataUrl() {
const { data_url: dataUrl } = this.activeAttachment;
if (!dataUrl) return '';
const fileName = dataUrl?.split('/').pop();
return decodeURIComponent(fileName || '');
},
imageRotationStyle() {
return {
transform: `rotate(${this.activeImageRotation}deg) scale(${this.zoomScale})`,
cursor: this.zoomScale < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
};
},
}, },
mounted() { ArrowRight: {
this.setImageAndVideoSrc(this.attachment); action: () => {
}, onClickChangeAttachment(
methods: { props.allAttachments[activeImageIndex.value + 1],
onClose() { activeImageIndex.value + 1
this.$emit('close'); );
},
onClickChangeAttachment(attachment, index) {
if (!attachment) {
return;
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
this.activeImageRotation = 0;
this.zoomScale = 1;
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
this.activeAttachment = attachment;
this.activeFileType = type;
},
getKeyboardEvents() {
return {
Escape: {
action: () => {
this.onClose();
},
},
ArrowLeft: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
},
},
ArrowRight: {
action: () => {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
},
},
};
},
onClickDownload() {
const { file_type: type, data_url: url } = this.activeAttachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
const link = document.createElement('a');
link.href = url;
link.download = `attachment.${type}`;
link.click();
},
onRotate(type) {
if (!this.isImage) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(this.activeImageRotation) === 360) {
this.activeImageRotation = rotation;
} else {
this.activeImageRotation += rotation;
}
},
onClickZoomImage() {
this.onZoom(0.1);
},
onZoom(scale) {
if (!this.isImage) {
return;
}
const newZoomScale = this.zoomScale + scale;
// Check if the new zoom scale is within the allowed range
if (newZoomScale > MAX_ZOOM_LEVEL) {
// Set zoom to max but do not reset to default
this.zoomScale = MAX_ZOOM_LEVEL;
return;
}
if (newZoomScale < MIN_ZOOM_LEVEL) {
// Set zoom to min but do not reset to default
this.zoomScale = MIN_ZOOM_LEVEL;
return;
}
// If within bounds, update the zoom scale
this.zoomScale = newZoomScale;
},
onWheelImageZoom(e) {
if (!this.isImage) {
return;
}
const scale = e.deltaY > 0 ? -0.1 : 0.1;
this.onZoom(scale);
}, },
}, },
}; };
useKeyboardEvents(keyboardEvents, galleryViewRef);
onMounted(() => {
setImageAndVideoSrc(props.attachment);
});
</script> </script>
<!-- eslint-disable vue/no-mutating-props --> <!-- eslint-disable vue/no-mutating-props -->
@@ -214,6 +218,7 @@ export default {
:on-close="onClose" :on-close="onClose"
> >
<div <div
ref="galleryViewRef"
v-on-clickaway="onClose" v-on-clickaway="onClose"
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden" class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
@click="onClose" @click="onClose"