Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eaac81f15 | ||
|
|
06a74ad52a | ||
|
|
e8d69d5891 | ||
|
|
0f95371f30 | ||
|
|
c454135507 | ||
|
|
1fe357c501 |
@@ -12,6 +12,62 @@ import {
|
|||||||
import messageReadActions from './actions/messageReadActions';
|
import messageReadActions from './actions/messageReadActions';
|
||||||
import messageTranslateActions from './actions/messageTranslateActions';
|
import messageTranslateActions from './actions/messageTranslateActions';
|
||||||
import * as Sentry from '@sentry/vue';
|
import * as Sentry from '@sentry/vue';
|
||||||
|
import { applyPageFilters } from './helpers';
|
||||||
|
import { matchesFilters } from './helpers/filterHelpers';
|
||||||
|
|
||||||
|
const DEFAULT_TAB = 'all';
|
||||||
|
|
||||||
|
const deriveTabKeys = (conversation, state, rootGetters) => {
|
||||||
|
const tabKeys = new Set([DEFAULT_TAB]);
|
||||||
|
const filtersByTab = state.filtersByTab || {};
|
||||||
|
const currentUserId = rootGetters.getCurrentUser?.id;
|
||||||
|
|
||||||
|
Object.entries(filtersByTab).forEach(([rawTabKey, filters = {}]) => {
|
||||||
|
const tabKey = rawTabKey || DEFAULT_TAB;
|
||||||
|
|
||||||
|
if (tabKey === 'appliedFilters') {
|
||||||
|
if (
|
||||||
|
state.appliedFilters?.length &&
|
||||||
|
matchesFilters(conversation, state.appliedFilters)
|
||||||
|
) {
|
||||||
|
tabKeys.add(tabKey);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { inboxId, status, labels = [], teamId, conversationType } = filters;
|
||||||
|
|
||||||
|
const matchesFilter = applyPageFilters(conversation, {
|
||||||
|
inboxId,
|
||||||
|
status,
|
||||||
|
labels,
|
||||||
|
teamId,
|
||||||
|
conversationType,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matchesFilter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabKey === 'me') {
|
||||||
|
if (conversation.meta?.assignee?.id === currentUserId) {
|
||||||
|
tabKeys.add(tabKey);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tabKey === 'unassigned') {
|
||||||
|
if (!conversation.meta?.assignee) {
|
||||||
|
tabKeys.add(tabKey);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tabKeys.add(tabKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(tabKeys);
|
||||||
|
};
|
||||||
|
|
||||||
export const hasMessageFailedWithExternalError = pendingMessage => {
|
export const hasMessageFailedWithExternalError = pendingMessage => {
|
||||||
// This helper is used to check if the message has failed with an external error.
|
// This helper is used to check if the message has failed with an external error.
|
||||||
@@ -27,10 +83,14 @@ export const hasMessageFailedWithExternalError = pendingMessage => {
|
|||||||
|
|
||||||
// actions
|
// actions
|
||||||
const actions = {
|
const actions = {
|
||||||
getConversation: async ({ commit }, conversationId) => {
|
getConversation: async ({ commit, state, rootGetters }, conversationId) => {
|
||||||
try {
|
try {
|
||||||
const response = await ConversationApi.show(conversationId);
|
const response = await ConversationApi.show(conversationId);
|
||||||
commit(types.UPDATE_CONVERSATION, response.data);
|
const tabKeys = deriveTabKeys(response.data, state, rootGetters);
|
||||||
|
commit(types.UPDATE_CONVERSATION, {
|
||||||
|
conversation: response.data,
|
||||||
|
tabKeys,
|
||||||
|
});
|
||||||
commit(`contacts/${types.SET_CONTACT_ITEM}`, response.data.meta.sender);
|
commit(`contacts/${types.SET_CONTACT_ITEM}`, response.data.meta.sender);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
@@ -125,11 +185,9 @@ const actions = {
|
|||||||
{ commit, state, dispatch },
|
{ commit, state, dispatch },
|
||||||
{ conversationId }
|
{ conversationId }
|
||||||
) => {
|
) => {
|
||||||
const { allConversations, syncConversationsMessages } = state;
|
const { conversationsById, syncConversationsMessages } = state;
|
||||||
const lastMessageId = syncConversationsMessages[conversationId];
|
const lastMessageId = syncConversationsMessages[conversationId];
|
||||||
const selectedChat = allConversations.find(
|
const selectedChat = conversationsById[conversationId];
|
||||||
conversation => conversation.id === conversationId
|
|
||||||
);
|
|
||||||
if (!selectedChat) return;
|
if (!selectedChat) return;
|
||||||
try {
|
try {
|
||||||
const { messages } = selectedChat;
|
const { messages } = selectedChat;
|
||||||
@@ -171,10 +229,8 @@ const actions = {
|
|||||||
{ commit, state },
|
{ commit, state },
|
||||||
{ conversationId }
|
{ conversationId }
|
||||||
) => {
|
) => {
|
||||||
const { allConversations } = state;
|
const { conversationsById } = state;
|
||||||
const selectedChat = allConversations.find(
|
const selectedChat = conversationsById[conversationId];
|
||||||
conversation => conversation.id === conversationId
|
|
||||||
);
|
|
||||||
if (!selectedChat) return;
|
if (!selectedChat) return;
|
||||||
const { messages } = selectedChat;
|
const { messages } = selectedChat;
|
||||||
const lastMessage = messages.last();
|
const lastMessage = messages.last();
|
||||||
@@ -337,7 +393,10 @@ const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
addConversation({ commit, state, dispatch, rootState }, conversation) {
|
addConversation(
|
||||||
|
{ commit, state, dispatch, rootState, rootGetters },
|
||||||
|
conversation
|
||||||
|
) {
|
||||||
const { currentInbox, appliedFilters } = state;
|
const { currentInbox, appliedFilters } = state;
|
||||||
const {
|
const {
|
||||||
inbox_id: inboxId,
|
inbox_id: inboxId,
|
||||||
@@ -353,7 +412,8 @@ const actions = {
|
|||||||
!isOnUnattendedView(rootState) &&
|
!isOnUnattendedView(rootState) &&
|
||||||
isMatchingInboxFilter
|
isMatchingInboxFilter
|
||||||
) {
|
) {
|
||||||
commit(types.ADD_CONVERSATION, conversation);
|
const tabKeys = deriveTabKeys(conversation, state, rootGetters);
|
||||||
|
commit(types.ADD_CONVERSATION, { conversation, tabKeys });
|
||||||
dispatch('contacts/setContact', sender);
|
dispatch('contacts/setContact', sender);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -370,11 +430,12 @@ const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateConversation({ commit, dispatch }, conversation) {
|
updateConversation({ commit, dispatch, state, rootGetters }, conversation) {
|
||||||
const {
|
const {
|
||||||
meta: { sender },
|
meta: { sender },
|
||||||
} = conversation;
|
} = conversation;
|
||||||
commit(types.UPDATE_CONVERSATION, conversation);
|
const tabKeys = deriveTabKeys(conversation, state, rootGetters);
|
||||||
|
commit(types.UPDATE_CONVERSATION, { conversation, tabKeys });
|
||||||
|
|
||||||
dispatch('conversationLabels/setConversationLabel', {
|
dispatch('conversationLabels/setConversationLabel', {
|
||||||
id: conversation.id,
|
id: conversation.id,
|
||||||
|
|||||||
@@ -8,22 +8,80 @@ import {
|
|||||||
} from '../../../helper/permissionsHelper';
|
} from '../../../helper/permissionsHelper';
|
||||||
import camelcaseKeys from 'camelcase-keys';
|
import camelcaseKeys from 'camelcase-keys';
|
||||||
|
|
||||||
|
const DEFAULT_TAB = 'all';
|
||||||
|
|
||||||
|
const normalizeTabKey = tabKey => tabKey || DEFAULT_TAB;
|
||||||
|
|
||||||
|
const buildOrderedIdListForTab = (state, tabKey) => {
|
||||||
|
const tabPages = state.conversationPagesByTab[normalizeTabKey(tabKey)];
|
||||||
|
if (!tabPages) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const seen = new Set();
|
||||||
|
const orderedIds = [];
|
||||||
|
Object.keys(tabPages)
|
||||||
|
.map(page => Number(page))
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.forEach(pageNumber => {
|
||||||
|
const ids = tabPages[String(pageNumber)] || [];
|
||||||
|
ids.forEach(id => {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
orderedIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return orderedIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
const conversationsFromIds = (state, ids) =>
|
||||||
|
ids.map(id => state.conversationsById[id]).filter(Boolean);
|
||||||
|
|
||||||
|
const buildTabList = (state, tabKey) => {
|
||||||
|
const orderedIds = buildOrderedIdListForTab(state, tabKey);
|
||||||
|
return conversationsFromIds(state, orderedIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildAllConversationList = state => {
|
||||||
|
const flattenIds = Object.values(state.conversationPagesByTab).reduce(
|
||||||
|
(acc, tabPages) => {
|
||||||
|
Object.values(tabPages).forEach(pageIds => {
|
||||||
|
pageIds.forEach(id => {
|
||||||
|
if (!acc.set.has(id)) {
|
||||||
|
acc.set.add(id);
|
||||||
|
acc.ids.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ set: new Set(), ids: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!flattenIds.ids.length) {
|
||||||
|
return Object.values(state.conversationsById);
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversationsFromIds(state, flattenIds.ids);
|
||||||
|
};
|
||||||
|
|
||||||
export const getSelectedChatConversation = ({
|
export const getSelectedChatConversation = ({
|
||||||
allConversations,
|
conversationsById,
|
||||||
selectedChatId,
|
selectedChatId,
|
||||||
}) =>
|
}) => {
|
||||||
allConversations.filter(conversation => conversation.id === selectedChatId);
|
const chat = conversationsById[selectedChatId];
|
||||||
|
return chat ? [chat] : [];
|
||||||
|
};
|
||||||
|
|
||||||
const getters = {
|
const getters = {
|
||||||
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
|
getAllConversations: state => {
|
||||||
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
|
const list = buildTabList(state, DEFAULT_TAB);
|
||||||
|
if (!list.length) {
|
||||||
|
return buildAllConversationList(state);
|
||||||
|
}
|
||||||
|
return [...list].sort((a, b) => sortComparator(a, b, state.chatSortFilter));
|
||||||
},
|
},
|
||||||
getFilteredConversations: (
|
getFilteredConversations: (state, _, __, rootGetters) => {
|
||||||
{ allConversations, chatSortFilter, appliedFilters },
|
|
||||||
_,
|
|
||||||
__,
|
|
||||||
rootGetters
|
|
||||||
) => {
|
|
||||||
const currentUser = rootGetters.getCurrentUser;
|
const currentUser = rootGetters.getCurrentUser;
|
||||||
const currentUserId = rootGetters.getCurrentUser.id;
|
const currentUserId = rootGetters.getCurrentUser.id;
|
||||||
const currentAccountId = rootGetters.getCurrentAccountId;
|
const currentAccountId = rootGetters.getCurrentAccountId;
|
||||||
@@ -31,11 +89,15 @@ const getters = {
|
|||||||
const permissions = getUserPermissions(currentUser, currentAccountId);
|
const permissions = getUserPermissions(currentUser, currentAccountId);
|
||||||
const userRole = getUserRole(currentUser, currentAccountId);
|
const userRole = getUserRole(currentUser, currentAccountId);
|
||||||
|
|
||||||
return allConversations
|
const baseList = buildTabList(state, 'appliedFilters').length
|
||||||
|
? buildTabList(state, 'appliedFilters')
|
||||||
|
: buildAllConversationList(state);
|
||||||
|
|
||||||
|
return baseList
|
||||||
.filter(conversation => {
|
.filter(conversation => {
|
||||||
const matchesFilterResult = matchesFilters(
|
const matchesFilterResult = matchesFilters(
|
||||||
conversation,
|
conversation,
|
||||||
appliedFilters
|
state.appliedFilters
|
||||||
);
|
);
|
||||||
const allowedForRole = applyRoleFilter(
|
const allowedForRole = applyRoleFilter(
|
||||||
conversation,
|
conversation,
|
||||||
@@ -46,13 +108,10 @@ const getters = {
|
|||||||
|
|
||||||
return matchesFilterResult && allowedForRole;
|
return matchesFilterResult && allowedForRole;
|
||||||
})
|
})
|
||||||
.sort((a, b) => sortComparator(a, b, chatSortFilter));
|
.sort((a, b) => sortComparator(a, b, state.chatSortFilter));
|
||||||
},
|
},
|
||||||
getSelectedChat: ({ selectedChatId, allConversations }) => {
|
getSelectedChat: ({ selectedChatId, conversationsById }) => {
|
||||||
const selectedChat = allConversations.find(
|
return conversationsById[selectedChatId] || {};
|
||||||
conversation => conversation.id === selectedChatId
|
|
||||||
);
|
|
||||||
return selectedChat || {};
|
|
||||||
},
|
},
|
||||||
getSelectedChatAttachments: ({ selectedChatId, attachments }) => {
|
getSelectedChatAttachments: ({ selectedChatId, attachments }) => {
|
||||||
return attachments[selectedChatId] || [];
|
return attachments[selectedChatId] || [];
|
||||||
@@ -72,10 +131,13 @@ const getters = {
|
|||||||
|
|
||||||
return lastEmail;
|
return lastEmail;
|
||||||
},
|
},
|
||||||
getMineChats: (_state, _, __, rootGetters) => activeFilters => {
|
getMineChats: (state, _, __, rootGetters) => activeFilters => {
|
||||||
const currentUserID = rootGetters.getCurrentUser?.id;
|
const currentUserID = rootGetters.getCurrentUser?.id;
|
||||||
|
const sourceList = buildTabList(state, 'me').length
|
||||||
|
? buildTabList(state, 'me')
|
||||||
|
: buildAllConversationList(state);
|
||||||
|
|
||||||
return _state.allConversations.filter(conversation => {
|
return sourceList.filter(conversation => {
|
||||||
const { assignee } = conversation.meta;
|
const { assignee } = conversation.meta;
|
||||||
const isAssignedToMe = assignee && assignee.id === currentUserID;
|
const isAssignedToMe = assignee && assignee.id === currentUserID;
|
||||||
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
||||||
@@ -95,22 +157,29 @@ const getters = {
|
|||||||
const hasAppliedFilters = _state.appliedFilters.length !== 0;
|
const hasAppliedFilters = _state.appliedFilters.length !== 0;
|
||||||
return hasAppliedFilters ? filterQueryGenerator(_state.appliedFilters) : [];
|
return hasAppliedFilters ? filterQueryGenerator(_state.appliedFilters) : [];
|
||||||
},
|
},
|
||||||
getUnAssignedChats: _state => activeFilters => {
|
getUnAssignedChats: state => activeFilters => {
|
||||||
return _state.allConversations.filter(conversation => {
|
const sourceList = buildTabList(state, 'unassigned').length
|
||||||
|
? buildTabList(state, 'unassigned')
|
||||||
|
: buildAllConversationList(state);
|
||||||
|
|
||||||
|
return sourceList.filter(conversation => {
|
||||||
const isUnAssigned = !conversation.meta.assignee;
|
const isUnAssigned = !conversation.meta.assignee;
|
||||||
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
||||||
return isUnAssigned && shouldFilter;
|
return isUnAssigned && shouldFilter;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getAllStatusChats: (_state, _, __, rootGetters) => activeFilters => {
|
getAllStatusChats: (state, _, __, rootGetters) => activeFilters => {
|
||||||
const currentUser = rootGetters.getCurrentUser;
|
const currentUser = rootGetters.getCurrentUser;
|
||||||
const currentUserId = rootGetters.getCurrentUser.id;
|
const currentUserId = rootGetters.getCurrentUser.id;
|
||||||
const currentAccountId = rootGetters.getCurrentAccountId;
|
const currentAccountId = rootGetters.getCurrentAccountId;
|
||||||
|
|
||||||
const permissions = getUserPermissions(currentUser, currentAccountId);
|
const permissions = getUserPermissions(currentUser, currentAccountId);
|
||||||
const userRole = getUserRole(currentUser, currentAccountId);
|
const userRole = getUserRole(currentUser, currentAccountId);
|
||||||
|
const sourceList = buildTabList(state, DEFAULT_TAB).length
|
||||||
|
? buildTabList(state, DEFAULT_TAB)
|
||||||
|
: buildAllConversationList(state);
|
||||||
|
|
||||||
return _state.allConversations.filter(conversation => {
|
return sourceList.filter(conversation => {
|
||||||
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
const shouldFilter = applyPageFilters(conversation, activeFilters);
|
||||||
const allowedForRole = applyRoleFilter(
|
const allowedForRole = applyRoleFilter(
|
||||||
conversation,
|
conversation,
|
||||||
@@ -142,10 +211,8 @@ const getters = {
|
|||||||
getChatStatusFilter: ({ chatStatusFilter }) => chatStatusFilter,
|
getChatStatusFilter: ({ chatStatusFilter }) => chatStatusFilter,
|
||||||
getChatSortFilter: ({ chatSortFilter }) => chatSortFilter,
|
getChatSortFilter: ({ chatSortFilter }) => chatSortFilter,
|
||||||
getSelectedInbox: ({ currentInbox }) => currentInbox,
|
getSelectedInbox: ({ currentInbox }) => currentInbox,
|
||||||
getConversationById: _state => conversationId => {
|
getConversationById: state => conversationId => {
|
||||||
return _state.allConversations.find(
|
return state.conversationsById[Number(conversationId)] || null;
|
||||||
value => value.id === Number(conversationId)
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
getConversationParticipants: _state => {
|
getConversationParticipants: _state => {
|
||||||
return _state.conversationParticipants;
|
return _state.conversationParticipants;
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ export const buildConversationList = (
|
|||||||
filterType
|
filterType
|
||||||
) => {
|
) => {
|
||||||
const { payload: conversationList, meta: metaData } = responseData;
|
const { payload: conversationList, meta: metaData } = responseData;
|
||||||
context.commit(types.SET_ALL_CONVERSATION, conversationList);
|
context.commit(types.SET_ALL_CONVERSATION, {
|
||||||
|
tab: filterType,
|
||||||
|
page: requestPayload.page || 1,
|
||||||
|
conversations: conversationList,
|
||||||
|
});
|
||||||
context.dispatch('conversationStats/set', metaData);
|
context.dispatch('conversationStats/set', metaData);
|
||||||
context.dispatch(
|
context.dispatch(
|
||||||
'conversationLabels/setBulkConversationLabels',
|
'conversationLabels/setBulkConversationLabels',
|
||||||
@@ -53,10 +57,14 @@ export const buildConversationList = (
|
|||||||
);
|
);
|
||||||
context.commit(types.CLEAR_LIST_LOADING_STATUS);
|
context.commit(types.CLEAR_LIST_LOADING_STATUS);
|
||||||
setContacts(context.commit, conversationList);
|
setContacts(context.commit, conversationList);
|
||||||
|
context.commit(types.SET_CONVERSATION_TAB_FILTERS, {
|
||||||
|
tab: filterType,
|
||||||
|
filters: requestPayload || {},
|
||||||
|
});
|
||||||
setPageFilter({
|
setPageFilter({
|
||||||
dispatch: context.dispatch,
|
dispatch: context.dispatch,
|
||||||
filter: filterType,
|
filter: filterType,
|
||||||
page: requestPayload.page,
|
page: requestPayload.page || 1,
|
||||||
markEndReached: !conversationList.length,
|
markEndReached: !conversationList.length,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,169 @@
|
|||||||
import types from '../../mutation-types';
|
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, sortComparator } from './helpers';
|
||||||
import { MESSAGE_STATUS } from 'shared/constants/messages';
|
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';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
|
const DEFAULT_TAB = 'all';
|
||||||
|
const DEFAULT_PAGE = 1;
|
||||||
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
const normalizeTabKey = tab => tab || DEFAULT_TAB;
|
||||||
|
|
||||||
|
const clonePagesWithUpdate = (pagesByTab, tabKey, updater) => {
|
||||||
|
const normalizedTab = normalizeTabKey(tabKey);
|
||||||
|
const existingTabPages = pagesByTab[normalizedTab] || {};
|
||||||
|
const nextTabPages = updater(existingTabPages);
|
||||||
|
if (nextTabPages === existingTabPages) {
|
||||||
|
return pagesByTab;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...pagesByTab,
|
||||||
|
[normalizedTab]: nextTabPages,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeIdFromTabPages = (tabPages, conversationId) => {
|
||||||
|
let mutated = false;
|
||||||
|
const nextPages = Object.entries(tabPages).reduce((acc, [pageKey, ids]) => {
|
||||||
|
const filteredIds = ids.filter(id => id !== conversationId);
|
||||||
|
if (filteredIds.length !== ids.length) {
|
||||||
|
mutated = true;
|
||||||
|
acc[pageKey] = filteredIds;
|
||||||
|
} else {
|
||||||
|
acc[pageKey] = ids;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
return mutated ? nextPages : tabPages;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeIdFromAllTabs = (pagesByTab, conversationId) => {
|
||||||
|
let mutated = false;
|
||||||
|
const nextPagesByTab = Object.entries(pagesByTab).reduce(
|
||||||
|
(acc, [tabKey, tabPages]) => {
|
||||||
|
const updatedTabPages = removeIdFromTabPages(tabPages, conversationId);
|
||||||
|
if (updatedTabPages !== tabPages) {
|
||||||
|
mutated = true;
|
||||||
|
}
|
||||||
|
acc[tabKey] = updatedTabPages;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
return mutated ? nextPagesByTab : pagesByTab;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebuildTabPagesWithConversation = (_state, tabKey, conversationId) => {
|
||||||
|
_state.conversationPagesByTab = clonePagesWithUpdate(
|
||||||
|
_state.conversationPagesByTab,
|
||||||
|
tabKey,
|
||||||
|
tabPages => {
|
||||||
|
const existingTabPages = Object.keys(tabPages).length
|
||||||
|
? tabPages
|
||||||
|
: { [String(DEFAULT_PAGE)]: [] };
|
||||||
|
|
||||||
|
const pageNumbers = Object.keys(existingTabPages)
|
||||||
|
.map(key => Number(key))
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const collectedIds = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
pageNumbers.forEach(pageNumber => {
|
||||||
|
const ids = existingTabPages[String(pageNumber)] || [];
|
||||||
|
ids.forEach(id => {
|
||||||
|
if (id === conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
collectedIds.push(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
collectedIds.push(conversationId);
|
||||||
|
|
||||||
|
const conversations = collectedIds
|
||||||
|
.map(id => _state.conversationsById[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const totalPagesNeeded = Math.max(
|
||||||
|
Math.ceil(conversations.length / DEFAULT_PAGE_SIZE),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
const basePageNumbers = pageNumbers.length
|
||||||
|
? [...pageNumbers]
|
||||||
|
: [DEFAULT_PAGE];
|
||||||
|
|
||||||
|
while (basePageNumbers.length < totalPagesNeeded) {
|
||||||
|
const lastPage = basePageNumbers[basePageNumbers.length - 1];
|
||||||
|
basePageNumbers.push(lastPage + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basePageNumbers.length > totalPagesNeeded) {
|
||||||
|
basePageNumbers.length = totalPagesNeeded;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conversations.length) {
|
||||||
|
return basePageNumbers.reduce((acc, pageNumber) => {
|
||||||
|
acc[String(pageNumber)] = [];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = conversations
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => sortComparator(a, b, _state.chatSortFilter));
|
||||||
|
|
||||||
|
const sortedIds = sorted.map(item => item.id);
|
||||||
|
|
||||||
|
const nextPages = {};
|
||||||
|
basePageNumbers.forEach((pageNumber, index) => {
|
||||||
|
const startIndex = index * DEFAULT_PAGE_SIZE;
|
||||||
|
nextPages[String(pageNumber)] = sortedIds.slice(
|
||||||
|
startIndex,
|
||||||
|
startIndex + DEFAULT_PAGE_SIZE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return nextPages;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertConversationEntity = (_state, conversation) => {
|
||||||
|
const existing = _state.conversationsById[conversation.id];
|
||||||
|
let updatedConversation = conversation;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (conversation.id === _state.selectedChatId) {
|
||||||
|
updatedConversation = {
|
||||||
|
...conversation,
|
||||||
|
allMessagesLoaded: existing.allMessagesLoaded,
|
||||||
|
messages: existing.messages,
|
||||||
|
dataFetched: existing.dataFetched,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
updatedConversation = { ...existing, ...conversation };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.conversationsById = {
|
||||||
|
..._state.conversationsById,
|
||||||
|
[conversation.id]: updatedConversation,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
allConversations: [],
|
conversationsById: {},
|
||||||
|
conversationPagesByTab: {},
|
||||||
|
filtersByTab: {},
|
||||||
attachments: {},
|
attachments: {},
|
||||||
listLoadingStatus: true,
|
listLoadingStatus: true,
|
||||||
chatStatusFilter: wootConstants.STATUS_TYPE.OPEN,
|
chatStatusFilter: wootConstants.STATUS_TYPE.OPEN,
|
||||||
@@ -26,36 +181,27 @@ const state = {
|
|||||||
|
|
||||||
// mutations
|
// mutations
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
[types.SET_ALL_CONVERSATION](_state, conversationList) {
|
[types.SET_ALL_CONVERSATION](_state, { tab, page, conversations }) {
|
||||||
const newAllConversations = [..._state.allConversations];
|
const pageNumber = page || DEFAULT_PAGE;
|
||||||
conversationList.forEach(conversation => {
|
const tabKey = normalizeTabKey(tab);
|
||||||
const indexInCurrentList = newAllConversations.findIndex(
|
const conversationIds = conversations.map(conversation => {
|
||||||
c => c.id === conversation.id
|
upsertConversationEntity(_state, conversation);
|
||||||
);
|
return conversation.id;
|
||||||
if (indexInCurrentList < 0) {
|
|
||||||
newAllConversations.push(conversation);
|
|
||||||
} else if (conversation.id !== _state.selectedChatId) {
|
|
||||||
// If the conversation is already in the list, replace it
|
|
||||||
// Added this to fix the issue of the conversation not being updated
|
|
||||||
// When reconnecting to the websocket. If the selectedChatId is not the same as
|
|
||||||
// the conversation.id in the store, replace the existing conversation with the new one
|
|
||||||
newAllConversations[indexInCurrentList] = conversation;
|
|
||||||
} else {
|
|
||||||
// If the conversation is already in the list and selectedChatId is the same,
|
|
||||||
// replace all data except the messages array, attachments, dataFetched, allMessagesLoaded
|
|
||||||
const existingConversation = newAllConversations[indexInCurrentList];
|
|
||||||
newAllConversations[indexInCurrentList] = {
|
|
||||||
...conversation,
|
|
||||||
allMessagesLoaded: existingConversation.allMessagesLoaded,
|
|
||||||
messages: existingConversation.messages,
|
|
||||||
dataFetched: existingConversation.dataFetched,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
_state.allConversations = newAllConversations;
|
|
||||||
|
_state.conversationPagesByTab = clonePagesWithUpdate(
|
||||||
|
_state.conversationPagesByTab,
|
||||||
|
tabKey,
|
||||||
|
tabPages => ({
|
||||||
|
...tabPages,
|
||||||
|
[String(pageNumber)]: conversationIds,
|
||||||
|
})
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[types.EMPTY_ALL_CONVERSATION](_state) {
|
[types.EMPTY_ALL_CONVERSATION](_state) {
|
||||||
_state.allConversations = [];
|
_state.conversationsById = {};
|
||||||
|
_state.conversationPagesByTab = {};
|
||||||
|
_state.filtersByTab = {};
|
||||||
_state.selectedChatId = null;
|
_state.selectedChatId = null;
|
||||||
},
|
},
|
||||||
[types.SET_ALL_MESSAGES_LOADED](_state) {
|
[types.SET_ALL_MESSAGES_LOADED](_state) {
|
||||||
@@ -72,17 +218,25 @@ export const mutations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
[types.SET_PREVIOUS_CONVERSATIONS](_state, { id, data }) {
|
[types.SET_PREVIOUS_CONVERSATIONS](_state, { id, data }) {
|
||||||
if (data.length) {
|
if (!data.length) {
|
||||||
const [chat] = _state.allConversations.filter(c => c.id === id);
|
return;
|
||||||
chat.messages.unshift(...data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chat = _state.conversationsById[id];
|
||||||
|
if (!chat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chat.messages = [...data, ...(chat.messages || [])];
|
||||||
},
|
},
|
||||||
[types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
|
[types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
|
||||||
_state.attachments[id] = [...data];
|
_state.attachments[id] = [...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.conversationsById[id];
|
||||||
if (!chat) return;
|
if (!chat) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
chat.messages = data;
|
chat.messages = data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -98,22 +252,27 @@ export const mutations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
|
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
|
||||||
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
|
const chat = _state.conversationsById[conversationId];
|
||||||
chat.meta.team = team;
|
if (chat) {
|
||||||
|
chat.meta.team = team;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.UPDATE_CONVERSATION_LAST_ACTIVITY](
|
[types.UPDATE_CONVERSATION_LAST_ACTIVITY](
|
||||||
_state,
|
_state,
|
||||||
{ lastActivityAt, conversationId }
|
{ lastActivityAt, conversationId }
|
||||||
) {
|
) {
|
||||||
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
|
const chat = _state.conversationsById[conversationId];
|
||||||
if (chat) {
|
if (!chat) {
|
||||||
chat.last_activity_at = lastActivityAt;
|
return;
|
||||||
}
|
}
|
||||||
|
chat.last_activity_at = lastActivityAt;
|
||||||
},
|
},
|
||||||
[types.ASSIGN_PRIORITY](_state, { priority, conversationId }) {
|
[types.ASSIGN_PRIORITY](_state, { priority, conversationId }) {
|
||||||
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
|
const chat = _state.conversationsById[conversationId];
|
||||||
chat.priority = priority;
|
if (chat) {
|
||||||
|
chat.priority = priority;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](_state, custom_attributes) {
|
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](_state, custom_attributes) {
|
||||||
@@ -177,59 +336,89 @@ export const mutations = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
|
[types.ADD_MESSAGE](_state, message) {
|
||||||
const { conversation_id: conversationId } = message;
|
const { conversation_id: conversationId } = message;
|
||||||
const [chat] = getSelectedChatConversation({
|
const chat = _state.conversationsById[conversationId];
|
||||||
allConversations,
|
if (!chat) {
|
||||||
selectedChatId: conversationId,
|
return;
|
||||||
});
|
}
|
||||||
if (!chat) return;
|
|
||||||
|
|
||||||
const pendingMessageIndex = findPendingMessageIndex(chat, message);
|
const pendingMessageIndex = findPendingMessageIndex(chat, message);
|
||||||
if (pendingMessageIndex !== -1) {
|
if (pendingMessageIndex !== -1) {
|
||||||
chat.messages[pendingMessageIndex] = message;
|
chat.messages[pendingMessageIndex] = message;
|
||||||
} else {
|
return;
|
||||||
chat.messages.push(message);
|
}
|
||||||
chat.timestamp = message.created_at;
|
|
||||||
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
|
chat.messages.push(message);
|
||||||
chat.unread_count = unreadCount;
|
chat.timestamp = message.created_at;
|
||||||
if (selectedChatId === conversationId) {
|
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
|
||||||
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
|
chat.unread_count = unreadCount;
|
||||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
if (_state.selectedChatId === conversationId) {
|
||||||
}
|
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
|
||||||
|
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.ADD_CONVERSATION](_state, conversation) {
|
[types.ADD_CONVERSATION](_state, { conversation, tabKeys = [DEFAULT_TAB] }) {
|
||||||
_state.allConversations.push(conversation);
|
upsertConversationEntity(_state, conversation);
|
||||||
|
|
||||||
|
tabKeys.forEach(tabKey => {
|
||||||
|
rebuildTabPagesWithConversation(_state, tabKey, conversation.id);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.DELETE_CONVERSATION](_state, conversationId) {
|
[types.DELETE_CONVERSATION](_state, conversationId) {
|
||||||
_state.allConversations = _state.allConversations.filter(
|
const { [conversationId]: removed, ...rest } = _state.conversationsById;
|
||||||
c => c.id !== conversationId
|
if (!removed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_state.conversationsById = rest;
|
||||||
|
_state.conversationPagesByTab = removeIdFromAllTabs(
|
||||||
|
_state.conversationPagesByTab,
|
||||||
|
conversationId
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.UPDATE_CONVERSATION](_state, conversation) {
|
[types.UPDATE_CONVERSATION](
|
||||||
const { allConversations } = _state;
|
_state,
|
||||||
const index = allConversations.findIndex(c => c.id === conversation.id);
|
{ conversation, tabKeys = [DEFAULT_TAB] }
|
||||||
|
) {
|
||||||
|
const existingConversation = _state.conversationsById[conversation.id];
|
||||||
|
|
||||||
if (index > -1) {
|
if (existingConversation) {
|
||||||
const selectedConversation = allConversations[index];
|
if (
|
||||||
|
conversation.updated_at &&
|
||||||
// ignore out of order events
|
existingConversation.updated_at &&
|
||||||
if (conversation.updated_at < selectedConversation.updated_at) {
|
conversation.updated_at < existingConversation.updated_at
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, ...updates } = conversation;
|
const { messages, ...updates } = conversation;
|
||||||
allConversations[index] = { ...selectedConversation, ...updates };
|
const mergedConversation = {
|
||||||
if (_state.selectedChatId === conversation.id) {
|
...existingConversation,
|
||||||
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
|
...updates,
|
||||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
};
|
||||||
}
|
_state.conversationsById = {
|
||||||
|
..._state.conversationsById,
|
||||||
|
[conversation.id]: mergedConversation,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
_state.allConversations.push(conversation);
|
upsertConversationEntity(_state, conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.conversationPagesByTab = removeIdFromAllTabs(
|
||||||
|
_state.conversationPagesByTab,
|
||||||
|
conversation.id
|
||||||
|
);
|
||||||
|
|
||||||
|
tabKeys.forEach(tabKey => {
|
||||||
|
rebuildTabPagesWithConversation(_state, tabKey, conversation.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_state.selectedChatId === conversation.id) {
|
||||||
|
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
|
||||||
|
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -245,7 +434,7 @@ export const mutations = {
|
|||||||
_state,
|
_state,
|
||||||
{ id, lastSeen, unreadCount = 0 }
|
{ id, lastSeen, unreadCount = 0 }
|
||||||
) {
|
) {
|
||||||
const [chat] = _state.allConversations.filter(c => c.id === id);
|
const chat = _state.conversationsById[id];
|
||||||
if (chat) {
|
if (chat) {
|
||||||
chat.agent_last_seen_at = lastSeen;
|
chat.agent_last_seen_at = lastSeen;
|
||||||
chat.unread_count = unreadCount;
|
chat.unread_count = unreadCount;
|
||||||
@@ -261,12 +450,14 @@ export const mutations = {
|
|||||||
|
|
||||||
// Update assignee on action cable message
|
// Update assignee on action cable message
|
||||||
[types.UPDATE_ASSIGNEE](_state, payload) {
|
[types.UPDATE_ASSIGNEE](_state, payload) {
|
||||||
const [chat] = _state.allConversations.filter(c => c.id === payload.id);
|
const chat = _state.conversationsById[payload.id];
|
||||||
chat.meta.assignee = payload.assignee;
|
if (chat) {
|
||||||
|
chat.meta.assignee = payload.assignee;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
|
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
|
||||||
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
|
const chat = _state.conversationsById[conversationId];
|
||||||
if (chat) {
|
if (chat) {
|
||||||
chat.meta.sender = payload;
|
chat.meta.sender = payload;
|
||||||
}
|
}
|
||||||
@@ -277,23 +468,49 @@ export const mutations = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
[types.SET_CONVERSATION_CAN_REPLY](_state, { conversationId, canReply }) {
|
[types.SET_CONVERSATION_CAN_REPLY](_state, { conversationId, canReply }) {
|
||||||
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
|
const chat = _state.conversationsById[conversationId];
|
||||||
if (chat) {
|
if (chat) {
|
||||||
chat.can_reply = canReply;
|
chat.can_reply = canReply;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.CLEAR_CONTACT_CONVERSATIONS](_state, contactId) {
|
[types.CLEAR_CONTACT_CONVERSATIONS](_state, contactId) {
|
||||||
const chats = _state.allConversations.filter(
|
const remaining = {};
|
||||||
c => c.meta.sender.id !== contactId
|
const removedIds = [];
|
||||||
);
|
Object.values(_state.conversationsById).forEach(conversation => {
|
||||||
_state.allConversations = chats;
|
if (conversation.meta?.sender?.id === contactId) {
|
||||||
|
removedIds.push(conversation.id);
|
||||||
|
} else {
|
||||||
|
remaining[conversation.id] = conversation;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!removedIds.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.conversationsById = remaining;
|
||||||
|
removedIds.forEach(conversationId => {
|
||||||
|
_state.conversationPagesByTab = removeIdFromAllTabs(
|
||||||
|
_state.conversationPagesByTab,
|
||||||
|
conversationId
|
||||||
|
);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
[types.SET_CONVERSATION_FILTERS](_state, data) {
|
[types.SET_CONVERSATION_FILTERS](_state, data) {
|
||||||
_state.appliedFilters = data;
|
_state.appliedFilters = data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
[types.SET_CONVERSATION_TAB_FILTERS](_state, { tab, filters }) {
|
||||||
|
const normalizedTab = normalizeTabKey(tab);
|
||||||
|
const { page, ...rest } = filters || {};
|
||||||
|
_state.filtersByTab = {
|
||||||
|
..._state.filtersByTab,
|
||||||
|
[normalizedTab]: rest,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
[types.CLEAR_CONVERSATION_FILTERS](_state) {
|
[types.CLEAR_CONVERSATION_FILTERS](_state) {
|
||||||
_state.appliedFilters = [];
|
_state.appliedFilters = [];
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default {
|
|||||||
UPDATE_CONVERSATION_CONTACT: 'UPDATE_CONVERSATION_CONTACT',
|
UPDATE_CONVERSATION_CONTACT: 'UPDATE_CONVERSATION_CONTACT',
|
||||||
CLEAR_CONTACT_CONVERSATIONS: 'CLEAR_CONTACT_CONVERSATIONS',
|
CLEAR_CONTACT_CONVERSATIONS: 'CLEAR_CONTACT_CONVERSATIONS',
|
||||||
SET_CONVERSATION_FILTERS: 'SET_CONVERSATION_FILTERS',
|
SET_CONVERSATION_FILTERS: 'SET_CONVERSATION_FILTERS',
|
||||||
|
SET_CONVERSATION_TAB_FILTERS: 'SET_CONVERSATION_TAB_FILTERS',
|
||||||
CLEAR_CONVERSATION_FILTERS: 'CLEAR_CONVERSATION_FILTERS',
|
CLEAR_CONVERSATION_FILTERS: 'CLEAR_CONVERSATION_FILTERS',
|
||||||
SET_CONVERSATION_LAST_SEEN: 'SET_CONVERSATION_LAST_SEEN',
|
SET_CONVERSATION_LAST_SEEN: 'SET_CONVERSATION_LAST_SEEN',
|
||||||
SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION:
|
SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION:
|
||||||
|
|||||||
Reference in New Issue
Block a user