<NME> conversationsStore.js
<BEF> /**
 * @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 {
	makePublic,
	makePrivate,
	setSIPEnabled,
	changeLobbyState,
	changeReadOnlyState,
	changeListable,
	createOneToOneConversation,
	addToFavorites,
	removeFromFavorites,
	fetchConversations,
	fetchConversation,
	setConversationName,
	setConversationDescription,
	deleteConversation,
	clearConversationHistory,
	setNotificationLevel,
	setNotificationCalls,
	setConversationPermissions,
	setCallPermissions,
	setMessageExpiration,
	setConversationPassword,
} from '../services/conversationsService.js'
import { getCurrentUser } from '@nextcloud/auth'
// eslint-disable-next-line import/extensions
import { CONVERSATION, WEBINAR, PARTICIPANT } from '../constants.js'

const DUMMY_CONVERSATION = {
	token: '',
	displayName: '',
	isFavorite: false,
	hasPassword: false,
	canEnableSIP: false,
	type: CONVERSATION.TYPE.PUBLIC,
	participantFlags: PARTICIPANT.CALL_FLAG.DISCONNECTED,
	participantType: PARTICIPANT.TYPE.USER,
	readOnly: CONVERSATION.STATE.READ_ONLY,
	listable: CONVERSATION.LISTABLE.NONE,
	hasCall: false,
	canStartCall: false,
	lobbyState: WEBINAR.LOBBY.NONE,
	lobbyTimer: 0,
	attendeePin: '',
}

const getDefaultState = () => {
	return {
		conversations: {
		},
	}
}

const state = {
	conversations: {
	},
}

const getters = {
	conversations: state => state.conversations,
	conversationsList: state => Object.values(state.conversations),
	/**
	 * Get a conversation providing it's token
	 *
	 * @param {object} state state object
	 * @return {Function} The callback function returning the conversation object
	 */
	conversation: state => token => state.conversations[token],
	dummyConversation: state => Object.assign({}, DUMMY_CONVERSATION),
}

const mutations = {
	/**
	 * Adds a conversation to the store.
	 *
	 * @param {object} state current store state;
	 * @param {object} conversation the conversation;
	 */
	addConversation(state, conversation) {
		Vue.set(state.conversations, conversation.token, conversation)
	},
	/**
	 * Deletes a conversation from the store.
	 *
	 * @param {object} state current store state;
	 * @param {object} token the token of the conversation to delete;
	 */
	deleteConversation(state, token) {
		Vue.delete(state.conversations, token)
	},
	/**
	 * Resets the store to it's original state
	 *
	 * @param {object} state current store state;
	 */
	purgeConversationsStore(state) {
		Object.assign(state, getDefaultState())
	},

	setConversationDescription(state, { token, description }) {
		Vue.set(state.conversations[token], 'description', description)
	},

	updateConversationLastReadMessage(state, { token, lastReadMessage }) {
		Vue.set(state.conversations[token], 'lastReadMessage', lastReadMessage)
	},

	updateConversationLastMessage(state, { token, lastMessage }) {
		Vue.set(state.conversations[token], 'lastMessage', lastMessage)
	},

	updateUnreadMessages(state, { token, unreadMessages, unreadMention }) {
		if (unreadMessages !== undefined) {
			Vue.set(state.conversations[token], 'unreadMessages', unreadMessages)
		}
		if (unreadMention !== undefined) {
			Vue.set(state.conversations[token], 'unreadMention', unreadMention)
		}
	},

	overwriteHasCallByChat(state, { token, hasCall }) {
		if (hasCall) {
			Vue.set(state.conversations[token], 'hasCallOverwrittenByChat', hasCall)
		} else {
			Vue.delete(state.conversations[token], 'hasCallOverwrittenByChat')
			participant: {
				inCall: conversation.participantFlags,
				lastPing: conversation.lastPing,
				sessionId: conversation.sessionId,
				participantType: conversation.participantType,
				attendeeId: conversation.attendeeId,
				actorType: conversation.actorType,
	setNotificationCalls(state, { token, notificationCalls }) {
		Vue.set(state.conversations[token], 'notificationCalls', notificationCalls)
	},

	setConversationPermissions(state, { token, permissions }) {
		Vue.set(state.conversations[token], 'defaultPermissions', permissions)
	},

	setCallPermissions(state, { token, permissions }) {
		Vue.set(state.conversations[token], 'callPermissions', permissions)
	},

	setMessageExpiration(state, { token, seconds }) {
		Vue.set(state.conversations[token], 'messageExpiration', seconds)
	},

	setConversationHasPassword(state, { token, hasPassword }) {
		Vue.set(state.conversations[token], 'hasPassword', hasPassword)
	},
}

const actions = {
	/**
	 * Add a conversation to the store and index the displayname.
	 *
	 * @param {object} context default store context;
	 * @param {object} conversation the conversation;
	 */
	addConversation(context, conversation) {
		context.commit('addConversation', conversation)

		let currentUser = {
			uid: context.getters.getUserId(),
			displayName: context.getters.getDisplayName(),
		}

		// Fallback to getCurrentUser() only if if has not been set yet (as
		// getCurrentUser() needs to be overriden in public share pages as it
		// always returns an anonymous user).
		if (!currentUser.uid) {
			currentUser = getCurrentUser()
		}
		context.dispatch('addParticipantOnce', {
			token: conversation.token,
			participant: {
				inCall: conversation.participantFlags,
				lastPing: conversation.lastPing,
				sessionIds: [conversation.sessionId],
				participantType: conversation.participantType,
				attendeeId: conversation.attendeeId,
				actorType: conversation.actorType,
				actorId: conversation.actorId, // FIXME check public share page handling
				userId: currentUser ? currentUser.uid : '',
				displayName: currentUser && currentUser.displayName ? currentUser.displayName : '', // TODO guest name from localstore?
			},
		})
	},

	/**
	 * Delete a conversation from the store.
	 *
	 * @param {object} context default store context;
	 * @param {object} token the token of the conversation to be deleted;
	 */
	deleteConversation(context, token) {
		// FIXME: rename to deleteConversationsFromStore or a better name
		context.dispatch('deleteMessages', token)
		context.commit('deleteConversation', token)
	},

	/**
	 * Delete a conversation from the server.
	 *
	 * @param {object} context default store context;
	 * @param {object} data the wrapping object;
	 * @param {object} data.token the token of the conversation to be deleted;
	 */
	async deleteConversationFromServer(context, { token }) {
		await deleteConversation(token)
		// upon success, also delete from store
		await context.dispatch('deleteConversation', token)
	},

	/**
	 * Delete all the messages from a conversation.
	 *
	 * @param {object} context default store context;
	 * @param {object} data the wrapping object;
	 * @param {object} data.token the token of the conversation whose history is
	 * to be cleared;
	 */
	async clearConversationHistory(context, { token }) {
		try {
			const response = await clearConversationHistory(token)
			context.dispatch('deleteMessages', token)
			return response
		} catch (error) {
			console.debug(
				t('spreed', 'Error while clearing conversation history'),
				error)
		}
	},

	/**
	 * Resets the store to it's original state.
	 *
	 * @param {object} context default store context;
	 */
	purgeConversationsStore(context) {
		// TODO: also purge messages ??
		context.commit('purgeConversationsStore')
	},

	async toggleGuests({ commit, getters }, { token, allowGuests }) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		if (allowGuests) {
			await makePublic(token)
			conversation.type = CONVERSATION.TYPE.PUBLIC
		} else {
			await makePrivate(token)
			conversation.type = CONVERSATION.TYPE.GROUP
		}

		commit('addConversation', conversation)
	},

	async toggleFavorite({ commit, getters }, { token, isFavorite }) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		// FIXME: logic is reversed
		if (isFavorite) {
			await removeFromFavorites(token)
		} else {
			await addToFavorites(token)
		}
		conversation.isFavorite = !isFavorite

		commit('addConversation', conversation)
	},

	async toggleLobby({ commit, getters }, { token, enableLobby }) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		if (enableLobby) {
			await changeLobbyState(token, WEBINAR.LOBBY.NON_MODERATORS)
			conversation.lobbyState = WEBINAR.LOBBY.NON_MODERATORS
		} else {
			await changeLobbyState(token, WEBINAR.LOBBY.NONE)
			conversation.lobbyState = WEBINAR.LOBBY.NONE
		}

		commit('addConversation', conversation)
	},

	async setConversationName({ commit, getters }, { token, name }) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		await setConversationName(token, name)
		conversation.displayName = name

		commit('addConversation', conversation)
	},

	async setConversationDescription({ commit }, { token, description }) {
		await setConversationDescription(token, description)
		commit('setConversationDescription', { token, description })
	},

	async setConversationPassword({ commit }, { token, newPassword }) {
		await setConversationPassword(token, newPassword)

		commit('setConversationHasPassword', {
			token,
			hasPassword: !!newPassword,
		})
	},

	async setReadOnlyState({ commit, getters }, { token, readOnly }) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		await changeReadOnlyState(token, readOnly)
		conversation.readOnly = readOnly

		commit('addConversation', conversation)
	},

	async setListable({ commit, getters }, { token, listable }) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		await changeListable(token, listable)
		conversation.listable = listable

		commit('addConversation', conversation)
	},

	async setLobbyTimer({ commit, getters }, { token, timestamp }) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		// The backend requires the state and timestamp to be set together.
		await changeLobbyState(token, conversation.lobbyState, timestamp)
		conversation.lobbyTimer = timestamp

		commit('addConversation', conversation)
	},

	async setSIPEnabled({ commit, getters }, { token, state }) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		await setSIPEnabled(token, state)
		conversation.sipEnabled = state

		commit('addConversation', conversation)
	},

	async markConversationRead({ commit, getters }, token) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		conversation.unreadMessages = 0
		conversation.unreadMention = false

		commit('addConversation', conversation)
	},

	async updateLastCommonReadMessage({ commit, getters }, { token, lastCommonReadMessage }) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		conversation.lastCommonReadMessage = lastCommonReadMessage

		commit('addConversation', conversation)
	},

	async updateConversationLastActive({ commit, getters }, token) {
		const conversation = Object.assign({}, getters.conversations[token])
		if (!conversation) {
			return
		}

		conversation.lastActivity = (new Date().getTime()) / 1000

		commit('addConversation', conversation)
	},

	async updateConversationLastMessage({ commit }, { token, lastMessage }) {
		/**
		 * Only use the last message as lastmessage when:
		 * 1. It's not a command reply
		 * 2. It's not a temporary message starting with "/" which is a user posting a command
		 * 3. It's not a reaction or deletion of a reaction
		 * 3. It's not a deletion of a message
		 */
		if ((lastMessage.actorType !== 'bots'
				|| lastMessage.actorId === 'changelog')
			&& lastMessage.systemMessage !== 'reaction'
			&& lastMessage.systemMessage !== 'poll_voted'
			&& lastMessage.systemMessage !== 'reaction_deleted'
			&& lastMessage.systemMessage !== 'reaction_revoked'
			&& lastMessage.systemMessage !== 'message_deleted'
			&& !(typeof lastMessage.id.startsWith === 'function'
				&& lastMessage.id.startsWith('temp-')
				&& lastMessage.message.startsWith('/'))) {
			commit('updateConversationLastMessage', { token, lastMessage })
		}
	},

	async updateConversationLastReadMessage({ commit }, { token, lastReadMessage }) {
		commit('updateConversationLastReadMessage', { token, lastReadMessage })
	},

	async overwriteHasCallByChat({ commit }, { token, hasCall }) {
		commit('overwriteHasCallByChat', { token, hasCall })
	},

	async fetchConversation({ dispatch }, { token }) {
		try {
			dispatch('clearMaintenanceMode')
			const response = await fetchConversation(token)
			dispatch('updateTalkVersionHash', response)
			dispatch('addConversation', response.data.ocs.data)
			return response
		} catch (error) {
			if (error?.response) {
				dispatch('checkMaintenanceMode', error.response)
			}
			throw error
		}
	},

	async fetchConversations({ dispatch }) {
		try {
			dispatch('clearMaintenanceMode')

			const response = await fetchConversations()
			dispatch('updateTalkVersionHash', response)
			dispatch('purgeConversationsStore')
			response.data.ocs.data.forEach(conversation => {
				dispatch('addConversation', conversation)
			})
			return response
		} catch (error) {
			if (error?.response) {
				dispatch('checkMaintenanceMode', error.response)
			}
			throw error
		}
	},

	async setNotificationLevel({ commit }, { token, notificationLevel }) {
		await setNotificationLevel(token, notificationLevel)

		commit('setNotificationLevel', { token, notificationLevel })
	},

	async setNotificationCalls({ commit }, { token, notificationCalls }) {
		await setNotificationCalls(token, notificationCalls)

		commit('setNotificationCalls', { token, notificationCalls })
	},

	/**
	 * Creates a new one to one conversation in the backend
	 * with the given actor then adds it to the store.
	 *
	 * @param {object} context default store context;
	 * @param {string} actorId actor id;
	 */
	async createOneToOneConversation(context, actorId) {
		const response = await createOneToOneConversation(actorId)
		const conversation = response.data.ocs.data
		context.dispatch('addConversation', conversation)

		return conversation
	},

	async setConversationPermissions(context, { token, permissions }) {
		await setConversationPermissions(token, permissions)
		context.commit('setConversationPermissions', { token, permissions })
	},

	async setMessageExpiration({ commit }, { token, seconds }) {
		await setMessageExpiration(token, seconds)
		commit('setMessageExpiration', { token, seconds })
	},

	async setCallPermissions(context, { token, permissions }) {
		await setCallPermissions(token, permissions)
		context.commit('setCallPermissions', { token, permissions })
	},
}

export default { state, mutations, getters, actions }

<MSG> Merge pull request #5482 from nextcloud/bugfix/5349/expose-multiple-sessions-raised-hand

Fix raised hand detection for multiple session, adjusted participant list response
<DFF> @@ -147,7 +147,7 @@ const actions = {
 			participant: {
 				inCall: conversation.participantFlags,
 				lastPing: conversation.lastPing,
-				sessionId: conversation.sessionId,
+				sessionIds: [conversation.sessionId],
 				participantType: conversation.participantType,
 				attendeeId: conversation.attendeeId,
 				actorType: conversation.actorType,

{'commit': "diff --git a/docs/participant.md b/docs/participant.md\nindex 86cfb240c..b120da475 100644\n--- a/docs/participant.md\n+++ b/docs/participant.md\n@@ -34,8 +34,8 @@\n         `participantType` | int | * | Permissions level of the participant (see [constants list](constants.md#participant-types))\n         `lastPing` | int | * | Timestamp of the last ping of the user (should be used for sorting)\n         `inCall` | int | * | Call flags the user joined with (see [constants list](constants.md#participant-in-call-flag))\n-        `sessionId` | string | * | `'0'` if not connected, otherwise a 512 character long string\n-        `status` | string | * | Optional: Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation \n+        `sessionIds` | array | * | array of session ids, each are 512 character long strings, or empty if no session\n+        `status` | string | * | Optional: Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation\n         `statusIcon` | string | * | Optional: Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation\n         `statusMessage` | string | * | Optional: Only available with `includeStatus=true`, for users with a set status and when there are less than 100 participants in the conversation\n \ndiff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php\nindex 4cd3e0078..0b50b1ca0 100644\n--- a/lib/Controller/RoomController.php\n+++ b/lib/Controller/RoomController.php\n@@ -921,14 +921,14 @@ class RoomController extends AEnvironmentAwareController {\n \t\t\t\t// Combine the session values: All inCall bit flags, newest lastPing and any sessionId (for online checking)\n \t\t\t\t$results[$attendeeId]['inCall'] |= $session->getInCall();\n \t\t\t\t$results[$attendeeId]['lastPing'] = max($results[$attendeeId]['lastPing'], $session->getLastPing());\n-\t\t\t\t$results[$attendeeId]['sessionId'] = $results[$attendeeId]['sessionId'] !== '0' ? $results[$attendeeId]['sessionId'] : $session->getSessionId();\n+\t\t\t\t$results[$attendeeId]['sessionIds'][] = $session->getSessionId();\n \t\t\t\tcontinue;\n \t\t\t}\n \n \t\t\t$result = [\n \t\t\t\t'inCall' => Participant::FLAG_DISCONNECTED,\n \t\t\t\t'lastPing' => 0,\n-\t\t\t\t'sessionId' => '0', // FIXME empty string or null?\n+\t\t\t\t'sessionIds' => [],\n \t\t\t\t'participantType' => $participant->getAttendee()->getParticipantType(),\n \t\t\t\t'attendeeId' => $attendeeId,\n \t\t\t\t'actorId' => $participant->getAttendee()->getActorId(),\n@@ -949,7 +949,7 @@ class RoomController extends AEnvironmentAwareController {\n \t\t\tif ($participant->getSession() instanceof Session) {\n \t\t\t\t$result['inCall'] = $participant->getSession()->getInCall();\n \t\t\t\t$result['lastPing'] = $participant->getSession()->getLastPing();\n-\t\t\t\t$result['sessionId'] = $participant->getSession()->getSessionId();\n+\t\t\t\t$result['sessionIds'] = [$participant->getSession()->getSessionId()];\n \t\t\t}\n \n \t\t\tif ($participant->getAttendee()->getActorType() === Attendee::ACTOR_USERS) {\ndiff --git a/src/components/RightSidebar/Participants/CurrentParticipants/CurrentParticipants.vue b/src/components/RightSidebar/Participants/CurrentParticipants/CurrentParticipants.vue\nindex b2e91f7cd..66772dd60 100644\n--- a/src/components/RightSidebar/Participants/CurrentParticipants/CurrentParticipants.vue\n+++ b/src/components/RightSidebar/Participants/CurrentParticipants/CurrentParticipants.vue\n@@ -145,19 +145,19 @@ export default {\n \t\t\t\treturn p2IsGroup ? -1 : 1\n \t\t\t}\n \n-\t\t\tlet session1 = participant1.sessionId\n-\t\t\tlet session2 = participant2.sessionId\n+\t\t\tlet hasSessions1 = !!participant1.sessionIds.length\n+\t\t\tlet hasSessions2 = !!participant2.sessionIds.length\n \t\t\tif (participant1.status === 'offline') {\n-\t\t\t\tsession1 = '0'\n+\t\t\t\thasSessions1 = false\n \t\t\t}\n \t\t\tif (participant2.status === 'offline') {\n-\t\t\t\tsession2 = '0'\n+\t\t\t\thasSessions2 = false\n \t\t\t}\n-\t\t\tif (session1 === '0') {\n-\t\t\t\tif (session2 !== '0') {\n+\t\t\tif (!hasSessions1) {\n+\t\t\t\tif (hasSessions2) {\n \t\t\t\t\treturn 1\n \t\t\t\t}\n-\t\t\t} else if (session2 === '0') {\n+\t\t\t} else if (!hasSessions2) {\n \t\t\t\treturn -1\n \t\t\t}\n \n@@ -167,8 +167,8 @@ export default {\n \t\t\t\treturn p1inCall ? -1 : 1\n \t\t\t}\n \n-\t\t\tconst p1HandRaised = this.$store.getters.getParticipantRaisedHand(session1)\n-\t\t\tconst p2HandRaised = this.$store.getters.getParticipantRaisedHand(session2)\n+\t\t\tconst p1HandRaised = this.$store.getters.getParticipantRaisedHand(participant1.sessionIds)\n+\t\t\tconst p2HandRaised = this.$store.getters.getParticipantRaisedHand(participant2.sessionIds)\n \t\t\tif (p1HandRaised.state !== p2HandRaised.state) {\n \t\t\t\treturn p1HandRaised.state ? -1 : 1\n \t\t\t}\ndiff --git a/src/components/RightSidebar/Participants/ParticipantsList/Participant/Participant.vue b/src/components/RightSidebar/Participants/ParticipantsList/Participant/Participant.vue\nindex 2d1e034da..05f98ff3f 100644\n--- a/src/components/RightSidebar/Participants/ParticipantsList/Participant/Participant.vue\n+++ b/src/components/RightSidebar/Participants/ParticipantsList/Participant/Participant.vue\n@@ -309,8 +309,8 @@ export default {\n \t\t\t\treturn false\n \t\t\t}\n \n-\t\t\tconst state = this.$store.getters.isParticipantRaisedHand(this.participant.sessionId)\n-\t\t\treturn state\n+\t\t\tconst raisedState = this.$store.getters.getParticipantRaisedHand(this.participant.sessionIds)\n+\t\t\treturn raisedState.state\n \t\t},\n \t\tcallIcon() {\n \t\t\tif (this.isSearched || this.participant.inCall === PARTICIPANT.CALL_FLAG.DISCONNECTED) {\n@@ -344,8 +344,8 @@ export default {\n \t\tparticipantType() {\n \t\t\treturn this.participant.participantType\n \t\t},\n-\t\tsessionId() {\n-\t\t\treturn this.participant.sessionId\n+\t\tsessionIds() {\n+\t\t\treturn this.participant.sessionIds || []\n \t\t},\n \t\tlastPing() {\n \t\t\treturn this.participant.lastPing\n@@ -375,14 +375,14 @@ export default {\n \t\t\t}\n \n \t\t\t// Guest\n-\t\t\treturn this.sessionId !== '0' && this.sessionId === this.currentParticipant.sessionId\n+\t\t\treturn this.sessionIds.length && this.sessionIds.indexOf(this.currentParticipant.sessionId) >= 0\n \t\t},\n \t\tselfIsModerator() {\n \t\t\treturn this.participantTypeIsModerator(this.currentParticipant.participantType)\n \t\t},\n \n \t\tisOffline() {\n-\t\t\treturn /* this.participant.status === 'offline' || */ this.sessionId === '0'\n+\t\t\treturn /* this.participant.status === 'offline' || */ !this.sessionIds.length\n \t\t},\n \t\tisGuest() {\n \t\t\treturn [PARTICIPANT.TYPE.GUEST, PARTICIPANT.TYPE.GUEST_MODERATOR].indexOf(this.participantType) !== -1\ndiff --git a/src/components/RightSidebar/Participants/ParticipantsTab.vue b/src/components/RightSidebar/Participants/ParticipantsTab.vue\nindex 32fa7c895..18fb8626d 100644\n--- a/src/components/RightSidebar/Participants/ParticipantsTab.vue\n+++ b/src/components/RightSidebar/Participants/ParticipantsTab.vue\n@@ -256,7 +256,7 @@ export default {\n \t\t\t\t\t\t|| participant.participantType === PARTICIPANT.TYPE.GUEST_MODERATOR) {\n \t\t\t\t\t\tthis.$store.dispatch('forceGuestName', {\n \t\t\t\t\t\t\ttoken: token,\n-\t\t\t\t\t\t\tactorId: Hex.stringify(SHA1(participant.sessionId)),\n+\t\t\t\t\t\t\tactorId: Hex.stringify(SHA1(participant.sessionIds[0])),\n \t\t\t\t\t\t\tactorDisplayName: participant.displayName,\n \t\t\t\t\t\t})\n \t\t\t\t\t} else if (participant.actorType === 'users' && hasUserStatuses) {\ndiff --git a/src/store/callViewStore.js b/src/store/callViewStore.js\nindex 4af4c89d9..bcf3e8b36 100644\n--- a/src/store/callViewStore.js\n+++ b/src/store/callViewStore.js\n@@ -46,11 +46,15 @@ const getters = {\n \tselectedVideoPeerId: (state) => {\n \t\treturn state.selectedVideoPeerId\n \t},\n-\tgetParticipantRaisedHand: (state) => (sessionId) => {\n-\t\treturn state.participantRaisedHands[sessionId] || { state: false, timestamp: null }\n-\t},\n-\tisParticipantRaisedHand: (state) => (sessionId) => {\n-\t\treturn state.participantRaisedHands[sessionId]?.state\n+\tgetParticipantRaisedHand: (state) => (sessionIds) => {\n+\t\tfor (let i = 0; i < sessionIds.length; i++) {\n+\t\t\tif (state.participantRaisedHands[sessionIds[i]]) {\n+\t\t\t\t// note: only the raised states are stored, so no need to confirm\n+\t\t\t\treturn state.participantRaisedHands[sessionIds[i]]\n+\t\t\t}\n+\t\t}\n+\n+\t\treturn { state: false, timestamp: null }\n \t},\n \tgetCachedBackgroundImageAverageColor: (state) => (videoBackgroundId) => {\n \t\treturn state.backgroundImageAverageColorCache[videoBackgroundId]\ndiff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js\nindex aade72893..7a61733a7 100644\n--- a/src/store/conversationsStore.js\n+++ b/src/store/conversationsStore.js\n@@ -147,7 +147,7 @@ const actions = {\n \t\t\tparticipant: {\n \t\t\t\tinCall: conversation.participantFlags,\n \t\t\t\tlastPing: conversation.lastPing,\n-\t\t\t\tsessionId: conversation.sessionId,\n+\t\t\t\tsessionIds: [conversation.sessionId],\n \t\t\t\tparticipantType: conversation.participantType,\n \t\t\t\tattendeeId: conversation.attendeeId,\n \t\t\t\tactorType: conversation.actorType,\n", 'commit_subject': 'Merge pull request #5482 from nextcloud/bugfix/5349/expose-multiple-sessions-raised-hand', 'repo_name': 'nextcloud/spreed', 'committer': {'name': 'GitHub', 'email': '1505422b2465e9a84f6fdfaa161078890c593f06@github.com', 'time_sec': '1618408751', 'tz_offset': '120', 'date': {'seconds': '1618408751'}}, 'commit_tree': '5ff4215d6950357ffcb7dec1c5a34685e005ea0f', 'commit_message': 'Merge pull request #5482 from nextcloud/bugfix/5349/expose-multiple-sessions-raised-hand\n\nFix raised hand detection for multiple session, adjusted participant list response', 'language_name': 'Shell', 'license': 'agpl-3.0', 'hunks': '@@ -147,7 +147,7 @@ const actions = {\n \t\t\tparticipant: {\n \t\t\t\tinCall: conversation.participantFlags,\n \t\t\t\tlastPing: conversation.lastPing,\n-\t\t\t\tsessionId: conversation.sessionId,\n+\t\t\t\tsessionIds: [conversation.sessionId],\n \t\t\t\tparticipantType: conversation.participantType,\n \t\t\t\tattendeeId: conversation.attendeeId,\n \t\t\t\tactorType: conversation.actorType,\n', 'addition_count': 1, 'deletion_count': 1, 'src_file': 'a/src/store/conversationsStore.js', 'tgt_file': 'b/src/store/conversationsStore.js', 'file_extension': '.js', 'before_file': '/**\n * @copyright Copyright (c) 2019 Marco Ambrosini <marcoambrosini@icloud.com>\n *\n * @author Marco Ambrosini <marcoambrosini@icloud.com>\n *\n * @license AGPL-3.0-or-later\n *\n * This program is free software: you can redistribute it and/or modify\n * it under the terms of the GNU Affero General Public License as\n * published by the Free Software Foundation, either version 3 of the\n * License, or (at your option) any later version.\n *\n * This program is distributed in the hope that it will be useful,\n * but WITHOUT ANY WARRANTY; without even the implied warranty of\n * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n * GNU Affero General Public License for more details.\n *\n * You should have received a copy of the GNU Affero General Public License\n * along with this program. If not, see <http://www.gnu.org/licenses/>.\n *\n */\nimport Vue from \'vue\'\nimport {\n\tmakePublic,\n\tmakePrivate,\n\tsetSIPEnabled,\n\tchangeLobbyState,\n\tchangeReadOnlyState,\n\tchangeListable,\n\tcreateOneToOneConversation,\n\taddToFavorites,\n\tremoveFromFavorites,\n\tfetchConversations,\n\tfetchConversation,\n\tsetConversationName,\n\tsetConversationDescription,\n\tdeleteConversation,\n\tclearConversationHistory,\n\tsetNotificationLevel,\n\tsetNotificationCalls,\n\tsetConversationPermissions,\n\tsetCallPermissions,\n\tsetMessageExpiration,\n\tsetConversationPassword,\n} from \'../services/conversationsService.js\'\nimport { getCurrentUser } from \'@nextcloud/auth\'\n// eslint-disable-next-line import/extensions\nimport { CONVERSATION, WEBINAR, PARTICIPANT } from \'../constants.js\'\n\nconst DUMMY_CONVERSATION = {\n\ttoken: \'\',\n\tdisplayName: \'\',\n\tisFavorite: false,\n\thasPassword: false,\n\tcanEnableSIP: false,\n\ttype: CONVERSATION.TYPE.PUBLIC,\n\tparticipantFlags: PARTICIPANT.CALL_FLAG.DISCONNECTED,\n\tparticipantType: PARTICIPANT.TYPE.USER,\n\treadOnly: CONVERSATION.STATE.READ_ONLY,\n\tlistable: CONVERSATION.LISTABLE.NONE,\n\thasCall: false,\n\tcanStartCall: false,\n\tlobbyState: WEBINAR.LOBBY.NONE,\n\tlobbyTimer: 0,\n\tattendeePin: \'\',\n}\n\nconst getDefaultState = () => {\n\treturn {\n\t\tconversations: {\n\t\t},\n\t}\n}\n\nconst state = {\n\tconversations: {\n\t},\n}\n\nconst getters = {\n\tconversations: state => state.conversations,\n\tconversationsList: state => Object.values(state.conversations),\n\t/**\n\t * Get a conversation providing it\'s token\n\t *\n\t * @param {object} state state object\n\t * @return {Function} The callback function returning the conversation object\n\t */\n\tconversation: state => token => state.conversations[token],\n\tdummyConversation: state => Object.assign({}, DUMMY_CONVERSATION),\n}\n\nconst mutations = {\n\t/**\n\t * Adds a conversation to the store.\n\t *\n\t * @param {object} state current store state;\n\t * @param {object} conversation the conversation;\n\t */\n\taddConversation(state, conversation) {\n\t\tVue.set(state.conversations, conversation.token, conversation)\n\t},\n\t/**\n\t * Deletes a conversation from the store.\n\t *\n\t * @param {object} state current store state;\n\t * @param {object} token the token of the conversation to delete;\n\t */\n\tdeleteConversation(state, token) {\n\t\tVue.delete(state.conversations, token)\n\t},\n\t/**\n\t * Resets the store to it\'s original state\n\t *\n\t * @param {object} state current store state;\n\t */\n\tpurgeConversationsStore(state) {\n\t\tObject.assign(state, getDefaultState())\n\t},\n\n\tsetConversationDescription(state, { token, description }) {\n\t\tVue.set(state.conversations[token], \'description\', description)\n\t},\n\n\tupdateConversationLastReadMessage(state, { token, lastReadMessage }) {\n\t\tVue.set(state.conversations[token], \'lastReadMessage\', lastReadMessage)\n\t},\n\n\tupdateConversationLastMessage(state, { token, lastMessage }) {\n\t\tVue.set(state.conversations[token], \'lastMessage\', lastMessage)\n\t},\n\n\tupdateUnreadMessages(state, { token, unreadMessages, unreadMention }) {\n\t\tif (unreadMessages !== undefined) {\n\t\t\tVue.set(state.conversations[token], \'unreadMessages\', unreadMessages)\n\t\t}\n\t\tif (unreadMention !== undefined) {\n\t\t\tVue.set(state.conversations[token], \'unreadMention\', unreadMention)\n\t\t}\n\t},\n\n\toverwriteHasCallByChat(state, { token, hasCall }) {\n\t\tif (hasCall) {\n\t\t\tVue.set(state.conversations[token], \'hasCallOverwrittenByChat\', hasCall)\n\t\t} else {\n\t\t\tVue.delete(state.conversations[token], \'hasCallOverwrittenByChat\')\n\t\t\tparticipant: {\n\t\t\t\tinCall: conversation.participantFlags,\n\t\t\t\tlastPing: conversation.lastPing,\n\t\t\t\tsessionId: conversation.sessionId,\n\t\t\t\tparticipantType: conversation.participantType,\n\t\t\t\tattendeeId: conversation.attendeeId,\n\t\t\t\tactorType: conversation.actorType,\n\tsetNotificationCalls(state, { token, notificationCalls }) {\n\t\tVue.set(state.conversations[token], \'notificationCalls\', notificationCalls)\n\t},\n\n\tsetConversationPermissions(state, { token, permissions }) {\n\t\tVue.set(state.conversations[token], \'defaultPermissions\', permissions)\n\t},\n\n\tsetCallPermissions(state, { token, permissions }) {\n\t\tVue.set(state.conversations[token], \'callPermissions\', permissions)\n\t},\n\n\tsetMessageExpiration(state, { token, seconds }) {\n\t\tVue.set(state.conversations[token], \'messageExpiration\', seconds)\n\t},\n\n\tsetConversationHasPassword(state, { token, hasPassword }) {\n\t\tVue.set(state.conversations[token], \'hasPassword\', hasPassword)\n\t},\n}\n\nconst actions = {\n\t/**\n\t * Add a conversation to the store and index the displayname.\n\t *\n\t * @param {object} context default store context;\n\t * @param {object} conversation the conversation;\n\t */\n\taddConversation(context, conversation) {\n\t\tcontext.commit(\'addConversation\', conversation)\n\n\t\tlet currentUser = {\n\t\t\tuid: context.getters.getUserId(),\n\t\t\tdisplayName: context.getters.getDisplayName(),\n\t\t}\n\n\t\t// Fallback to getCurrentUser() only if if has not been set yet (as\n\t\t// getCurrentUser() needs to be overriden in public share pages as it\n\t\t// always returns an anonymous user).\n\t\tif (!currentUser.uid) {\n\t\t\tcurrentUser = getCurrentUser()\n\t\t}\n\t\tcontext.dispatch(\'addParticipantOnce\', {\n\t\t\ttoken: conversation.token,\n\t\t\tparticipant: {\n\t\t\t\tinCall: conversation.participantFlags,\n\t\t\t\tlastPing: conversation.lastPing,\n\t\t\t\tsessionIds: [conversation.sessionId],\n\t\t\t\tparticipantType: conversation.participantType,\n\t\t\t\tattendeeId: conversation.attendeeId,\n\t\t\t\tactorType: conversation.actorType,\n\t\t\t\tactorId: conversation.actorId, // FIXME check public share page handling\n\t\t\t\tuserId: currentUser ? currentUser.uid : \'\',\n\t\t\t\tdisplayName: currentUser && currentUser.displayName ? currentUser.displayName : \'\', // TODO guest name from localstore?\n\t\t\t},\n\t\t})\n\t},\n\n\t/**\n\t * Delete a conversation from the store.\n\t *\n\t * @param {object} context default store context;\n\t * @param {object} token the token of the conversation to be deleted;\n\t */\n\tdeleteConversation(context, token) {\n\t\t// FIXME: rename to deleteConversationsFromStore or a better name\n\t\tcontext.dispatch(\'deleteMessages\', token)\n\t\tcontext.commit(\'deleteConversation\', token)\n\t},\n\n\t/**\n\t * Delete a conversation from the server.\n\t *\n\t * @param {object} context default store context;\n\t * @param {object} data the wrapping object;\n\t * @param {object} data.token the token of the conversation to be deleted;\n\t */\n\tasync deleteConversationFromServer(context, { token }) {\n\t\tawait deleteConversation(token)\n\t\t// upon success, also delete from store\n\t\tawait context.dispatch(\'deleteConversation\', token)\n\t},\n\n\t/**\n\t * Delete all the messages from a conversation.\n\t *\n\t * @param {object} context default store context;\n\t * @param {object} data the wrapping object;\n\t * @param {object} data.token the token of the conversation whose history is\n\t * to be cleared;\n\t */\n\tasync clearConversationHistory(context, { token }) {\n\t\ttry {\n\t\t\tconst response = await clearConversationHistory(token)\n\t\t\tcontext.dispatch(\'deleteMessages\', token)\n\t\t\treturn response\n\t\t} catch (error) {\n\t\t\tconsole.debug(\n\t\t\t\tt(\'spreed\', \'Error while clearing conversation history\'),\n\t\t\t\terror)\n\t\t}\n\t},\n\n\t/**\n\t * Resets the store to it\'s original state.\n\t *\n\t * @param {object} context default store context;\n\t */\n\tpurgeConversationsStore(context) {\n\t\t// TODO: also purge messages ??\n\t\tcontext.commit(\'purgeConversationsStore\')\n\t},\n\n\tasync toggleGuests({ commit, getters }, { token, allowGuests }) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\tif (allowGuests) {\n\t\t\tawait makePublic(token)\n\t\t\tconversation.type = CONVERSATION.TYPE.PUBLIC\n\t\t} else {\n\t\t\tawait makePrivate(token)\n\t\t\tconversation.type = CONVERSATION.TYPE.GROUP\n\t\t}\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync toggleFavorite({ commit, getters }, { token, isFavorite }) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\t// FIXME: logic is reversed\n\t\tif (isFavorite) {\n\t\t\tawait removeFromFavorites(token)\n\t\t} else {\n\t\t\tawait addToFavorites(token)\n\t\t}\n\t\tconversation.isFavorite = !isFavorite\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync toggleLobby({ commit, getters }, { token, enableLobby }) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\tif (enableLobby) {\n\t\t\tawait changeLobbyState(token, WEBINAR.LOBBY.NON_MODERATORS)\n\t\t\tconversation.lobbyState = WEBINAR.LOBBY.NON_MODERATORS\n\t\t} else {\n\t\t\tawait changeLobbyState(token, WEBINAR.LOBBY.NONE)\n\t\t\tconversation.lobbyState = WEBINAR.LOBBY.NONE\n\t\t}\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync setConversationName({ commit, getters }, { token, name }) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\tawait setConversationName(token, name)\n\t\tconversation.displayName = name\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync setConversationDescription({ commit }, { token, description }) {\n\t\tawait setConversationDescription(token, description)\n\t\tcommit(\'setConversationDescription\', { token, description })\n\t},\n\n\tasync setConversationPassword({ commit }, { token, newPassword }) {\n\t\tawait setConversationPassword(token, newPassword)\n\n\t\tcommit(\'setConversationHasPassword\', {\n\t\t\ttoken,\n\t\t\thasPassword: !!newPassword,\n\t\t})\n\t},\n\n\tasync setReadOnlyState({ commit, getters }, { token, readOnly }) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\tawait changeReadOnlyState(token, readOnly)\n\t\tconversation.readOnly = readOnly\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync setListable({ commit, getters }, { token, listable }) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\tawait changeListable(token, listable)\n\t\tconversation.listable = listable\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync setLobbyTimer({ commit, getters }, { token, timestamp }) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\t// The backend requires the state and timestamp to be set together.\n\t\tawait changeLobbyState(token, conversation.lobbyState, timestamp)\n\t\tconversation.lobbyTimer = timestamp\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync setSIPEnabled({ commit, getters }, { token, state }) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\tawait setSIPEnabled(token, state)\n\t\tconversation.sipEnabled = state\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync markConversationRead({ commit, getters }, token) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\tconversation.unreadMessages = 0\n\t\tconversation.unreadMention = false\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync updateLastCommonReadMessage({ commit, getters }, { token, lastCommonReadMessage }) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\tconversation.lastCommonReadMessage = lastCommonReadMessage\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync updateConversationLastActive({ commit, getters }, token) {\n\t\tconst conversation = Object.assign({}, getters.conversations[token])\n\t\tif (!conversation) {\n\t\t\treturn\n\t\t}\n\n\t\tconversation.lastActivity = (new Date().getTime()) / 1000\n\n\t\tcommit(\'addConversation\', conversation)\n\t},\n\n\tasync updateConversationLastMessage({ commit }, { token, lastMessage }) {\n\t\t/**\n\t\t * Only use the last message as lastmessage when:\n\t\t * 1. It\'s not a command reply\n\t\t * 2. It\'s not a temporary message starting with "/" which is a user posting a command\n\t\t * 3. It\'s not a reaction or deletion of a reaction\n\t\t * 3. It\'s not a deletion of a message\n\t\t */\n\t\tif ((lastMessage.actorType !== \'bots\'\n\t\t\t\t|| lastMessage.actorId === \'changelog\')\n\t\t\t&& lastMessage.systemMessage !== \'reaction\'\n\t\t\t&& lastMessage.systemMessage !== \'poll_voted\'\n\t\t\t&& lastMessage.systemMessage !== \'reaction_deleted\'\n\t\t\t&& lastMessage.systemMessage !== \'reaction_revoked\'\n\t\t\t&& lastMessage.systemMessage !== \'message_deleted\'\n\t\t\t&& !(typeof lastMessage.id.startsWith === \'function\'\n\t\t\t\t&& lastMessage.id.startsWith(\'temp-\')\n\t\t\t\t&& lastMessage.message.startsWith(\'/\'))) {\n\t\t\tcommit(\'updateConversationLastMessage\', { token, lastMessage })\n\t\t}\n\t},\n\n\tasync updateConversationLastReadMessage({ commit }, { token, lastReadMessage }) {\n\t\tcommit(\'updateConversationLastReadMessage\', { token, lastReadMessage })\n\t},\n\n\tasync overwriteHasCallByChat({ commit }, { token, hasCall }) {\n\t\tcommit(\'overwriteHasCallByChat\', { token, hasCall })\n\t},\n\n\tasync fetchConversation({ dispatch }, { token }) {\n\t\ttry {\n\t\t\tdispatch(\'clearMaintenanceMode\')\n\t\t\tconst response = await fetchConversation(token)\n\t\t\tdispatch(\'updateTalkVersionHash\', response)\n\t\t\tdispatch(\'addConversation\', response.data.ocs.data)\n\t\t\treturn response\n\t\t} catch (error) {\n\t\t\tif (error?.response) {\n\t\t\t\tdispatch(\'checkMaintenanceMode\', error.response)\n\t\t\t}\n\t\t\tthrow error\n\t\t}\n\t},\n\n\tasync fetchConversations({ dispatch }) {\n\t\ttry {\n\t\t\tdispatch(\'clearMaintenanceMode\')\n\n\t\t\tconst response = await fetchConversations()\n\t\t\tdispatch(\'updateTalkVersionHash\', response)\n\t\t\tdispatch(\'purgeConversationsStore\')\n\t\t\tresponse.data.ocs.data.forEach(conversation => {\n\t\t\t\tdispatch(\'addConversation\', conversation)\n\t\t\t})\n\t\t\treturn response\n\t\t} catch (error) {\n\t\t\tif (error?.response) {\n\t\t\t\tdispatch(\'checkMaintenanceMode\', error.response)\n\t\t\t}\n\t\t\tthrow error\n\t\t}\n\t},\n\n\tasync setNotificationLevel({ commit }, { token, notificationLevel }) {\n\t\tawait setNotificationLevel(token, notificationLevel)\n\n\t\tcommit(\'setNotificationLevel\', { token, notificationLevel })\n\t},\n\n\tasync setNotificationCalls({ commit }, { token, notificationCalls }) {\n\t\tawait setNotificationCalls(token, notificationCalls)\n\n\t\tcommit(\'setNotificationCalls\', { token, notificationCalls })\n\t},\n\n\t/**\n\t * Creates a new one to one conversation in the backend\n\t * with the given actor then adds it to the store.\n\t *\n\t * @param {object} context default store context;\n\t * @param {string} actorId actor id;\n\t */\n\tasync createOneToOneConversation(context, actorId) {\n\t\tconst response = await createOneToOneConversation(actorId)\n\t\tconst conversation = response.data.ocs.data\n\t\tcontext.dispatch(\'addConversation\', conversation)\n\n\t\treturn conversation\n\t},\n\n\tasync setConversationPermissions(context, { token, permissions }) {\n\t\tawait setConversationPermissions(token, permissions)\n\t\tcontext.commit(\'setConversationPermissions\', { token, permissions })\n\t},\n\n\tasync setMessageExpiration({ commit }, { token, seconds }) {\n\t\tawait setMessageExpiration(token, seconds)\n\t\tcommit(\'setMessageExpiration\', { token, seconds })\n\t},\n\n\tasync setCallPermissions(context, { token, permissions }) {\n\t\tawait setCallPermissions(token, permissions)\n\t\tcontext.commit(\'setCallPermissions\', { token, permissions })\n\t},\n}\n\nexport default { state, mutations, getters, actions }\n', 'message': 'Merge pull request #5482 from nextcloud/bugfix/5349/expose-multiple-sessions-raised-hand\n\nFix raised hand detection for multiple session, adjusted participant list response', 'author_name': 'GitHub', 'lang': 'js'}