diff --git a/src/components/RightSidebar/Participants/ParticipantsList/Participant/Participant.vue b/src/components/RightSidebar/Participants/ParticipantsList/Participant/Participant.vue index 296517b38a8..71fcd28394d 100644 --- a/src/components/RightSidebar/Participants/ParticipantsList/Participant/Participant.vue +++ b/src/components/RightSidebar/Participants/ParticipantsList/Participant/Participant.vue @@ -230,11 +230,11 @@ import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip.js' import AvatarWrapper from '../../../../AvatarWrapper/AvatarWrapper.vue' import ParticipantPermissionsEditor from './ParticipantPermissionsEditor/ParticipantPermissionsEditor.vue' +import { useIsInCall } from '../../../../../composables/useIsInCall.js' import { CONVERSATION, PARTICIPANT, ATTENDEE } from '../../../../../constants.js' import readableNumber from '../../../../../mixins/readableNumber.js' import UserStatus from '../../../../../mixins/userStatus.js' - -// Material design icons +import { formattedTime } from '../../../../../utils/formattedTime.js' export default { name: 'Participant', @@ -298,11 +298,18 @@ export default { emits: ['click-participant'], + setup() { + const isInCall = useIsInCall() + return { isInCall } + }, + data() { return { isUserNameTooltipVisible: false, isStatusTooltipVisible: false, permissionsEditor: false, + speakingInterval: null, + timeSpeaking: null, } }, @@ -341,7 +348,9 @@ export default { }, statusMessage() { - return this.getStatusMessage(this.participant) + return (this.isInCall && this.participant.inCall && this.timeSpeaking) + ? '💬 ' + t('spreed', '{time} talking time', { time: formattedTime(this.timeSpeaking, true) }) + : this.getStatusMessage(this.participant) }, statusMessageTooltip() { @@ -483,6 +492,14 @@ export default { return this.participant.sessionIds || [] }, + participantSpeakingInformation() { + return this.$store.getters.getParticipantSpeakingInformation(this.token, this.sessionIds) + }, + + participantSpeaking() { + return this.participantSpeakingInformation?.speaking + }, + lastPing() { return this.participant.lastPing }, @@ -619,8 +636,31 @@ export default { return '' }, }, + watch: { + participantSpeaking(value) { + if (value === true) { + if (!this.speakingInterval) { + this.speakingInterval = setInterval(this.computeElapsedTime, 1000) + } + } else { + clearInterval(this.speakingInterval) + this.speakingInterval = null + } + }, + + 'participant.inCall'(value) { + if (!value) { + for (const sessionId of this.sessionIds) { + this.$store.dispatch('setSpeaking', { token: this.token, sessionId, speaking: false }) + } + this.timeSpeaking = 0 + } + }, + }, methods: { + formattedTime, + updateUserNameNeedsTooltip() { // check if ellipsized const e = this.$refs.userName @@ -724,6 +764,20 @@ export default { showError(t('spreed', 'Could not modify permissions for {displayName}', { displayName: this.computedName })) } }, + + computeElapsedTime() { + if (!this.participantSpeakingInformation) { + return null + } + + const { speaking, lastTimestamp, totalCountedTime } = this.participantSpeakingInformation + + if (!speaking) { + this.timeSpeaking = totalCountedTime + } else { + this.timeSpeaking = Date.now() - lastTimestamp + totalCountedTime + } + }, }, } diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js index 36e037354e0..2e2334dd962 100644 --- a/src/store/conversationsStore.js +++ b/src/store/conversationsStore.js @@ -691,6 +691,9 @@ const actions = { const response = await fetchConversation(token) dispatch('updateTalkVersionHash', response) dispatch('addConversation', response.data.ocs.data) + if (!response.data.ocs.data.callFlag) { + dispatch('purgeSpeakingStore', { token }) + } return response } catch (error) { if (error?.response) { diff --git a/src/store/participantsStore.js b/src/store/participantsStore.js index b63ad6d2842..ef6df84f505 100644 --- a/src/store/participantsStore.js +++ b/src/store/participantsStore.js @@ -57,6 +57,8 @@ const state = { }, typing: { }, + speaking: { + }, } const getters = { @@ -138,6 +140,24 @@ const getters = { }) }, + /** + * Gets the speaking information for the participant. + * + * @param {object} state - the state object. + * param {string} token - the conversation token. + * param {Array} sessionIds - session identifiers for the participant. + * @return {object|undefined} + */ + getParticipantSpeakingInformation: (state) => (token, sessionIds) => { + if (!state.speaking[token]) { + return false + } + + // look for existing sessionId in the store + const sessionId = sessionIds.find(sessionId => state.speaking[token][sessionId]) + return state.speaking[token][sessionId] + }, + /** * Replaces the legacy getParticipant getter. Returns a callback function in which you can * pass in the token and attendeeId as arguments to get the participant object. @@ -321,6 +341,57 @@ const mutations = { } }, + /** + * Sets the speaking status of a participant in a conversation / call. + * + * Note that "updateParticipant" should not be called to add a "speaking" + * property to an existing participant, as the participant would be reset + * when the participants are purged whenever they are fetched again. + * Similarly, "addParticipant" can not be called either to add a participant + * if it was not fetched yet but the signaling reported it as being speaking, + * as the attendeeId would be unknown. + * + * @param {object} state - current store state. + * @param {object} data - the wrapping object. + * @param {string} data.token - the conversation token participant is speaking in. + * @param {string} data.sessionId - the Nextcloud session ID of the participant. + * @param {boolean} data.speaking - whether the participant is speaking or not + */ + setSpeaking(state, { token, sessionId, speaking }) { + // create a dummy object for current call + if (!state.speaking[token]) { + Vue.set(state.speaking, token, {}) + } + if (!state.speaking[token][sessionId]) { + Vue.set(state.speaking[token], sessionId, { speaking: null, lastTimestamp: 0, totalCountedTime: 0 }) + } + + const currentTimestamp = Date.now() + const currentSpeakingState = state.speaking[token][sessionId].speaking + + // when stopped speaking, update the total talking time + if (!speaking) { + state.speaking[token][sessionId].totalCountedTime += (currentTimestamp - state.speaking[token][sessionId].lastTimestamp) + } + + // don't change state for consecutive identical signals + if (currentSpeakingState !== speaking) { + state.speaking[token][sessionId].speaking = speaking + state.speaking[token][sessionId].lastTimestamp = currentTimestamp + } + }, + + /** + * Purge the speaking information (when the call ends). + * + * @param {object} state - current store state. + * @param {object} data - the wrapping object. + * @param {string} data.token - the conversation token. + */ + purgeSpeakingStore(state, { token }) { + Vue.delete(state.speaking, token) + }, + /** * Purge a given conversation from the previously added participants. * @@ -503,7 +574,7 @@ const actions = { }, 10000) }, - async leaveCall({ commit, getters }, { token, participantIdentifier, all = false }) { + async leaveCall({ commit, dispatch, getters }, { token, participantIdentifier, all = false }) { if (!participantIdentifier?.sessionId) { console.error('Trying to leave call without sessionId') } @@ -526,6 +597,9 @@ const actions = { sessionId: participantIdentifier.sessionId, flags: PARTICIPANT.CALL_FLAG.DISCONNECTED, }) + + // re-fetch conversation after leaving to update callFlag + await dispatch('fetchConversation', { token }) }, /** @@ -750,6 +824,14 @@ const actions = { context.commit('setTyping', { token, sessionId, typing: true, expirationTimeout }) } }, + + setSpeaking(context, { token, sessionId, speaking }) { + context.commit('setSpeaking', { token, sessionId, speaking }) + }, + + purgeSpeakingStore(context, { token }) { + context.commit('purgeSpeakingStore', { token }) + }, } export default { state, mutations, getters, actions }