Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Participant) - count total talking time within call for participants #10068

Merged
merged 4 commits into from
Aug 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/components/AvatarWrapper/AvatarWrapper.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
'avatar-wrapper--offline': offline,
'avatar-wrapper--small': small,
'avatar-wrapper--condensed': condensed,
'avatar-wrapper--highlighted': highlighted,
}"
:style="{'--condensed-overlap': condensedOverlap}">
<div v-if="iconClass"
Expand Down Expand Up @@ -89,6 +90,10 @@ export default {
type: Boolean,
default: false,
},
highlighted: {
type: Boolean,
default: false,
},
disableTooltip: {
type: Boolean,
default: false,
Expand Down Expand Up @@ -160,11 +165,13 @@ export default {
.avatar-wrapper {
height: 44px;
width: 44px;
border-radius: 44px;
@include avatar-mixin(44px);

&--small {
height: 22px;
width: 22px;
border-radius: 22px;
@include avatar-mixin(22px);
}

Expand All @@ -188,6 +195,10 @@ export default {
background: rgba(var(--color-main-background-rgb), .4) !important;
}
}

&--highlighted {
outline: 2px solid var(--color-primary-element);
}
}

</style>
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
disable-tooltip
:show-user-status="showUserStatus && !isSearched"
:preloaded-user-status="preloadedUserStatus"
:highlighted="isParticipantSpeaking"
:offline="isOffline" />

<!-- Participant's data -->
Expand Down Expand Up @@ -67,6 +68,7 @@
<div v-else-if="statusMessage"
ref="statusMessage"
class="participant-row__status"
:class="{'participant-row__status--highlighted': isParticipantSpeaking}"
@mouseover="updateStatusNeedsTooltip()">
<span v-tooltip.auto="statusMessageTooltip">{{ statusMessage }}</span>
</div>
Expand Down Expand Up @@ -230,11 +232,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',
Expand Down Expand Up @@ -298,11 +300,18 @@ export default {
emits: ['click-participant'],
setup() {
const isInCall = useIsInCall()
return { isInCall }
},
data() {
return {
isUserNameTooltipVisible: false,
isStatusTooltipVisible: false,
permissionsEditor: false,
speakingInterval: null,
timeSpeaking: null,
}
},
Expand Down Expand Up @@ -341,7 +350,13 @@ export default {
},
statusMessage() {
return this.getStatusMessage(this.participant)
if (this.isInCall && this.participant.inCall && this.timeSpeaking) {
Antreesy marked this conversation as resolved.
Show resolved Hide resolved
return this.isParticipantSpeaking
? '💬 ' + t('spreed', '{time} talking …', { time: formattedTime(this.timeSpeaking, true) })
: '💬 ' + t('spreed', '{time} talking time', { time: formattedTime(this.timeSpeaking, true) })
} else {
return this.getStatusMessage(this.participant)
}
},
statusMessageTooltip() {
Expand Down Expand Up @@ -483,6 +498,14 @@ export default {
return this.participant.sessionIds || []
},
participantSpeakingInformation() {
return this.$store.getters.getParticipantSpeakingInformation(this.token, this.sessionIds)
},
isParticipantSpeaking() {
return this.participantSpeakingInformation?.speaking
},
lastPing() {
return this.participant.lastPing
},
Expand Down Expand Up @@ -619,8 +642,25 @@ export default {
return ''
},
},
watch: {
isParticipantSpeaking(speaking) {
if (speaking) {
if (!this.speakingInterval) {
this.speakingInterval = setInterval(this.computeElapsedTime, 1000)
}
} else {
if (speaking === undefined) {
this.timeSpeaking = 0
}
clearInterval(this.speakingInterval)
this.speakingInterval = null
}
},
},
methods: {
formattedTime,
updateUserNameNeedsTooltip() {
// check if ellipsized
const e = this.$refs.userName
Expand Down Expand Up @@ -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
}
},
},
}
</script>
Expand Down Expand Up @@ -789,6 +843,9 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--highlighted {
font-weight: bold;
}
}
&__icon {
width: 44px;
Expand Down
33 changes: 8 additions & 25 deletions src/components/TopBar/CallTime.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
:size="20"
fill-color="var(--color-loading-light)" />
</template>
{{ formattedTime }}
{{ formattedTime(callTime) }}
</NcButton>
</template>

Expand Down Expand Up @@ -83,6 +83,7 @@ import NcPopover from '@nextcloud/vue/dist/Components/NcPopover.js'

import { CALL } from '../../constants.js'
import isInLobby from '../../mixins/isInLobby.js'
import { formattedTime } from '../../utils/formattedTime.js'

export default {
name: 'CallTime',
Expand Down Expand Up @@ -131,30 +132,6 @@ export default {
return new Date(this.start * 1000)
},

/**
* Calculates the stopwatch string given the callTime (ms)
*
* @return {string} The formatted time
*/
formattedTime() {
if (!this.callTime) {
return '-- : --'
}
let seconds = Math.floor((this.callTime / 1000) % 60)
if (seconds < 10) {
seconds = '0' + seconds
}
let minutes = Math.floor((this.callTime / (1000 * 60)) % 60)
if (minutes < 10) {
minutes = '0' + minutes
}
const hours = Math.floor((this.callTime / (1000 * 60 * 60)) % 24)
if (hours === 0) {
return minutes + ' : ' + seconds
}
return hours + ' : ' + minutes + ' : ' + seconds
},

token() {
return this.$store.getters.getToken()
},
Expand Down Expand Up @@ -213,6 +190,12 @@ export default {
},

methods: {
/**
* Calculates the stopwatch string given the callTime (ms)
*
*/
formattedTime,

stopRecording() {
this.$store.dispatch('stopCallRecording', {
token: this.token,
Expand Down
80 changes: 80 additions & 0 deletions src/store/participantsStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const state = {
},
typing: {
},
speaking: {
},
}

const getters = {
Expand Down Expand Up @@ -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<string>} sessionIds - session identifiers for the participant.
* @return {object|undefined}
*/
getParticipantSpeakingInformation: (state) => (token, sessionIds) => {
if (!state.speaking[token]) {
return undefined
}

// 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.
Expand Down Expand Up @@ -321,6 +341,58 @@ 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 speaking has stopped, update the total talking time
if (!speaking && state.speaking[token][sessionId].lastTimestamp) {
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 for recent call when local participant leaves call
* (including cases when the call ends for everyone).
*
* @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.
*
Expand Down Expand Up @@ -750,6 +822,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 }
26 changes: 26 additions & 0 deletions src/utils/formattedTime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Calculates the stopwatch string given the time (ms)
*
* @param {number} time the time in ms
* @param {boolean} [condensed=false] the format of string to show
* @return {string} The formatted time
*/
function formattedTime(time, condensed = false) {
if (!time) {
return condensed ? '--:--' : '-- : --'
}

const seconds = Math.floor((time / 1000) % 60)
const minutes = Math.floor((time / (1000 * 60)) % 60)
const hours = Math.floor((time / (1000 * 60 * 60)) % 24)

return [
hours,
minutes.toString().padStart(2, '0'),
seconds.toString().padStart(2, '0'),
].filter(num => !!num).join(condensed ? ':' : ' : ')
}

export {
formattedTime,
}
Loading
Loading