Skip to content
Open
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
13 changes: 10 additions & 3 deletions src/components/NewMessage/NewMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ import { CONVERSATION, MESSAGE, PARTICIPANT, PRIVACY } from '../../constants.ts'
import BrowserStorage from '../../services/BrowserStorage.js'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts'
import { setTyping } from '../../services/participantsService.js'
import { useActorStore } from '../../stores/actor.ts'
import { useChatStore } from '../../stores/chat.ts'
import { useChatExtrasStore } from '../../stores/chatExtras.ts'
Expand Down Expand Up @@ -937,12 +938,12 @@ export default {

if (!this.typingInterval) {
// Send first signal after first keystroke
this.$store.dispatch('sendTypingSignal', { typing: true })
this.sendTypingSignal(true)

// Continuously send signals with 10s interval if still typing
this.typingInterval = setInterval(() => {
if (this.wasTypingWithinInterval) {
this.$store.dispatch('sendTypingSignal', { typing: true })
this.sendTypingSignal(true)
this.wasTypingWithinInterval = false
} else {
this.clearTypingInterval()
Expand All @@ -960,12 +961,18 @@ export default {
},

resetTypingIndicator() {
this.$store.dispatch('sendTypingSignal', { typing: false })
this.sendTypingSignal(false)
if (this.typingInterval) {
this.clearTypingInterval()
}
},

async sendTypingSignal(isTyping) {
if (this.currentConversationIsJoined) {
await setTyping(isTyping)
}
},

updateChatInput(text) {
if (this.messageToEdit) {
this.chatExtrasStore.setChatEditInput({
Expand Down
19 changes: 17 additions & 2 deletions src/components/NewMessage/NewMessageTypingIndicator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import AvatarWrapper from '../AvatarWrapper/AvatarWrapper.vue'
import { AVATAR } from '../../constants.ts'
import { useActorStore } from '../../stores/actor.ts'
import { useGuestNameStore } from '../../stores/guestName.ts'
import { useSignalingStateStore } from '../../stores/signalingState.ts'

export default {
name: 'NewMessageTypingIndicator',
Expand All @@ -50,9 +51,11 @@ export default {

setup() {
const guestNameStore = useGuestNameStore()
const signalingStateStore = useSignalingStateStore()
return {
AVATAR,
guestNameStore,
signalingStateStore,
actorStore: useActorStore(),
}
},
Expand All @@ -63,11 +66,23 @@ export default {
},

externalTypingSignals() {
return this.$store.getters.externalTypingSignals(this.token)
return this.signalingStateStore.externalTypingSignals(this.token)
},

/**
* Get list of participants filtered to include only those that are currently typing
*/
typingParticipants() {
return this.$store.getters.participantsListTyping(this.token)
if (!this.externalTypingSignals.length) {
return []
}

return this.$store.getters.participantsList(this.token).filter((attendee) => {
// Check if participant's sessionId matches with any of sessionIds from signaling...
return this.externalTypingSignals.some((sessionId) => attendee.sessionIds.includes(sessionId))
// ... and it's not the participant with same actorType and actorId as yourself
&& !this.actorStore.checkIfSelfIsActor(attendee)
})
},

visibleParticipants() {
Expand Down
108 changes: 0 additions & 108 deletions src/store/participantsStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
resendInvitations,
sendCallNotification,
setPermissions,
setTyping,
} from '../services/participantsService.js'
import SessionStorage from '../services/SessionStorage.js'
import { talkBroadcastChannel } from '../services/talkBroadcastChannel.js'
Expand Down Expand Up @@ -82,8 +81,6 @@ function state() {
},
connectionFailed: {
},
typing: {
},
speaking: {
},
// TODO: moved from callViewStore, separate to callExtras (with typing + speaking)
Expand Down Expand Up @@ -130,56 +127,6 @@ const getters = {
return []
},

/**
* Gets the array of external session ids.
*
* @param {object} state - the state object.
* @return {Array} the typing session IDs array.
*/
externalTypingSignals: (state) => (token) => {
if (!state.typing[token]) {
return []
}
const actorStore = useActorStore()
return Object.keys(state.typing[token]).filter((sessionId) => actorStore.sessionId !== sessionId)
},

/**
* Gets the array of external session ids.
*
* @param {object} state - the state object.
* @return {boolean} the typing status of actor.
*/
actorIsTyping: (state) => {
if (!state.typing[tokenStore.token]) {
return false
}
const actorStore = useActorStore()
return Object.keys(state.typing[tokenStore.token]).some((sessionId) => actorStore.sessionId === sessionId)
},

/**
* Gets the participants array filtered to include only those that are
* currently typing.
*
* @param {object} state - the state object.
* @param {object} getters - the getters object.
* @return {Array} the participants array (for registered users only).
*/
participantsListTyping: (state, getters) => (token) => {
if (!getters.externalTypingSignals(token).length) {
return []
}

const actorStore = useActorStore()
return getters.participantsList(token).filter((attendee) => {
// Check if participant's sessionId matches with any of sessionIds from signaling...
return getters.externalTypingSignals(token).some((sessionId) => attendee.sessionIds.includes(sessionId))
// ... and it's not the participant with same actorType and actorId as yourself
&& !actorStore.checkIfSelfIsActor(attendee)
})
},

/**
* Gets the speaking information for the participant.
*
Expand Down Expand Up @@ -393,41 +340,6 @@ const mutations = {
}
},

/**
* Sets the typing status of a participant in a conversation.
*
* Note that "updateParticipant" should not be called to add a "typing"
* 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 typing,
* as the attendeeId would be unknown.
*
* @param {object} state - current store state.
* @param {object} data - the wrapping object.
* @param {string} data.token - the conversation that the participant is
* typing in.
* @param {string} data.sessionId - the Nextcloud session ID of the
* participant.
* @param {boolean} data.typing - whether the participant is typing or not.
* @param {number} data.expirationTimeout - id of timeout to watch for received signal expiration.
*/
setTyping(state, { token, sessionId, typing, expirationTimeout }) {
if (!state.typing[token]) {
state.typing[token] = {}
}

if (state.typing[token][sessionId]) {
clearTimeout(state.typing[token][sessionId].expirationTimeout)
}

if (typing) {
state.typing[token][sessionId] = { expirationTimeout }
} else {
delete state.typing[token][sessionId]
}
},

/**
* Sets the speaking status of a participant in a conversation / call.
*
Expand Down Expand Up @@ -1193,26 +1105,6 @@ const actions = {
context.commit('updateParticipant', { token, attendeeId, updatedData })
},

async sendTypingSignal(context, { typing }) {
if (!tokenStore.currentConversationIsJoined) {
return
}

await setTyping(typing)
},

async setTyping(context, { token, sessionId, typing }) {
if (!typing) {
context.commit('setTyping', { token, sessionId, typing: false })
} else {
const expirationTimeout = setTimeout(() => {
// If updated 'typing' signal doesn't come in last 15s, remove it from store
context.commit('setTyping', { token, sessionId, typing: false })
}, 15000)
context.commit('setTyping', { token, sessionId, typing: true, expirationTimeout })
}
},

setSpeaking(context, { attendeeId, speaking }) {
// We should update time before speaking state, to be able to check previous state
context.commit('updateTimeSpeaking', { attendeeId, speaking })
Expand Down
87 changes: 87 additions & 0 deletions src/stores/signalingState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { defineStore } from 'pinia'
import { reactive } from 'vue'
import { useActorStore } from './actor.ts'

type TypingState = {
expirationTimeout: ReturnType<typeof setTimeout>
}

/**
* Store for signaling state used in chat and call (typing, speaking, raised hands)
*/
export const useSignalingStateStore = defineStore('signalingState', () => {
const typing = reactive<Record<string, Record<string, TypingState>>>({})

const actorStore = useActorStore()

/**
* Get the array of external session ids for a conversation (excluding current user)
*
* @param token - conversation token
*/
function externalTypingSignals(token: string): string[] {
if (!typing[token]) {
return []
}

return Object.keys(typing[token]).filter((sessionId) => actorStore.sessionId !== sessionId)
}

/**
* Check whether the current actor is typing in a conversation
*
* @param token - conversation token
*/
function isSelfActorTyping(token: string): boolean {
if (!typing[token]) {
return false
}
return Object.keys(typing[token]).some((sessionId) => actorStore.sessionId === sessionId)
}

/**
* Sets the typing status of a participant in a conversation.
*
* Note that "updateParticipant" should not be called to add a "typing"
* 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 typing,
* as the attendeeId would be unknown.
*
* @param payload - the wrapping object.
* @param payload.token - the conversation that the participant is typing in.
* @param payload.sessionId - the Nextcloud session ID of the participant.
* @param payload.isTyping - whether the participant is typing or not.
*/
function setTyping({ token, sessionId, isTyping }: { token: string, sessionId: string, isTyping: boolean }) {
if (!typing[token]) {
typing[token] = {}
}

if (typing[token][sessionId]) {
clearTimeout(typing[token][sessionId].expirationTimeout)
}

if (isTyping) {
const expirationTimeout = setTimeout(() => {
// If updated 'typing' signal doesn't come in last 15s, remove it from store
setTyping({ token, sessionId, isTyping: false })
}, 15000)
typing[token][sessionId] = { expirationTimeout }
} else {
delete typing[token][sessionId]
}
}

return {
externalTypingSignals,
isSelfActorTyping,
setTyping,
}
})
21 changes: 10 additions & 11 deletions src/utils/SignalingTypingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { useActorStore } from '../stores/actor.ts'
import pinia from '../stores/pinia.ts'
import { useSignalingStateStore } from '../stores/signalingState.ts'
import { useTokenStore } from '../stores/token.ts'
import SignalingParticipantList from './SignalingParticipantList.js'

Expand All @@ -15,12 +16,10 @@ import SignalingParticipantList from './SignalingParticipantList.js'
*
* It is expected that the typing status of the current participant will be
* modified only when the current conversation is joined.
*
* @param {object} store the Vuex store
*/
export default function SignalingTypingHandler(store) {
this._store = store
export default function SignalingTypingHandler() {
this._actorStore = useActorStore(pinia)
this._signalingStateStore = useSignalingStateStore(pinia)
this._tokenStore = useTokenStore(pinia)

this._signaling = null
Expand Down Expand Up @@ -92,10 +91,10 @@ SignalingTypingHandler.prototype = {
})
}

this._store.dispatch('setTyping', {
this._signalingStateStore.setTyping({
token: this._tokenStore.token,
sessionId: this._actorStore.sessionId,
typing,
isTyping: typing,
})
},

Expand All @@ -109,15 +108,15 @@ SignalingTypingHandler.prototype = {
return
}

this._store.dispatch('setTyping', {
this._signalingStateStore.setTyping({
token: this._tokenStore.token,
sessionId: participant.nextcloudSessionId,
typing: data.type === 'startedTyping',
isTyping: data.type === 'startedTyping',
})
},

_handleParticipantsJoined(SignalingParticipantList, participants) {
if (!this._store.getters.actorIsTyping) {
if (!this._signalingStateStore.isSelfActorTyping(this._tokenStore.token)) {
return
}

Expand All @@ -131,10 +130,10 @@ SignalingTypingHandler.prototype = {

_handleParticipantsLeft(SignalingParticipantList, participants) {
for (const participant of participants) {
this._store.dispatch('setTyping', {
this._signalingStateStore.setTyping({
token: this._tokenStore.token,
sessionId: participant.nextcloudSessionId,
typing: false,
isTyping: false,
})
}
},
Expand Down
Loading
Loading