feat: Attachments view (#7156)

* feat: Attachments view with key shortcuts and dynamically updates when user delete or sent new attachments

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sivin Varghese
2023-06-05 19:21:47 +05:30
committed by GitHub
parent 9f3d155822
commit b333d0c986
15 changed files with 607 additions and 32 deletions

View File

@@ -127,6 +127,10 @@ class ConversationApi extends ApiClient {
user_ids: userIds, user_ids: userIds,
}); });
} }
getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
} }
export default new ConversationApi(); export default new ConversationApi();

View File

@@ -210,5 +210,12 @@ describe('#ConversationAPI', () => {
{ params: { page: payload.page } } { params: { page: payload.page } }
); );
}); });
it('#getAllAttachments', () => {
conversationAPI.getAllAttachments(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/1/attachments'
);
});
}); });
}); });

View File

@@ -29,13 +29,13 @@
} }
.modal-image { .modal-image {
max-height: 90%; max-height: 80vh;
max-width: 90%; max-width: 80vw;
} }
.modal-video { .modal-video {
max-height: 75vh; max-height: 80vh;
max-width: 100%; max-width: 80vw;
} }
&::before { &::before {
@@ -53,16 +53,6 @@
width: 100%; width: 100%;
} }
} }
.video {
.modal-container {
width: auto;
.modal--close {
z-index: var(--z-index-low);
}
}
}
} }
.conversations-list-wrap { .conversations-list-wrap {
@@ -400,4 +390,3 @@
margin-bottom: 0; margin-bottom: 0;
} }
} }

View File

@@ -53,22 +53,11 @@
</span> </span>
<div v-if="!isPending && hasAttachments"> <div v-if="!isPending && hasAttachments">
<div v-for="attachment in data.attachments" :key="attachment.id"> <div v-for="attachment in data.attachments" :key="attachment.id">
<bubble-image <bubble-image-audio-video
v-if="attachment.file_type === 'image' && !hasImageError" v-if="isAttachmentImageVideoAudio(attachment.file_type)"
:url="attachment.data_url" :attachment="attachment"
@error="onImageLoadError" @error="onImageLoadError"
/> />
<audio
v-else-if="attachment.file_type === 'audio'"
controls
class="skip-context-menu"
>
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<bubble-video
v-else-if="attachment.file_type === 'video'"
:url="attachment.data_url"
/>
<bubble-location <bubble-location
v-else-if="attachment.file_type === 'location'" v-else-if="attachment.file_type === 'location'"
:latitude="attachment.coordinates_lat" :latitude="attachment.coordinates_lat"
@@ -144,11 +133,12 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import BubbleActions from './bubble/Actions'; import BubbleActions from './bubble/Actions';
import BubbleFile from './bubble/File'; import BubbleFile from './bubble/File';
import BubbleImage from './bubble/Image'; import BubbleImage from './bubble/Image';
import BubbleVideo from './bubble/Video';
import BubbleImageAudioVideo from './bubble/ImageAudioVideo';
import BubbleIntegration from './bubble/Integration.vue'; import BubbleIntegration from './bubble/Integration.vue';
import BubbleLocation from './bubble/Location'; import BubbleLocation from './bubble/Location';
import BubbleMailHead from './bubble/MailHead'; import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text'; import BubbleText from './bubble/Text';
import BubbleVideo from './bubble/Video.vue';
import BubbleContact from './bubble/Contact'; import BubbleContact from './bubble/Contact';
import Spinner from 'shared/components/Spinner'; import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu'; import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
@@ -165,11 +155,12 @@ export default {
BubbleActions, BubbleActions,
BubbleFile, BubbleFile,
BubbleImage, BubbleImage,
BubbleVideo,
BubbleImageAudioVideo,
BubbleIntegration, BubbleIntegration,
BubbleLocation, BubbleLocation,
BubbleMailHead, BubbleMailHead,
BubbleText, BubbleText,
BubbleVideo,
BubbleContact, BubbleContact,
ContextMenu, ContextMenu,
Spinner, Spinner,
@@ -447,6 +438,9 @@ export default {
clearTimeout(this.higlightTimeout); clearTimeout(this.higlightTimeout);
}, },
methods: { methods: {
isAttachmentImageVideoAudio(fileType) {
return ['image', 'audio', 'video'].includes(fileType);
},
hasMediaAttachment(type) { hasMediaAttachment(type) {
if (this.hasAttachments && this.data.attachments.length > 0) { if (this.hasAttachments && this.data.attachments.length > 0) {
const { attachments = [{}] } = this.data; const { attachments = [{}] } = this.data;

View File

@@ -279,6 +279,7 @@ export default {
if (newChat.id === oldChat.id) { if (newChat.id === oldChat.id) {
return; return;
} }
this.fetchAllAttachmentsFromCurrentChat();
this.selectedTweetId = null; this.selectedTweetId = null;
}, },
}, },
@@ -290,6 +291,7 @@ export default {
mounted() { mounted() {
this.addScrollListener(); this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
}, },
beforeDestroy() { beforeDestroy() {
@@ -298,6 +300,9 @@ export default {
}, },
methods: { methods: {
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() { removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage); bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet); bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);

View File

@@ -0,0 +1,106 @@
<template>
<div class="message-text__wrap" :class="attachmentTypeClasses">
<img
v-if="isImage && !isImageError"
:src="attachment.data_url"
@click="onClick"
@error="onImgError()"
/>
<video
v-if="isVideo"
:src="attachment.data_url"
muted
playsInline
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu">
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<gallery-view
v-if="show"
:show.sync="show"
:attachment="attachment"
:all-attachments="filteredCurrentChatAttachments"
@error="onImgError()"
@close="onClose"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
import GalleryView from '../components/GalleryView';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
components: {
GalleryView,
},
props: {
attachment: {
type: Object,
required: true,
},
},
data() {
return {
show: false,
isImageError: false,
};
},
computed: {
...mapGetters({
currentChatAttachments: 'getSelectedChatAttachments',
}),
isImage() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
},
attachmentTypeClasses() {
return {
image: this.isImage,
video: this.isVideo,
};
},
filteredCurrentChatAttachments() {
const attachments = this.currentChatAttachments.filter(attachment =>
['image', 'video', 'audio'].includes(attachment.file_type)
);
return attachments;
},
},
watch: {
attachment() {
this.isImageError = false;
},
},
methods: {
onClose() {
this.show = false;
},
onClick(e) {
if (hasPressedCommand(e)) {
window.open(this.attachment.data_url, '_blank');
return;
}
this.show = true;
},
onImgError() {
this.isImageError = true;
this.$emit('error');
},
},
};
</script>

View File

@@ -0,0 +1,201 @@
<template>
<woot-modal full-width :show.sync="show" :on-close="onClose">
<div v-on-clickaway="onClose" class="gallery-modal--wrap" @click="onClose">
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="secondary"
icon="chevron-left"
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex - 1],
activeImageIndex - 1
)
"
/>
</div>
<div class="attachments-viewer">
<div class="attachment-view">
<img
v-if="isImage"
:key="attachmentSrc"
:src="attachmentSrc"
class="modal-image skip-context-menu"
@click.stop
/>
<video
v-if="isVideo"
:key="attachmentSrc"
:src="attachmentSrc"
controls
playsInline
class="modal-video skip-context-menu"
@click.stop
/>
<audio
v-if="isAudio"
:key="attachmentSrc"
controls
class="skip-context-menu"
@click.stop
>
<source :src="`${attachmentSrc}?t=${Date.now()}`" />
</audio>
</div>
</div>
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="secondary"
:disabled="activeImageIndex === allAttachments.length - 1"
icon="chevron-right"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
activeImageIndex + 1
)
"
/>
</div>
</div>
</woot-modal>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import {
isEscape,
hasPressedArrowLeftKey,
hasPressedArrowRightKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
mixins: [eventListenerMixins, clickaway],
props: {
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
},
data() {
return {
attachmentSrc: '',
activeFileType: '',
activeImageIndex:
this.allAttachments.findIndex(
attachment => attachment.id === this.attachment.id
) || 0,
};
},
computed: {
hasMoreThanOneAttachment() {
return this.allAttachments.length > 1;
},
isImage() {
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.activeFileType === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
},
},
mounted() {
this.setImageAndVideoSrc(this.attachment);
},
methods: {
onClose() {
this.$emit('close');
},
onClickChangeAttachment(attachment, index) {
if (!attachment) {
return;
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
this.attachmentSrc = attachment.data_url;
this.activeFileType = type;
},
onKeyDownHandler(e) {
if (isEscape(e)) {
this.onClose();
} else if (hasPressedArrowLeftKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
} else if (hasPressedArrowRightKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
}
},
},
};
</script>
<style lang="scss" scoped>
.gallery-modal--wrap {
display: flex;
flex-direction: row;
align-items: center;
width: inherit;
height: inherit;
.attachments-viewer {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
.attachment-view {
display: flex;
align-items: center;
justify-content: center;
img {
margin: 0 auto;
}
video {
margin: 0 auto;
}
}
}
.attachment-toggle--button {
width: var(--space-mega);
min-width: var(--space-mega);
display: flex;
justify-content: center;
}
}
</style>

View File

@@ -86,6 +86,18 @@ const actions = {
} }
}, },
fetchAllAttachments: async ({ commit }, conversationId) => {
try {
const { data } = await ConversationApi.getAllAttachments(conversationId);
commit(types.SET_ALL_ATTACHMENTS, {
id: conversationId,
data: data.payload,
});
} catch (error) {
// Handle error
}
},
syncActiveConversationMessages: async ( syncActiveConversationMessages: async (
{ commit, state, dispatch }, { commit, state, dispatch },
{ conversationId } { conversationId }
@@ -247,6 +259,10 @@ const actions = {
...response.data, ...response.data,
status: MESSAGE_STATUS.SENT, status: MESSAGE_STATUS.SENT,
}); });
commit(types.ADD_CONVERSATION_ATTACHMENTS, {
...response.data,
status: MESSAGE_STATUS.SENT,
});
} catch (error) { } catch (error) {
const errorMessage = error.response const errorMessage = error.response
? error.response.data.error ? error.response.data.error
@@ -269,6 +285,7 @@ const actions = {
conversationId: message.conversation_id, conversationId: message.conversation_id,
canReply: true, canReply: true,
}); });
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
} }
}, },
@@ -283,6 +300,7 @@ const actions = {
try { try {
const { data } = await MessageApi.delete(conversationId, messageId); const { data } = await MessageApi.delete(conversationId, messageId);
commit(types.ADD_MESSAGE, data); commit(types.ADD_MESSAGE, data);
commit(types.DELETE_CONVERSATION_ATTACHMENTS, data);
} catch (error) { } catch (error) {
throw new Error(error); throw new Error(error);
} }

View File

@@ -32,6 +32,11 @@ const getters = {
); );
return selectedChat || {}; return selectedChat || {};
}, },
getSelectedChatAttachments: (_state, _getters) => {
const selectedChat = _getters.getSelectedChat;
const { attachments } = selectedChat;
return attachments;
},
getLastEmailInSelectedChat: (stage, _getters) => { getLastEmailInSelectedChat: (stage, _getters) => {
const selectedChat = _getters.getSelectedChat; const selectedChat = _getters.getSelectedChat;
const { messages = [] } = selectedChat; const { messages = [] } = selectedChat;

View File

@@ -3,6 +3,7 @@ import types from '../../mutation-types';
import getters, { getSelectedChatConversation } from './getters'; import getters, { getSelectedChatConversation } from './getters';
import actions from './actions'; import actions from './actions';
import { findPendingMessageIndex } from './helpers'; import { findPendingMessageIndex } from './helpers';
import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents'; import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
@@ -56,6 +57,13 @@ export const mutations = {
chat.messages.unshift(...data); chat.messages.unshift(...data);
} }
}, },
[types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
if (data.length) {
const [chat] = _state.allConversations.filter(c => c.id === id);
Vue.set(chat, 'attachments', []);
chat.attachments.push(...data);
}
},
[types.SET_MISSING_MESSAGES](_state, { id, data }) { [types.SET_MISSING_MESSAGES](_state, { id, data }) {
const [chat] = _state.allConversations.filter(c => c.id === id); const [chat] = _state.allConversations.filter(c => c.id === id);
if (!chat) return; if (!chat) return;
@@ -115,6 +123,44 @@ export const mutations = {
Vue.set(chat, 'muted', false); Vue.set(chat, 'muted', false);
}, },
[types.ADD_CONVERSATION_ATTACHMENTS]({ allConversations }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
allConversations,
selectedChatId: conversationId,
});
if (!chat) return;
const isMessageSent =
message.status === MESSAGE_STATUS.SENT && message.attachments;
if (isMessageSent) {
message.attachments.forEach(attachment => {
if (!chat.attachments.some(a => a.id === attachment.id)) {
chat.attachments.push(attachment);
}
});
}
},
[types.DELETE_CONVERSATION_ATTACHMENTS]({ allConversations }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
allConversations,
selectedChatId: conversationId,
});
if (!chat) return;
const isMessageSent = message.status === MESSAGE_STATUS.SENT;
if (isMessageSent) {
const attachmentIndex = chat.attachments.findIndex(
a => a.message_id === message.id
);
if (attachmentIndex !== -1) chat.attachments.splice(attachmentIndex, 1);
}
},
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) { [types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
const { conversation_id: conversationId } = message; const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({ const [chat] = getSelectedChatConversation({

View File

@@ -204,6 +204,7 @@ describe('#actions', () => {
]); ]);
}); });
}); });
describe('#addMessage', () => { describe('#addMessage', () => {
it('sends correct mutations if message is incoming', () => { it('sends correct mutations if message is incoming', () => {
const message = { const message = {
@@ -218,6 +219,7 @@ describe('#actions', () => {
types.SET_CONVERSATION_CAN_REPLY, types.SET_CONVERSATION_CAN_REPLY,
{ conversationId: 1, canReply: true }, { conversationId: 1, canReply: true },
], ],
[types.ADD_CONVERSATION_ATTACHMENTS, message],
]); ]);
}); });
it('sends correct mutations if message is not an incoming message', () => { it('sends correct mutations if message is not an incoming message', () => {
@@ -436,10 +438,13 @@ describe('#actions', () => {
describe('#deleteMessage', () => { describe('#deleteMessage', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
const [conversationId, messageId] = [1, 1]; const [conversationId, messageId] = [1, 1];
axios.delete.mockResolvedValue({ data: { id: 1, content: 'deleted' } }); axios.delete.mockResolvedValue({
data: { id: 1, content: 'deleted' },
});
await actions.deleteMessage({ commit }, { conversationId, messageId }); await actions.deleteMessage({ commit }, { conversationId, messageId });
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.ADD_MESSAGE, { id: 1, content: 'deleted' }], [types.ADD_MESSAGE, { id: 1, content: 'deleted' }],
[types.DELETE_CONVERSATION_ATTACHMENTS, { id: 1, content: 'deleted' }],
]); ]);
}); });
it('sends no actions if API is error', async () => { it('sends no actions if API is error', async () => {
@@ -554,4 +559,40 @@ describe('#addMentions', () => {
], ],
]); ]);
}); });
describe('#fetchAllAttachments', () => {
it('fetches all attachments', async () => {
axios.get.mockResolvedValue({
data: {
payload: [
{
id: 1,
message_id: 1,
file_type: 'image',
data_url: '',
thumb_url: '',
},
],
},
});
await actions.fetchAllAttachments({ commit }, 1);
expect(commit.mock.calls).toEqual([
[
types.SET_ALL_ATTACHMENTS,
{
id: 1,
data: [
{
id: 1,
message_id: 1,
file_type: 'image',
data_url: '',
thumb_url: '',
},
],
},
],
]);
});
});
}); });

View File

@@ -305,4 +305,34 @@ describe('#getters', () => {
}); });
}); });
}); });
describe('#getSelectedChatAttachments', () => {
it('Returns attachments in selected chat', () => {
const state = {};
const getSelectedChat = {
attachments: [
{
id: 1,
file_name: 'test1',
},
{
id: 2,
file_name: 'test2',
},
],
};
expect(
getters.getSelectedChatAttachments(state, { getSelectedChat })
).toEqual([
{
id: 1,
file_name: 'test1',
},
{
id: 2,
file_name: 'test2',
},
]);
});
});
}); });

View File

@@ -278,4 +278,121 @@ describe('#mutations', () => {
expect(state.appliedFilters).toEqual([]); expect(state.appliedFilters).toEqual([]);
}); });
}); });
describe('#SET_ALL_ATTACHMENTS', () => {
it('set all attachments', () => {
const state = {
allConversations: [{ id: 1 }],
};
const data = [{ id: 1, name: 'test' }];
mutations[types.SET_ALL_ATTACHMENTS](state, { id: 1, data });
expect(state.allConversations[0].attachments).toEqual(data);
});
});
describe('#ADD_CONVERSATION_ATTACHMENTS', () => {
it('add conversation attachments', () => {
const state = {
allConversations: [{ id: 1, attachments: [] }],
};
const message = {
conversation_id: 1,
status: 'sent',
attachments: [{ id: 1, name: 'test' }],
};
mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toEqual(
message.attachments
);
});
it('should not add duplicate attachments', () => {
const state = {
allConversations: [
{
id: 1,
attachments: [{ id: 1, name: 'existing' }],
},
],
};
const message = {
conversation_id: 1,
status: 'sent',
attachments: [
{ id: 1, name: 'existing' },
{ id: 2, name: 'new' },
],
};
mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(2);
expect(state.allConversations[0].attachments).toContainEqual({
id: 1,
name: 'existing',
});
expect(state.allConversations[0].attachments).toContainEqual({
id: 2,
name: 'new',
});
});
it('should not add attachments if chat not found', () => {
const state = {
allConversations: [{ id: 1, attachments: [] }],
};
const message = {
conversation_id: 2,
status: 'sent',
attachments: [{ id: 1, name: 'test' }],
};
mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(0);
});
});
describe('#DELETE_CONVERSATION_ATTACHMENTS', () => {
it('delete conversation attachments', () => {
const state = {
allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
};
const message = {
conversation_id: 1,
status: 'sent',
id: 1,
};
mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(0);
});
it('should not delete attachments for non-matching message id', () => {
const state = {
allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
};
const message = {
conversation_id: 1,
status: 'sent',
id: 2,
};
mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(1);
});
it('should not delete attachments if chat not found', () => {
const state = {
allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
};
const message = {
conversation_id: 2,
status: 'sent',
id: 1,
};
mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(1);
});
});
}); });

View File

@@ -49,6 +49,10 @@ export default {
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY', UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES', SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY', SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
// Inboxes // Inboxes

View File

@@ -94,6 +94,14 @@ export const hasPressedArrowDownKey = e => {
return e.keyCode === 40; return e.keyCode === 40;
}; };
export const hasPressedArrowLeftKey = e => {
return e.keyCode === 37;
};
export const hasPressedArrowRightKey = e => {
return e.keyCode === 39;
};
export const hasPressedCommandPlusKKey = e => { export const hasPressedCommandPlusKKey = e => {
return e.metaKey && e.keyCode === 75; return e.metaKey && e.keyCode === 75;
}; };