You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1066 lines
33 KiB
1066 lines
33 KiB
/**
|
|
* @copyright Copyright (c) 2019 Marco Ambrosini <marcoambrosini@icloud.com>
|
|
*
|
|
* @author Marco Ambrosini <marcoambrosini@icloud.com>
|
|
*
|
|
* @license AGPL-3.0-or-later
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as
|
|
* published by the Free Software Foundation, either version 3 of the
|
|
* License, or (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*
|
|
*/
|
|
import Vue from 'vue'
|
|
import {
|
|
deleteMessage,
|
|
updateLastReadMessage,
|
|
fetchMessages,
|
|
lookForNewMessages,
|
|
postNewMessage,
|
|
postRichObjectToConversation,
|
|
addReactionToMessage,
|
|
removeReactionFromMessage,
|
|
} from '../services/messagesService.js'
|
|
|
|
import SHA256 from 'crypto-js/sha256'
|
|
import Hex from 'crypto-js/enc-hex'
|
|
import CancelableRequest from '../utils/cancelableRequest.js'
|
|
import { showError } from '@nextcloud/dialogs'
|
|
import {
|
|
ATTENDEE,
|
|
} from '../constants.js'
|
|
|
|
/**
|
|
* Returns whether the given message contains a mention to self, directly
|
|
* or indirectly through a global mention.
|
|
*
|
|
* @param {object} context store context
|
|
* @param {object} message message object
|
|
* @return {boolean} true if the message contains a mention to self or all,
|
|
* false otherwise
|
|
*/
|
|
function hasMentionToSelf(context, message) {
|
|
if (!message.messageParameters) {
|
|
return false
|
|
}
|
|
|
|
for (const key in message.messageParameters) {
|
|
const param = message.messageParameters[key]
|
|
|
|
if (param.type === 'call') {
|
|
return true
|
|
}
|
|
if (param.type === 'guest'
|
|
&& context.getters.getActorType() === ATTENDEE.ACTOR_TYPE.GUESTS
|
|
&& param.id === ('guest/' + context.getters.getActorId())
|
|
) {
|
|
return true
|
|
}
|
|
if (param.type === 'user'
|
|
&& context.getters.getActorType() === ATTENDEE.ACTOR_TYPE.USERS
|
|
&& param.id === context.getters.getUserId()
|
|
) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
const state = {
|
|
/**
|
|
* Map of conversation token to message list
|
|
*/
|
|
messages: {},
|
|
/**
|
|
* Map of conversation token to first known message id
|
|
*/
|
|
firstKnown: {},
|
|
/**
|
|
* Map of conversation token to last known message id
|
|
*/
|
|
lastKnown: {},
|
|
|
|
/**
|
|
* Cached last read message id for display.
|
|
*/
|
|
visualLastReadMessageId: {},
|
|
|
|
/**
|
|
* Stores the cancel function returned by `cancelableFetchMessages`,
|
|
* which allows to cancel the previous request for old messages
|
|
* when quickly switching to a new conversation.
|
|
*/
|
|
cancelFetchMessages: null,
|
|
/**
|
|
* Stores the cancel function returned by `cancelableLookForNewMessages`,
|
|
* which allows to cancel the previous long polling request for new
|
|
* messages before making another one.
|
|
*/
|
|
cancelLookForNewMessages: {},
|
|
/**
|
|
* Array of temporary message id to cancel function for the "postNewMessage" action
|
|
*/
|
|
cancelPostNewMessage: {},
|
|
}
|
|
|
|
const getters = {
|
|
/**
|
|
* Returns whether more messages can be loaded, which means that the current
|
|
* message list doesn't yet contain all future messages.
|
|
* If false, the next call to "lookForNewMessages" will be blocking/long-polling.
|
|
*
|
|
* @param {object} state the state object.
|
|
* @param {object} getters the getters object.
|
|
* @return {boolean} true if more messages exist that needs loading, false otherwise
|
|
*/
|
|
hasMoreMessagesToLoad: (state, getters) => (token) => {
|
|
const conversation = getters.conversation(token)
|
|
if (!conversation) {
|
|
return false
|
|
}
|
|
|
|
return getters.getLastKnownMessageId(token) < conversation.lastMessage.id
|
|
},
|
|
|
|
/**
|
|
* Gets the messages array
|
|
*
|
|
* @param {object} state the state object.
|
|
* @return {Array} the messages array (if there are messages in the store)
|
|
*/
|
|
messagesList: (state) => (token) => {
|
|
if (state.messages[token]) {
|
|
return Object.values(state.messages[token]).filter(message => {
|
|
// Filter out reaction messages
|
|
if (message.systemMessage === 'reaction' || message.systemMessage === 'reaction_deleted' || message.systemMessage === 'reaction_revoked') {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
})
|
|
}
|
|
return []
|
|
},
|
|
messages: (state) => (token) => {
|
|
if (state.messages[token]) {
|
|
return state.messages[token]
|
|
}
|
|
return {}
|
|
},
|
|
message: (state) => (token, id) => {
|
|
if (state.messages[token][id]) {
|
|
return state.messages[token][id]
|
|
}
|
|
return {}
|
|
},
|
|
|
|
getTemporaryReferences: (state) => (token, referenceId) => {
|
|
if (!state.messages[token]) {
|
|
return []
|
|
}
|
|
|
|
return Object.values(state.messages[token]).filter(message => {
|
|
return message.referenceId === referenceId
|
|
&& ('' + message.id).startsWith('temp-')
|
|
})
|
|
},
|
|
|
|
getFirstKnownMessageId: (state) => (token) => {
|
|
if (state.firstKnown[token]) {
|
|
return state.firstKnown[token]
|
|
}
|
|
return null
|
|
},
|
|
|
|
getLastKnownMessageId: (state) => (token) => {
|
|
if (state.lastKnown[token]) {
|
|
return state.lastKnown[token]
|
|
}
|
|
return null
|
|
},
|
|
|
|
getVisualLastReadMessageId: (state) => (token) => {
|
|
if (state.visualLastReadMessageId[token]) {
|
|
return state.visualLastReadMessageId[token]
|
|
}
|
|
return null
|
|
},
|
|
|
|
isSendingMessages: (state) => {
|
|
// the cancel handler only exists when a message is being sent
|
|
return Object.keys(state.cancelPostNewMessage).length !== 0
|
|
},
|
|
|
|
// Returns true if the message has reactions
|
|
hasReactions: (state) => (token, messageId) => {
|
|
return Object.keys(state.messages[token][messageId].reactions).length !== 0
|
|
},
|
|
}
|
|
|
|
const mutations = {
|
|
setCancelFetchMessages(state, cancelFunction) {
|
|
state.cancelFetchMessages = cancelFunction
|
|
},
|
|
|
|
setCancelLookForNewMessages(state, { requestId, cancelFunction }) {
|
|
if (cancelFunction) {
|
|
Vue.set(state.cancelLookForNewMessages, requestId, cancelFunction)
|
|
} else {
|
|
Vue.delete(state.cancelLookForNewMessages, requestId)
|
|
}
|
|
},
|
|
|
|
setCancelPostNewMessage(state, { messageId, cancelFunction }) {
|
|
if (cancelFunction) {
|
|
Vue.set(state.cancelPostNewMessage, messageId, cancelFunction)
|
|
} else {
|
|
Vue.delete(state.cancelPostNewMessage, messageId)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Adds a message to the store.
|
|
*
|
|
* @param {object} state current store state;
|
|
* @param {object} message the message;
|
|
*/
|
|
addMessage(state, message) {
|
|
if (!state.messages[message.token]) {
|
|
Vue.set(state.messages, message.token, {})
|
|
}
|
|
if (state.messages[message.token][message.id]) {
|
|
Vue.set(state.messages[message.token], message.id,
|
|
Object.assign(state.messages[message.token][message.id], message)
|
|
)
|
|
} else {
|
|
Vue.set(state.messages[message.token], message.id, message)
|
|
}
|
|
},
|
|
/**
|
|
* Deletes a message from the store.
|
|
*
|
|
* @param {object} state current store state;
|
|
* @param {object} message the message;
|
|
*/
|
|
deleteMessage(state, message) {
|
|
if (state.messages[message.token][message.id]) {
|
|
Vue.delete(state.messages[message.token], message.id)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Deletes a message from the store.
|
|
*
|
|
* @param {object} state current store state;
|
|
* @param {object} data the wrapping object;
|
|
* @param {object} data.message the message;
|
|
* @param {string} data.placeholder Placeholder message until deleting finished
|
|
*/
|
|
markMessageAsDeleting(state, { message, placeholder }) {
|
|
Vue.set(state.messages[message.token][message.id], 'messageType', 'comment_deleted')
|
|
Vue.set(state.messages[message.token][message.id], 'message', placeholder)
|
|
},
|
|
/**
|
|
* Adds a temporary message to the store.
|
|
*
|
|
* @param {object} state current store state;
|
|
* @param {object} message the temporary message;
|
|
*/
|
|
addTemporaryMessage(state, message) {
|
|
if (!state.messages[message.token]) {
|
|
Vue.set(state.messages, message.token, {})
|
|
}
|
|
Vue.set(state.messages[message.token], message.id, message)
|
|
},
|
|
|
|
/**
|
|
* Adds a temporary message to the store.
|
|
*
|
|
* @param {object} state current store state;
|
|
* @param {object} data the wrapping object;
|
|
* @param {object} data.message the temporary message;
|
|
* @param {string} data.reason the reason the temporary message failed;
|
|
*/
|
|
markTemporaryMessageAsFailed(state, { message, reason }) {
|
|
if (state.messages[message.token][message.id]) {
|
|
Vue.set(state.messages[message.token][message.id], 'sendingFailure', reason)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* @param {object} state current store state;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.token Token of the conversation
|
|
* @param {string} data.id Id of the first known chat message
|
|
*/
|
|
setFirstKnownMessageId(state, { token, id }) {
|
|
Vue.set(state.firstKnown, token, id)
|
|
},
|
|
|
|
/**
|
|
* @param {object} state current store state;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.token Token of the conversation
|
|
* @param {string} data.id Id of the last known chat message
|
|
*/
|
|
setLastKnownMessageId(state, { token, id }) {
|
|
Vue.set(state.lastKnown, token, id)
|
|
},
|
|
|
|
/**
|
|
* @param {object} state current store state;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.token Token of the conversation
|
|
* @param {string} data.id Id of the last read chat message
|
|
*/
|
|
setVisualLastReadMessageId(state, { token, id }) {
|
|
Vue.set(state.visualLastReadMessageId, token, id)
|
|
},
|
|
|
|
/**
|
|
* Deletes the messages entry from the store for the given conversation token.
|
|
*
|
|
* @param {object} state current store state
|
|
* @param {string} token Token of the conversation
|
|
*/
|
|
deleteMessages(state, token) {
|
|
if (state.firstKnown[token]) {
|
|
Vue.delete(state.firstKnown, token)
|
|
}
|
|
if (state.lastKnown[token]) {
|
|
Vue.delete(state.lastKnown, token)
|
|
}
|
|
if (state.visualLastReadMessageId[token]) {
|
|
Vue.delete(state.visualLastReadMessageId, token)
|
|
}
|
|
if (state.messages[token]) {
|
|
Vue.delete(state.messages, token)
|
|
}
|
|
},
|
|
|
|
// Increases reaction count for a particular reaction on a message
|
|
addReactionToMessage(state, { token, messageId, reaction }) {
|
|
if (!state.messages[token][messageId].reactions[reaction]) {
|
|
Vue.set(state.messages[token][messageId].reactions, reaction, 0)
|
|
}
|
|
const reactionCount = state.messages[token][messageId].reactions[reaction] + 1
|
|
Vue.set(state.messages[token][messageId].reactions, reaction, reactionCount)
|
|
|
|
if (!state.messages[token][messageId].reactionsSelf) {
|
|
Vue.set(state.messages[token][messageId], 'reactionsSelf', [reaction])
|
|
} else {
|
|
state.messages[token][messageId].reactionsSelf.push(reaction)
|
|
}
|
|
},
|
|
|
|
// Decreases reaction count for a particular reaction on a message
|
|
removeReactionFromMessage(state, { token, messageId, reaction }) {
|
|
const reactionCount = state.messages[token][messageId].reactions[reaction] - 1
|
|
Vue.set(state.messages[token][messageId].reactions, reaction, reactionCount)
|
|
if (state.messages[token][messageId].reactions[reaction] <= 0) {
|
|
Vue.delete(state.messages[token][messageId].reactions, reaction)
|
|
}
|
|
|
|
if (state.messages[token][messageId].reactionsSelf) {
|
|
const i = state.messages[token][messageId].reactionsSelf.indexOf(reaction)
|
|
if (i !== -1) {
|
|
Vue.delete(state.messages[token][messageId], 'reactionsSelf', i)
|
|
}
|
|
}
|
|
},
|
|
}
|
|
|
|
const actions = {
|
|
|
|
/**
|
|
* Adds message to the store.
|
|
*
|
|
* If the message has a parent message object,
|
|
* first it adds the parent to the store.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} message the message;
|
|
*/
|
|
processMessage(context, message) {
|
|
if (message.parent) {
|
|
context.commit('addMessage', message.parent)
|
|
message.parent = message.parent.id
|
|
}
|
|
|
|
if (message.referenceId) {
|
|
const tempMessages = context.getters.getTemporaryReferences(message.token, message.referenceId)
|
|
tempMessages.forEach(tempMessage => {
|
|
context.commit('deleteMessage', tempMessage)
|
|
})
|
|
}
|
|
|
|
if (message.systemMessage === 'reaction' || message.systemMessage === 'reaction_revoked') {
|
|
context.commit('resetReactions', {
|
|
token: message.token,
|
|
messageId: message.parent,
|
|
})
|
|
}
|
|
|
|
context.commit('addMessage', message)
|
|
|
|
if ((message.messageType === 'comment' && message.message === '{file}' && message.messageParameters?.file)
|
|
|| (message.messageType === 'comment' && message.message === '{object}' && message.messageParameters?.object)) {
|
|
context.dispatch('addSharedItemMessage', {
|
|
message,
|
|
})
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Delete a message
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {object} data.message the message to be deleted;
|
|
* @param {string} data.placeholder Placeholder message until deleting finished
|
|
*/
|
|
async deleteMessage(context, { message, placeholder }) {
|
|
const messageObject = Object.assign({}, context.getters.message(message.token, message.id))
|
|
context.commit('markMessageAsDeleting', { message, placeholder })
|
|
|
|
let response
|
|
try {
|
|
response = await deleteMessage(message)
|
|
} catch (e) {
|
|
// Restore the previous message state
|
|
context.commit('addMessage', messageObject)
|
|
throw e
|
|
}
|
|
|
|
const systemMessage = response.data.ocs.data
|
|
if (systemMessage.parent) {
|
|
context.commit('addMessage', systemMessage.parent)
|
|
systemMessage.parent = systemMessage.parent.id
|
|
}
|
|
|
|
context.commit('addMessage', systemMessage)
|
|
|
|
return response.status
|
|
},
|
|
|
|
/**
|
|
* Creates a temporary message ready to be posted, based
|
|
* on the message to be replied and the current actor
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.text message string;
|
|
* @param {string} data.token conversation token;
|
|
* @param {string} data.uploadId upload id;
|
|
* @param {number} data.index index;
|
|
* @param {object} data.file file to upload;
|
|
* @param {string} data.localUrl local URL of file to upload;
|
|
* @param {boolean} data.isVoiceMessage whether the temporary file is a voice message
|
|
* @return {object} temporary message
|
|
*/
|
|
createTemporaryMessage(context, { text, token, uploadId, index, file, localUrl, isVoiceMessage }) {
|
|
const messageToBeReplied = context.getters.getMessageToBeReplied(token)
|
|
const date = new Date()
|
|
let tempId = 'temp-' + date.getTime()
|
|
const messageParameters = {}
|
|
if (file) {
|
|
tempId += '-' + uploadId + '-' + Math.random()
|
|
messageParameters.file = {
|
|
type: 'file',
|
|
file,
|
|
mimetype: file.type,
|
|
id: tempId,
|
|
name: file.newName || file.name,
|
|
// index, will be the id from now on
|
|
uploadId,
|
|
localUrl,
|
|
index,
|
|
}
|
|
}
|
|
|
|
const message = Object.assign({}, {
|
|
id: tempId,
|
|
actorId: context.getters.getActorId(),
|
|
actorType: context.getters.getActorType(),
|
|
actorDisplayName: context.getters.getDisplayName(),
|
|
timestamp: 0,
|
|
systemMessage: '',
|
|
messageType: isVoiceMessage ? 'voice-message' : '',
|
|
message: text,
|
|
messageParameters,
|
|
token,
|
|
isReplyable: false,
|
|
sendingFailure: '',
|
|
reactions: {},
|
|
referenceId: Hex.stringify(SHA256(tempId)),
|
|
})
|
|
|
|
/**
|
|
* If the current message is a quote-reply message, add the parent key to the
|
|
* temporary message object.
|
|
*/
|
|
if (messageToBeReplied) {
|
|
message.parent = messageToBeReplied.id
|
|
}
|
|
return message
|
|
},
|
|
|
|
/**
|
|
* Add a temporary message generated in the client to
|
|
* the store, these messages are deleted once the full
|
|
* message object is received from the server.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} message the temporary message;
|
|
*/
|
|
addTemporaryMessage(context, message) {
|
|
context.commit('addTemporaryMessage', message)
|
|
// Update conversations list order
|
|
context.dispatch('updateConversationLastActive', message.token)
|
|
},
|
|
|
|
/**
|
|
* Mark a temporary message as failed to allow retrying it again
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {object} data.message the temporary message;
|
|
* @param {string} data.reason the reason the temporary message failed;
|
|
*/
|
|
markTemporaryMessageAsFailed(context, { message, reason }) {
|
|
context.commit('markTemporaryMessageAsFailed', { message, reason })
|
|
},
|
|
|
|
/**
|
|
* Remove temporary message from store after receiving the parsed one from server
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} message the temporary message;
|
|
*/
|
|
removeTemporaryMessageFromStore(context, message) {
|
|
context.commit('deleteMessage', message)
|
|
},
|
|
|
|
/**
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.token Token of the conversation
|
|
* @param {string} data.id Id of the first known chat message
|
|
*/
|
|
setFirstKnownMessageId(context, { token, id }) {
|
|
context.commit('setFirstKnownMessageId', { token, id })
|
|
},
|
|
|
|
/**
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.token Token of the conversation
|
|
* @param {string} data.id Id of the last known chat message
|
|
*/
|
|
setLastKnownMessageId(context, { token, id }) {
|
|
context.commit('setLastKnownMessageId', { token, id })
|
|
},
|
|
|
|
/**
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.token Token of the conversation
|
|
* @param {string} data.id Id of the last read chat message
|
|
*/
|
|
setVisualLastReadMessageId(context, { token, id }) {
|
|
context.commit('setVisualLastReadMessageId', { token, id })
|
|
},
|
|
|
|
/**
|
|
* Deletes all messages of a conversation from the store only.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {string} token the token of the conversation to be deleted;
|
|
*/
|
|
deleteMessages(context, token) {
|
|
context.commit('deleteMessages', token)
|
|
},
|
|
|
|
/**
|
|
* Clears the last read message marker by moving it to the last message
|
|
* in the conversation.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {object} data.token the token of the conversation to be updated;
|
|
* @param {boolean} data.updateVisually whether to also clear the marker visually in the UI;
|
|
*/
|
|
async clearLastReadMessage(context, { token, updateVisually = false }) {
|
|
const conversation = context.getters.conversations[token]
|
|
if (!conversation || !conversation.lastMessage) {
|
|
return
|
|
}
|
|
// set the id to the last message
|
|
context.dispatch('updateLastReadMessage', { token, id: conversation.lastMessage.id, updateVisually })
|
|
context.dispatch('markConversationRead', token)
|
|
},
|
|
|
|
/**
|
|
* Updates the last read message in the store and also in the backend.
|
|
* Optionally also updated the marker visually in the UI if specified.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {object} data.token the token of the conversation to be updated;
|
|
* @param {number} data.id the id of the message on which to set the read marker;
|
|
* @param {boolean} data.updateVisually whether to also update the marker visually in the UI;
|
|
*/
|
|
async updateLastReadMessage(context, { token, id = 0, updateVisually = false }) {
|
|
const conversation = context.getters.conversations[token]
|
|
if (!conversation || conversation.lastReadMessage === id) {
|
|
return
|
|
}
|
|
|
|
if (id === 0) {
|
|
console.warn('updateLastReadMessage: should not set read marker with id=0')
|
|
}
|
|
|
|
// optimistic early commit to avoid indicator flickering
|
|
context.dispatch('updateConversationLastReadMessage', { token, lastReadMessage: id })
|
|
if (updateVisually) {
|
|
context.commit('setVisualLastReadMessageId', { token, id })
|
|
}
|
|
|
|
if (context.getters.getUserId()) {
|
|
// only update on server side if there's an actual user, not guest
|
|
await updateLastReadMessage(token, id)
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Fetches messages that belong to a particular conversation
|
|
* specified with its token.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.token the conversation token;
|
|
* @param {object} data.requestOptions request options;
|
|
* @param {string} data.lastKnownMessageId last known message id;
|
|
* @param {boolean} data.includeLastKnown whether to include the last known message in the response;
|
|
*/
|
|
async fetchMessages(context, { token, lastKnownMessageId, includeLastKnown, requestOptions }) {
|
|
context.dispatch('cancelFetchMessages')
|
|
|
|
// Get a new cancelable request function and cancel function pair
|
|
const { request, cancel } = CancelableRequest(fetchMessages)
|
|
// Assign the new cancel function to our data value
|
|
context.commit('setCancelFetchMessages', cancel)
|
|
|
|
const response = await request({
|
|
token,
|
|
lastKnownMessageId,
|
|
includeLastKnown,
|
|
}, requestOptions)
|
|
|
|
let newestKnownMessageId = 0
|
|
|
|
if ('x-chat-last-common-read' in response.headers) {
|
|
const lastCommonReadMessage = parseInt(response.headers['x-chat-last-common-read'], 10)
|
|
context.dispatch('updateLastCommonReadMessage', {
|
|
token,
|
|
lastCommonReadMessage,
|
|
})
|
|
}
|
|
|
|
// Process each messages and adds it to the store
|
|
response.data.ocs.data.forEach(message => {
|
|
if (message.actorType === ATTENDEE.ACTOR_TYPE.GUESTS) {
|
|
// update guest display names cache
|
|
context.dispatch('setGuestNameIfEmpty', message)
|
|
}
|
|
context.dispatch('processMessage', message)
|
|
newestKnownMessageId = Math.max(newestKnownMessageId, message.id)
|
|
})
|
|
|
|
if (response.headers['x-chat-last-given']) {
|
|
context.dispatch('setFirstKnownMessageId', {
|
|
token,
|
|
id: parseInt(response.headers['x-chat-last-given'], 10),
|
|
})
|
|
}
|
|
|
|
// For guests we also need to set the last known message id
|
|
// after the first grab of the history, otherwise they start loading
|
|
// the full history with fetchMessages().
|
|
if (includeLastKnown && newestKnownMessageId
|
|
&& !context.getters.getLastKnownMessageId(token)) {
|
|
context.dispatch('setLastKnownMessageId', {
|
|
token,
|
|
id: newestKnownMessageId,
|
|
})
|
|
}
|
|
|
|
return response
|
|
},
|
|
|
|
/**
|
|
* Cancels a previously running "fetchMessages" action if applicable.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @return {boolean} true if a request got cancelled, false otherwise
|
|
*/
|
|
cancelFetchMessages(context) {
|
|
if (context.state.cancelFetchMessages) {
|
|
context.state.cancelFetchMessages('canceled')
|
|
context.commit('setCancelFetchMessages', null)
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
|
|
/**
|
|
* Fetches newly created messages that belong to a particular conversation
|
|
* specified with its token.
|
|
*
|
|
* This call will long-poll when hasMoreMessagesToLoad() returns false.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.token The conversation token;
|
|
* @param {string} data.requestId id to identify request uniquely
|
|
* @param {object} data.requestOptions request options;
|
|
* @param {number} data.lastKnownMessageId The id of the last message in the store.
|
|
*/
|
|
async lookForNewMessages(context, { token, lastKnownMessageId, requestId, requestOptions }) {
|
|
context.dispatch('cancelLookForNewMessages', { requestId })
|
|
|
|
// Get a new cancelable request function and cancel function pair
|
|
const { request, cancel } = CancelableRequest(lookForNewMessages)
|
|
|
|
// Assign the new cancel function to our data value
|
|
context.commit('setCancelLookForNewMessages', { cancelFunction: cancel, requestId })
|
|
|
|
const response = await request({ token, lastKnownMessageId }, requestOptions)
|
|
context.commit('setCancelLookForNewMessages', { requestId })
|
|
|
|
if ('x-chat-last-common-read' in response.headers) {
|
|
const lastCommonReadMessage = parseInt(response.headers['x-chat-last-common-read'], 10)
|
|
context.dispatch('updateLastCommonReadMessage', {
|
|
token,
|
|
lastCommonReadMessage,
|
|
})
|
|
}
|
|
|
|
const conversation = context.getters.conversation(token)
|
|
let countNewMessages = 0
|
|
let hasNewMention = conversation.unreadMention
|
|
let lastMessage = null
|
|
// Process each messages and adds it to the store
|
|
response.data.ocs.data.forEach(message => {
|
|
if (message.actorType === ATTENDEE.ACTOR_TYPE.GUESTS) {
|
|
// update guest display names cache,
|
|
// force in case the display name has changed since
|
|
// the last fetch
|
|
context.dispatch('forceGuestName', message)
|
|
}
|
|
context.dispatch('processMessage', message)
|
|
if (!lastMessage || message.id > lastMessage.id) {
|
|
if (!message.systemMessage) {
|
|
countNewMessages++
|
|
|
|
// parse mentions data to update "conversation.unreadMention",
|
|
// if needed
|
|
if (!hasNewMention && hasMentionToSelf(context, message)) {
|
|
hasNewMention = true
|
|
}
|
|
}
|
|
lastMessage = message
|
|
}
|
|
|
|
// Overwrite the conversation.hasCall property so people can join
|
|
// after seeing the message in the chat.
|
|
if (conversation && conversation.lastMessage && message.id > conversation.lastMessage.id) {
|
|
if (message.systemMessage === 'call_started') {
|
|
context.dispatch('overwriteHasCallByChat', {
|
|
token,
|
|
hasCall: true,
|
|
})
|
|
} else if (message.systemMessage === 'call_ended'
|
|
|| message.systemMessage === 'call_ended_everyone'
|
|
|| message.systemMessage === 'call_missed') {
|
|
context.dispatch('overwriteHasCallByChat', {
|
|
token,
|
|
hasCall: false,
|
|
})
|
|
}
|
|
}
|
|
|
|
// in case we encounter an already read message, reset the counter
|
|
// this is probably unlikely to happen unless one starts browsing from
|
|
// an earlier page and scrolls down
|
|
if (conversation.lastReadMessage === message.id) {
|
|
// discard counters
|
|
countNewMessages = 0
|
|
hasNewMention = conversation.unreadMention
|
|
}
|
|
})
|
|
|
|
context.dispatch('setLastKnownMessageId', {
|
|
token,
|
|
id: parseInt(response.headers['x-chat-last-given'], 10),
|
|
})
|
|
|
|
if (conversation && conversation.lastMessage && lastMessage.id > conversation.lastMessage.id) {
|
|
context.dispatch('updateConversationLastMessage', {
|
|
token,
|
|
lastMessage,
|
|
})
|
|
|
|
// only increase the counter if the conversation store was out of sync with the message list
|
|
if (countNewMessages > 0) {
|
|
context.commit('updateUnreadMessages', {
|
|
token,
|
|
unreadMessages: conversation.unreadMessages + countNewMessages,
|
|
// only update the value if it's been changed to true
|
|
unreadMention: conversation.unreadMention !== hasNewMention ? hasNewMention : undefined,
|
|
})
|
|
}
|
|
}
|
|
|
|
return response
|
|
},
|
|
|
|
/**
|
|
* Cancels a previously running "lookForNewMessages" action if applicable.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {string} requestId request id
|
|
* @return {boolean} true if a request got cancelled, false otherwise
|
|
*/
|
|
cancelLookForNewMessages(context, { requestId }) {
|
|
if (context.state.cancelLookForNewMessages[requestId]) {
|
|
context.state.cancelLookForNewMessages[requestId]('canceled')
|
|
context.commit('setCancelLookForNewMessages', { requestId })
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
|
|
/**
|
|
* Sends the given temporary message to the server.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {object} data Passed in parameters
|
|
* @param {object} data.temporaryMessage temporary message, must already have been added to messages list.
|
|
* @param {object} data.options post request options.
|
|
*/
|
|
async postNewMessage(context, { temporaryMessage, options }) {
|
|
const { request, cancel } = CancelableRequest(postNewMessage)
|
|
context.commit('setCancelPostNewMessage', { messageId: temporaryMessage.id, cancelFunction: cancel })
|
|
|
|
const timeout = setTimeout(() => {
|
|
context.dispatch('cancelPostNewMessage', { messageId: temporaryMessage.id })
|
|
context.dispatch('markTemporaryMessageAsFailed', {
|
|
message: temporaryMessage,
|
|
reason: 'timeout',
|
|
})
|
|
}, 30000)
|
|
|
|
try {
|
|
const response = await request(temporaryMessage, options)
|
|
clearTimeout(timeout)
|
|
context.commit('setCancelPostNewMessage', { messageId: temporaryMessage.id, cancelFunction: null })
|
|
|
|
if ('x-chat-last-common-read' in response.headers) {
|
|
const lastCommonReadMessage = parseInt(response.headers['x-chat-last-common-read'], 10)
|
|
context.dispatch('updateLastCommonReadMessage', {
|
|
token: temporaryMessage.token,
|
|
lastCommonReadMessage,
|
|
})
|
|
}
|
|
|
|
// If successful, deletes the temporary message from the store
|
|
context.dispatch('removeTemporaryMessageFromStore', temporaryMessage)
|
|
|
|
const message = response.data.ocs.data
|
|
// And adds the complete version of the message received
|
|
// by the server
|
|
context.dispatch('processMessage', message)
|
|
|
|
const conversation = context.getters.conversation(temporaryMessage.token)
|
|
|
|
// update lastMessage and lastReadMessage
|
|
// do it conditionally because there could have been more messages appearing concurrently
|
|
if (conversation && conversation.lastMessage && message.id > conversation.lastMessage.id) {
|
|
context.dispatch('updateConversationLastMessage', {
|
|
token: conversation.token,
|
|
lastMessage: message,
|
|
})
|
|
}
|
|
if (conversation && message.id > conversation.lastReadMessage) {
|
|
// no await to make it async
|
|
context.dispatch('updateLastReadMessage', {
|
|
token: conversation.token,
|
|
id: message.id,
|
|
updateVisually: true,
|
|
})
|
|
}
|
|
|
|
return response
|
|
} catch (error) {
|
|
if (timeout) {
|
|
clearTimeout(timeout)
|
|
}
|
|
context.commit('setCancelPostNewMessage', { messageId: temporaryMessage.id, cancelFunction: null })
|
|
|
|
let statusCode = null
|
|
console.error(`error while submitting message ${error}`, error)
|
|
if (error.isAxiosError) {
|
|
statusCode = error?.response?.status
|
|
}
|
|
|
|
// FIXME: don't use showError here but set a flag
|
|
// somewhere that makes Vue trigger the error message
|
|
|
|
// 403 when room is read-only, 412 when switched to lobby mode
|
|
if (statusCode === 403) {
|
|
showError(t('spreed', 'No permission to post messages in this conversation'))
|
|
context.dispatch('markTemporaryMessageAsFailed', {
|
|
message: temporaryMessage,
|
|
reason: 'read-only',
|
|
})
|
|
} else if (statusCode === 412) {
|
|
showError(t('spreed', 'No permission to post messages in this conversation'))
|
|
context.dispatch('markTemporaryMessageAsFailed', {
|
|
message: temporaryMessage,
|
|
reason: 'lobby',
|
|
})
|
|
} else {
|
|
showError(t('spreed', 'Could not post message: {errorMessage}', { errorMessage: error.message || error }))
|
|
context.dispatch('markTemporaryMessageAsFailed', {
|
|
message: temporaryMessage,
|
|
reason: 'other',
|
|
})
|
|
}
|
|
throw error
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Cancels a previously running "postNewMessage" action if applicable.
|
|
*
|
|
* @param {object} context default store context;
|
|
* @param {string} messageId the message id for which to cancel;
|
|
* @return {boolean} true if a request got cancelled, false otherwise
|
|
*/
|
|
cancelPostNewMessage(context, { messageId }) {
|
|
if (context.state.cancelPostNewMessage[messageId]) {
|
|
context.state.cancelPostNewMessage[messageId]('canceled')
|
|
context.commit('setCancelPostNewMessage', { messageId, cancelFunction: null })
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
|
|
/**
|
|
* Posts a simple text message to a room
|
|
*
|
|
* @param {object} context default store context;
|
|
* will be forwarded;
|
|
* @param {object} data the wrapping object;
|
|
* @param {object} data.messageToBeForwarded the message object;
|
|
*/
|
|
async forwardMessage(context, { messageToBeForwarded }) {
|
|
const response = await postNewMessage(messageToBeForwarded, { silent: false })
|
|
return response
|
|
},
|
|
|
|
/**
|
|
* Posts a simple text message to a room
|
|
*
|
|
* @param {object} context default store context;
|
|
* will be forwarded;
|
|
* @param {object} data the wrapping object;
|
|
* @param {string} data.token token of the target conversation
|
|
* @param {object} data.richObject the rich object;
|
|
*/
|
|
async forwardRichObject(context, { token, richObject }) {
|
|
const response = await postRichObjectToConversation(token, richObject)
|
|
return response
|
|
},
|
|
|
|
/**
|
|
* Adds a single reaction to a message for the current user.
|
|
*
|
|
* @param {*} context the context object
|
|
* @param {*} param1 conversation token, message id and selected emoji (string)
|
|
*/
|
|
async addReactionToMessage(context, { token, messageId, selectedEmoji }) {
|
|
try {
|
|
context.commit('addReactionToMessage', {
|
|
token,
|
|
messageId,
|
|
reaction: selectedEmoji,
|
|
})
|
|
// The response return an array with the reaction details for this message
|
|
const response = await addReactionToMessage(token, messageId, selectedEmoji)
|
|
// We replace the reaction details in the reactions store and wipe the old
|
|
// values
|
|
context.dispatch('updateReactions', {
|
|
token,
|
|
messageId,
|
|
reactionsDetails: response.data.ocs.data,
|
|
})
|
|
} catch (error) {
|
|
// Restore the previous state if the request fails
|
|
context.commit('removeReactionFromMessage', {
|
|
token,
|
|
messageId,
|
|
reaction: selectedEmoji,
|
|
})
|
|
console.error(error)
|
|
showError(t('spreed', 'Failed to add reaction'))
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Removes a single reaction from a message for the current user.
|
|
*
|
|
* @param {*} context the context object
|
|
* @param {*} param1 conversation token, message id and selected emoji (string)
|
|
*/
|
|
async removeReactionFromMessage(context, { token, messageId, selectedEmoji }) {
|
|
try {
|
|
context.commit('removeReactionFromMessage', {
|
|
token,
|
|
messageId,
|
|
reaction: selectedEmoji,
|
|
})
|
|
// The response return an array with the reaction details for this message
|
|
const response = await removeReactionFromMessage(token, messageId, selectedEmoji)
|
|
// We replace the reaction details in the reactions store and wipe the old
|
|
// values
|
|
context.dispatch('updateReactions', {
|
|
token,
|
|
messageId,
|
|
reactionsDetails: response.data.ocs.data,
|
|
})
|
|
} catch (error) {
|
|
// Restore the previous state if the request fails
|
|
context.commit('addReactionToMessage', {
|
|
token,
|
|
messageId,
|
|
reaction: selectedEmoji,
|
|
})
|
|
console.error(error)
|
|
showError(t('spreed', 'Failed to remove reaction'))
|
|
}
|
|
},
|
|
}
|
|
|
|
export default { state, mutations, getters, actions }
|