Files
f7_talk/src/App.vue
T
glb_adm 01acfa3b40
Type checking / changes (push) Has been cancelled
Type checking / test (push) Has been cancelled
Type checking / typescript-summary (push) Has been cancelled
Node tests / changes (push) Has been cancelled
Node tests / test (push) Has been cancelled
Node tests / test-summary (push) Has been cancelled
UPSTREAM BASELINE: nextcloud/spreed v22.0.12 (без изменений)
Источник: https://github.com/nextcloud/spreed/archive/refs/tags/v22.0.12.tar.gz
С этого коммита ветка официального Nextcloud Talk отрезана (решение владельца 2026-07-06).
Все дальнейшие изменения — только наши; версии релизов: 22.0.12-f7.N.
2026-07-06 14:07:50 +00:00

636 lines
20 KiB
Vue

<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcContent
:class="{ 'icon-loading': loading, 'in-call': isInCall }"
appName="talk">
<LeftSidebar v-if="getUserId" ref="leftSidebar" />
<NcAppContent>
<router-view />
</NcAppContent>
<RightSidebar :isInCall="isInCall" />
<MediaSettings v-model:recordingConsentGiven="recordingConsentGiven" />
<SettingsDialog />
<ConversationSettingsDialog />
<PollManager />
</NcContent>
</template>
<script>
import { getCurrentUser } from '@nextcloud/auth'
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import debounce from 'debounce'
import { provide } from 'vue'
import { START_LOCATION } from 'vue-router'
import NcAppContent from '@nextcloud/vue/components/NcAppContent'
import NcContent from '@nextcloud/vue/components/NcContent'
import ConversationSettingsDialog from './components/ConversationSettings/ConversationSettingsDialog.vue'
import LeftSidebar from './components/LeftSidebar/LeftSidebar.vue'
import MediaSettings from './components/MediaSettings/MediaSettings.vue'
import PollManager from './components/PollViewer/PollManager.vue'
import RightSidebar from './components/RightSidebar/RightSidebar.vue'
import SettingsDialog from './components/SettingsDialog/SettingsDialog.vue'
import ConfirmDialog from './components/UIShared/ConfirmDialog.vue'
import { useActiveSession } from './composables/useActiveSession.js'
import {
toggleFullscreen,
useDocumentFullscreen,
} from './composables/useDocumentFullscreen.ts'
import { useDocumentTitle } from './composables/useDocumentTitle.ts'
import { useGetMessagesProvider } from './composables/useGetMessages.ts'
import { useGetToken } from './composables/useGetToken.ts'
import { useHashCheck } from './composables/useHashCheck.js'
import { useInterceptNotifications } from './composables/useInterceptNotifications.ts'
import { useIsInCall } from './composables/useIsInCall.js'
import { useSessionIssueHandler } from './composables/useSessionIssueHandler.ts'
import { CONVERSATION, PARTICIPANT } from './constants.ts'
import BrowserStorage from './services/BrowserStorage.js'
import { EventBus } from './services/EventBus.ts'
import { leaveConversationSync } from './services/participantsService.js'
import { useActorStore } from './stores/actor.ts'
import { useCallViewStore } from './stores/callView.ts'
import { useSidebarStore } from './stores/sidebar.ts'
import { useTokenStore } from './stores/token.ts'
import { checkBrowser } from './utils/browserCheck.ts'
import { signalingKill } from './utils/webrtc/index.js'
export default {
name: 'App',
components: {
NcAppContent,
NcContent,
LeftSidebar,
RightSidebar,
SettingsDialog,
ConversationSettingsDialog,
MediaSettings,
PollManager,
},
setup() {
useDocumentTitle()
// Provide context for MessagesList mounted in different places
useGetMessagesProvider()
// Intercept some notifications and handle them in the Talk app
useInterceptNotifications()
// Add provided value to check if we're in the main app or plugin
provide('Talk:isMainApp', true)
useDocumentFullscreen()
return {
token: useGetToken(),
tokenStore: useTokenStore(),
isInCall: useIsInCall(),
isLeavingAfterSessionIssue: useSessionIssueHandler(),
isMobile: useIsMobile(),
isNextcloudTalkHashDirty: useHashCheck(),
supportSessionState: useActiveSession(),
callViewStore: useCallViewStore(),
sidebarStore: useSidebarStore(),
actorStore: useActorStore(),
}
},
data() {
return {
loading: false,
isRefreshingCurrentConversation: false,
skipLeaveWarning: false,
recordingConsentGiven: false,
debounceRefreshCurrentConversation: () => {},
}
},
computed: {
unreadCountsMap() {
return this.$store.getters.conversationsList.reduce((acc, conversation) => {
if (conversation.isArchived) {
// Do not consider archived conversations in counting
return acc
}
if (conversation.unreadMessages > 0) {
acc.conversations++
acc.messages += conversation.unreadMessages
}
if (conversation.unreadMention) {
acc.mentions++
}
if (conversation.unreadMentionDirect) {
acc.mentionsDirect++
}
return acc
}, {
conversations: 0,
messages: 0,
mentions: 0,
mentionsDirect: 0,
})
},
getUserId() {
return this.actorStore.userId
},
isSendingMessages() {
return this.$store.getters.isSendingMessages
},
warnLeaving() {
return !this.isLeavingAfterSessionIssue && this.isInCall
},
/**
* The current conversation
*
* @return {object} The conversation object.
*/
currentConversation() {
return this.$store.getters.conversation(this.token)
},
},
watch: {
token(newValue, oldValue) {
const shouldShowSidebar = BrowserStorage.getItem('sidebarOpen') !== 'false'
if (!shouldShowSidebar || this.isMobile) {
this.sidebarStore.hideSidebar({ cache: false })
} else if (shouldShowSidebar) {
this.sidebarStore.showSidebar({ cache: false })
}
// Reset recording consent if switch doesn't happen within breakout rooms or main room
if (!this.isBreakoutRoomsNavigation(oldValue, newValue)) {
this.recordingConsentGiven = false
}
},
isInCall: {
immediate: true,
handler(value) {
const toggle = this.$refs.leftSidebar?.$refs.leftSidebar?.$el.querySelector('button.app-navigation-toggle')
if (value) {
toggle?.setAttribute('data-theme-dark', true)
} else {
toggle?.removeAttribute('data-theme-dark')
}
},
},
unreadCountsMap: {
deep: true,
immediate: true,
handler(value) {
emit('talk:unread:updated', value)
},
},
},
beforeCreate() {
const authorizedUser = getCurrentUser()?.uid || null
const lastLoggedInUser = BrowserStorage.getItem('last_logged_in_user')
if (authorizedUser !== lastLoggedInUser) {
// TODO introduce helper/util to list and clear all sensitive data
// or create BrowserSensitiveStorage for this purposes,
// if we have more than one source
BrowserStorage.removeItem('cachedConversations')
}
if (authorizedUser) {
BrowserStorage.setItem('last_logged_in_user', authorizedUser)
}
},
created() {
window.addEventListener('beforeunload', this.preventUnload)
useHotKey('f', this.handleAppSearch, { ctrl: true, stop: true, prevent: true })
useHotKey('f', toggleFullscreen)
if (getCurrentUser()) {
useHotKey('Escape', this.openRoot, { stop: true, prevent: true })
}
},
beforeUnmount() {
this.debounceRefreshCurrentConversation.clear?.()
if (!getCurrentUser()) {
EventBus.off('should-refresh-conversations', this.debounceRefreshCurrentConversation)
}
window.removeEventListener('beforeunload', this.preventUnload)
EventBus.off('joined-conversation')
EventBus.off('switch-to-conversation')
EventBus.off('conversations-received')
EventBus.off('forbidden-route')
},
beforeMount() {
if (!getCurrentUser()) {
/**
* When guest opens a public conversation, we wait for it to be fetched,
* then setting the 30 seconds interval to update information.
* Joining is handled by router (initial navigation to 'conversation')
*/
EventBus.once('conversations-received', (params) => {
setInterval(() => {
this.refreshCurrentConversation()
}, 30_000)
})
EventBus.on('should-refresh-conversations', this.debounceRefreshCurrentConversation)
}
window.addEventListener('unload', () => {
console.info('Navigating away, leaving conversation')
if (this.token) {
SessionStorage.removeItem('joined_conversation')
// We have to do this synchronously, because in unload and beforeunload
// Promises, async and await are prohibited.
signalingKill()
if (!this.isLeavingAfterSessionIssue) {
leaveConversationSync(this.token)
}
}
})
EventBus.on('switch-to-conversation', async (params) => {
if (this.isInCall) {
this.callViewStore.setForceCallView(true)
const enableAudio = !BrowserStorage.getItem('audioDisabled_' + this.token)
const enableVideo = !BrowserStorage.getItem('videoDisabled_' + this.token)
const enableVirtualBackground = !!BrowserStorage.getItem('virtualBackgroundEnabled_' + this.token)
const virtualBackgroundType = BrowserStorage.getItem('virtualBackgroundType_' + this.token)
const virtualBackgroundBlurStrength = BrowserStorage.getItem('virtualBackgroundBlurStrength_' + this.token)
const virtualBackgroundUrl = BrowserStorage.getItem('virtualBackgroundUrl_' + this.token)
// Fetch conversation object, if it's not known yet to the client
if (!this.$store.getters.conversation(params.token)) {
await this.fetchSingleConversation(params.token)
}
const conversation = this.$store.getters.conversation(this.token)
const previousParticipants = []
if (conversation.type === CONVERSATION.TYPE.ONE_TO_ONE) {
previousParticipants.push(conversation.name)
}
EventBus.once('joined-conversation', async ({ token }) => {
if (params.token !== token) {
return
}
if (enableAudio) {
BrowserStorage.removeItem('audioDisabled_' + token)
} else {
BrowserStorage.setItem('audioDisabled_' + token, 'true')
}
if (enableVideo) {
BrowserStorage.removeItem('videoDisabled_' + token)
} else {
BrowserStorage.setItem('videoDisabled_' + token, 'true')
}
if (enableVirtualBackground) {
BrowserStorage.setItem('virtualBackgroundEnabled_' + token, 'true')
} else {
BrowserStorage.removeItem('virtualBackgroundEnabled_' + token)
}
if (virtualBackgroundType) {
BrowserStorage.setItem('virtualBackgroundType_' + token, virtualBackgroundType)
} else {
BrowserStorage.removeItem('virtualBackgroundType_' + token)
}
if (virtualBackgroundBlurStrength) {
BrowserStorage.setItem('virtualBackgroundBlurStrength' + token, virtualBackgroundBlurStrength)
} else {
BrowserStorage.removeItem('virtualBackgroundBlurStrength' + token)
}
if (virtualBackgroundUrl) {
BrowserStorage.setItem('virtualBackgroundUrl_' + token, virtualBackgroundUrl)
} else {
BrowserStorage.removeItem('virtualBackgroundUrl_' + token)
}
const conversation = this.$store.getters.conversation(token)
let flags = PARTICIPANT.CALL_FLAG.IN_CALL
if (conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_AUDIO) {
flags |= PARTICIPANT.CALL_FLAG.WITH_AUDIO
}
if (conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_VIDEO) {
flags |= PARTICIPANT.CALL_FLAG.WITH_VIDEO
}
const payload = {
token: params.token,
participantIdentifier: this.actorStore.participantIdentifier,
flags,
silent: true,
recordingConsent: this.recordingConsentGiven,
}
if (conversation.objectType === CONVERSATION.OBJECT_TYPE.EXTENDED) {
payload.silent = false
if (previousParticipants.length) {
payload.silentFor = previousParticipants
}
}
await this.$store.dispatch('joinCall', payload)
this.callViewStore.setForceCallView(false)
})
}
this.skipLeaveWarning = true
this.$router.push({ name: 'conversation', params: { token: params.token } })
})
EventBus.on('conversations-received', (params) => {
if (this.$route === START_LOCATION) {
// Initial navigation, should be handled in beforeRouteChangeListener
return
}
if (this.$route.name === 'conversation'
&& !this.$store.getters.conversation(this.token)) {
if (!params.singleConversation) {
console.info('Conversations received, but the current conversation is not in the list, trying to get potential public conversation manually')
this.refreshCurrentConversation()
} else {
console.info('Conversation received, but the current conversation is not in the list. Redirecting to /apps/spreed/not-found')
this.skipLeaveWarning = true
this.$router.push({ name: 'notfound' })
}
}
})
EventBus.on('forbidden-route', (params) => {
this.$router.push({ name: 'forbidden' })
})
const beforeRouteChangeListener = async (to, from, next) => {
if (this.isNextcloudTalkHashDirty) {
// Nextcloud Talk configuration changed, reload the page when changing configuration
window.location = generateUrl('call/' + to.params.token)
return
}
if (from.name === 'conversation' && from.params.token !== to.params.token) {
// Await to properly close session / leave call before joining another one
await this.$store.dispatch('leaveConversation', { token: from.params.token })
}
/**
* This runs whenever the new route is a conversation.
*/
if (to.name === 'conversation' && from.params.token !== to.params.token) {
// Fetch conversation object, if it's not known yet to the client
if (!this.$store.getters.conversation(to.params.token)) {
const result = await this.fetchSingleConversation(to.params.token)
if (!result) {
// If the conversation is not found, block further navigation,
// it is handled in the fetchSingleConversation method
return
}
}
this.$store.dispatch('joinConversation', { token: to.params.token })
}
next()
}
this.$router.afterEach((to, from) => {
/**
* Update current token in the token store
*/
if (from.params.token !== to.params.token) {
this.tokenStore.updateToken(to.params.token ?? '')
}
/**
* Fires a global event that tells the whole app that the route has changed. The event
* carries the from and to objects as payload
*/
EventBus.emit('route-change', { from, to })
})
/**
* Global before guard, this is called whenever a navigation is triggered.
* When app is initializing and router is not ready yet,
* first navigation will be made from initial state { name : undefined }
*/
this.$router.beforeEach((to, from, next) => {
if (to.fullPath === from.fullPath) {
// Block duplicated navigation
return
}
if (from.name === 'conversation' && to.name === 'conversation' && from.params.token === to.params.token) {
// Navigating within the same conversation
beforeRouteChangeListener(to, from, next)
} else if (!this.warnLeaving || this.skipLeaveWarning) {
// Safe to navigate
beforeRouteChangeListener(to, from, next)
} else {
spawnDialog(ConfirmDialog, {
name: t('spreed', 'Leave call'),
message: t('spreed', 'Navigating away from the page will leave the call in {conversation}', {
conversation: this.currentConversation?.displayName ?? '',
}),
buttons: [
{
label: t('spreed', 'Stay in call'),
variant: 'primary',
},
{
label: t('spreed', 'Leave call'),
variant: 'error',
callback: () => {
beforeRouteChangeListener(to, from, next)
},
},
],
})
}
this.skipLeaveWarning = false
})
},
async mounted() {
this.debounceRefreshCurrentConversation = debounce(this.refreshCurrentConversation, 3000)
if (!IS_DESKTOP) {
checkBrowser()
}
},
methods: {
t,
refreshCurrentConversation() {
this.fetchSingleConversation(this.token)
},
preventUnload(event) {
if (!this.warnLeaving && !this.isSendingMessages) {
return
}
event.preventDefault()
},
async fetchSingleConversation(token) {
if (this.isRefreshingCurrentConversation) {
return
}
this.isRefreshingCurrentConversation = true
let isSuccessfullyFetched = false
try {
/**
* Fetches a single conversation
*/
const response = await this.$store.dispatch('fetchConversation', { token })
isSuccessfullyFetched = true
/**
* Emits a global event that is used in App.vue to update the page title once the
* ( if the current route is a conversation and once the conversations are received)
*/
EventBus.emit('conversations-received', { singleConversation: response.data.ocs.data })
} catch (exception) {
if (exception.response?.status === 404) {
console.info('Conversation received, but the current conversation is not in the list. Redirecting to /apps/spreed/not-found')
this.skipLeaveWarning = true
this.$router.push({ name: 'notfound' })
} else if (exception.response?.status === 403) {
console.info('Attendee/IP address is no longer authorized to participate (banned). Redirecting to /apps/spreed/forbidden')
this.skipLeaveWarning = true
this.$router.push({ name: 'forbidden' })
} else {
console.error('Error getting room data', exception)
showError(t('spreed', 'Error occurred when getting the conversation information'))
}
} finally {
this.isRefreshingCurrentConversation = false
}
return isSuccessfullyFetched
},
// Upon pressing Ctrl+F, focus SearchBox native input in the LeftSidebar
handleAppSearch() {
emit('toggle-navigation', {
open: true,
})
this.$nextTick(() => {
this.$refs.leftSidebar.$refs.searchBox.focus()
})
},
/**
* Check if conversation was switched within breakout rooms and parent room.
*
* @param {string} oldToken The old conversation's token
* @param {string} newToken The new conversation's token
* @return {boolean}
*/
isBreakoutRoomsNavigation(oldToken, newToken) {
const oldConversation = this.$store.getters.conversation(oldToken)
const newConversation = this.$store.getters.conversation(newToken)
// One of rooms is undefined
if (!oldConversation || !newConversation) {
return false
}
// Parent to breakout
if (oldConversation.breakoutRoomMode !== CONVERSATION.BREAKOUT_ROOM_MODE.NOT_CONFIGURED
&& newConversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM) {
return true
}
// Breakout to parent
if (oldConversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM
&& newConversation.breakoutRoomMode !== CONVERSATION.BREAKOUT_ROOM_MODE.NOT_CONFIGURED) {
return true
}
// Breakout to breakout
return oldConversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM && newConversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM
},
openRoot() {
if (this.$route.name !== 'root' && !this.isInCall) {
this.$router.push({ name: 'root' })
}
},
},
}
</script>
<style lang="scss">
/* FIXME: remove after https://github.com/nextcloud/nextcloud-vue/issues/2097 is solved */
.mx-datepicker-main.mx-datepicker-popup {
z-index: 10001 !important;
}
/* FIXME: Align styles of NcModal header with NcDialog header. Remove if all are migrated */
.modal-wrapper h2.nc-dialog-alike-header {
font-size: 21px;
text-align: center;
height: fit-content;
min-height: var(--default-clickable-area);
line-height: var(--default-clickable-area);
overflow-wrap: break-word;
margin-block: 0 12px;
}
// Styles for the app content at fullscreen mode
:root:has(body.talk-in-fullscreen) /* Default theme values override */,
body.talk-in-fullscreen /* Theme values override */ {
--body-container-margin: 0px !important;
--body-container-radius: 0px !important;
--header-height: 0px !important;
}
body.talk-in-fullscreen {
#header {
display: none !important;
}
}
// Overwrites styles from public.scss in public conversations
body#body-public {
--footer-height: 0;
}
</style>
<style lang="scss" scoped>
.content {
&.in-call {
:deep(.app-content) {
background-color: transparent;
}
}
// Fix fullscreen black bar on top
&:fullscreen {
padding-top: 0;
:deep(.app-sidebar) {
height: 100vh !important;
}
}
}
</style>