UPSTREAM BASELINE: nextcloud/spreed v22.0.12 (без изменений)
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

Источник: https://github.com/nextcloud/spreed/archive/refs/tags/v22.0.12.tar.gz
С этого коммита ветка официального Nextcloud Talk отрезана (решение владельца 2026-07-06).
Все дальнейшие изменения — только наши; версии релизов: 22.0.12-f7.N.
This commit is contained in:
2026-07-06 14:07:50 +00:00
commit 01acfa3b40
1716 changed files with 613013 additions and 0 deletions
+635
View File
@@ -0,0 +1,635 @@
<!--
- 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>
+450
View File
@@ -0,0 +1,450 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="talkChatTab">
<div v-if="isTalkSidebarSupportedForFile === undefined" class="emptycontent ui-not-ready-placeholder">
<div class="icon icon-loading" />
</div>
<div v-else-if="!isTalkSidebarSupportedForFile" class="emptycontent file-not-shared">
<div class="icon icon-talk" />
<h2>{{ t('spreed', 'Discuss this file') }}</h2>
<p>{{ t('spreed', 'Share this file with others to discuss it') }}</p>
<NcButton variant="primary" @click="openSharingTab">
{{ t('spreed', 'Share this file') }}
</NcButton>
</div>
<div v-else-if="isTalkSidebarSupportedForFile && !token" class="emptycontent room-not-joined">
<div class="icon icon-talk" />
<h2>{{ t('spreed', 'Discuss this file') }}</h2>
<NcButton variant="primary" @click="joinConversation">
{{ t('spreed', 'Join conversation') }}
</NcButton>
</div>
<template v-else>
<FilesSidebarCallView v-if="isInFile && isInCall" />
<FilesSidebarChatView />
</template>
</div>
</template>
<script>
import { getCurrentUser } from '@nextcloud/auth'
import Axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { defineAsyncComponent, defineComponent, h } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import LoadingComponent from './components/LoadingComponent.vue'
import { useIsInCall } from './composables/useIsInCall.js'
import { useSessionIssueHandler } from './composables/useSessionIssueHandler.ts'
import { EventBus } from './services/EventBus.ts'
import { getFileConversation } from './services/filesIntegrationServices.ts'
import {
leaveConversationSync,
} from './services/participantsService.js'
import SessionStorage from './services/SessionStorage.js'
import { useActorStore } from './stores/actor.ts'
import { useTokenStore } from './stores/token.ts'
import { checkBrowser } from './utils/browserCheck.ts'
import CancelableRequest from './utils/cancelableRequest.js'
import { signalingKill } from './utils/webrtc/index.js'
export default {
name: 'FilesSidebarTabApp',
components: {
FilesSidebarChatView: defineAsyncComponent({
loader: () => import(/* webpackChunkName: "files-sidebar-tab-chunk" */'./views/FilesSidebarChatView.vue'),
loadingComponent: defineComponent(() => h(LoadingComponent, { class: 'tab-loading' })),
}),
FilesSidebarCallView: defineAsyncComponent({
loader: () => import(/* webpackChunkName: "files-sidebar-call-chunk" */'./views/FilesSidebarCallView.vue'),
loadingComponent: defineComponent(() => h(LoadingComponent, { class: 'tab-loading' })),
}),
NcButton,
},
setup() {
return {
isInCall: useIsInCall(),
isLeavingAfterSessionIssue: useSessionIssueHandler(),
actorStore: useActorStore(),
tokenStore: useTokenStore(),
}
},
data() {
return {
// needed for reactivity
Talk: OCA.Talk,
sidebarState: OCA.Files.Sidebar.state,
/**
* Stores the cancel function returned by `cancelablePollNewMessages`,
*/
cancelGetFileConversation: () => {},
isTalkSidebarSupportedForFile: undefined,
}
},
computed: {
token() {
return this.tokenStore.token
},
fileInfo() {
return this.Talk.fileInfo || {}
},
fileId() {
return this.fileInfo.id
},
fileIdForToken() {
return this.tokenStore.fileIdForToken
},
/**
* Returns whether the sidebar is opened in the file of the current
* conversation or not.
*
* Note that false is returned too when the sidebar is closed, even if
* the conversation is active in the current file.
*
* @return {boolean} true if the sidebar is opened in the file, false
* otherwise.
*/
isInFile() {
return this.fileId === this.fileIdForToken
},
isChatTheActiveTab() {
// FIXME check for empty active tab is currently needed because the
// activeTab is not set when opening the sidebar from the "Details"
// action (which opens the first tab, which is the Chat tab).
return !this.sidebarState.activeTab || this.sidebarState.activeTab === 'chat'
},
},
watch: {
fileInfo: {
immediate: true,
handler(fileInfo) {
if (this.token && (!fileInfo || fileInfo.id !== this.fileIdForToken)) {
this.leaveConversation()
}
this.setTalkSidebarSupportedForFile(fileInfo)
},
},
isChatTheActiveTab: {
immediate: true,
handler(isChatTheActiveTab) {
this.forceTabsContentStyleWhenChatTabIsActive(isChatTheActiveTab)
// recheck the file info in case the sharing info was changed
this.setTalkSidebarSupportedForFile(this.fileInfo)
},
},
},
created() {
// The fetchCurrentConversation event handler/callback is started and
// stopped from different FilesSidebarTabApp instances, so it needs to
// be stored in a common place. Moreover, as the bound method would be
// overriden when a new instance is created the one used as handler is
// a wrapper that calls the latest bound method. This makes possible to
// register and unregister it from different instances.
if (!OCA.Talk.fetchCurrentConversationWrapper) {
OCA.Talk.fetchCurrentConversationWrapper = function() {
OCA.Talk.fetchCurrentConversationBound()
}
}
OCA.Talk.fetchCurrentConversationBound = this.fetchCurrentConversation.bind(this)
},
beforeMount() {
this.actorStore.setCurrentUser(getCurrentUser())
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)
}
}
})
},
methods: {
t,
async joinConversation() {
checkBrowser()
try {
await this.getFileConversation()
} catch (error) {
console.debug('Could not get file conversation. Is it a file and shared?')
return
}
// TODO: move to store under a special action ?
// Remove the conversation to ensure that the old data is not used
// before fetching it again if this conversation is joined again.
await this.$store.dispatch('deleteConversation', this.token)
// Remove the participant to ensure that it will be set again fresh
// if this conversation is joined again.
await this.$store.dispatch('purgeParticipantsStore', this.token)
await this.$router.push({ name: 'conversation', params: { token: this.token } })
await this.$store.dispatch('joinConversation', { token: this.token })
// The current participant (which is automatically set when fetching
// the current conversation) is needed for the MessagesList to start
// getting the messages, and both the current conversation and the
// current participant are needed for CallButton. No need to wait
// for it, but fetching the conversation needs to be done once the
// user has joined the conversation (otherwise only limited data
// would be received if the user was not a participant of the
// conversation yet).
this.fetchCurrentConversation()
// FIXME The participant will not be updated with the server data
// when the conversation is got again (as "addParticipantOnce" is
// used), although that should not be a problem given that only the
// "inCall" flag (which is locally updated when joining and leaving
// a call) is currently used.
if (loadState('spreed', 'signaling_mode') !== 'internal') {
EventBus.on('should-refresh-conversations', OCA.Talk.fetchCurrentConversationWrapper)
EventBus.on('signaling-participant-list-changed', OCA.Talk.fetchCurrentConversationWrapper)
} else {
// The "should-refresh-conversations" event is triggered only when
// the external signaling server is used; when the internal
// signaling server is used periodic polling has to be used
// instead.
OCA.Talk.fetchCurrentConversationIntervalId = window.setInterval(OCA.Talk.fetchCurrentConversationWrapper, 30000)
}
},
leaveConversation() {
EventBus.off('should-refresh-conversations', OCA.Talk.fetchCurrentConversationWrapper)
EventBus.off('signaling-participant-list-changed', OCA.Talk.fetchCurrentConversationWrapper)
window.clearInterval(OCA.Talk.fetchCurrentConversationIntervalId)
this.$store.dispatch('leaveConversation', { token: this.token })
this.tokenStore.updateTokenAndFileIdForToken('', null)
},
async getFileConversation() {
// Clear previous requests if there's one pending
this.cancelGetFileConversation('canceled')
// Get a new cancelable request function and cancel function pair
const { request, cancel } = CancelableRequest(getFileConversation)
// Assign the new cancel function to our data value
this.cancelGetFileConversation = cancel
// Make the request
try {
const response = await request(this.fileId)
this.tokenStore.updateTokenAndFileIdForToken(response.data.ocs.data.token, this.fileId)
} catch (exception) {
if (Axios.isCancel(exception)) {
console.debug('The request has been canceled', exception)
} else {
throw exception
}
}
},
async fetchCurrentConversation() {
if (!this.token) {
return
}
await this.$store.dispatch('fetchConversation', { token: this.token })
},
/**
* Sets whether the Talk sidebar is supported for the file or not.
*
* In some cases it is not possible to know if the Talk sidebar is
* supported for the file or not just from the data in the FileInfo (for
* example, for files in a folder shared by the current user). Due to
* that this function is asynchronous; isTalkSidebarSupportedForFile
* will be set as soon as possible (in some cases, immediately) with
* either true or false, depending on whether the Talk sidebar is
* supported for the file or not.
*
* The Talk sidebar is supported for a file if the file is shared with
* the current user or by the current user to another user (as a user,
* group...), or if the file is a descendant of a folder that meets
* those conditions.
*
* @param {OCA.Files.FileInfo} fileInfo the FileInfo to check
*/
async setTalkSidebarSupportedForFile(fileInfo) {
this.isTalkSidebarSupportedForFile = undefined
if (!fileInfo) {
this.isTalkSidebarSupportedForFile = false
return
}
if (fileInfo.get('type') === 'dir') {
this.isTalkSidebarSupportedForFile = false
return
}
if (fileInfo.get('shareOwnerId')) {
// Shared with me
// TODO How to check that it is not a remote share? At least for
// local shares "shareTypes" is not defined when shared with me.
this.isTalkSidebarSupportedForFile = true
return
}
if (!fileInfo.get('shareTypes')) {
// When it is not possible to know whether the Talk sidebar is
// supported for a file or not only from the data in the
// FileInfo it is necessary to query the server.
// FIXME If the file is shared this will create the conversation
// if it does not exist yet.
try {
this.isTalkSidebarSupportedForFile = (await getFileConversation(fileInfo.id)) || false
} catch (error) {
this.isTalkSidebarSupportedForFile = false
}
return
}
const shareTypes = fileInfo.get('shareTypes').filter(function(shareType) {
// Ensure that shareType is an integer (as in the past shareType
// could be an integer or a string depending on whether the
// Sharing tab was opened or not).
shareType = parseInt(shareType)
return shareType === OC.Share.SHARE_TYPE_USER
|| shareType === OC.Share.SHARE_TYPE_GROUP
|| shareType === OC.Share.SHARE_TYPE_CIRCLE
|| shareType === OC.Share.SHARE_TYPE_ROOM
|| shareType === OC.Share.SHARE_TYPE_LINK
|| shareType === OC.Share.SHARE_TYPE_EMAIL
})
if (shareTypes.length === 0) {
// When it is not possible to know whether the Talk sidebar is
// supported for a file or not only from the data in the
// FileInfo it is necessary to query the server.
// FIXME If the file is shared this will create the conversation
// if it does not exist yet.
try {
this.isTalkSidebarSupportedForFile = (await getFileConversation(fileInfo.id)) || false
} catch (error) {
this.isTalkSidebarSupportedForFile = false
}
return
}
this.isTalkSidebarSupportedForFile = true
},
openSharingTab() {
OCA.Files.Sidebar.setActiveTab('sharing')
},
/**
* Dirty hack to set the style in the tabs container.
*
* This is needed to force the scroll bars on the tabs container instead
* of on the whole sidebar.
*
* Additionally a minimum height is forced to ensure that the height of
* the chat view will be at least 300px, even if the info view is large
* and the screen short; in that case a scroll bar will be shown for the
* sidebar, but even if that looks really bad it is better than an
* unusable chat view.
*
* @param {boolean} isChatTheActiveTab whether the active tab is the
* chat tab or not.
*/
forceTabsContentStyleWhenChatTabIsActive(isChatTheActiveTab) {
const tabs = document.querySelector('.app-sidebar-tabs')
const tabsContent = document.querySelector('.app-sidebar-tabs__content')
if (isChatTheActiveTab) {
this.savedTabsMinHeight = tabs.style.minHeight
this.savedTabsOverflow = tabs.style.overflow
this.savedTabsContentOverflow = tabsContent.style.overflow
this.savedTabsContentStyle = true
tabs.style.minHeight = '300px'
tabs.style.overflow = 'hidden'
tabsContent.style.overflow = 'hidden'
} else if (this.savedTabsContentStyle) {
tabs.style.minHeight = this.savedTabsMinHeight
tabs.style.overflow = this.savedTabsOverflow
tabsContent.style.overflow = this.savedTabsContentOverflow
delete this.savedTabsMinHeight
delete this.savedTabsOverflow
delete this.savedTabsContentOverflow
this.savedTabsContentStyle = false
}
},
},
}
</script>
<style>
/* FIXME: Align styles of NcModal header with NcDialog header. Remove if all are migrated */
body .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;
}
</style>
<style scoped>
.talkChatTab {
height: 100%;
display: flex;
flex-grow: 1;
flex-direction: column;
}
.emptycontent {
/* Override default top margin set in server and center vertically
* instead. */
margin-top: unset;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.tab-loading {
height: 100%;
}
</style>
@@ -0,0 +1,94 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div id="submit-wrapper" class="request-password-wrapper">
<!-- "submit-wrapper" is used to mimic the login button and thus get
automatic colouring of the confirm icon by the Theming app. -->
<NcButton
id="request-password-button"
variant="primary"
:wide="true"
:disabled="isRequestInProgress"
@click="requestPassword">
{{ t('spreed', 'Request password') }}
</NcButton>
</div>
<p v-if="hasRequestFailed" class="warning error-message">
{{ t('spreed', 'Error requesting the password.') }}
</p>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import { useGetToken } from './composables/useGetToken.ts'
import { getPublicShareAuthConversationToken } from './services/filesIntegrationServices.ts'
import { useTokenStore } from './stores/token.ts'
import { checkBrowser } from './utils/browserCheck.ts'
export default {
name: 'PublicShareAuthRequestPasswordButton',
components: {
NcButton,
},
props: {
shareToken: {
type: String,
required: true,
},
},
setup() {
return {
token: useGetToken(),
tokenStore: useTokenStore(),
}
},
data() {
return {
isRequestLoading: false,
hasRequestFailed: false,
}
},
computed: {
iconClass() {
return {
'icon-confirm-white': !this.isRequestInProgress,
'icon-loading-small-dark': this.isRequestInProgress,
}
},
isRequestInProgress() {
return this.isRequestLoading || !!this.token
},
},
methods: {
t,
async requestPassword() {
checkBrowser()
this.hasRequestFailed = false
this.isRequestLoading = true
try {
const response = await getPublicShareAuthConversationToken(this.shareToken)
this.tokenStore.updateToken(response.data.ocs.data.token)
} catch (exception) {
this.hasRequestFailed = true
}
this.isRequestLoading = false
},
},
}
</script>
+291
View File
@@ -0,0 +1,291 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<TransitionWrapper name="slide-right">
<aside v-if="isOpen" id="talk-sidebar">
<div v-if="!token" class="emptycontent">
<div class="icon icon-talk" />
<h2>{{ t('spreed', 'This conversation has ended') }}</h2>
</div>
<template v-else>
<TopBar isInCall isSidebar />
<CallView :token="token" isSidebar />
<InternalSignalingHint />
<RouterView />
<PollManager />
<PollViewer />
<MediaSettings v-model:recordingConsentGiven="recordingConsentGiven" />
</template>
</aside>
</TransitionWrapper>
</template>
<script>
import { getCurrentUser, getGuestNickname } from '@nextcloud/auth'
import { emit } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import CallView from './components/CallView/CallView.vue'
import MediaSettings from './components/MediaSettings/MediaSettings.vue'
import PollManager from './components/PollViewer/PollManager.vue'
import PollViewer from './components/PollViewer/PollViewer.vue'
import InternalSignalingHint from './components/RightSidebar/InternalSignalingHint.vue'
import TopBar from './components/TopBar/TopBar.vue'
import TransitionWrapper from './components/UIShared/TransitionWrapper.vue'
import { useGetMessagesProvider } from './composables/useGetMessages.ts'
import { useHashCheck } from './composables/useHashCheck.js'
import { useSessionIssueHandler } from './composables/useSessionIssueHandler.ts'
import { EventBus } from './services/EventBus.ts'
import {
leaveConversationSync,
setGuestUserName,
} from './services/participantsService.js'
import SessionStorage from './services/SessionStorage.js'
import { useActorStore } from './stores/actor.ts'
import { useTokenStore } from './stores/token.ts'
import { signalingKill } from './utils/webrtc/index.js'
export default {
name: 'PublicShareAuthSidebar',
components: {
InternalSignalingHint,
CallView,
MediaSettings,
PollManager,
PollViewer,
TopBar,
TransitionWrapper,
},
setup() {
useHashCheck()
useGetMessagesProvider()
return {
isLeavingAfterSessionIssue: useSessionIssueHandler(),
actorStore: useActorStore(),
tokenStore: useTokenStore(),
}
},
data() {
return {
fetchCurrentConversationIntervalId: null,
isWaitingToClose: false,
recordingConsentGiven: false,
}
},
computed: {
token() {
return this.tokenStore.token
},
conversation() {
return this.$store.getters.conversation(this.token)
},
isOpen() {
return this.conversation || this.isWaitingToClose
},
},
watch: {
token(token) {
if (token) {
this.joinConversation()
}
},
conversation(conversation) {
if (!conversation) {
this.isWaitingToClose = true
window.setTimeout(() => {
this.isWaitingToClose = false
}, 5000)
}
},
},
beforeMount() {
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)
}
}
})
},
methods: {
t,
async joinConversation() {
const currentUser = getCurrentUser()
const guestNickname = getGuestNickname()
if (currentUser) {
this.actorStore.setCurrentUser(currentUser)
} else if (guestNickname) {
this.actorStore.setDisplayName(guestNickname)
}
await this.$router.push({ name: 'conversation', params: { token: this.token } })
await this.$store.dispatch('joinConversation', { token: this.token })
// Add guest name to the store, only possible after joining the conversation
if (guestNickname) {
await setGuestUserName(this.token, guestNickname)
}
// Fetching the conversation needs to be done once the user has
// joined the conversation (otherwise only limited data would be
// received if the user was not a participant of the conversation
// yet).
await this.fetchCurrentConversation()
// FIXME The participant will not be updated with the server data
// when the conversation is got again (as "addParticipantOnce" is
// used), although that should not be a problem given that only the
// "inCall" flag (which is locally updated when joining and leaving
// a call) is currently used.
if (loadState('spreed', 'signaling_mode') !== 'internal') {
EventBus.on('should-refresh-conversations', this.fetchCurrentConversation)
EventBus.on('signaling-participant-list-changed', this.fetchCurrentConversation)
} else {
// The "should-refresh-conversations" event is triggered only when
// the external signaling server is used; when the internal
// signaling server is used periodic polling has to be used
// instead.
this.fetchCurrentConversationIntervalId = window.setInterval(this.fetchCurrentConversation, 30000)
}
emit('talk:media-settings:show', 'video-verification')
},
async fetchCurrentConversation() {
if (!this.token) {
return
}
try {
await this.$store.dispatch('fetchConversation', { token: this.token })
// Although the current participant is automatically added to
// the participants store it must be explicitly set in the
// actors store.
if (!this.actorStore.userId) {
// Set the current actor/participant for guests
const conversation = this.$store.getters.conversation(this.token)
// Setting a guest only uses "sessionId" and "participantType".
this.actorStore.setCurrentParticipant(conversation)
}
} catch (exception) {
window.clearInterval(this.fetchCurrentConversationIntervalId)
this.$store.dispatch('deleteConversation', this.token)
this.tokenStore.updateToken('')
}
},
},
}
</script>
<style lang="css">
#talk-sidebar,
#talk-sidebar *,
#talk-sidebar *::before,
#talk-sidebar *::after {
box-sizing: border-box;
}
</style>
<style lang="scss" scoped>
@use './assets/variables' as *;
/* Styles based on the NcAppSidebar */
#talk-sidebar {
position: relative;
flex-shrink: 0;
width: clamp(300px, 27vw, 500px);
height: 100%;
background: var(--color-main-background);
border-inline-start: 1px solid var(--color-border);
overflow-x: hidden;
overflow-y: auto;
z-index: 1500;
display: flex;
flex-direction: column;
justify-content: center;
/* Unset conflicting rules from guest.css for the sidebar. */
text-align: start;
& > .emptycontent {
/* Remove default margin-top as it is unneeded when showing only the empty
* content in a flex sidebar. */
margin-top: 0;
}
& #call-container {
position: relative;
flex-grow: 1;
/* Prevent shadows of videos from leaking on other elements. */
overflow: hidden;
/* Distribute available height between call container and chat view. */
height: 40%;
/* Ensure that the background will be black also in voice only calls. */
background-color: $color-call-background;
:deep(.videoContainer.promoted video) {
/* Base the size of the video on its width instead of on its height;
* otherwise the video could appear in full height but cropped on the sides
* due to the space available in the sidebar being typically larger in
* vertical than in horizontal. */
width: 100%;
height: auto;
}
}
& .chatView {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
flex-grow: 1;
/* Distribute available height between call container and chat view. */
height: 60%;
}
& :deep(.wrapper) {
margin-top: 0;
}
/* Restore rules from style.scss overwritten by guest.css for the sidebar. */
& :deep(a) {
color: var(--color-main-text);
font-weight: inherit;
}
}
</style>
+375
View File
@@ -0,0 +1,375 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<TransitionWrapper name="slide-right">
<aside v-if="isOpen" id="talk-sidebar">
<div v-if="!conversation" class="emptycontent room-not-joined">
<div class="icon icon-talk" />
<h2>{{ t('spreed', 'Discuss this file') }}</h2>
<NcButton
variant="primary"
class="button-centered"
:disabled="joiningConversation"
@click="joinConversation">
<template #icon>
<NcLoadingIcon v-if="joiningConversation" />
</template>
{{ t('spreed', 'Join conversation') }}
</NcButton>
</div>
<template v-else>
<TopBar v-if="isInCall" isInCall isSidebar />
<CallView v-if="isInCall" :token="token" isSidebar />
<InternalSignalingHint />
<CallButton v-if="!isInCall" class="call-button" />
<CallFailedDialog v-if="connectionFailed" :token="token" />
<RouterView />
<PollManager />
<PollViewer />
<MediaSettings v-model:recordingConsentGiven="recordingConsentGiven" />
</template>
</aside>
</TransitionWrapper>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import CallFailedDialog from './components/CallView/CallFailedDialog.vue'
import CallView from './components/CallView/CallView.vue'
import MediaSettings from './components/MediaSettings/MediaSettings.vue'
import PollManager from './components/PollViewer/PollManager.vue'
import PollViewer from './components/PollViewer/PollViewer.vue'
import InternalSignalingHint from './components/RightSidebar/InternalSignalingHint.vue'
import CallButton from './components/TopBar/CallButton.vue'
import TopBar from './components/TopBar/TopBar.vue'
import TransitionWrapper from './components/UIShared/TransitionWrapper.vue'
import { useGetMessagesProvider } from './composables/useGetMessages.ts'
import { useHashCheck } from './composables/useHashCheck.js'
import { useIsInCall } from './composables/useIsInCall.js'
import { useSessionIssueHandler } from './composables/useSessionIssueHandler.ts'
import { EventBus } from './services/EventBus.ts'
import { getPublicShareConversationData } from './services/filesIntegrationServices.ts'
import {
leaveConversationSync,
} from './services/participantsService.js'
import SessionStorage from './services/SessionStorage.js'
import { useActorStore } from './stores/actor.ts'
import { useTokenStore } from './stores/token.ts'
import { checkBrowser } from './utils/browserCheck.ts'
import { signalingKill } from './utils/webrtc/index.js'
export default {
name: 'PublicShareSidebar',
components: {
InternalSignalingHint,
CallButton,
CallFailedDialog,
CallView,
MediaSettings,
NcButton,
NcLoadingIcon,
PollManager,
PollViewer,
TopBar,
TransitionWrapper,
},
props: {
shareToken: {
type: String,
required: true,
},
state: {
type: Object,
required: true,
},
},
setup() {
useHashCheck()
useGetMessagesProvider()
return {
isInCall: useIsInCall(),
isLeavingAfterSessionIssue: useSessionIssueHandler(),
actorStore: useActorStore(),
tokenStore: useTokenStore(),
}
},
data() {
return {
fetchCurrentConversationIntervalId: null,
joiningConversation: false,
recordingConsentGiven: false,
}
},
computed: {
token() {
return this.tokenStore.token
},
conversation() {
return this.$store.getters.conversation(this.token)
},
isOpen() {
return this.state.isOpen
},
warnLeaving() {
return !this.isLeavingAfterSessionIssue && this.isInCall
},
connectionFailed() {
return this.$store.getters.connectionFailed(this.token)
},
},
created() {
window.addEventListener('beforeunload', this.preventUnload)
},
beforeMount() {
window.addEventListener('unload', () => {
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)
}
}
})
},
beforeUnmount() {
window.removeEventListener('beforeunload', this.preventUnload)
},
methods: {
t,
preventUnload(event) {
if (!this.warnLeaving) {
return
}
event.preventDefault()
},
async joinConversation() {
checkBrowser()
this.joiningConversation = true
try {
await this.getPublicShareConversationData()
await this.$router.push({ name: 'conversation', params: { token: this.token } })
await this.$store.dispatch('joinConversation', { token: this.token })
} catch (exception) {
this.joiningConversation = false
showError(t('spreed', 'Error occurred when joining the conversation'))
console.error(exception)
return
}
// No need to wait for it, but fetching the conversation needs to be
// done once the user has joined the conversation (otherwise only
// limited data would be received if the user was not a participant
// of the conversation yet).
this.fetchCurrentConversation()
// FIXME The participant will not be updated with the server data
// when the conversation is got again (as "addParticipantOnce" is
// used), although that should not be a problem given that only the
// "inCall" flag (which is locally updated when joining and leaving
// a call) is currently used.
if (loadState('spreed', 'signaling_mode') !== 'internal') {
EventBus.on('should-refresh-conversations', this.fetchCurrentConversation)
EventBus.on('signaling-participant-list-changed', this.fetchCurrentConversation)
} else {
// The "should-refresh-conversations" event is triggered only when
// the external signaling server is used; when the internal
// signaling server is used periodic polling has to be used
// instead.
this.fetchCurrentConversationIntervalId = window.setInterval(this.fetchCurrentConversation, 30000)
}
},
async getPublicShareConversationData() {
const response = await getPublicShareConversationData(this.shareToken)
this.tokenStore.updateToken(response.data.ocs.data.token)
if (response.data.ocs.data.userId) {
// Instead of using "getCurrentUser()" the current user is set
// from the data returned by the controller (as the public share
// page uses the incognito mode, and thus it always returns an
// anonymous user).
//
// When the external signaling server is used it should wait
// until the current user is set before trying to connect, as
// otherwise the connection would fail due to a mismatch between
// the user ID given when connecting to the backend (an
// anonymous user) and the user that fetched the signaling
// settings (the actual user). However, if that happens the
// signaling server will retry the connection again and again,
// so at some point the anonymous user will have been overriden
// with the current user and the connection will succeed.
this.actorStore.setCurrentUser({
uid: response.data.ocs.data.userId,
displayName: response.data.ocs.data.userDisplayName,
})
}
},
async fetchCurrentConversation() {
if (!this.token) {
return
}
try {
await this.$store.dispatch('fetchConversation', { token: this.token })
// Although the current participant is automatically added to
// the participants store it must be explicitly set in the
// actors store.
if (!this.actorStore.userId) {
// Set the current actor/participant for guests
const conversation = this.$store.getters.conversation(this.token)
// Setting a guest only uses "sessionId" and "participantType".
this.actorStore.setCurrentParticipant(conversation)
}
} catch (exception) {
window.clearInterval(this.fetchCurrentConversationIntervalId)
this.$store.dispatch('deleteConversation', this.token)
this.tokenStore.updateToken('')
}
this.joiningConversation = false
},
},
}
</script>
<style>
footer {
transition: width var(--animation-quick);
}
#content-vue:has(#talk-sidebar) ~ footer {
width: calc(100% - 2 * var(--body-container-margin) - clamp(300px, 27vw, 500px));
}
</style>
<style lang="scss" scoped>
/* Properties based on the app-sidebar */
#talk-sidebar {
height: 100%;
position: relative;
flex-shrink: 0;
width: clamp(300px, 27vw, 500px);
background: var(--color-main-background);
border-inline-start: 1px solid var(--color-border);
overflow-x: hidden;
overflow-y: auto;
z-index: 1500;
display: flex;
flex-direction: column;
justify-content: center;
}
#talk-sidebar > .emptycontent {
/* Remove default margin-top as it is unneeded when showing only the empty
* content in a flex sidebar. */
margin-top: 0;
}
#talk-sidebar .call-button {
margin: calc(var(--default-grid-baseline) * 2) auto;
}
#talk-sidebar .button-centered {
/*
* When there is an icon the servers empty-content rule
* .emptycontent [class*="icon-"] is matching button-vue--icon-and-text
* setting the height to 64px, so we need to reset this.
*/
height: var(--default-clickable-area) !important;
margin: 0 auto;
}
#talk-sidebar #call-container {
position: relative;
flex-grow: 1;
/* Prevent shadows of videos from leaking on other elements. */
overflow: hidden;
/* Show the call container in a 16/9 proportion based on the sidebar
* width. This is the same proportion used for previews of images by the
* SidebarPreviewManager. */
padding-bottom: 56.25%;
max-height: 56.25%;
/* Override the call container height so it properly adjusts to the 16/9
* proportion. */
height: unset;
}
#talk-sidebar #call-container :deep(.videoContainer) {
/* The video container has some small padding to prevent the video from
* reaching the edges, but it also uses "width: 100%", so the padding should
* be included in the full width of the element. */
box-sizing: border-box;
}
#talk-sidebar #call-container :deep(.videoContainer.promoted video) {
/* Base the size of the video on its width instead of on its height;
* otherwise the video could appear in full height but cropped on the sides
* due to the space available in the sidebar being typically larger in
* vertical than in horizontal. */
width: 100%;
height: auto;
}
#talk-sidebar #call-container :deep(.nameIndicator) {
/* The name indicator has some small padding to prevent the name from
* reaching the edges, but it also uses "width: 100%", so the padding should
* be included in the full width of the element. */
box-sizing: border-box;
}
#talk-sidebar .chatView {
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
flex-grow: 1;
/* Distribute available height between call container and chat view. */
height: 50%;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<!--
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcHeaderButton
id="talk-sidebar-trigger"
:title="ariaLabel"
:aria-label="ariaLabel"
@click="emit('click')">
<template #icon>
<IconMessageTextOutline :size="20" />
</template>
</NcHeaderButton>
</template>
<script setup lang="ts">
import type { UnwrapNestedRefs } from 'vue'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
import IconMessageTextOutline from 'vue-material-design-icons/MessageTextOutline.vue'
const props = defineProps<{
sidebarState: UnwrapNestedRefs<{ isOpen: boolean }>
}>()
const emit = defineEmits<{
(event: 'click'): void
}>()
const ariaLabel = computed(() => {
return props.sidebarState.isOpen
? t('spreed', 'Close Talk sidebar')
: t('spreed', 'Open Talk sidebar')
})
</script>
<style scoped>
#talk-sidebar-trigger {
margin-inline-start: var(--default-grid-baseline);
}
</style>
+79
View File
@@ -0,0 +1,79 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import CallView from './components/CallView/CallView.vue'
import { useGetToken } from './composables/useGetToken.ts'
import SessionStorage from './services/SessionStorage.js'
import { useSoundsStore } from './stores/sounds.js'
import { useTokenStore } from './stores/token.ts'
import { signalingKill } from './utils/webrtc/index.js'
const router = useRouter()
const route = useRoute()
const soundsStore = useSoundsStore()
const token = useGetToken()
const tokenStore = useTokenStore()
onBeforeMount(async () => {
await router.isReady()
if (route.name === 'recording') {
tokenStore.updateToken(route.params.token as string)
await soundsStore.setShouldPlaySounds(false)
}
// This should not be strictly needed, as the recording server is
// expected to clean up before leaving, but just in case.
window.addEventListener('unload', () => {
console.info('Navigating away, leaving conversation')
if (token.value) {
SessionStorage.removeItem('joined_conversation')
// We have to do this synchronously, because in unload and
// beforeunload Promises, async and await are prohibited.
signalingKill()
}
})
})
</script>
<template>
<CallView :token="token" isRecording />
</template>
<style lang="scss">
/** Hide public footer gap from recording */
#body-public {
--footer-height: 0 !important;
}
/** Hide public interface from recording */
#header .header-end {
display: none !important;
}
/* The CallView descendants expect border-box to be set, as in the normal UI the
* CallView is a descendant of NcContent, which applies the border-box to all
* its descendants.
*/
#call-container {
--wrapper-padding: 0 !important;
* {
box-sizing: border-box;
}
#videos {
inset: 0;
height: 100%;
}
.video-container {
width: 100%;
}
}
</style>
+248
View File
@@ -0,0 +1,248 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Capabilities } from '../types/index.ts'
export const mockedCapabilities: Capabilities = {
spreed: {
features: [
'audio',
'video',
'chat-v2',
'conversation-v4',
'guest-signaling',
'empty-group-room',
'guest-display-names',
'multi-room-users',
'favorites',
'last-room-activity',
'no-ping',
'system-messages',
'delete-messages',
'mention-flag',
'in-call-flags',
'conversation-call-flags',
'notification-levels',
'invite-groups-and-mails',
'locked-one-to-one-rooms',
'read-only-rooms',
'listable-rooms',
'chat-read-marker',
'chat-unread',
'webinary-lobby',
'start-call-flag',
'chat-replies',
'circles-support',
'force-mute',
'sip-support',
'sip-support-nopin',
'chat-read-status',
'phonebook-search',
'raise-hand',
'room-description',
'rich-object-sharing',
'temp-user-avatar-api',
'geo-location-sharing',
'voice-message-sharing',
'signaling-v3',
'publishing-permissions',
'clear-history',
'direct-mention-flag',
'notification-calls',
'conversation-permissions',
'rich-object-list-media',
'rich-object-delete',
'unified-search',
'chat-permission',
'silent-send',
'silent-call',
'send-call-notification',
'talk-polls',
'breakout-rooms-v1',
'recording-v1',
'avatar',
'chat-get-context',
'single-conversation-status',
'chat-keep-notifications',
'typing-privacy',
'remind-me-later',
'bots-v1',
'markdown-messages',
'media-caption',
'session-state',
'note-to-self',
'recording-consent',
'sip-support-dialout',
'delete-messages-unlimited',
'edit-messages',
'silent-send-state',
'chat-read-last',
'federation-v1',
'federation-v2',
'ban-v1',
'chat-reference-id',
'mention-permissions',
'edit-messages-note-to-self',
'archived-conversations-v2',
'talk-polls-drafts',
'download-call-participants',
'email-csv-import',
'conversation-creation-password',
'call-notification-state-api',
'schedule-meeting',
'edit-draft-poll',
'conversation-creation-all',
'important-conversations',
'unbind-conversation',
'sip-direct-dialin',
'dashboard-event-rooms',
'mutual-calendar-events',
'upcoming-reminders',
'sensitive-conversations',
'threads',
// Conditional features
'message-expiration',
'reactions',
'chat-summary-api',
'call-end-to-end-encryption',
],
'features-local': [
'favorites',
'chat-read-status',
'listable-rooms',
'phonebook-search',
'temp-user-avatar-api',
'unified-search',
'avatar',
'remind-me-later',
'note-to-self',
'archived-conversations-v2',
'chat-summary-api',
'call-notification-state-api',
'schedule-meeting',
'conversation-creation-all',
'important-conversations',
'sip-direct-dialin',
'dashboard-event-rooms',
'mutual-calendar-events',
'upcoming-reminders',
'sensitive-conversations',
],
config: {
attachments: {
allowed: true,
folder: '/Talk',
},
call: {
enabled: true,
'breakout-rooms': true,
recording: true,
'recording-consent': 0,
'supported-reactions': ['❤️', '🎉', '👏', '👍', '👎', '😂', '🤩', '🤔', '😲', '😥'],
'predefined-backgrounds': ['1_office.jpg', '2_home.jpg', '3_abstract.jpg'],
'predefined-backgrounds-v2': ['/apps/spreed/img/backgrounds/1_office.jpg', '/apps/spreed/img/backgrounds/2_home.jpg', '/apps/spreed/img/backgrounds/3_abstract.jpg'],
'can-upload-background': true,
'sip-enabled': true,
'sip-dialout-enabled': true,
'default-phone-region': '',
'can-enable-sip': true,
'start-without-media': false,
'max-duration': 0,
'blur-virtual-background': false,
'end-to-end-encryption': false,
'live-transcription': false,
'play-sounds': true,
'grid-limit': 0,
'grid-limit-enforced': false,
},
chat: {
'max-length': 32000,
'read-privacy': 0,
'has-translation-providers': true,
'has-translation-task-providers': true,
'typing-privacy': 0,
'summary-threshold': 100,
'matterbridge-enabled': false,
},
conversations: {
'can-create': true,
'force-passwords': false,
'list-style': 'two-lines',
'description-length': 2000,
'retention-event': 28,
'retention-phone': 7,
'retention-instant-meetings': 1,
},
federation: {
enabled: false,
'incoming-enabled': false,
'outgoing-enabled': false,
'only-trusted-servers': true,
},
previews: {
'max-gif-size': 3145728,
},
signaling: {
'session-ping-limit': 200,
'hello-v2-token-key': '123',
mode: 'internal',
},
experiments: {
enabled: 0,
},
permissions: {
'max-default': 510,
'max-custom': 511,
default: 502,
},
},
'config-local': {
attachments: [
'allowed',
'folder',
],
call: [
'predefined-backgrounds',
'predefined-backgrounds-v2',
'can-upload-background',
'start-without-media',
'blur-virtual-background',
],
chat: [
'read-privacy',
'has-translation-providers',
'has-translation-task-providers',
'typing-privacy',
'summary-threshold',
],
conversations: [
'can-create',
'list-style',
'description-length',
],
federation: [
'enabled',
'incoming-enabled',
'outgoing-enabled',
'only-trusted-servers',
],
previews: [
'max-gif-size',
],
signaling: [
'session-ping-limit',
'hello-v2-token-key',
],
experiments: [
'enabled',
],
},
version: '20.0.0-dev.0',
},
}
export const mockedRemotes = {
'https://nextcloud1.local': { ...mockedCapabilities, hash: 'abc123', tokens: ['TOKEN3FED1'] },
'https://nextcloud2.local': { ...mockedCapabilities, hash: 'def123', tokens: ['TOKEN5FED2'] },
}
+10
View File
@@ -0,0 +1,10 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createTalkRouter } from '../router/router.ts'
const router = createTalkRouter()
router.addRoute({ path: '/', name: 'none', redirect: '/apps/spreed', component: { template: '<div />' } })
export default router
+5
View File
@@ -0,0 +1,5 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default '<svg>SvgMock</svg>'
+18
View File
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { vi } from 'vitest'
// https://github.com/focus-trap/tabbable#testing-in-jsdom
export default async () => {
const tabbable = await vi.importActual('tabbable')
return {
...tabbable,
tabbable: vi.fn(),
focusable: vi.fn(),
isFocusable: vi.fn(),
isTabbable: vi.fn(),
}
}
+13
View File
@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { vi } from 'vitest'
import { createClient } from 'webdav'
vi.mock('webdav', () => ({
createClient: vi.fn(),
}))
export { createClient }
+49
View File
@@ -0,0 +1,49 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
.settings-section-placeholder {
--settings-section-placeholder-header-height: 30px;
--settings-section-placeholder-line-height: 1lh;
--settings-section-placeholder-padding: 1em;
--settings-section-placeholder-image: linear-gradient(90deg, var(--color-placeholder-light) 65%, var(--color-placeholder-dark) 70%, var(--color-placeholder-light) 75%);
position: relative;
height: calc(2 * (7 * var(--default-grid-baseline)) + var(--settings-section-placeholder-header-height) + 3 * (var(--settings-section-placeholder-line-height) + 1em));
}
.settings-section-placeholder::before,
.settings-section-placeholder::after {
content: '';
position: absolute;
inset: calc(7 * var(--default-grid-baseline));
background-clip: content-box;
background-origin: content-box;
animation: loading-animation 3s forwards infinite linear;
}
.settings-section-placeholder::before {
max-width: 300px;
background: var(--settings-section-placeholder-image) 0 0 / 200vw var(--settings-section-placeholder-header-height) repeat-x content-box;
}
.settings-section-placeholder::after {
max-width: 900px;
background:
var(--settings-section-placeholder-image) 0 calc(var(--settings-section-placeholder-header-height) + 1em + 0 * (var(--settings-section-placeholder-line-height) + 1em)) / 200vw var(--settings-section-placeholder-line-height) repeat-x content-box,
var(--settings-section-placeholder-image) 0 calc(var(--settings-section-placeholder-header-height) + 1em + 1 * (var(--settings-section-placeholder-line-height) + 1em)) / 200vw var(--settings-section-placeholder-line-height) repeat-x content-box,
var(--settings-section-placeholder-image) 0 calc(var(--settings-section-placeholder-header-height) + 1em + 2 * (var(--settings-section-placeholder-line-height) + 1em)) / 200vw var(--settings-section-placeholder-line-height) repeat-x content-box;
}
.settings-section-placeholder + .settings-section-placeholder {
border-top: 1px solid var(--color-border);
}
@keyframes loading-animation {
0% {
background-position-x: 0;
}
100% {
background-position-x: 140vw;
}
}
+68
View File
@@ -0,0 +1,68 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
@mixin markdown {
// Overwrite core styles, otherwise h4 is lesser than default font-size
h4 {
font-size: 100%;
}
em {
font-style: italic;
}
ul,
ol {
/* stylelint-disable-next-line csstools/use-logical */
padding-left: 0;
padding-inline-start: 15px;
&.contains-task-list {
padding: 0;
}
}
input:disabled + .checkbox-content {
opacity: 1 !important;
}
div:has(table) {
overflow-x: auto;
}
pre {
padding: 4px;
margin: 0;
border-radius: var(--border-radius);
background-color: var(--color-background-dark);
& code {
margin: 0;
padding: 0;
}
}
code {
display: inline-block;
max-width: 100%;
padding: 2px 4px;
margin: 2px 0;
border-radius: var(--border-radius);
background-color: var(--color-background-dark);
}
blockquote {
/* stylelint-disable-next-line csstools/use-logical */
padding-left: 0;
padding-inline-start: 13px;
/* stylelint-disable-next-line csstools/use-logical */
border-left: none;
border-inline-start: 4px solid var(--color-border-dark);
}
img {
max-width: min(100%, 600px);
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/* Special layout to include the Talk sidebar */
/* The original style of the body is kept until the layout has been adjusted to
* include the Talk sidebar. If only "#body-login" was used, immediately after
* load and before the sidebar was injected the original elements would be using
* the style for the adjusted layout, which is not the proper one for them, and
* this would cause the elements to "jump" to their final position once the
* layout was adjusted. */
body.talk-sidebar-enabled {
/* Move rules set for body by guest.scss to the wrapped body. */
flex-direction: row;
height: 100vh;
}
body.talk-sidebar-enabled #body-login {
display: flex;
justify-content: center;
background-position: 50% 50%;
background-repeat: repeat;
background-size: 275px, contain;
background-attachment: fixed;
width: 100%;
height: 100%;
/* Changed from guest.scss. */
flex-direction: row;
align-items: stretch;
}
/* #body-login should be used to override the #content rules set in server. */
#body-login #content {
position: relative;
flex-grow: 1;
flex-direction: column;
align-items: center;
height: auto;
overflow-x: hidden;
/* Override "padding-top: 50px" set in server. */
padding-top: 0;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
.reference-picker .conversation-icon,
.unified-search .conversation-icon {
background-color: var(--color-background-darker);
background-size: 22px !important;
}
/* We always want to use the white icons, this is why we don't use var(--color-white) here.*/
.reference-picker .conversation-icon.icon-public-white,
.unified-search .conversation-icon.icon-public-white {
background-image: url(../../img/icon-public-white.svg);
}
.reference-picker .conversation-icon.icon-contacts-white,
.unified-search .conversation-icon.icon-contacts-white {
background-image: url(../../img/icon-contacts-white.svg);
}
.reference-picker .conversation-icon.icon-password-white,
.unified-search .conversation-icon.icon-password-white {
background-image: url(../../img/icon-password-white.svg);
}
.reference-picker .conversation-icon.icon-text-white,
.unified-search .conversation-icon.icon-text-white {
background-image: url(../../img/icon-text-white.svg);
}
.reference-picker .conversation-icon.icon-mail-white,
.unified-search .conversation-icon.icon-mail-white {
background-image: url(../../img/icon-mail-white.svg);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/** Messages list dimensions:
* - text max width: ~750px (80 characters per line is recommended by W3C standard)
* - avatar width: 32px (AVATAR.SIZE.SMALL) + 16px (paddings) = 48px
* - info width: 8ch(~68px) (timestamp) + 40px (checkmark with paddings) = ~108px
* - list max width: 48px (avatar width) + 1058px (text width with paddings) + ~108px (info width) = ~1214px
* - input max width: ~1214px (list max width) - 100px (send button) = ~1114px
*/
$messages-text-max-width: calc(50 * var(--default-font-size));
$messages-avatar-width: calc(32px + 4 * var(--default-grid-baseline));
$messages-info-width: calc(8ch + var(--clickable-area-small, 24px) + 4 * var(--default-grid-baseline));
$messages-list-max-width: calc($messages-avatar-width + $messages-text-max-width + 2 * var(--default-grid-baseline) + $messages-info-width);
$messages-input-max-width: calc($messages-list-max-width - 100px);
// background color of call container
$color-call-background: rgba(0, 0, 0, 0.7);
// transition
$transition-duration-quick: var(--animation-quick, 100ms);
$transition-duration-slow: var(--animation-slow, 300ms);
$transition: all $transition-duration-quick ease-in-out;
$transition-slow: all $transition-duration-slow ease-in-out;
// To be in sync with nextcloud-vue
$breakpoint-mobile-small: 512px;
+24
View File
@@ -0,0 +1,24 @@
/**
* SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCSPNonce } from '@nextcloud/auth'
import { t } from '@nextcloud/l10n'
import { requestRoomSelection } from './utils/requestRoomSelection.js'
__webpack_nonce__ = getCSPNonce()
// eslint-disable-next-line
__webpack_public_path__ = OC.linkTo('spreed', 'js/')
window.OCP.Collaboration.registerType('room', {
action: async () => {
const conversation = await requestRoomSelection('spreed-room-select', {})
if (!conversation) {
throw new Error('User cancelled resource selection')
}
return conversation.token
},
typeString: t('spreed', 'Link to a conversation'),
typeIconClass: 'icon-talk',
})
@@ -0,0 +1,251 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="allowed_groups" class="videocalls section">
<h2>{{ t('spreed', 'Limit to groups') }}</h2>
<p class="settings-hint">
{{ t('spreed', 'When at least one group is selected, only people of the listed groups can be part of conversations.') }}
</p>
<p class="settings-hint">
{{ t('spreed', 'Guests can still join public conversations.') }}
</p>
<p class="settings-hint">
{{ t('spreed', 'Users that cannot use Talk anymore will still be listed as participants in their previous conversations and also their chat messages will be kept.') }}
</p>
<div class="grid">
<NcSelect
v-model="allowedGroups"
inputId="allow_groups_use_talk"
:inputLabel="t('spreed', 'Limit using Talk')"
name="allow_groups_use_talk"
class="form__select"
:options="groups"
:placeholder="t('spreed', 'Limit using Talk')"
:disabled="loading"
:multiple="true"
:searchable="true"
:tagWidth="60"
:loading="loadingGroups"
:showNoOptions="false"
keepOpen
trackBy="id"
label="displayname"
noWrap
@search="debounceSearchGroup" />
<NcButton
variant="primary"
:disabled="loading"
@click="saveAllowedGroups">
{{ saveLabelAllowedGroups }}
</NcButton>
<NcSelect
v-model="canStartConversations"
inputId="allow_groups_start_conversation"
:inputLabel="t('spreed', 'Limit creating a public and group conversation')"
name="allow_groups_start_conversation"
class="form__select"
:options="groups"
:placeholder="t('spreed', 'Limit creating conversations')"
:disabled="loading"
:multiple="true"
:searchable="true"
:tagWidth="60"
:loading="loadingGroups"
:showNoOptions="false"
keepOpen
trackBy="id"
label="displayname"
noWrap
@search="debounceSearchGroup" />
<NcButton
variant="primary"
:disabled="loading"
@click="saveStartConversationsGroups">
{{ saveLabelStartConversations }}
</NcButton>
<NcSelect
v-model="startCalls"
inputId="start_calls"
:inputLabel="t('spreed', 'Limit starting a call')"
name="allow_groups_start_calls"
class="form__select"
:options="startCallOptions"
:placeholder="t('spreed', 'Limit starting calls')"
label="label"
trackBy="value"
:clearable="false"
noWrap
:disabled="loading || loadingStartCalls"
@update:modelValue="saveStartCalls" />
</div>
<p>
<em>{{ t('spreed', 'When a call has started, everyone with access to the conversation can join the call.') }}</em>
</p>
</section>
</template>
<script>
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import debounce from 'debounce'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcSelect from '@nextcloud/vue/components/NcSelect'
const startCallOptions = [
{ value: 0, label: t('spreed', 'Everyone') },
{ value: 1, label: t('spreed', 'Users and moderators') },
{ value: 2, label: t('spreed', 'Moderators only') },
{ value: 3, label: t('spreed', 'Disable calls') },
]
export default {
name: 'AllowedGroups',
components: {
NcButton,
NcSelect,
},
data() {
return {
loading: false,
loadingGroups: false,
loadingStartCalls: false,
groups: [],
allowedGroups: [],
canStartConversations: [],
saveLabelAllowedGroups: t('spreed', 'Save changes'),
saveLabelStartConversations: t('spreed', 'Save changes'),
startCallOptions,
startCalls: startCallOptions[0],
debounceSearchGroup: () => {},
}
},
mounted() {
this.loading = true
this.allowedGroups = loadState('spreed', 'allowed_groups', []).sort(function(a, b) {
return a.displayname.localeCompare(b.displayname)
})
this.canStartConversations = loadState('spreed', 'start_conversations', []).sort(function(a, b) {
return a.displayname.localeCompare(b.displayname)
})
this.startCalls = startCallOptions[parseInt(loadState('spreed', 'start_calls'))]
// Make a unique list with the groups we know from allowedGroups and canStartConversations
// Unique checking is done by turning the group objects (with id and name)
// into json strings and afterwards back again
const mergedGroups = Array.from(new Set(this.allowedGroups.concat(this.canStartConversations)
.map((g) => JSON.stringify(g)))).map((g) => JSON.parse(g))
this.groups = mergedGroups.sort(function(a, b) {
return a.displayname.localeCompare(b.displayname)
})
this.loading = false
this.debounceSearchGroup = debounce(this.searchGroup, 500)
this.debounceSearchGroup('')
},
beforeUnmount() {
this.debounceSearchGroup.clear?.()
},
methods: {
t,
async searchGroup(query) {
this.loadingGroups = true
try {
const response = await axios.get(generateOcsUrl('cloud/groups/details'), {
search: query,
limit: 20,
offset: 0,
})
this.groups = response.data.ocs.data.groups.sort(function(a, b) {
return a.displayname.localeCompare(b.displayname)
})
} catch (err) {
console.error('Could not fetch groups', err)
} finally {
this.loadingGroups = false
}
},
saveAllowedGroups() {
this.loading = true
this.loadingGroups = true
this.saveLabelAllowedGroups = t('spreed', 'Saving …')
const groups = this.allowedGroups.map((group) => {
return group.id
})
OCP.AppConfig.setValue('spreed', 'allowed_groups', JSON.stringify(groups), {
success: () => {
this.loading = false
this.loadingGroups = false
this.saveLabelAllowedGroups = t('spreed', 'Saved!')
setTimeout(() => {
this.saveLabelAllowedGroups = t('spreed', 'Save changes')
}, 5000)
},
})
},
saveStartConversationsGroups() {
this.loading = true
this.loadingGroups = true
this.saveLabelStartConversations = t('spreed', 'Saving …')
const groups = this.canStartConversations.map((group) => {
return group.id
})
OCP.AppConfig.setValue('spreed', 'start_conversations', JSON.stringify(groups), {
success: () => {
this.loading = false
this.loadingGroups = false
this.saveLabelStartConversations = t('spreed', 'Saved!')
setTimeout(() => {
this.saveLabelStartConversations = t('spreed', 'Save changes')
}, 5000)
},
})
},
saveStartCalls() {
this.loadingStartCalls = true
OCP.AppConfig.setValue('spreed', 'start_calls', String(this.startCalls.value), {
success: () => {
this.loadingStartCalls = false
},
})
},
},
}
</script>
<style lang="scss" scoped>
.grid {
display: grid;
grid-template-columns: 3fr 1fr;
align-items: flex-end;
gap: calc(var(--default-grid-baseline) * 2);
width: fit-content;
&__select {
min-width: 300px !important;
}
}
</style>
@@ -0,0 +1,196 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="bots_settings" class="bots-settings section">
<h2>{{ t('spreed', 'Bots settings') }}</h2>
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="settings-hint" v-html="botsSettingsDescription" />
<ul v-if="bots.length" class="bots-settings__list">
<li class="bots-settings__item bots-settings__item--head">
<div class="state">
{{ t('spreed', 'State') }}
</div>
<div class="name">
{{ t('spreed', 'Name') }}
</div>
<div class="description">
{{ t('spreed', 'Description') }}
</div>
<div class="last-error">
{{ t('spreed', 'Last error') }}
</div>
<div class="error-count">
{{ t('spreed', 'Total errors count') }}
</div>
</li>
<li
v-for="bot in botsExtended"
:key="bot.id"
class="bots-settings__item">
<div class="state">
<span
class="state__icon"
:aria-label="bot.state_icon_label"
:title="bot.state_icon_label">
<component
:is="bot.state_icon_component"
:fillColor="bot.state_icon_color" />
</span>
</div>
<div class="name bold">
{{ bot.name }}
</div>
<div class="description">
{{ bot.description }}
</div>
<div :id="`last_error_bot_${bot.id}`" class="last-error">
<NcPopover
v-if="bot.last_error_message"
container="#bots_settings"
noFocusTrap>
<template #trigger>
<NcButton variant="error" :aria-label="bot.last_error_message">
{{ bot.last_error_date }}
</NcButton>
</template>
<div class="last-error__popover-content">
{{ bot.last_error_message }}
</div>
</NcPopover>
</div>
<div class="error-count">
<span v-if="bot.error_count">
{{ bot.error_count }}
</span>
</div>
</li>
</ul>
<NcButton
variant="primary"
href="https://nextcloud-talk.readthedocs.io/en/latest/bot-list/"
target="_blank"
rel="noreferrer nofollow">
{{ t('spreed', 'Find more bots') }}
</NcButton>
</section>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import IconCancel from 'vue-material-design-icons/Cancel.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconLockOutline from 'vue-material-design-icons/LockOutline.vue'
import { BOT } from '../../constants.ts'
import { getAllBots } from '../../services/botsService.ts'
import { formatDateTime } from '../../utils/formattedTime.ts'
export default {
name: 'BotsSettings',
components: {
NcPopover,
NcButton,
},
data() {
return {
loading: true,
bots: [],
}
},
computed: {
botsSettingsDescription() {
let description = t('spreed', 'The following bots are installed on this server. In the documentation you can find details how to {linkstart1}build your own bot{linkend} or a {linkstart2}list of bots{linkend} to enable on your server.')
if (!this.bots.length) {
description = t('spreed', 'No bots are installed on this server. In the documentation you can find details how to {linkstart1}build your own bot{linkend} or a {linkstart2}list of bots{linkend} to enable on your server.')
}
return description
.replace('{linkstart1}', '<a target="_blank" rel="noreferrer nofollow" class="external" href="https://nextcloud-talk.readthedocs.io/en/latest/bots/">')
.replace('{linkstart2}', '<a target="_blank" rel="noreferrer nofollow" class="external" href="https://nextcloud-talk.readthedocs.io/en/latest/bot-list/">')
.replaceAll('{linkend}', ' ↗</a>')
},
botsExtended() {
return this.bots.map((bot) => ({
...bot,
...this.getStateIcon(bot.state),
description: bot.description ?? t('spreed', 'Description is not provided'),
last_error_date: bot.last_error_date ? formatDateTime(bot.last_error_date * 1000, 'shortDateWithTimeSeconds') : '---',
}))
},
},
async mounted() {
this.loading = true
try {
const response = await getAllBots()
this.bots = response.data.ocs.data
} catch (error) {
console.error(error)
}
this.loading = false
},
methods: {
t,
getStateIcon(state) {
switch (state) {
case BOT.STATE.NO_SETUP:
return { state_icon_component: IconLockOutline, state_icon_label: t('spreed', 'Locked for moderators'), state_icon_color: 'var(--color-favorite)' }
case BOT.STATE.ENABLED:
return { state_icon_component: IconCheck, state_icon_label: t('spreed', 'Enabled'), state_icon_color: 'var(--color-border-success)' }
case BOT.STATE.UNAVAILABLE:
return { state_icon_component: IconCancel, state_icon_label: t('spreed', 'App disabled'), state_icon_color: 'var(--color-text-maxcontrast)' }
case BOT.STATE.DISABLED:
default:
return { state_icon_component: IconCancel, state_icon_label: t('spreed', 'Disabled'), state_icon_color: 'var(--color-border-error)' }
}
},
},
}
</script>
<style scoped lang="scss">
.bots-settings {
&__item {
display: grid;
grid-template-columns: minmax(50px, 100px) 1fr 2fr minmax(100px, 250px) minmax(50px, 100px);
grid-column-gap: 5px;
&:not(:last-child) {
margin-bottom: 10px;
}
&--head {
padding-bottom: 5px;
border-bottom: 1px solid var(--color-border);
font-weight: bold;
}
.bold {
font-weight: bold;
}
.last-error__popover-content {
margin: calc(var(--default-grid-baseline) * 2);
}
}
&__list {
margin-bottom: 30px;
}
}
</style>
@@ -0,0 +1,269 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="federation_settings" class="federation section">
<h2>
{{ t('spreed', 'Federation') }}
<small>{{ t('spreed', 'Beta') }}</small>
</h2>
<p class="settings-hint additional-top-margin">
{{ t('spreed', 'Federated chats and calls work already. Attachment handling is coming in a future version.') }}
</p>
<NcCheckboxRadioSwitch
:modelValue="isFederationEnabled"
:disabled="loading"
type="switch"
@update:modelValue="saveFederationEnabled">
{{ t('spreed', 'Enable Federation in Talk app') }}
</NcCheckboxRadioSwitch>
<template v-if="isFederationEnabled">
<h3>{{ t('spreed', 'Permissions') }}</h3>
<NcCheckboxRadioSwitch
:modelValue="isFederationIncomingEnabled"
:disabled="loading"
type="switch"
@update:modelValue="saveFederationIncomingEnabled">
{{ t('spreed', 'Allow users to be invited to federated conversations') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
:modelValue="isFederationOutgoingEnabled"
:disabled="loading"
type="switch"
@update:modelValue="saveFederationOutgoingEnabled">
{{ t('spreed', 'Allow users to invite federated users into conversation') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
:modelValue="isFederationOnlyTrustedServersEnabled"
:disabled="loading"
type="switch"
@update:modelValue="saveFederationOnlyTrustedServersEnabled">
{{ t('spreed', 'Only allow to federate with trusted servers') }}
</NcCheckboxRadioSwitch>
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="settings-hint additional-top-margin" v-html="trustedServersLink" />
<h3>{{ t('spreed', 'Limit to groups') }}</h3>
<p class="settings-hint additional-top-margin">
{{ t('spreed', 'When at least one group is selected, only people of the listed groups can invite federated users to conversations.') }}
</p>
<div class="form">
<NcSelect
v-model="allowedGroups"
inputId="allow_groups_invite_federated"
:inputLabel="t('spreed', 'Groups allowed to invite federated users')"
name="allow_groups_invite_federated"
class="form__select"
:options="groups"
:placeholder="t('spreed', 'Select groups …')"
:disabled="loading"
multiple
searchable
:tagWidth="60"
:loading="loadingGroups"
:showNoOptions="false"
keepOpen
trackBy="id"
label="displayname"
noWrap
@search="debounceSearchGroup" />
<NcButton
variant="primary"
:disabled="loading"
@click="saveAllowedGroups">
{{ saveLabelAllowedGroups }}
</NcButton>
</div>
</template>
</section>
</template>
<script>
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import debounce from 'debounce'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcSelect from '@nextcloud/vue/components/NcSelect'
const FEDERATION_ENABLED = loadState('spreed', 'federation_enabled', false)
const FEDERATION_INCOMING_ENABLED = loadState('spreed', 'federation_incoming_enabled', true)
const FEDERATION_OUTGOING_ENABLED = loadState('spreed', 'federation_outgoing_enabled', true)
const FEDERATION_ONLY_TRUSTED_SERVERS = loadState('spreed', 'federation_only_trusted_servers', false)
const FEDERATION_ALLOWED_GROUPS = loadState('spreed', 'federation_allowed_groups', [])
export default {
name: 'FederationSettings',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcSelect,
},
data() {
return {
loading: false,
isFederationEnabled: FEDERATION_ENABLED,
isFederationIncomingEnabled: FEDERATION_INCOMING_ENABLED,
isFederationOutgoingEnabled: FEDERATION_OUTGOING_ENABLED,
isFederationOnlyTrustedServersEnabled: FEDERATION_ONLY_TRUSTED_SERVERS,
loadingGroups: false,
groups: [],
allowedGroups: [],
saveLabelAllowedGroups: t('spreed', 'Save changes'),
debounceSearchGroup: () => {},
}
},
computed: {
trustedServersLink() {
const href = generateUrl('/settings/admin/sharing#ocFederationSettings')
return t('spreed', 'Trusted servers can be configured at {linkstart}Sharing settings page{linkend}.')
.replace('{linkstart}', `<a target="_blank" rel="noreferrer nofollow" class="external" href="${href}">`)
.replaceAll('{linkend}', ' ↗</a>')
},
},
mounted() {
// allowed groups come as an array of string ids here
this.allowedGroups = FEDERATION_ALLOWED_GROUPS.sort((a, b) => a.localeCompare(b))
this.debounceSearchGroup = debounce(this.searchGroup, 500)
this.debounceSearchGroup('')
},
beforeUnmount() {
this.debounceSearchGroup.clear?.()
},
methods: {
t,
saveFederationEnabled(value) {
this.loading = true
OCP.AppConfig.setValue('spreed', 'federation_enabled', value ? 'yes' : 'no', {
success: () => {
this.loading = false
this.isFederationEnabled = value
},
})
},
saveFederationIncomingEnabled(value) {
this.loading = true
OCP.AppConfig.setValue('spreed', 'federation_incoming_enabled', value ? '1' : '0', {
success: () => {
this.loading = false
this.isFederationIncomingEnabled = value
},
})
},
saveFederationOutgoingEnabled(value) {
this.loading = true
OCP.AppConfig.setValue('spreed', 'federation_outgoing_enabled', value ? '1' : '0', {
success: () => {
this.loading = false
this.isFederationOutgoingEnabled = value
},
})
},
saveFederationOnlyTrustedServersEnabled(value) {
this.loading = true
OCP.AppConfig.setValue('spreed', 'federation_only_trusted_servers', value ? '1' : '0', {
success: () => {
this.loading = false
this.isFederationOnlyTrustedServersEnabled = value
},
})
},
async searchGroup(query) {
this.loadingGroups = true
try {
const response = await axios.get(generateOcsUrl('cloud/groups/details'), {
search: query,
limit: 20,
offset: 0,
})
this.groups = response.data.ocs.data.groups.sort(function(a, b) {
return a.displayname.localeCompare(b.displayname)
})
// repopulate allowed groups with full group objects to show display name
const allowedGroupIds = this.allowedGroups.map((group) => typeof group === 'object' ? group.id : group)
this.allowedGroups = this.groups.filter((group) => allowedGroupIds.includes(group.id))
} catch (err) {
console.error('Could not fetch groups', err)
} finally {
this.loadingGroups = false
}
},
saveAllowedGroups() {
this.loading = true
this.loadingGroups = true
this.saveLabelAllowedGroups = t('spreed', 'Saving …')
const groups = this.allowedGroups.map((group) => typeof group === 'object' ? group.id : group)
OCP.AppConfig.setValue('spreed', 'federation_allowed_groups', JSON.stringify(groups), {
success: () => {
this.loading = false
this.loadingGroups = false
this.saveLabelAllowedGroups = t('spreed', 'Saved!')
setTimeout(() => {
this.saveLabelAllowedGroups = t('spreed', 'Save changes')
}, 5000)
},
})
},
},
}
</script>
<style scoped lang="scss">
small {
color: var(--color-favorite);
border: 1px solid var(--color-favorite);
border-radius: 16px;
padding: 0 9px;
}
h3 {
margin-top: 24px;
font-weight: bold;
}
.additional-top-margin {
margin-top: 10px;
}
.form {
display: flex;
align-items: flex-end;
gap: 10px;
padding-top: 5px;
&__select {
min-width: 300px !important;
}
}
</style>
@@ -0,0 +1,229 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="general_settings" class="videocalls section">
<h2>{{ t('spreed', 'General settings') }}</h2>
<h3>{{ t('spreed', 'Default notification settings') }}</h3>
<NcSelect
v-model="defaultGroupNotification"
class="default-group-notification"
inputId="default_group_notification_input"
:inputLabel="t('spreed', 'Default group notification')"
name="default_group_notification"
:options="defaultGroupNotificationOptions"
:clearable="false"
:placeholder="t('spreed', 'Default group notification for new groups')"
label="label"
trackBy="value"
noWrap
:disabled="loading || loadingDefaultGroupNotification"
@update:modelValue="saveDefaultGroupNotification" />
<h3>{{ t('spreed', 'Integration into other apps') }}</h3>
<NcCheckboxRadioSwitch
:modelValue="isConversationsFilesChecked"
:disabled="loading || loadingConversationsFiles"
type="switch"
@update:modelValue="saveConversationsFiles">
{{ t('spreed', 'Allow conversations on files') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
:modelValue="isConversationsFilesPublicSharesChecked"
:disabled="loading || loadingConversationsFiles || !isConversationsFilesChecked"
type="switch"
@update:modelValue="saveConversationsFilesPublicShares">
{{ t('spreed', 'Allow conversations on public shares for files') }}
</NcCheckboxRadioSwitch>
<template v-if="hasSignalingServers">
<h3>
{{ t('spreed', 'End-to-end encrypted calls') }}
<small>{{ t('spreed', 'Beta') }}</small>
</h3>
<NcCheckboxRadioSwitch
v-model="isE2EECallsEnabled"
type="switch"
:disabled="loading || !canEnableE2EECalls"
@update:modelValue="updateE2EECallsEnabled">
{{ t('spreed', 'Enable encryption') }}
</NcCheckboxRadioSwitch>
<NcNoteCard
v-if="!canEnableE2EECalls"
type="warning"
:text="t('spreed', 'End-to-end encrypted calls with a configured SIP bridge require a newer version of the High-performance backend and SIP bridge.')" />
<NcNoteCard
v-else
type="warning"
:text="t('spreed', 'Mobile clients do not support end-to-end encrypted calls at the moment.')" />
</template>
</section>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import { getTalkConfig } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts'
const defaultGroupNotificationOptions = [
{ value: 1, label: t('spreed', 'All messages') },
{ value: 2, label: t('spreed', '@-mentions only') },
{ value: 3, label: t('spreed', 'Off') },
]
export default {
name: 'GeneralSettings',
components: {
NcNoteCard,
NcCheckboxRadioSwitch,
NcSelect,
},
props: {
hasSignalingServers: {
type: Boolean,
required: true,
},
},
data() {
return {
loading: true,
loadingConversationsFiles: false,
loadingDefaultGroupNotification: false,
defaultGroupNotificationOptions,
defaultGroupNotification: defaultGroupNotificationOptions[1],
conversationsFiles: parseInt(loadState('spreed', 'conversations_files')) === 1,
conversationsFilesPublicShares: parseInt(loadState('spreed', 'conversations_files_public_shares')) === 1,
hasFeatureJoinFeatures: false,
isE2EECallsEnabled: getTalkConfig('local', 'call', 'end-to-end-encryption'),
hasSIPBridge: !!loadState('spreed', 'sip_bridge_shared_secret'),
}
},
computed: {
isConversationsFilesChecked() {
return this.conversationsFiles
},
isConversationsFilesPublicSharesChecked() {
return this.conversationsFilesPublicShares
},
canEnableE2EECalls() {
return this.hasFeatureJoinFeatures || !this.hasSIPBridge
},
},
mounted() {
this.loading = true
this.defaultGroupNotification = defaultGroupNotificationOptions[parseInt(loadState('spreed', 'default_group_notification')) - 1]
this.loading = false
EventBus.on('signaling-server-connected', this.updateSignalingDetails)
EventBus.on('sip-settings-updated', this.updateSipDetails)
},
beforeUnmount() {
EventBus.off('signaling-server-connected', this.updateSignalingDetails)
EventBus.off('sip-settings-updated', this.updateSipDetails)
},
methods: {
t,
updateSignalingDetails(signaling) {
this.hasFeatureJoinFeatures = signaling.hasFeature('join-features')
},
updateSipDetails(settings) {
this.hasSIPBridge = !!settings.sharedSecret
},
updateE2EECallsEnabled(value) {
this.loading = true
OCP.AppConfig.setValue('spreed', 'call_end_to_end_encryption', value ? '1' : '0', {
success: () => {
this.loading = false
},
})
},
saveDefaultGroupNotification() {
this.loadingDefaultGroupNotification = true
OCP.AppConfig.setValue('spreed', 'default_group_notification', this.defaultGroupNotification.value, {
success: () => {
this.loadingDefaultGroupNotification = false
},
})
},
saveConversationsFiles(checked) {
this.loadingConversationsFiles = true
this.conversationsFiles = checked
OCP.AppConfig.setValue('spreed', 'conversations_files', this.conversationsFiles ? '1' : '0', {
success: () => {
if (!this.conversationsFiles) {
// When the file integration is disabled, the share integration is also disabled
OCP.AppConfig.setValue('spreed', 'conversations_files_public_shares', '0', {
success: () => {
this.conversationsFilesPublicShares = false
this.loadingConversationsFiles = false
},
})
} else {
this.loadingConversationsFiles = false
}
},
})
},
saveConversationsFilesPublicShares(checked) {
this.loadingConversationsFiles = true
this.conversationsFilesPublicShares = checked
OCP.AppConfig.setValue('spreed', 'conversations_files_public_shares', this.conversationsFilesPublicShares ? '1' : '0', {
success: () => {
this.loadingConversationsFiles = false
},
})
},
},
}
</script>
<style scoped lang="scss">
h3 {
margin-top: 24px;
font-weight: 600;
}
small {
color: var(--color-favorite);
border: 1px solid var(--color-favorite);
border-radius: 16px;
padding: 0 9px;
}
.default-group-notification {
min-width: 300px !important;
}
</style>
@@ -0,0 +1,321 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section
v-if="!hasSignalingServers || trialAccount.length !== 0"
id="hosted_signaling_server"
class="hosted-signaling section">
<h2>
{{ t('spreed', 'Hosted High-performance backend') }}
</h2>
<p class="settings-hint">
{{ t('spreed', 'Our partner Struktur AG provides a service where a hosted signaling server can be requested. For this you only need to fill out the form below and your Nextcloud will request it. Once the server is set up for you the credentials will be filled automatically. This will overwrite the existing signaling server settings.') }}
{{ t('spreed', 'If your high-performance backend account includes STUN and/or TURN functionality, the settings will be updated accordingly.') }}
</p>
<template v-if="!trialAccount.status">
<NcTextField
v-model="hostedHPBNextcloudUrl"
class="form__textfield"
name="hosted_hpb_nextcloud_url"
placeholder="https://cloud.example.org/"
:disabled="loading"
:label="t('spreed', 'URL of this Nextcloud instance')"
labelVisible />
<NcTextField
v-model="hostedHPBFullName"
class="form__textfield"
name="full_name"
placeholder="Jane Doe"
:disabled="loading"
:label="t('spreed', 'Full name of the user requesting the trial')"
labelVisible />
<NcTextField
v-model="hostedHPBEmail"
class="form__textfield"
name="hosted_hpb_email"
placeholder="jane@example.org"
:disabled="loading"
:label="t('spreed', 'Email of the user')"
labelVisible />
<NcSelect
v-model="hostedHPBLanguage"
inputId="hosted_hpb_language_input"
:inputLabel=" t('spreed', 'Language')"
class="form__select"
name="hosted_hpb_language"
:disabled="loading"
:aria-label="t('spreed', 'Language')"
:placeholder="t('spreed', 'Language')"
:options="languages"
:clearable="false"
label="name"
trackBy="code"
noWrap />
<NcSelect
v-model="hostedHPBCountry"
inputId="hosted_hpb_country_input"
:inputLabel=" t('spreed', 'Country')"
class="form__select"
name="hosted_hpb_country"
:disabled="loading"
:aria-label="t('spreed', 'Country')"
:placeholder="t('spreed', 'Country')"
:options="countries"
:clearable="false"
label="name"
trackBy="code"
noWrap />
<NcButton
class="additional-top-margin"
:disabled="!hostedHPBFilled || loading"
@click="requestHPBTrial">
{{ t('spreed', 'Request signaling server trial') }}
</NcButton>
<p v-if="requestError !== ''" class="warning">
{{ requestError }}
</p>
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="settings-hint additional-top-margin" v-html="disclaimerHint" />
</template>
<template v-else>
<p class="settings-hint additional-top-margin">
{{ t('spreed', 'You can see the current status of your hosted signaling server in the following table.') }}
</p>
<table>
<tbody>
<tr>
<td>{{ t('spreed', 'Status') }}</td>
<td>{{ translatedStatus }}</td>
</tr>
<tr>
<td>{{ t('spreed', 'Created at') }}</td>
<td>{{ createdDate }}</td>
</tr>
<tr>
<td>{{ t('spreed', 'Expires at') }}</td>
<td>{{ expiryDate }}</td>
</tr>
<tr v-if="trialAccount.limits?.users">
<td>{{ t('spreed', 'Limits') }}</td>
<td>{{ n('spreed', '%n user', '%n users', trialAccount.limits.users) }}</td>
</tr>
<tr>
<td>{{ t('spreed', 'STUN included') }}</td>
<td>
{{ trialAccount.stun?.servers ? t('spreed', 'Yes') : t('spreed', 'No') }}
</td>
</tr>
<tr>
<td>{{ t('spreed', 'TURN included') }}</td>
<td>
{{ trialAccount.turn?.servers ? t('spreed', 'Yes') : t('spreed', 'No') }}
</td>
</tr>
</tbody>
</table>
<p
v-if="requestError !== ''"
class="warning">
{{ requestError }}
</p>
<NcButton
variant="error"
class="additional-top-margin"
:disabled="loading"
@click="deleteAccount">
{{ t('spreed', 'Delete the signaling server account') }}
</NcButton>
</template>
</section>
</template>
<script>
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { n, t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { EventBus } from '../../services/EventBus.ts'
import { formatDateTime } from '../../utils/formattedTime.ts'
export default {
name: 'HostedSignalingServer',
components: {
NcButton,
NcSelect,
NcTextField,
},
props: {
hasSignalingServers: {
type: Boolean,
required: true,
},
},
data() {
return {
hostedHPBNextcloudUrl: '',
hostedHPBFullName: '',
hostedHPBEmail: '',
hostedHPBLanguage: '',
hostedHPBCountry: '',
requestError: '',
loading: false,
showForm: true,
trialAccount: [],
languages: [],
countries: [],
}
},
computed: {
hostedHPBFilled() {
return this.hostedHPBNextcloudUrl !== ''
&& this.hostedHPBFullName !== ''
&& this.hostedHPBEmail !== ''
&& this.hostedHPBLanguage !== ''
&& this.hostedHPBCountry !== ''
},
disclaimerHint() {
return t('spreed', 'By clicking the button above the information in the form is sent to the servers of Struktur AG. You can find further information at {linkstart}spreed.eu{linkend}.')
.replace('{linkstart}', '<a target="_blank" rel="noreferrer nofollow" class="external" href="https://www.spreed.eu/nextcloud-talk-high-performance-backend/">')
.replace('{linkend}', ' ↗</a>')
},
translatedStatus() {
switch (this.trialAccount.status) {
case 'pending':
return t('spreed', 'Pending')
case 'error':
return t('spreed', 'Error')
case 'blocked':
return t('spreed', 'Blocked')
case 'active':
return t('spreed', 'Active')
case 'expired':
return t('spreed', 'Expired')
}
return ''
},
expiryDate() {
return this.trialAccount.expires
? formatDateTime(this.trialAccount.expires, 'shortDateNumeric')
: t('spreed', 'Never')
},
createdDate() {
return formatDateTime(this.trialAccount.created, 'shortDateNumeric')
},
},
beforeMount() {
const state = loadState('spreed', 'hosted_signaling_server_prefill')
this.hostedHPBNextcloudUrl = state.url
this.hostedHPBFullName = state.fullName
this.hostedHPBEmail = state.email
this.trialAccount = loadState('spreed', 'hosted_signaling_server_trial_data')
const languagesAndCountries = loadState('spreed', 'hosted_signaling_server_language_data')
// two lists of {code: "es", name: "Español"} - one is in 'commonLanguages' and one in 'otherLanguages'
this.languages = [...languagesAndCountries.languages.commonLanguages, ...languagesAndCountries.languages.otherLanguages]
// list of {code: "France", name: "France"}
this.countries = languagesAndCountries.countries
this.hostedHPBLanguage = this.languages.find((language) => language.code === state.language) ?? this.languages[0]
this.hostedHPBCountry = this.countries.find((country) => country.code === state.country) ?? this.countries[0]
},
methods: {
t,
n,
async requestHPBTrial() {
this.requestError = ''
this.loading = true
try {
const res = await axios.post(generateOcsUrl('apps/spreed/api/v1/hostedsignalingserver/requesttrial'), {
url: this.hostedHPBNextcloudUrl,
name: this.hostedHPBFullName,
email: this.hostedHPBEmail,
language: this.hostedHPBLanguage.code,
country: this.hostedHPBCountry.code,
})
this.trialAccount = res.data.ocs.data
} catch (err) {
this.requestError = err?.response?.data?.ocs?.data?.message || t('spreed', 'The trial could not be requested. Please try again later.')
} finally {
this.loading = false
}
},
async deleteAccount() {
this.requestError = ''
this.loading = true
try {
await axios.delete(generateOcsUrl('apps/spreed/api/v1/hostedsignalingserver/delete'))
this.trialAccount = []
} catch (err) {
this.requestError = err?.response?.data?.ocs?.data?.message || t('spreed', 'The account could not be deleted. Please try again later.')
} finally {
this.loading = false
}
},
},
}
</script>
<style lang="scss" scoped>
.hosted-signaling {
.form {
&__textfield {
width: 300px;
margin-top: 12px;
}
&__select {
min-width: 300px !important;
}
}
}
.additional-top-margin {
margin-top: 10px;
}
td {
padding: 5px;
border-bottom: 1px solid var(--color-border);
}
tr:last-child td {
border-bottom: none;
}
tr :first-child {
opacity: .5;
}
</style>
@@ -0,0 +1,186 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="matterbridge_settings" class="matterbridge section">
<h2>
{{ t('spreed', 'Matterbridge integration') }}
<small>{{ t('spreed', 'Beta') }}</small>
</h2>
<template v-if="matterbridgeVersion">
<p class="settings-hint">
{{ installedVersion }}
</p>
<NcCheckboxRadioSwitch
:modelValue="isEnabled"
@update:modelValue="saveMatterbridgeEnabled">
{{ t('spreed', 'Enable Matterbridge integration') }}
</NcCheckboxRadioSwitch>
</template>
<template v-else>
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="settings-hint" v-html="description" />
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="settings-hint" v-html="customBinaryText" />
<p v-if="errorText" class="settings-hint">
{{ errorText }}
</p>
<NcButton
:disabled="isInstalling"
@click="enableMatterbridgeApp">
<template v-if="isInstalling" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ installButtonText }}
</NcButton>
</template>
</section>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import {
enableMatterbridgeApp,
getMatterbridgeVersion,
stopAllBridges,
} from '../../services/matterbridgeService.js'
export default {
name: 'MatterbridgeIntegration',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcLoadingIcon,
},
data() {
return {
matterbridgeEnabled: loadState('spreed', 'matterbridge_enable'),
matterbridgeVersion: loadState('spreed', 'matterbridge_version'),
isInstalling: false,
error: loadState('spreed', 'matterbridge_error'),
}
},
computed: {
isEnabled() {
return this.matterbridgeEnabled
},
installedVersion() {
return t('spreed', 'Installed version: {version}', {
version: this.matterbridgeVersion,
})
},
description() {
return t('spreed', 'You can install the Matterbridge to link Nextcloud Talk to some other services, visit their {linkstart1}GitHub page{linkend} for more details. Downloading and installing the app can take a while. In case it times out, please install it manually from the {linkstart2}Nextcloud App Store{linkend}.')
.replace('{linkstart1}', '<a target="_blank" rel="noreferrer nofollow" class="external" href="https://github.com/42wim/matterbridge/wiki">')
.replace('{linkstart2}', '<a target="_blank" rel="noreferrer nofollow" class="external" href="https://apps.nextcloud.com/apps/talk_matterbridge">')
.replace(/{linkend}/g, ' ↗</a>')
},
errorText() {
if (this.error === 'binary_permissions') {
return t('spreed', 'Matterbridge binary has incorrect permissions. Please make sure the Matterbridge binary file is owned by the correct user and can be executed. It can be found in "/…/nextcloud/apps/talk_matterbridge/bin/".')
} else if (this.error === 'binary') {
return t('spreed', 'Matterbridge binary was not found or couldn\'t be executed.')
} else {
return ''
}
},
customBinaryText() {
return t('spreed', 'You can also set the path to the Matterbridge binary manually via the config. Check the {linkstart}Matterbridge integration documentation{linkend} for more information.')
.replace('{linkstart}', '<a target="_blank" rel="noreferrer nofollow" class="external" href="https://nextcloud-talk.readthedocs.io/en/latest/matterbridge/">')
.replace(/{linkend}/g, ' ↗</a>')
},
installButtonText() {
return this.isInstalling
? t('spreed', 'Downloading …')
: t('spreed', 'Install Talk Matterbridge')
},
},
methods: {
t,
saveMatterbridgeEnabled() {
this.matterbridgeEnabled = !this.matterbridgeEnabled
OCP.AppConfig.setValue('spreed', 'enable_matterbridge', this.matterbridgeEnabled ? '1' : '0', {
success: () => {
if (!this.matterbridgeEnabled) {
stopAllBridges()
}
},
})
},
async enableMatterbridgeApp() {
if (OC.PasswordConfirmation.requiresPasswordConfirmation()) {
OC.PasswordConfirmation.requirePasswordConfirmation(this.enableMatterbridgeAppCallback, {}, () => {
showError(t('spreed', 'An error occurred while installing the Matterbridge app'))
})
}
this.enableMatterbridgeAppCallback()
},
async enableMatterbridgeAppCallback() {
this.isInstalling = true
try {
await enableMatterbridgeApp()
} catch (e) {
showError(t('spreed', 'An error occurred while installing the Talk Matterbridge. Please install it manually'), {
onClick: () => {
window.open('https://apps.nextcloud.com/apps/talk_matterbridge', '_blank')
},
})
return
}
try {
const response = await getMatterbridgeVersion()
this.matterbridgeVersion = response.data.ocs.data.version
this.matterbridgeEnabled = true
this.saveMatterbridgeEnabled()
this.error = ''
} catch (error) {
console.error(error)
showError(t('spreed', 'Failed to execute Matterbridge binary.'))
if (error?.response?.data?.ocs?.data?.error) {
this.error = error.response.data.ocs.data.error
} else {
this.error = 'binary'
}
}
this.isInstalling = false
},
},
}
</script>
<style lang="scss" scoped>
h2 {
small {
color: var(--color-favorite);
border: 1px solid var(--color-favorite);
border-radius: 16px;
padding: 0 9px;
}
}
</style>
@@ -0,0 +1,221 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<li class="recording-server">
<NcTextField
ref="recording_server"
v-model="recordingServer"
class="recording-server__textfield"
name="recording_server"
placeholder="https://recording.example.org"
:disabled="loading"
:label="t('spreed', 'Recording backend URL')" />
<NcCheckboxRadioSwitch
:modelValue="verify"
class="recording-server__checkbox"
@update:modelValue="updateVerify">
{{ t('spreed', 'Validate SSL certificate') }}
</NcCheckboxRadioSwitch>
<NcButton
v-show="!loading"
variant="tertiary"
:title="t('spreed', 'Delete this server')"
:aria-label="t('spreed', 'Delete this server')"
@click="removeServer">
<template #icon>
<IconTrashCanOutline :size="20" />
</template>
</NcButton>
<span v-if="server" class="test-connection">
<NcLoadingIcon v-if="!checked" :size="20" />
<IconAlertCircleOutline v-else-if="errorMessage" :size="20" fillColor="var(--color-border-error)" />
<IconCheck v-else :size="20" fillColor="var(--color-border-success)" />
{{ connectionState }}
</span>
<NcButton
v-if="server && checked"
variant="tertiary"
:title="t('spreed', 'Test this server')"
:aria-label="t('spreed', 'Test this server')"
@click="checkServerVersion">
<template #icon>
<IconReload :size="20" />
</template>
</NcButton>
</li>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconReload from 'vue-material-design-icons/Reload.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import { getWelcomeMessage } from '../../services/recordingService.js'
export default {
name: 'RecordingServer',
components: {
IconAlertCircleOutline,
IconCheck,
IconTrashCanOutline,
IconReload,
NcButton,
NcCheckboxRadioSwitch,
NcLoadingIcon,
NcTextField,
},
props: {
server: {
type: String,
default: '',
required: true,
},
verify: {
type: Boolean,
default: false,
required: true,
},
index: {
type: Number,
default: -1,
required: true,
},
loading: {
type: Boolean,
default: false,
},
},
emits: ['removeServer', 'update:server', 'update:verify'],
data() {
return {
checked: false,
errorMessage: '',
versionFound: '',
}
},
computed: {
connectionState() {
if (!this.checked) {
return t('spreed', 'Status: Checking connection')
}
if (this.errorMessage) {
return this.errorMessage
}
return t('spreed', 'OK: Running version: {version}', {
version: this.versionFound,
})
},
recordingServer: {
get() {
return this.server
},
set(value) {
this.$emit('update:server', value)
},
},
},
watch: {
loading(isLoading) {
if (!isLoading) {
this.checkServerVersion()
}
},
},
mounted() {
if (this.server) {
this.checkServerVersion()
}
},
methods: {
t,
removeServer() {
this.$emit('removeServer', this.index)
},
updateVerify(checked) {
this.$emit('update:verify', checked)
},
async checkServerVersion() {
this.checked = false
this.errorMessage = ''
this.versionFound = ''
try {
const response = await getWelcomeMessage(this.index)
this.checked = true
this.versionFound = response.data.ocs.data.version
} catch (exception) {
this.checked = true
const data = exception.response.data.ocs.data
const error = data.error
if (error === 'CAN_NOT_CONNECT') {
this.errorMessage = t('spreed', 'Error: Cannot connect to server')
} else if (error === 'IS_SIGNALING_SERVER') {
this.errorMessage = t('spreed', 'Error: Server seems to be a Signaling server')
} else if (error === 'JSON_INVALID') {
this.errorMessage = t('spreed', 'Error: Server did not respond with proper JSON')
} else if (error === 'CERTIFICATE_EXPIRED') {
this.errorMessage = t('spreed', 'Error: Certificate expired')
} else if (error === 'TIME_OUT_OF_SYNC') {
this.errorMessage = t('spreed', 'Error: System times of Nextcloud server and Recording backend server are out of sync. Please make sure that both servers are connected to a time-server or manually synchronize their time.')
} else if (error) {
this.errorMessage = t('spreed', 'Error: Server responded with: {error}', data)
} else {
this.errorMessage = t('spreed', 'Error: Unknown error occurred')
}
}
},
},
}
</script>
<style lang="scss" scoped>
.recording-server {
display: flex;
align-items: center;
& &__textfield {
width: 300px;
flex-shrink: 0;
}
&__checkbox {
margin: 0 18px;
}
}
.test-connection {
display: inline-flex;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,297 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="recording_server" class="recording-servers section">
<h2>
{{ t('spreed', 'Recording backend') }}
</h2>
<NcNoteCard
v-if="!hasSignalingServers"
type="warning"
:text="t('spreed', 'Recording backend configuration is only possible with a High-performance backend.')" />
<template v-else>
<NcNoteCard v-if="showUploadLimitWarning" type="warning" :text="uploadLimitWarning" />
<TransitionWrapper
v-if="servers.length"
name="fade"
tag="ul"
group>
<RecordingServer
v-for="(server, index) in servers"
:key="`server${index}`"
v-model:server="servers[index].server"
v-model:verify="servers[index].verify"
:index="index"
:loading="loading"
@removeServer="removeServer"
@update:server="debounceUpdateServers"
@update:verify="debounceUpdateServers" />
</TransitionWrapper>
<NcButton
v-else
class="additional-top-margin"
:disabled="loading"
@click="newServer">
<template #icon>
<NcLoadingIcon v-if="loading" :size="20" />
<IconPlus v-else :size="20" />
</template>
{{ t('spreed', 'Add a new recording backend server') }}
</NcButton>
<NcPasswordField
v-model="secret"
class="form__textfield additional-top-margin"
name="recording_secret"
asText
:disabled="loading"
:placeholder="t('spreed', 'Shared secret')"
:label="t('spreed', 'Shared secret')"
labelVisible
@update:modelValue="debounceUpdateServers" />
<template v-if="servers.length && recordingConsentCapability">
<h3>{{ t('spreed', 'Recording consent') }}</h3>
<template v-for="level in recordingConsentOptions" :key="level.value">
<NcCheckboxRadioSwitch
v-model="recordingConsentSelected"
:value="level.value.toString()"
name="recording-consent"
type="radio"
:disabled="loading"
@update:modelValue="setRecordingConsent">
{{ level.label }}
</NcCheckboxRadioSwitch>
<p class="consent-description">
{{ getRecordingConsentDescription(level.value) }}
</p>
</template>
</template>
<template v-if="servers.length">
<h3>{{ t('spreed', 'Recording transcription') }}</h3>
<!-- FIXME hidden until transcription quality is appropriate -->
<NcCheckboxRadioSwitch
v-if="false"
v-model="recordingTranscriptionEnabled"
type="switch"
:disabled="loading"
@update:modelValue="setRecordingTranscription">
{{ t('spreed', 'Automatically transcribe call recordings with a transcription provider') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-model="recordingSummaryEnabled"
type="switch"
:disabled="loading"
@update:modelValue="setRecordingSummary">
{{ t('spreed', 'Automatically summarize call recordings with transcription and summary providers') }}
</NcCheckboxRadioSwitch>
</template>
</template>
</section>
</template>
<script>
import { showSuccess } from '@nextcloud/dialogs'
import { formatFileSize } from '@nextcloud/files'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import debounce from 'debounce'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import RecordingServer from '../../components/AdminSettings/RecordingServer.vue'
import TransitionWrapper from '../UIShared/TransitionWrapper.vue'
import { CONFIG } from '../../constants.ts'
import { hasTalkFeature } from '../../services/CapabilitiesManager.ts'
const recordingConsentCapability = hasTalkFeature('local', 'recording-consent')
const recordingConsentOptions = [
{ value: CONFIG.RECORDING_CONSENT.OFF, label: t('spreed', 'Disabled for all calls') },
{ value: CONFIG.RECORDING_CONSENT.REQUIRED, label: t('spreed', 'Enabled for all calls') },
{ value: CONFIG.RECORDING_CONSENT.OPTIONAL, label: t('spreed', 'Configurable on conversation level by moderators') },
]
export default {
name: 'RecordingServers',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcLoadingIcon,
NcNoteCard,
NcPasswordField,
IconPlus,
RecordingServer,
TransitionWrapper,
},
props: {
hasSignalingServers: {
type: Boolean,
required: true,
},
},
setup() {
return {
recordingConsentCapability,
recordingConsentOptions,
}
},
data() {
return {
servers: [],
secret: '',
uploadLimit: 0,
loading: false,
saved: false,
recordingConsentSelected: loadState('spreed', 'recording_consent').toString(),
recordingTranscriptionEnabled: loadState('spreed', 'call_recording_transcription'),
recordingSummaryEnabled: loadState('spreed', 'call_recording_summary'),
debounceUpdateServers: () => {},
}
},
computed: {
showUploadLimitWarning() {
return this.uploadLimit !== 0 && this.uploadLimit < 512 * (1024 ** 2)
},
uploadLimitWarning() {
return t('spreed', 'The PHP settings "upload_max_filesize" or "post_max_size" only will allow to upload files up to {maxUpload}.', {
maxUpload: formatFileSize(this.uploadLimit, true, true),
})
},
},
beforeMount() {
this.debounceUpdateServers = debounce(this.updateServers, 1000)
const state = loadState('spreed', 'recording_servers')
this.servers = state.servers
this.secret = state.secret
this.uploadLimit = parseInt(state.uploadLimit, 10)
},
beforeUnmount() {
this.debounceUpdateServers.clear?.()
},
methods: {
t,
removeServer(index) {
this.servers.splice(index, 1)
this.debounceUpdateServers()
},
newServer() {
this.servers.push({
server: '',
verify: false,
})
},
async updateServers() {
this.loading = true
this.servers = this.servers.filter((server) => server.server.trim() !== '')
OCP.AppConfig.setValue('spreed', 'recording_servers', JSON.stringify({
servers: this.servers,
secret: this.secret,
}), {
success: () => {
showSuccess(t('spreed', 'Recording backend settings saved'))
this.loading = false
this.toggleSave()
},
})
},
setRecordingConsent(value) {
this.loading = true
OCP.AppConfig.setValue('spreed', 'recording_consent', value, {
success: () => {
this.loading = false
},
})
},
setRecordingTranscription(value) {
this.loading = true
OCP.AppConfig.setValue('spreed', 'call_recording_transcription', value ? 'yes' : 'no', {
success: () => {
this.loading = false
},
})
},
setRecordingSummary(value) {
this.loading = true
OCP.AppConfig.setValue('spreed', 'call_recording_summary', value ? 'yes' : 'no', {
success: () => {
this.loading = false
},
})
},
getRecordingConsentDescription(value) {
switch (value) {
case CONFIG.RECORDING_CONSENT.OPTIONAL:
return t('spreed', 'Moderators will be allowed to enable consent on conversation level. The consent to be recorded will be required for each participant before joining every call in this conversation.')
case CONFIG.RECORDING_CONSENT.REQUIRED:
return t('spreed', 'The consent to be recorded will be required for each participant before joining every call.')
case CONFIG.RECORDING_CONSENT.OFF:
default:
return t('spreed', 'The consent to be recorded is not required.')
}
},
toggleSave() {
this.saved = true
setTimeout(() => {
this.saved = false
}, 3000)
},
},
}
</script>
<style lang="scss" scoped>
.recording-servers {
.form__textfield {
width: 300px;
}
}
.additional-top-margin {
margin-top: 10px;
}
h3 {
margin-top: 24px;
font-weight: 600;
}
.consent-description {
margin-bottom: 12px;
opacity: 0.7;
}
</style>
+333
View File
@@ -0,0 +1,333 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div id="sip-bridge" class="sip-bridge section">
<h2>{{ t('spreed', 'SIP configuration') }}</h2>
<NcNoteCard
v-if="!hasSignalingServers"
type="warning"
:text="t('spreed', 'SIP configuration is only possible with a High-performance backend.')" />
<template v-else>
<NcCheckboxRadioSwitch
v-model="dialOutEnabled"
type="switch"
:disabled="loading || !dialOutSupported">
{{ t('spreed', 'Enable SIP Dial-out option') }}
</NcCheckboxRadioSwitch>
<NcNoteCard
v-if="!dialOutSupported"
type="warning"
:text="t('spreed', 'Signaling server needs to be updated to supported SIP Dial-out feature.')" />
<template v-if="dialOutEnabled">
<NcCheckboxRadioSwitch
v-model="dialOutAnonymous"
type="switch"
:disabled="loading">
{{ t('spreed', 'Do not show SIP Dial-out caller number') }}
</NcCheckboxRadioSwitch>
<p class="settings-hint">
{{ t('spreed', 'Anonymous number should appear as "unknown" or "withheld number" to call recipient') }}
</p>
</template>
<template v-if="dialOutEnabled && !dialOutAnonymous">
<label for="sip-dialout-number" class="form__label additional-top-margin">
{{ t('spreed', 'Dial-out number') }}
</label>
<NcTextField
id="sip-dialout-number"
v-model="dialOutNumber"
class="form"
name="sip-dialout-number"
:disabled="loading"
placeholder="+49123456789"
labelOutside />
<p class="settings-hint additional-top-margin">
{{ t('spreed', 'E164 formatted number used as a fallback caller number for outgoing calls') }}
</p>
<label for="sip-dialout-prefix" class="form__label additional-top-margin">
{{ t('spreed', 'Dial-out prefix') }}
</label>
<NcTextField
id="sip-dialout-prefix"
v-model="dialOutPrefix"
class="form"
name="sip-dialout-prefix"
:disabled="loading"
labelOutside />
<p class="settings-hint additional-top-margin">
{{ t('spreed', 'Prefix to configured user number for outgoing calls (default is `+`)') }}
</p>
</template>
<NcSelect
v-model="sipGroups"
inputId="sip-group-enabled"
:inputLabel="t('spreed', 'Restrict SIP configuration')"
class="form form__select"
:options="groups"
:placeholder="t('spreed', 'Enable SIP configuration')"
:disabled="loading"
:multiple="true"
:searchable="true"
:tagWidth="60"
:loading="loadingGroups"
:showNoOptions="false"
keepOpen
trackBy="id"
label="displayname"
noWrap
@search="debounceSearchGroup" />
<p class="settings-hint settings-hint--after-select">
{{ t('spreed', 'Only users of the following groups can enable SIP in conversations they moderate') }}
</p>
<label for="sip-shared-secret" class="form__label additional-top-margin">
{{ t('spreed', 'Shared secret') }}
</label>
<NcPasswordField
id="sip-shared-secret"
v-model="sharedSecret"
class="form"
name="sip-shared-secret"
asText
:disabled="loading"
:placeholder="t('spreed', 'Shared secret')"
labelOutside />
<label for="dial-in-info" class="form__label additional-top-margin">
{{ t('spreed', 'Dial-in information') }}
</label>
<NcTextArea
id="dial-in-info"
v-model="dialInInfo"
name="message"
class="form form__textarea"
rows="4"
:disabled="loading"
:placeholder="t('spreed', 'Phone number (Country)')" />
<p class="settings-hint">
{{ t('spreed', 'This information is sent in invitation emails as well as displayed in the sidebar to all participants.') }}
</p>
<NcButton
variant="primary"
class="additional-top-margin"
:disabled="loading || !isEdited"
@click="saveSIPSettings">
{{ t('spreed', 'Save changes') }}
</NcButton>
</template>
</div>
</template>
<script>
import axios from '@nextcloud/axios'
import { showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import debounce from 'debounce'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import { EventBus } from '../../services/EventBus.ts'
import { setSIPSettings } from '../../services/settingsService.ts'
import { getWelcomeMessage } from '../../services/signalingService.js'
export default {
name: 'SIPBridge',
components: {
NcCheckboxRadioSwitch,
NcButton,
NcNoteCard,
NcSelect,
NcTextArea,
NcTextField,
NcPasswordField,
},
props: {
hasSignalingServers: {
type: Boolean,
required: true,
},
},
data() {
return {
loading: false,
loadingGroups: false,
groups: [],
sipGroups: [],
dialInInfo: '',
sharedSecret: '',
dialOutEnabled: false,
dialOutAnonymous: false,
dialOutNumber: '',
dialOutPrefix: '',
currentSetup: {},
dialOutSupported: false,
debounceSearchGroup: () => {},
}
},
computed: {
isEdited() {
return this.currentSetup.sharedSecret !== this.sharedSecret
|| this.currentSetup.dialInInfo !== this.dialInInfo
|| this.currentSetup.dialOutEnabled !== this.dialOutEnabled
|| this.currentSetup.dialOutAnonymous !== this.dialOutAnonymous
|| this.currentSetup.dialOutNumber !== this.dialOutNumber
|| this.currentSetup.dialOutPrefix !== this.dialOutPrefix
|| this.currentSetup.sipGroups !== this.sipGroups.map((group) => group.id).join('_')
},
},
mounted() {
this.debounceSearchGroup = debounce(this.searchGroup, 500)
this.loading = true
this.groups = loadState('spreed', 'sip_bridge_groups').sort(function(a, b) {
return a.displayname.localeCompare(b.displayname)
})
this.sipGroups = this.groups
this.dialInInfo = loadState('spreed', 'sip_bridge_dialin_info')
this.dialOutEnabled = loadState('spreed', 'sip_bridge_dialout')
this.dialOutAnonymous = loadState('spreed', 'sip_bridge_dialout_anonymous')
this.dialOutNumber = loadState('spreed', 'sip_bridge_dialout_number')
this.dialOutPrefix = loadState('spreed', 'sip_bridge_dialout_prefix')
this.sharedSecret = loadState('spreed', 'sip_bridge_shared_secret')
this.debounceSearchGroup('')
this.loading = false
this.saveCurrentSetup()
this.isDialoutSupported()
},
beforeUnmount() {
this.debounceSearchGroup.clear?.()
},
methods: {
t,
async searchGroup(query) {
this.loadingGroups = true
try {
const response = await axios.get(generateOcsUrl('cloud/groups/details'), {
search: query,
limit: 20,
offset: 0,
})
this.groups = response.data.ocs.data.groups.sort(function(a, b) {
return a.displayname.localeCompare(b.displayname)
})
} catch (err) {
console.error('Could not fetch groups', err)
} finally {
this.loadingGroups = false
}
},
saveCurrentSetup() {
this.currentSetup = {
sharedSecret: this.sharedSecret,
dialInInfo: this.dialInInfo,
dialOutEnabled: this.dialOutEnabled,
dialOutAnonymous: this.dialOutAnonymous,
dialOutNumber: this.dialOutNumber,
dialOutPrefix: this.dialOutPrefix,
sipGroups: this.sipGroups.map((group) => group.id).join('_'),
}
EventBus.emit('sip-settings-updated', this.currentSetup)
},
async saveSIPSettings() {
this.loading = true
this.saveLabel = t('spreed', 'Saving …')
const sipGroups = this.sipGroups.map((group) => {
return group.id
})
await setSIPSettings({
sipGroups,
sharedSecret: this.sharedSecret,
dialInInfo: this.dialInInfo,
})
if (this.currentSetup.dialOutEnabled !== this.dialOutEnabled) {
await OCP.AppConfig.setValue('spreed', 'sip_dialout', this.dialOutEnabled ? 'yes' : 'no')
}
if (this.currentSetup.dialOutAnonymous !== this.dialOutAnonymous) {
await OCP.AppConfig.setValue('spreed', 'sip_bridge_dialout_anonymous', this.dialOutAnonymous)
}
if (this.currentSetup.dialOutNumber !== this.dialOutNumber) {
await OCP.AppConfig.setValue('spreed', 'sip_bridge_dialout_number', this.dialOutNumber)
}
if (this.currentSetup.dialOutPrefix !== this.dialOutPrefix) {
await OCP.AppConfig.setValue('spreed', 'sip_bridge_dialout_prefix', this.dialOutPrefix)
}
this.loading = false
this.saveCurrentSetup()
showSuccess(t('spreed', 'SIP configuration saved!'))
},
async isDialoutSupported() {
const servers = loadState('spreed', 'signaling_servers').servers
for (let index = 0; index < servers.length; index++) {
try {
const response = await getWelcomeMessage(index)
const data = response.data.ocs.data
// At least one server has the dialout feature
if (!data.warning || (data.warning === 'UPDATE_OPTIONAL' && !(data.features?.includes('dialout')))) {
this.dialOutSupported = true
break
}
} catch (exception) {
this.dialOutSupported = false
}
}
},
},
}
</script>
<style lang="scss" scoped>
.sip-bridge {
h3 {
margin-top: 24px;
font-weight: 600;
}
.form {
width: 300px;
&__textarea {
margin-bottom: 6px;
}
&__select {
margin-bottom: 12px;
}
}
}
.settings-hint--after-select {
margin-top: 0;
}
.additional-top-margin {
margin-top: 10px;
}
</style>
@@ -0,0 +1,307 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<li class="signaling-server">
<NcTextField
ref="signaling_server"
v-model="signalingServer"
class="signaling-server__textfield"
name="signaling_server"
placeholder="wss://signaling.example.org"
:disabled="loading"
:label="t('spreed', 'High-performance backend URL')" />
<NcCheckboxRadioSwitch
:modelValue="verify"
class="signaling-server__checkbox"
@update:modelValue="updateVerify">
{{ t('spreed', 'Validate SSL certificate') }}
</NcCheckboxRadioSwitch>
<NcButton
v-show="!loading"
variant="tertiary"
:title="t('spreed', 'Delete this server')"
:aria-label="t('spreed', 'Delete this server')"
@click="removeServer">
<template #icon>
<IconTrashCanOutline :size="20" />
</template>
</NcButton>
<span v-if="server" class="test-connection">
<NcLoadingIcon v-if="!checked" :size="20" />
<IconAlertCircleOutline v-else-if="errorMessage" :size="20" fillColor="var(--color-border-error)" />
<IconAlertCircleOutline v-else-if="warningMessage" :size="20" fillColor="var(--color-favorite)" />
<IconCheck v-else :size="20" fillColor="var(--color-border-success)" />
{{ connectionState }}
<NcButton
v-if="server && checked"
variant="tertiary"
:title="t('spreed', 'Test this server')"
:aria-label="t('spreed', 'Test this server')"
@click="checkServerVersion">
<template #icon>
<IconReload :size="20" />
</template>
</NcButton>
</span>
<ul v-if="signalingTestInfo.length" class="test-connection-data">
<li
v-for="(row, idx) in signalingTestInfo"
:key="idx"
class="test-connection-data__item">
<span class="test-connection-data__caption">
{{ row.caption }}
</span>
<span>
{{ row.description }}
</span>
</li>
</ul>
</li>
</template>
<script>
import { t } from '@nextcloud/l10n'
import { getBaseUrl } from '@nextcloud/router'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconReload from 'vue-material-design-icons/Reload.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import { EventBus } from '../../services/EventBus.ts'
import { fetchSignalingSettings, getWelcomeMessage } from '../../services/signalingService.js'
import { createConnection } from '../../utils/SignalingStandaloneTest.js'
export default {
name: 'SignalingServer',
components: {
IconAlertCircleOutline,
IconCheck,
IconTrashCanOutline,
IconReload,
NcButton,
NcCheckboxRadioSwitch,
NcLoadingIcon,
NcTextField,
},
props: {
server: {
type: String,
default: '',
required: true,
},
verify: {
type: Boolean,
default: false,
required: true,
},
index: {
type: Number,
default: -1,
required: true,
},
loading: {
type: Boolean,
default: false,
},
},
emits: ['removeServer', 'update:server', 'update:verify'],
data() {
return {
checked: false,
errorMessage: '',
warningMessage: '',
versionFound: '',
signalingTestInfo: [],
}
},
computed: {
connectionState() {
if (!this.checked) {
return t('spreed', 'Status: Checking connection')
}
if (this.errorMessage) {
return this.errorMessage
}
if (this.warningMessage) {
return this.warningMessage
}
return t('spreed', 'OK: Running version: {version}', {
version: this.versionFound,
})
},
signalingServer: {
get() {
return this.server
},
set(value) {
this.$emit('update:server', value)
},
},
},
watch: {
loading(isLoading) {
if (!isLoading) {
this.checkServerVersion()
}
},
},
mounted() {
if (this.server) {
this.checkServerVersion()
}
},
methods: {
t,
removeServer() {
this.$emit('removeServer', this.index)
},
updateVerify(checked) {
this.$emit('update:verify', checked)
},
async checkServerVersion() {
this.checked = false
this.signalingTestInfo = []
this.errorMessage = ''
this.warningMessage = ''
this.versionFound = ''
try {
const response = await getWelcomeMessage(this.index)
const data = response.data.ocs.data
this.versionFound = data.version
if (data.warning === 'UPDATE_OPTIONAL') {
this.warningMessage = t('spreed', 'Warning: Running version: {version}; Server does not support all features of this Talk version, missing features: {features}', {
version: this.versionFound,
features: data.features.join(', '),
})
}
await this.testWebSocketConnection(this.server)
} catch (exception) {
const data = exception.response.data.ocs.data
const error = data.error
if (error === 'CAN_NOT_CONNECT') {
this.errorMessage = t('spreed', 'Error: Cannot connect to server')
} else if (error === 'JSON_INVALID') {
this.errorMessage = t('spreed', 'Error: Server did not respond with proper JSON')
} else if (error === 'CERTIFICATE_EXPIRED') {
this.errorMessage = t('spreed', 'Error: Certificate expired')
} else if (error === 'TIME_OUT_OF_SYNC') {
this.errorMessage = t('spreed', 'Error: System times of Nextcloud server and High-performance backend server are out of sync. Please make sure that both servers are connected to a time-server or manually synchronize their time.')
} else if (error === 'UPDATE_REQUIRED') {
this.versionFound = data.version || t('spreed', 'Could not get version')
this.errorMessage = t('spreed', 'Error: Running version: {version}; Server needs to be updated to be compatible with this version of Talk', {
version: this.versionFound,
})
} else if (error) {
this.errorMessage = t('spreed', 'Error: Server responded with: {error}', data)
} else {
this.errorMessage = t('spreed', 'Error: Unknown error occurred')
}
} finally {
this.checked = true
}
},
async testWebSocketConnection(url) {
const response = await fetchSignalingSettings({ token: '' }, {})
const settings = response.data.ocs.data
const signalingTest = createConnection(settings, url)
this.signalingTestInfo = [
{ caption: t('spreed', 'Nextcloud base URL'), description: getBaseUrl() },
{ caption: t('spreed', 'Talk Backend URL'), description: signalingTest.getBackendUrl() },
{ caption: t('spreed', 'WebSocket URL'), description: signalingTest.url },
]
try {
await signalingTest.connect()
this.signalingTestInfo.push({ caption: t('spreed', 'Available features'), description: signalingTest.features.join(', ') })
EventBus.emit('signaling-server-connected', signalingTest)
} catch (exception) {
if (exception.socketMessage) {
this.errorMessage = t('spreed', 'Error: Websocket connection failed')
this.signalingTestInfo.push({ caption: t('spreed', 'Error code'), description: exception.socketMessage.error.code })
this.signalingTestInfo.push({ caption: t('spreed', 'Error message'), description: exception.socketMessage.error.message })
} else if (exception.CSPViolation) {
this.warningMessage = t('spreed', 'Error: Websocket connection failed')
this.signalingTestInfo.push({ caption: t('spreed', 'Error code'), description: exception.CSPViolation.type })
this.signalingTestInfo.push({ caption: t('spreed', 'Error message'), description: exception.CSPViolation.message })
} else {
console.error(exception)
this.errorMessage = t('spreed', 'Error: Websocket connection failed. Check browser console')
}
}
},
},
}
</script>
<style lang="scss" scoped>
.signaling-server {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--default-grid-baseline);
margin-bottom: 10px;
& &__textfield {
width: 300px;
flex-shrink: 0;
}
&__checkbox {
margin: 0 18px;
}
}
.test-connection {
flex-basis: fit-content;
display: inline-flex;
align-items: center;
gap: var(--default-grid-baseline);
}
.test-connection-data {
flex-basis: 100%;
display: inline-grid;
grid-template-columns: auto auto;
gap: var(--default-grid-baseline);
&__item {
display: contents;
}
&__caption {
font-weight: bold;
margin-inline-end: var(--default-grid-baseline);
}
}
</style>
@@ -0,0 +1,224 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="signaling_server" class="signaling-servers section">
<NcNoteCard
v-if="!serversProxy.length"
type="warning"
:heading="t('spreed', 'Nextcloud Talk setup not complete')">
{{ t('spreed', 'Please note that in calls with more than 2 participants without the High-performance backend, participants will most likely experience connectivity issues and cause high load on participating devices.') }}
{{ t('spreed', 'Install the High-performance backend to ensure calls with multiple participants work seamlessly.') }}
<NcButton
v-if="props.hasValidSubscription"
variant="primary"
class="additional-top-margin"
href="https://portal.nextcloud.com/article/Nextcloud-Talk/High-Performance-Backend/Installation-of-Nextcloud-Talk-High-Performance-Backend">
{{ t('spreed', 'Nextcloud portal') }}
</NcButton>
<NcButton
v-else
variant="primary"
class="additional-top-margin"
href="https://nextcloud-talk.readthedocs.io/en/latest/quick-install/">
{{ t('spreed', 'Quick installation guide') }}
</NcButton>
</NcNoteCard>
<h2>
{{ t('spreed', 'High-performance backend') }}
</h2>
<p class="settings-hint">
{{ t('spreed', 'The High-performance backend is required for calls and conversations with multiple participants. Without the backend, all participants have to upload their own video individually for each other participant, which will most likely cause connectivity issues and a high load on participating devices.') }}
</p>
<NcNoteCard
v-if="serversProxy.length && !isCacheConfigured"
type="warning"
:text="t('spreed', 'It is highly recommended to set up a distributed cache when using Nextcloud Talk with a High-performance backend.')" />
<ul v-if="serversProxy.length">
<SignalingServer
v-for="(server, index) in serversProxy"
:key="index"
v-model:server="server.server"
v-model:verify="server.verify"
:index="index"
:loading="loading"
@removeServer="removeServer"
@update:server="debounceUpdateServers"
@update:verify="debounceUpdateServers" />
</ul>
<NcButton
v-if="!serversProxy.length || isClusteredMode"
class="additional-top-margin"
:disabled="loading"
@click="newServer">
<template #icon>
<NcLoadingIcon v-if="loading" :size="20" />
<IconPlus v-else :size="20" />
</template>
{{ t('spreed', 'Add High-performance backend server') }}
</NcButton>
<NcPasswordField
v-if="serversProxy.length"
v-model="secretProxy"
class="form__textfield additional-top-margin"
name="signaling_secret"
asText
:disabled="loading"
:placeholder="t('spreed', 'Shared secret')"
:label="t('spreed', 'Shared secret')"
labelVisible
@update:modelValue="debounceUpdateServers" />
<template v-if="!serversProxy.length">
<NcCheckboxRadioSwitch
v-model="showWarningProxy"
type="switch"
class="additional-top-margin"
:disabled="loading"
@update:modelValue="updateHideWarning">
{{ t('spreed', 'Warn about connectivity issues in calls with more than 2 participants') }}
</NcCheckboxRadioSwitch>
</template>
</section>
</template>
<script setup lang="ts">
import type { InitialState } from '../../types/index.ts'
import { showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import debounce from 'debounce'
import { computed, onBeforeUnmount, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import SignalingServer from '../../components/AdminSettings/SignalingServer.vue'
import { SIGNALING } from '../../constants.ts'
const props = defineProps<{
hideWarning: InitialState['spreed']['signaling_servers']['hideWarning']
secret: InitialState['spreed']['signaling_servers']['secret']
servers: InitialState['spreed']['signaling_servers']['servers']
hasValidSubscription: InitialState['spreed']['has_valid_subscription']
}>()
const emit = defineEmits<{
(e: 'update:servers', value: InitialState['spreed']['signaling_servers']['servers']): void
(e: 'update:secret', value: InitialState['spreed']['signaling_servers']['secret']): void
(e: 'update:hideWarning', value: InitialState['spreed']['signaling_servers']['hideWarning']): void
}>()
const isCacheConfigured = loadState('spreed', 'has_cache_configured')
const isClusteredMode = loadState('spreed', 'signaling_mode') === SIGNALING.MODE.CLUSTER_CONVERSATION
const loading = ref(false)
const serversProxy = computed({
get() {
return props.servers
},
set(value) {
emit('update:servers', value)
},
})
const secretProxy = computed({
get() {
return props.secret
},
set(value) {
emit('update:secret', value)
},
})
/** Opposite value of hideWarning */
const showWarningProxy = computed({
get() {
return !props.hideWarning
},
set(value) {
emit('update:hideWarning', !value)
},
})
const debounceUpdateServers = debounce(updateServers, 1000)
onBeforeUnmount(() => {
debounceUpdateServers.clear()
})
/**
* Removes HPB server from the list
*
* @param index index of server (remnant from clustered setup, should be always 0)
*/
function removeServer(index: number) {
serversProxy.value.splice(index, 1)
debounceUpdateServers()
}
/**
* Adds HPB server to the list
*/
function newServer() {
serversProxy.value.push({ server: '', verify: true })
}
/**
* Update hideWarning value on server
*
* @param showWarning new value
*/
function updateHideWarning(showWarning: boolean) {
loading.value = true
/** showWarningProxy is opposite value of hideWarning, so should flip here */
OCP.AppConfig.setValue('spreed', 'hide_signaling_warning', !showWarning ? 'yes' : 'no', {
success: () => {
if (!showWarning) {
showSuccess(t('spreed', 'Missing High-performance backend warning hidden'))
}
loading.value = false
},
})
}
/**
* Update servers list / secret value on server
*/
function updateServers() {
loading.value = true
OCP.AppConfig.setValue('spreed', 'signaling_servers', JSON.stringify({
servers: serversProxy.value.filter((server) => server.server.trim() !== ''),
secret: secretProxy.value,
}), {
success: () => {
showSuccess(t('spreed', 'High-performance backend settings saved'))
loading.value = false
},
})
}
</script>
<style lang="scss" scoped>
.signaling-servers {
.form__textfield {
width: 300px;
}
}
.additional-top-margin {
margin-top: 1em !important;
}
</style>
+142
View File
@@ -0,0 +1,142 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<li class="stun-server">
<!-- "stun:" scheme is untranslated -->
<div class="stun-server__wrapper">
<label :for="`stun_server_${index}`">stun:</label>
<NcTextField
ref="stun_server"
v-model="stunServer"
:inputId="`stun_server_${index}`"
name="stun_server"
class="stun-server__input"
placeholder="stunserver:port"
:disabled="loading"
:aria-label="t('spreed', 'STUN server URL')"
labelOutside />
</div>
<IconAlertCircleOutline
v-show="!isValidServer"
class="stun-server__alert"
:title="t('spreed', 'The server address is invalid')"
fillColor="var(--color-border-error)" />
<NcButton
v-show="!loading"
variant="tertiary"
:aria-label="t('spreed', 'Delete this server')"
@click="removeServer">
<template #icon>
<IconTrashCanOutline :size="20" />
</template>
</NcButton>
</li>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
export default {
name: 'StunServer',
components: {
IconAlertCircleOutline,
IconTrashCanOutline,
NcButton,
NcTextField,
},
props: {
server: {
type: String,
default: '',
required: true,
},
index: {
type: Number,
default: -1,
required: true,
},
loading: {
type: Boolean,
default: false,
},
},
emits: ['removeServer', 'update:server'],
computed: {
stunServer: {
get() {
return this.server
},
set(value) {
this.$emit('update:server', value)
},
},
isValidServer() {
let server = this.server
// Remove HTTP or HTTPS protocol, if provided
if (server.startsWith('https://')) {
server = server.slice(8)
} else if (server.startsWith('http://')) {
server = server.slice(7)
}
const parts = server.split(':')
return parts.length === 2
&& parts[1].match(/^([1-9]\d{0,4})$/) !== null
&& parseInt(parts[1]) <= Math.pow(2, 16)
},
},
methods: {
t,
removeServer() {
this.$emit('removeServer', this.index)
},
},
}
</script>
<style lang="scss" scoped>
.stun-server {
display: flex;
align-items: center;
margin-bottom: calc(var(--default-grid-baseline) * 2);
gap: var(--default-grid-baseline);
&__wrapper {
display: flex;
align-items: center;
gap: 4px;
width: 300px;
}
// Override NcInputField styles
&__input {
margin-block-start: 0 !important;
}
&__alert {
width: var(--default-clickable-area);
height: var(--default-clickable-area);
}
}
</style>
@@ -0,0 +1,146 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="stun_server" class="videocalls section">
<h2>
{{ t('spreed', 'STUN servers') }}
</h2>
<p class="settings-hint">
{{ t('spreed', 'A STUN server is used to determine the public IP address of participants behind a router.') }}
</p>
<TransitionWrapper
name="fade"
class="stun-servers"
tag="ul"
group>
<StunServer
v-for="(server, index) in servers"
:key="`server${index}`"
v-model:server="servers[index]"
:index="index"
:loading="loading"
@removeServer="removeServer"
@update:server="debounceUpdateServers" />
</TransitionWrapper>
<NcButton
class="additional-top-margin"
:disabled="loading"
@click="newServer">
<template #icon>
<NcLoadingIcon v-if="loading" :size="20" />
<IconPlus v-else :size="20" />
</template>
{{ t('spreed', 'Add a new STUN server') }}
</NcButton>
</section>
</template>
<script>
import { showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import debounce from 'debounce'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import StunServer from '../../components/AdminSettings/StunServer.vue'
import TransitionWrapper from '../UIShared/TransitionWrapper.vue'
export default {
name: 'StunServers',
components: {
NcLoadingIcon,
NcButton,
StunServer,
IconPlus,
TransitionWrapper,
},
data() {
return {
servers: [],
hasInternetConnection: true,
loading: false,
saved: false,
debounceUpdateServers: () => {},
}
},
beforeMount() {
this.servers = loadState('spreed', 'stun_servers')
this.hasInternetConnection = loadState('spreed', 'has_internet_connection')
this.debounceUpdateServers = debounce(this.updateServers, 1000)
},
beforeUnmount() {
this.debounceUpdateServers.clear?.()
},
methods: {
t,
removeServer(index) {
this.servers.splice(index, 1)
if (this.servers.length === 0) {
this.addDefaultServer()
}
this.debounceUpdateServers()
},
newServer() {
this.servers.push('')
},
addDefaultServer() {
if (this.hasInternetConnection) {
this.servers.push('stun.nextcloud.com:443')
}
},
async updateServers() {
this.loading = true
const servers = []
this.servers.forEach((server) => {
if (server.startsWith('https://')) {
server = server.slice(8)
} else if (server.startsWith('http://')) {
server = server.slice(7)
}
servers.push(server)
})
this.servers = servers
OCP.AppConfig.setValue('spreed', 'stun_servers', JSON.stringify(servers), {
success: () => {
showSuccess(t('spreed', 'STUN settings saved'))
this.loading = false
this.toggleSave()
},
})
},
toggleSave() {
this.saved = true
setTimeout(() => {
this.saved = false
}, 3000)
},
},
}
</script>
<style lang="scss">
.additional-top-margin {
margin-top: 10px;
}
</style>
+444
View File
@@ -0,0 +1,444 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<li class="turn-server">
<NcSelect
v-model="turnSchemes"
class="turn-server__select"
name="turn_schemes"
:disabled="loading"
:aria-label-combobox="t('spreed', 'TURN server schemes')"
:options="schemesOptions"
:clearable="false"
:searchable="false"
label="label"
trackBy="value"
noWrap />
<NcTextField
ref="turn_server"
v-model="turnServer"
name="turn_server"
placeholder="turnserver:port"
class="turn-server__textfield"
:class="{ error: turnServerError }"
:title="turnServerError"
:disabled="loading"
:label="t('spreed', 'TURN server URL')" />
<NcPasswordField
ref="turn_secret"
v-model="turnSecret"
name="turn_secret"
asText
placeholder="secret"
class="turn-server__textfield"
:disabled="loading"
:label="t('spreed', 'TURN server secret')" />
<NcSelect
v-model="turnProtocols"
class="turn-server__select"
name="turn_protocols"
:disabled="loading"
:aria-label-combobox="t('spreed', 'TURN server protocols')"
:options="protocolOptions"
:clearable="false"
:searchable="false"
label="label"
trackBy="value"
noWrap />
<NcButton
v-show="!loading"
variant="tertiary"
:aria-label="testResult"
:disabled="!testAvailable"
@click="testServer">
<template #icon>
<NcLoadingIcon v-if="testing" :size="20" />
<IconAlertCircleOutline v-else-if="testingError" fillColor="var(--color-border-error)" />
<IconCheck v-else-if="testingSuccess" fillColor="var(--color-border-success)" />
<IconPulse v-else />
</template>
</NcButton>
<NcButton
v-show="!loading"
variant="tertiary"
:aria-label="t('spreed', 'Delete this server')"
@click="removeServer">
<template #icon>
<IconTrashCanOutline :size="20" />
</template>
</NcButton>
</li>
</template>
<script>
import { t } from '@nextcloud/l10n'
import Base64 from 'crypto-js/enc-base64.js'
import hmacSHA1 from 'crypto-js/hmac-sha1.js'
import debounce from 'debounce'
import webrtcSupport from 'webrtcsupport'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconPulse from 'vue-material-design-icons/Pulse.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import { isCertificateValid } from '../../services/certificateService.ts'
import { convertToUnix } from '../../utils/formattedTime.ts'
export default {
name: 'TurnServer',
components: {
NcLoadingIcon,
IconAlertCircleOutline,
IconCheck,
IconTrashCanOutline,
NcButton,
NcSelect,
NcTextField,
NcPasswordField,
IconPulse,
},
props: {
schemes: {
type: String,
default: '',
required: true,
},
server: {
type: String,
default: '',
required: true,
},
secret: {
type: String,
default: '',
required: true,
},
protocols: {
type: String,
default: '',
required: true,
},
index: {
type: Number,
default: -1,
required: true,
},
loading: {
type: Boolean,
default: false,
},
},
emits: ['removeServer', 'update:schemes', 'update:server', 'update:secret', 'update:protocols'],
data() {
return {
testing: false,
testingError: false,
testingSuccess: false,
debounceTestServer: () => {},
}
},
computed: {
turnServer: {
get() {
return this.server
},
set(value) {
this.updateServer(value)
},
},
turnSchemes: {
get() {
return this.schemesOptions.find((i) => i.value === this.schemes)
},
set(value) {
this.updateSchemes(value)
},
},
turnProtocols: {
get() {
return this.protocolOptions.find((i) => i.value === this.protocols)
},
set(value) {
this.updateProtocols(value)
},
},
turnSecret: {
get() {
return this.secret
},
set(value) {
this.updateSecret(value)
},
},
turnServerError() {
if (this.schemes.includes('turns') && /^(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?$/.test(this.server.trim())) {
return t('spreed', '{schema} scheme must be used with a domain', { schema: 'turns:' })
}
return false
},
protocolOptions() {
return [
{ value: 'udp,tcp', label: t('spreed', '{option1} and {option2}', { option1: 'UDP', option2: 'TCP' }) },
{ value: 'udp', label: t('spreed', '{option} only', { option: 'UDP' }) },
{ value: 'tcp', label: t('spreed', '{option} only', { option: 'TCP' }) },
]
},
schemesOptions() {
return [
{ value: 'turn,turns', label: t('spreed', '{option1} and {option2}', { option1: 'turn:', option2: 'turns:' }) },
{ value: 'turn', label: t('spreed', '{option} only', { option: 'turn:' }) },
{ value: 'turns', label: t('spreed', '{option} only', { option: 'turns:' }) },
]
},
testResult() {
if (this.testingSuccess) {
return t('spreed', 'OK: Successful ICE candidates returned by the TURN server')
} else if (this.testingError) {
return t('spreed', 'Error: No working ICE candidates returned by the TURN server')
} else if (this.testing) {
return t('spreed', 'Testing whether the TURN server returns ICE candidates')
}
return t('spreed', 'Test this server')
},
testAvailable() {
const schemes = this.schemes.split(',')
const protocols = this.protocols.split(',')
return !!(schemes.length && this.server && this.secret && protocols.length)
},
},
mounted() {
this.debounceTestServer = debounce(this.testServer, 1000)
this.testing = false
this.testingError = false
this.testingSuccess = false
},
beforeUnmount() {
this.debounceTestServer.clear?.()
},
methods: {
t,
testServer() {
this.testingError = false
this.testingSuccess = false
const schemes = this.schemes.split(',')
const protocols = this.protocols.split(',')
if (!this.testAvailable) {
return
}
this.testing = true
const urls = []
for (let i = 0; i < schemes.length; i++) {
for (let j = 0; j < protocols.length; j++) {
urls.push(schemes[i] + ':' + this.server + '?transport=' + protocols[j])
}
}
const expires = convertToUnix(Date.now()) + 5 * 60
const username = expires + ':turn-test-user'
const password = Base64.stringify(hmacSHA1(username, this.secret))
const iceServer = {
username,
credential: password,
urls,
}
// Create a PeerConnection with no streams, but force a m=audio line.
const config = {
iceServers: [
iceServer,
],
iceTransportPolicy: 'relay',
}
const offerOptions = {
offerToReceiveAudio: 1,
}
console.info('Creating PeerConnection with', config)
const candidates = []
const pc = new RTCPeerConnection(config)
const timeout = setTimeout(() => {
this.notifyTurnResult(candidates, timeout)
pc.close()
}, 10000)
pc.onicecandidate = this.iceCallback.bind(this, pc, candidates, timeout)
pc.onicegatheringstatechange = this.gatheringStateChange.bind(this, pc, candidates, timeout)
// This test will always fail without a data channel on Safari
if (webrtcSupport.supportDataChannel) {
pc.createDataChannel('status')
}
pc.createOffer(offerOptions).then(
(description) => {
pc.setLocalDescription(description)
},
(error) => {
console.error('Error creating offer', error)
this.notifyTurnResult(candidates, timeout)
pc.close()
},
)
},
iceCallback(pc, candidates, timeout, e) {
if (e.candidate) {
const parseCandidate = this.parseCandidate(e.candidate.candidate)
candidates.push(parseCandidate)
// We received a relay candidate, no need to wait any longer
if (parseCandidate.type.includes('relay')) {
pc.close()
this.notifyTurnResult(candidates, timeout)
}
} else if (!('onicegatheringstatechange' in RTCPeerConnection.prototype)) {
pc.close()
this.notifyTurnResult(candidates, timeout)
}
},
notifyTurnResult(candidates, timeout) {
console.info('Received candidates', candidates)
const types = candidates.map((cand) => cand.type)
if (types.includes('relay')) {
if (!this.schemes.includes('turns')) {
// No 'turns' is used and we received relay candidates -> TURN is working
this.testing = false
this.testingSuccess = true
} else {
// We received relay candidates, but since 'turns' is used, we check the certificate additionally
isCertificateValid(this.server).then((isValid) => {
this.testing = false
this.testingSuccess = isValid
this.testingError = !isValid
})
}
} else {
this.testing = false
this.testingError = true
}
setTimeout(() => {
this.testingError = false
this.testingSuccess = false
}, 30000)
clearTimeout(timeout)
},
// Parse a candidate:foo string into an object, for easier use by other methods.
parseCandidate(text) {
const candidateStr = 'candidate:'
const pos = text.indexOf(candidateStr) + candidateStr.length
const parts = text.slice(pos).split(' ')
return {
component: parts[1],
type: parts[7],
foundation: parts[0],
protocol: parts[2],
address: parts[4],
port: parts[5],
priority: parts[3],
}
},
gatheringStateChange(pc, candidates, timeout) {
if (pc.iceGatheringState !== 'complete') {
return
}
pc.close()
this.notifyTurnResult(candidates, timeout)
},
removeServer() {
this.$emit('removeServer', this.index)
},
updateSchemes(event) {
this.$emit('update:schemes', event.value)
this.debounceTestServer()
},
updateServer(value) {
this.$emit('update:server', value)
this.debounceTestServer()
},
updateSecret(value) {
this.$emit('update:secret', value)
this.debounceTestServer()
},
updateProtocols(event) {
this.$emit('update:protocols', event.value)
this.debounceTestServer()
},
},
}
</script>
<style lang="scss" scoped>
.turn-server {
display: grid;
grid-template-columns: minmax(100px, 180px) 1fr 1fr minmax(100px, 180px) var(--default-clickable-area) var(--default-clickable-area);
grid-column-gap: 4px;
align-items: center;
margin-bottom: 4px;
& &__textfield {
&.error :deep(.input-field__input) {
border: 2px solid var(--color-border-error);
}
}
& &__select {
margin-block-start: 6px;
min-width: unset;
}
}
</style>
@@ -0,0 +1,162 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="turn_server" class="videocalls section">
<h2>
{{ t('spreed', 'TURN servers') }}
</h2>
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="settings-hint" v-html="documentationHint" />
<TransitionWrapper
class="turn-servers"
name="fade"
tag="ul"
group>
<TurnServer
v-for="(server, index) in servers"
:key="`server${index}`"
v-model:schemes="servers[index].schemes"
v-model:server="servers[index].server"
v-model:secret="servers[index].secret"
v-model:protocols="servers[index].protocols"
:index="index"
:loading="loading"
@removeServer="removeServer"
@update:schemes="debounceUpdateServers"
@update:server="debounceUpdateServers"
@update:secret="debounceUpdateServers"
@update:protocols="debounceUpdateServers" />
</TransitionWrapper>
<NcButton
class="additional-top-margin"
:disabled="loading"
@click="newServer">
<template #icon>
<NcLoadingIcon v-if="loading" :size="20" />
<IconPlus v-else :size="20" />
</template>
{{ t('spreed', 'Add a new TURN server') }}
</NcButton>
</section>
</template>
<script>
import { showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import debounce from 'debounce'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import TurnServer from '../../components/AdminSettings/TurnServer.vue'
import TransitionWrapper from '../UIShared/TransitionWrapper.vue'
export default {
name: 'TurnServers',
components: {
NcLoadingIcon,
NcButton,
TurnServer,
IconPlus,
TransitionWrapper,
},
data() {
return {
servers: [],
loading: false,
saved: false,
debounceUpdateServers: () => {},
}
},
computed: {
documentationHint() {
return t('spreed', 'A TURN server is used to proxy the traffic from participants behind a firewall. If individual participants cannot connect to others a TURN server is most likely required. See {linkstart}this documentation{linkend} for setup instructions.')
.replace('{linkstart}', '<a target="_blank" rel="noreferrer nofollow" class="external" href="https://nextcloud-talk.readthedocs.io/en/latest/TURN/">')
.replace('{linkend}', ' ↗</a>')
},
},
beforeMount() {
this.debounceUpdateServers = debounce(this.updateServers, 1000)
this.servers = loadState('spreed', 'turn_servers')
},
beforeUnmount() {
this.debounceUpdateServers.clear?.()
},
methods: {
t,
removeServer(index) {
this.servers.splice(index, 1)
this.debounceUpdateServers()
},
newServer() {
this.servers.push({
schemes: 'turn', // default to turn only
server: '',
secret: '',
protocols: 'udp,tcp', // default to udp AND tcp
})
},
async updateServers() {
const servers = []
this.servers.forEach((server) => {
const data = {
schemes: server.schemes,
server: server.server,
secret: server.secret,
protocols: server.protocols,
}
if (data.server.startsWith('https://')) {
data.server = data.server.slice(8)
} else if (data.server.startsWith('http://')) {
data.server = data.server.slice(7)
}
if (data.secret === '') {
return
}
servers.push(data)
})
this.loading = true
OCP.AppConfig.setValue('spreed', 'turn_servers', JSON.stringify(servers), {
success: () => {
showSuccess(t('spreed', 'TURN settings saved'))
this.loading = false
this.toggleSave()
},
})
},
toggleSave() {
this.saved = true
setTimeout(() => {
this.saved = false
}, 3000)
},
},
}
</script>
<style lang="scss">
.additional-top-margin {
margin-top: 10px;
}
</style>
@@ -0,0 +1,161 @@
<!--
- SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div id="web_server_setup_checks" class="section">
<h2>
{{ t('spreed', 'Web server setup checks') }}
</h2>
<NcNoteCard v-if="apacheWarning" :type="apacheWarningType" :text="apacheWarning" />
<ul class="web-server-setup-checks">
<li class="virtual-background">
{{ t('spreed', 'Files required for virtual background can be loaded') }}
<NcButton
variant="tertiary"
class="vue-button-inline"
:title="virtualBackgroundAvailableTitle"
:aria-label="virtualBackgroundAvailableAriaLabel"
@click="checkVirtualBackground">
<template #icon>
<IconAlertCircleOutline v-if="virtualBackgroundAvailable === false" :size="20" fillColor="var(--color-border-error)" />
<IconCheck v-else-if="virtualBackgroundAvailable === true" :size="20" fillColor="var(--color-border-success)" />
<NcLoadingIcon v-else :size="20" />
</template>
</NcButton>
</li>
</ul>
</div>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateFilePath } from '@nextcloud/router'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import { VIRTUAL_BACKGROUND } from '../../constants.ts'
import VideoStreamBackgroundEffect from '../../utils/media/effects/virtual-background/VideoStreamBackgroundEffect.js'
import VirtualBackground from '../../utils/media/pipeline/VirtualBackground.js'
export default {
name: 'WebServerSetupChecks',
components: {
NcLoadingIcon,
IconAlertCircleOutline,
NcButton,
NcNoteCard,
IconCheck,
},
data() {
return {
virtualBackgroundLoaded: undefined,
apachePHPConfiguration: '',
}
},
computed: {
virtualBackgroundAvailable() {
return this.virtualBackgroundLoaded
},
virtualBackgroundAvailableAriaLabel() {
if (this.virtualBackgroundAvailable === false) {
return t('spreed', 'Failed')
}
if (this.virtualBackgroundAvailable === true) {
return t('spreed', 'OK')
}
return t('spreed', 'Checking …')
},
virtualBackgroundAvailableTitle() {
if (this.virtualBackgroundAvailable === false && !VirtualBackground.isWasmSupported()) {
return t('spreed', 'Failed: WebAssembly is disabled or not supported in this browser. Please enable WebAssembly or use a browser with support for it to do the check.')
}
if (this.virtualBackgroundAvailable === false) {
return t('spreed', 'Failed: ".wasm" and ".tflite" files were not properly returned by the web server. Please check "System requirements" section in Talk documentation.')
}
if (this.virtualBackgroundAvailable === true) {
return t('spreed', 'OK: ".wasm" and ".tflite" files were properly returned by the web server.')
}
return t('spreed', 'Checking …')
},
apacheWarning() {
if (this.apachePHPConfiguration === 'invalid') {
return t('spreed', 'It seems that the PHP and Apache configuration is not compatible. Please note that PHP can only be used with the MPM_PREFORK module and PHP-FPM can only be used with the MPM_EVENT module.')
}
// Disabling this for now as there were too many false catches (VMs, AIO, nginx, permissions issue, …)
// if (this.apachePHPConfiguration === 'unknown') {
// return t('spreed', 'Could not detect the PHP and Apache configuration because exec is disabled or apachectl is not working as expected. Please note that PHP can only be used with the MPM_PREFORK module and PHP-FPM can only be used with the MPM_EVENT module.')
// }
return ''
},
apacheWarningType() {
if (this.apachePHPConfiguration === 'invalid') {
return 'error'
}
return 'warning'
},
},
mounted() {
this.apachePHPConfiguration = loadState('spreed', 'valid_apache_php_configuration')
},
beforeMount() {
this.checkVirtualBackground()
},
methods: {
t,
checkVirtualBackground() {
if (!VirtualBackground.isWasmSupported()) {
this.virtualBackgroundLoaded = false
return
}
this.virtualBackgroundLoaded = undefined
// Pass only the essential options to check if the files can be
// loaded.
const options = {
virtualBackground: {
type: VIRTUAL_BACKGROUND.BACKGROUND_TYPE.BLUR,
},
webGL: VirtualBackground.isWebGLSupported(),
}
const videoStreamBackgroundEffect = new VideoStreamBackgroundEffect(options)
videoStreamBackgroundEffect.load().then(() => {
this.virtualBackgroundLoaded = true
}).catch(() => {
this.virtualBackgroundLoaded = false
})
},
},
}
</script>
<style lang="scss" scoped>
.vue-button-inline {
display: inline-block !important;
}
</style>
@@ -0,0 +1,134 @@
import { t } from '@nextcloud/l10n'
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mount, shallowMount } from '@vue/test-utils'
import { describe, expect, it, test } from 'vitest'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import AvatarWrapper from './AvatarWrapper.vue'
import { ATTENDEE, AVATAR } from '../../constants.ts'
describe('AvatarWrapper.vue', () => {
const USER_ID = 'user-id'
const USER_NAME = 'John Doe'
const PRELOADED_USER_STATUS = { status: 'online', message: null, icon: null }
describe('render user avatar', () => {
test('component renders NcAvatar with standard size by default', () => {
const wrapper = shallowMount(AvatarWrapper, {
props: {
name: USER_NAME,
},
})
const avatar = wrapper.findComponent(NcAvatar)
expect(avatar.exists()).toBeTruthy()
expect(avatar.props('size')).toBe(AVATAR.SIZE.DEFAULT)
})
test('component does render NcAvatar for non-users', () => {
const wrapper = shallowMount(AvatarWrapper, {
props: {
name: 'Email Guest',
source: ATTENDEE.ACTOR_TYPE.EMAILS,
},
})
const avatar = wrapper.findComponent(NcAvatar)
expect(avatar.exists()).toBeTruthy()
})
test('component does render NcAvatar for federated users', () => {
const wrapper = shallowMount(AvatarWrapper, {
props: {
token: 'XXXTOKENXXX',
name: 'Federated User',
source: ATTENDEE.ACTOR_TYPE.FEDERATED_USERS,
},
})
const avatar = wrapper.findComponent(NcAvatar)
expect(avatar.exists()).toBeTruthy()
})
test('component renders NcAvatar with specified size', () => {
const size = 22
const wrapper = shallowMount(AvatarWrapper, {
props: {
name: USER_NAME,
size,
},
})
const avatar = wrapper.findComponent(NcAvatar)
expect(avatar.props('size')).toBe(size)
})
test('component pass props to NcAvatar correctly', async () => {
const wrapper = shallowMount(AvatarWrapper, {
props: {
id: USER_ID,
name: USER_NAME,
source: ATTENDEE.ACTOR_TYPE.USERS,
showUserStatus: true,
preloadedUserStatus: PRELOADED_USER_STATUS,
},
})
const avatar = wrapper.findComponent(NcAvatar)
await avatar.vm.$nextTick()
expect(avatar.props('user')).toBe(USER_ID)
expect(avatar.props('displayName')).toBe(USER_NAME)
expect(avatar.props('hideStatus')).toBe(false)
expect(avatar.props('verboseStatus')).toBe(true)
expect(avatar.props('preloadedUserStatus')).toStrictEqual(PRELOADED_USER_STATUS)
expect(avatar.props('size')).toBe(AVATAR.SIZE.DEFAULT)
})
})
describe('render specific icons', () => {
const testCases = [
[null, ATTENDEE.CHANGELOG_BOT_ID, 'Talk updates', ATTENDEE.ACTOR_TYPE.BOTS, 'icon-changelog'],
[null, ATTENDEE.SAMPLE_BOT_ID, 'Nextcloud', ATTENDEE.ACTOR_TYPE.BOTS, 'icon-changelog'],
[null, 'federated_user/id', USER_NAME, ATTENDEE.ACTOR_TYPE.FEDERATED_USERS, 'icon-user'],
[null, 'guest/id', '', ATTENDEE.ACTOR_TYPE.GUESTS, 'icon-user'],
[null, 'guest/id', t('spreed', 'Guest'), ATTENDEE.ACTOR_TYPE.GUESTS, 'icon-user'],
[null, 'guest/id', t('spreed', 'Guest'), ATTENDEE.ACTOR_TYPE.EMAILS, 'icon-user'],
[null, 'deleted_users', '', ATTENDEE.ACTOR_TYPE.DELETED_USERS, 'icon-user'],
['new', 'guest/id', 'test@mail.com', ATTENDEE.ACTOR_TYPE.EMAILS, 'icon-mail'],
[null, 'sha-phone', '+12345...', ATTENDEE.ACTOR_TYPE.PHONES, 'icon-phone'],
[null, 'team/id', 'Team', ATTENDEE.ACTOR_TYPE.CIRCLES, 'icon-team'],
[null, 'group/id', 'Group', ATTENDEE.ACTOR_TYPE.GROUPS, 'icon-contacts'],
]
it.each(testCases)('renders for token \'%s\', id \'%s\', name \'%s\' and source \'%s\' icon \'%s\'', (token, id, name, source, result) => {
const wrapper = shallowMount(AvatarWrapper, {
props: { token, id, name, source },
})
const avatar = wrapper.find('.avatar')
expect(avatar.exists()).toBeTruthy()
expect(avatar.attributes('iconclass')).toContain(result)
})
})
describe('render specific symbols', () => {
const testCases = [
['guest/id', USER_NAME, ATTENDEE.ACTOR_TYPE.GUESTS, USER_NAME.charAt(0)],
['guest/id', USER_NAME, ATTENDEE.ACTOR_TYPE.EMAILS, USER_NAME.charAt(0)],
['bot-id', USER_NAME, ATTENDEE.ACTOR_TYPE.BOTS, '>_'],
]
it.each(testCases)('renders for id \'%s\', name \'%s\' and source \'%s\' symbol \'%s\'', (id, name, source, result) => {
const wrapper = mount(AvatarWrapper, {
props: { name, source },
})
const avatar = wrapper.find('.avatar')
expect(avatar.exists()).toBeTruthy()
expect(avatar.text()).toBe(result)
})
})
})
@@ -0,0 +1,377 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="avatar-wrapper" :class="avatarClass" :style="avatarStyle">
<NcAvatar
v-if="isSpecialAvatar"
:key="(isDarkTheme ? 'dark-' : 'light-') + '_' + id"
class="avatar"
:user="id"
:url="!isFederatedUser ? undefined : avatarUrl"
:iconClass="iconClass"
:displayName="name"
:disableTooltip="disableTooltip"
disableMenu
isNoUser
:hideStatus="!showUserStatus"
:verboseStatus="false"
:preloadedUserStatus="preloadedUserStatus ?? {}"
:size="size">
<template v-if="characterIcon" #icon>
<div class="avatar" :class="characterIconClass">
{{ characterIcon }}
</div>
</template>
</NcAvatar>
<NcAvatar
v-else
:key="id + (isDarkTheme ? '-dark' : '-light')"
:user="id"
:displayName="name"
:menuContainer="menuContainer"
:disableTooltip="disableTooltip"
:disableMenu="disableMenu"
:hideStatus="!showUserStatus"
:verboseStatus="!showUserStatusCompact"
:preloadedUserStatus="preloadedUserStatus"
:size="size" />
<!-- Override user status for federated users -->
<span
v-if="showUserStatus && isFederatedUser"
class="avatar-wrapper__user-status"
role="img"
aria-hidden="false"
:aria-label="t('spreed', 'Federated user')">
<WebIcon :size="14" />
</span>
<NcLoadingIcon
v-if="loading"
:size="size / 2"
class="loading-avatar" />
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import { useIsDarkTheme } from '@nextcloud/vue/composables/useIsDarkTheme'
import { ref } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import WebIcon from 'vue-material-design-icons/Web.vue'
import { ATTENDEE, AVATAR } from '../../constants.ts'
import { getUserProxyAvatarOcsUrl } from '../../services/avatarService.ts'
export default {
name: 'AvatarWrapper',
components: {
NcAvatar,
WebIcon,
NcLoadingIcon,
},
props: {
token: {
type: String,
default: null,
},
name: {
type: String,
required: true,
},
id: {
type: String,
default: null,
},
source: {
type: String,
default: null,
},
size: {
type: Number,
default: AVATAR.SIZE.DEFAULT,
},
condensed: {
type: Boolean,
default: false,
},
condensedOverlap: {
type: Number,
default: 2,
},
offline: {
type: Boolean,
default: false,
},
highlighted: {
type: Boolean,
default: false,
},
disableTooltip: {
type: Boolean,
default: false,
},
disableMenu: {
type: Boolean,
default: false,
},
showUserStatus: {
type: Boolean,
default: false,
},
showUserStatusCompact: {
type: Boolean,
default: false,
},
preloadedUserStatus: {
type: Object,
default: undefined,
},
menuContainer: {
type: String,
default: undefined,
},
loading: {
type: Boolean,
default: false,
},
},
setup() {
const isDarkTheme = useIsDarkTheme()
const failed = ref(false)
return {
isDarkTheme,
failed,
}
},
computed: {
// Determines which icon is displayed
iconClass() {
if (!this.source) {
return ''
}
switch (this.source) {
case ATTENDEE.ACTOR_TYPE.USERS:
case ATTENDEE.ACTOR_TYPE.BRIDGED:
return !this.failed ? '' : 'icon-user'
case ATTENDEE.ACTOR_TYPE.EMAILS:
return this.token === 'new' ? 'icon-mail' : !this.hasCustomName ? 'icon-user' : ''
case ATTENDEE.ACTOR_TYPE.GUESTS:
return !this.hasCustomName ? 'icon-user' : ''
case ATTENDEE.ACTOR_TYPE.FEDERATED_USERS:
return (this.token && !this.failed) ? '' : 'icon-user'
case ATTENDEE.ACTOR_TYPE.DELETED_USERS:
return 'icon-user'
case ATTENDEE.ACTOR_TYPE.PHONES:
return 'icon-phone'
case ATTENDEE.ACTOR_TYPE.BOTS:
return [ATTENDEE.CHANGELOG_BOT_ID, ATTENDEE.SAMPLE_BOT_ID].includes(this.id) ? 'icon-changelog' : ''
case ATTENDEE.ACTOR_TYPE.CIRCLES:
return 'icon-team'
case ATTENDEE.ACTOR_TYPE.GROUPS:
default:
return 'icon-contacts'
}
},
characterIconClass() {
if (this.source === ATTENDEE.ACTOR_TYPE.EMAILS && this.token !== 'new' && this.hasCustomName) {
return 'guest'
} else if (this.source === ATTENDEE.ACTOR_TYPE.GUESTS && this.hasCustomName) {
return 'guest'
} else if (this.isBot) {
return 'bot'
}
return undefined
},
avatarClass() {
return {
'avatar-wrapper--dark': this.isDarkTheme,
'avatar-wrapper--offline': this.offline,
'avatar-wrapper--condensed': this.condensed,
'avatar-wrapper--highlighted': this.highlighted,
}
},
avatarStyle() {
return {
'--avatar-size': this.size + 'px',
'--condensed-overlap': this.condensedOverlap,
}
},
isFederatedUser() {
return this.source === ATTENDEE.ACTOR_TYPE.FEDERATED_USERS
},
isBot() {
return this.source === ATTENDEE.ACTOR_TYPE.BOTS && this.id !== ATTENDEE.CHANGELOG_BOT_ID && this.id !== ATTENDEE.SAMPLE_BOT_ID
},
isGuestUser() {
return [ATTENDEE.ACTOR_TYPE.GUESTS, ATTENDEE.ACTOR_TYPE.EMAILS].includes(this.source)
},
hasCustomName() {
return this.name?.trim() && this.name !== t('spreed', 'Guest')
},
characterIcon() {
if (this.isBot) {
return '>_'
}
if (!this.isGuestUser || !this.hasCustomName || this.token === 'new') {
return ''
}
return this.name?.trim()?.toUpperCase()?.charAt(0) ?? '?'
},
avatarUrl() {
return getUserProxyAvatarOcsUrl(this.token, this.id, this.isDarkTheme, this.size > AVATAR.SIZE.MEDIUM ? 512 : 64)
},
isSpecialAvatar() {
return this.isGuestUser || this.iconClass || this.isBot || (this.isFederatedUser && this.token)
},
},
watch: {
avatarUrl() {
this.failed = false
},
},
methods: {
t,
},
}
</script>
<style lang="scss" scoped>
.avatar-wrapper {
position: relative;
height: var(--avatar-size);
width: var(--avatar-size);
border-radius: var(--avatar-size);
&--dark .avatar {
background-color: #3B3B3B !important;
}
.avatar {
position: sticky;
top: 0;
display: block;
width: var(--avatar-size);
height: var(--avatar-size);
max-height: var(--avatar-size);
max-width: var(--avatar-size);
line-height: var(--avatar-size);
font-size: calc(var(--avatar-size) / 2);
background-color: var(--color-text-maxcontrast-default);
&.icon {
background-size: calc(var(--avatar-size) / 2);
&.icon-changelog {
background-size: cover !important;
}
}
&.bot {
padding-inline-start: 5px;
background-color: var(--color-background-darker);
}
&.guest {
color: #ffffff;
padding: 0;
display: block;
text-align: center;
margin-inline: auto;
}
}
&--condensed {
width: unset;
height: unset;
margin-inline-start: calc(var(--condensed-overlap) * -1px);
display: flex;
& > .icon,
& > .guest,
:deep(img) {
outline: 2px solid var(--color-main-background);
}
}
&--offline {
opacity: .4;
& :deep(.avatardiv) {
background: rgba(var(--color-main-background-rgb), .4) !important;
}
}
&--highlighted {
outline: 2px solid var(--color-primary-element);
}
&__user-status {
position: absolute;
inset-inline-end: -4px;
bottom: -4px;
height: 18px;
width: 18px;
border: 2px solid var(--color-main-background);
background-color: var(--color-main-background);
border-radius: 50%;
}
}
.loading-avatar {
position: absolute;
top: 0;
inset-inline-start: 0;
width: 100%;
height: 100%;
}
.avatar-wrapper:not(.avatar-wrapper--dark) {
// FIXME: update the used color in NcAvatar
// TOREMOVE: when fixed in @nextcloud/vue
:deep(.avatar-class-icon) {
background-color: var(--color-text-maxcontrast-default);
}
}
:deep(.icon-user) {
background-size: calc(var(--avatar-size) / 2);
}
</style>
@@ -0,0 +1,212 @@
<!--
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcModal
:class="{ 'modal-mask__participants-step': isEditingParticipants }"
:container="container"
:labelId="dialogHeaderId"
@close="$emit('close')">
<div
class="breakout-rooms-editor"
:class="{ 'breakout-rooms-editor__participants-step': isEditingParticipants }">
<h2 :id="dialogHeaderId" class="nc-dialog-alike-header">
{{ modalTitle }}
</h2>
<template v-if="!isEditingParticipants">
<div class="breakout-rooms-editor__main">
<label class="breakout-rooms-editor__caption" for="room-number">{{ t('spreed', 'Number of breakout rooms') }} </label>
<p v-if="isInvalidAmount" class="breakout-rooms-editor__error-hint">
{{ t('spreed', 'You can create from 1 to 20 breakout rooms.') }}
</p>
<NcInputField
id="room-number"
ref="inputField"
v-model="amount"
class="breakout-rooms-editor__number-input"
type="number"
min="1"
max="20" />
<label class="breakout-rooms-editor__caption">{{ t('spreed', 'Assignment method') }}</label>
<fieldset>
<NcCheckboxRadioSwitch
v-model="mode"
value="1"
name="mode_radio"
type="radio">
{{ t('spreed', 'Automatically assign participants') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-model="mode"
value="2"
name="mode_radio"
type="radio">
{{ t('spreed', 'Manually assign participants') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-model="mode"
value="3"
name="mode_radio"
type="radio">
{{ t('spreed', 'Allow participants to choose') }}
</NcCheckboxRadioSwitch>
</fieldset>
</div>
<div class="breakout-rooms-editor__buttons">
<NcButton
v-if="mode === '2'"
variant="primary"
:disabled="isInvalidAmount"
@click="isEditingParticipants = true">
{{ t('spreed', 'Assign participants to rooms') }}
</NcButton>
<NcButton
v-else
variant="primary"
:disabled="isInvalidAmount"
@click="handleCreateRooms">
{{ t('spreed', 'Create rooms') }}
</NcButton>
</div>
</template>
<template v-else>
<BreakoutRoomsParticipantsEditor
:token="token"
:roomNumber="amount"
@close="$emit('close')"
@back="isEditingParticipants = false"
@createRooms="handleCreateRooms" />
</template>
</div>
</NcModal>
</template>
<script>
import { t } from '@nextcloud/l10n'
import { ref, useId } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import NcModal from '@nextcloud/vue/components/NcModal'
import BreakoutRoomsParticipantsEditor from './BreakoutRoomsParticipantsEditor.vue'
import { useBreakoutRoomsStore } from '../../stores/breakoutRooms.ts'
export default {
name: 'BreakoutRoomsEditor',
components: {
BreakoutRoomsParticipantsEditor,
NcButton,
NcCheckboxRadioSwitch,
NcInputField,
NcModal,
},
props: {
token: {
type: String,
required: true,
},
container: {
type: String,
default: 'body',
},
},
emits: ['close'],
setup() {
const mode = ref('1')
const amount = ref(2)
const attendeeMap = ref('')
const isEditingParticipants = ref(false)
const isInvalidAmount = ref(false)
const dialogHeaderId = `breakout-rooms-header-${useId()}`
return {
breakoutRoomsStore: useBreakoutRoomsStore(),
mode,
amount,
attendeeMap,
isEditingParticipants,
isInvalidAmount,
dialogHeaderId,
}
},
computed: {
modalTitle() {
return this.isEditingParticipants
? t('spreed', 'Assign participants to rooms')
: t('spreed', 'Configure breakout rooms')
},
},
watch: {
amount(value) {
this.isInvalidAmount = isNaN(value) || !this.$refs.inputField.$refs.input?.checkValidity()
},
},
methods: {
t,
async handleCreateRooms() {
try {
await this.breakoutRoomsStore.configureBreakoutRooms({
token: this.token,
mode: this.mode,
amount: this.amount,
})
this.$emit('close')
} catch (error) {
console.debug(error)
}
},
},
}
</script>
<style lang="scss" scoped>
.breakout-rooms-editor {
display: flex;
flex-direction: column;
padding: 20px;
justify-content: flex-start;
&__number-input {
display: block;
margin-bottom: calc(var(--default-grid-baseline) * 4);
}
&__caption {
font-weight: bold;
display: block;
margin: calc(var(--default-grid-baseline) * 3) 0 calc(var(--default-grid-baseline) * 2) 0;
}
&__error-hint {
color: var(--color-text-error);
font-size: 0.8rem;
}
&__participants-step {
height: 100%;
}
&__main {
height: 100%;
align-self: flex-start;
}
&__buttons {
display: flex;
justify-content: flex-end;
gap: calc(var(--default-grid-baseline) * 2);
width: 100%;
}
}
</style>
@@ -0,0 +1,370 @@
<!--
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="participants-editor">
<ul class="participants-editor__scroller">
<BreakoutRoomItem
key="unassigned"
class="participants-editor__section"
:name="t('spreed', 'Unassigned participants')">
<SelectableParticipant
v-for="participant in unassignedParticipants"
:key="participant.attendeeId"
v-model:checked="selectedParticipants"
:value="participant.attendeeId"
:participant="participant" />
</BreakoutRoomItem>
<BreakoutRoomItem
v-for="(item, index) in assignments"
:key="index"
class="participants-editor__section"
:name="roomName(index)">
<SelectableParticipant
v-for="attendeeId in item"
:key="attendeeId"
v-model:checked="selectedParticipants"
:value="assignments"
:participant="attendeesById[attendeeId]" />
</BreakoutRoomItem>
</ul>
<div class="participants-editor__buttons">
<NcButton
v-if="breakoutRoomsConfigured"
class="delete"
:title="deleteButtonLabel"
:aria-label="deleteButtonLabel"
variant="error"
@click="deleteBreakoutRooms">
<template #icon>
<IconTrashCanOutline :size="20" />
</template>
{{ deleteButtonLabel }}
</NcButton>
<NcButton
v-if="!isReorganizingAttendees"
variant="tertiary"
@click="goBack">
<template #icon>
<IconArrowLeft class="bidirectional-icon" :size="20" />
</template>
{{ t('spreed', 'Back') }}
</NcButton>
<NcButton v-if="hasAssigned" variant="tertiary" @click="resetAssignments">
<template #icon>
<Reload :size="20" />
</template>
{{ resetButtonLabel }}
</NcButton>
<NcActions
v-if="hasSelected"
variant="primary"
container=".participants-editor__buttons"
placement="top"
:menuName="t('spreed', 'Assign')">
<NcActionButton
v-for="(item, index) in assignments"
:key="index"
closeAfterClick
@click="assignAttendees(index)">
<template #icon>
<DotsCircle :size="20" />
</template>
{{ roomName(index) }}
</NcActionButton>
</NcActions>
<NcButton
:disabled="!hasAssigned"
:variant="hasUnassigned ? 'secondary' : 'primary'"
@click="handleSubmit">
{{ confirmButtonLabel }}
</NcButton>
</div>
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import { provide } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
import DotsCircle from 'vue-material-design-icons/DotsCircle.vue'
import Reload from 'vue-material-design-icons/Reload.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import BreakoutRoomItem from '../RightSidebar/BreakoutRooms/BreakoutRoomItem.vue'
import ConfirmDialog from '../UIShared/ConfirmDialog.vue'
import SelectableParticipant from './SelectableParticipant.vue'
import { ATTENDEE, CONVERSATION, PARTICIPANT } from '../../constants.ts'
import { useBreakoutRoomsStore } from '../../stores/breakoutRooms.ts'
export default {
name: 'BreakoutRoomsParticipantsEditor',
components: {
NcActions,
NcActionButton,
DotsCircle,
Reload,
BreakoutRoomItem,
SelectableParticipant,
NcButton,
IconArrowLeft,
IconTrashCanOutline,
},
props: {
token: {
type: String,
required: true,
},
roomNumber: {
type: Number,
default: undefined,
},
breakoutRooms: {
type: Array,
default: () => [],
},
},
emits: ['back', 'close'],
setup() {
// Add a visual bulk selection state for SelectableParticipant component
provide('bulkParticipantsSelection', true)
return {
breakoutRoomsStore: useBreakoutRoomsStore(),
}
},
data() {
return {
selectedParticipants: [],
assignments: [],
}
},
computed: {
participants() {
return this.$store.getters.participantsList(this.token).filter((participant) => {
return (participant.participantType === PARTICIPANT.TYPE.USER
|| participant.participantType === PARTICIPANT.TYPE.GUEST)
&& participant.actorType === ATTENDEE.ACTOR_TYPE.USERS
})
},
attendeesById() {
return this.$store.state.participantsStore.attendees[this.token]
},
unassignedParticipants() {
if (this.assignments.length === 0) {
return []
}
// Flatten assignments array
const assignedParticipants = this.assignments.flat()
return this.participants.filter((participant) => {
return !assignedParticipants.includes(participant.attendeeId)
})
},
hasSelected() {
return this.selectedParticipants.length > 0
},
hasAssigned() {
return this.assignments.flat().length > 0
},
// True if there's one or more unassigned participants
hasUnassigned() {
return this.unassignedParticipants.length > 0
},
// If the breakoutRooms prop is populated it means that this component is
// being used to reorganize the attendees of an existing breakout room.
isReorganizingAttendees() {
return this.breakoutRooms.length
},
confirmButtonLabel() {
return this.isReorganizingAttendees ? t('spreed', 'Confirm') : t('spreed', 'Create breakout rooms')
},
resetButtonLabel() {
return t('spreed', 'Reset')
},
conversation() {
return this.$store.getters.conversation(this.token)
},
breakoutRoomsConfigured() {
return this.conversation.breakoutRoomMode !== CONVERSATION.BREAKOUT_ROOM_MODE.NOT_CONFIGURED
},
deleteButtonLabel() {
return t('spreed', 'Delete breakout rooms')
},
},
created() {
this.initialiseAssignments()
},
methods: {
t,
/**
* Initialise the assignments array.
*
* @param {boolean} forceReset If true, the assignments array will be reset if the breakoutRooms prop is populated.
*/
initialiseAssignments(forceReset) {
if (this.isReorganizingAttendees && !forceReset) {
this.assignments = this.breakoutRooms.map((room) => {
const participantInBreakoutRoomActorIdList = this.$store.getters.participantsList(room.token)
.map((participant) => participant.actorId)
return this.participants.filter((participant) => {
return participantInBreakoutRoomActorIdList.includes(participant.actorId)
}).map((participant) => participant.attendeeId)
})
} else {
this.assignments = Array.from(Array(this.isReorganizingAttendees
? this.breakoutRooms.length
: this.roomNumber), () => [])
}
},
assignAttendees(roomIndex) {
this.selectedParticipants.forEach((attendeeId) => {
if (this.unassignedParticipants.find((participant) => participant.attendeeId === attendeeId)) {
this.assignments[roomIndex].push(attendeeId)
return
}
const assignedRoomIndex = this.assignments.findIndex((room) => room.includes(attendeeId))
if (assignedRoomIndex === roomIndex) {
return
}
this.assignments[assignedRoomIndex].splice(this.assignments[assignedRoomIndex].findIndex((id) => id === attendeeId), 1)
this.assignments[roomIndex].push(attendeeId)
})
this.selectedParticipants = []
},
roomName(index) {
return this.breakoutRooms[index]?.displayName
?? t('spreed', 'Room {roomNumber}', { roomNumber: index + 1 })
},
resetAssignments() {
this.selectedParticipants = []
this.assignments = []
this.initialiseAssignments(true)
},
goBack() {
this.$emit('back')
},
handleSubmit() {
this.isReorganizingAttendees ? this.reorganizeAttendees() : this.createRooms()
},
createAttendeeMap() {
const attendeeMap = {}
this.assignments.forEach((room, index) => {
room.forEach((attendeeId) => {
attendeeMap[attendeeId] = index
})
})
return JSON.stringify(attendeeMap)
},
createRooms() {
this.breakoutRoomsStore.configureBreakoutRooms({
token: this.token,
mode: 2,
amount: this.roomNumber,
attendeeMap: this.createAttendeeMap(),
})
this.$emit('close')
},
reorganizeAttendees() {
this.breakoutRoomsStore.reorganizeAttendees({
token: this.token,
attendeeMap: this.createAttendeeMap(),
})
this.$emit('close')
},
async deleteBreakoutRooms() {
const confirmDeleteBreakoutRooms = await spawnDialog(ConfirmDialog, {
container: '.participants-editor',
name: t('spreed', 'Delete breakout rooms'),
message: t('spreed', 'Current breakout rooms and settings will be lost'),
buttons: [
{ label: t('spreed', 'Cancel'), variant: 'tertiary', callback: () => undefined },
{ label: t('spreed', 'Delete breakout rooms'), variant: 'error', callback: () => true },
],
})
if (!confirmDeleteBreakoutRooms) {
return
}
await this.breakoutRoomsStore.deleteBreakoutRooms(this.token)
},
},
}
</script>
<style lang="scss" scoped>
.participants-editor {
display: flex;
width: 100%;
flex-direction: column;
gap: var(--default-grid-baseline);
height: calc(100% - 57px); // heading 30px * 1.5 line-height + 12px margin-bottom
&__section {
margin: calc(var(--default-grid-baseline) * 2) 0 calc(var(--default-grid-baseline) * 4);
}
&__scroller {
height: 100%;
overflow: auto;
}
&__buttons {
display: flex;
justify-content: flex-end;
gap: calc(var(--default-grid-baseline) * 2);
padding-top: 10px;
}
}
// Warning dialog when deleting breakout rooms
:deep(.dialog) {
padding-block: 0px 8px;
padding-inline: 12px 8px;
}
.delete {
margin-inline-end: auto;
}
</style>
@@ -0,0 +1,241 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<label class="selectable-participant" :data-nav-id="participantNavigationId">
<input
v-model="modelProxy"
:value="value"
:aria-label="participantAriaLabel"
:disabled="isLocked"
type="checkbox"
class="selectable-participant__checkbox"
@keydown.enter.stop.prevent="handleEnter">
<!-- Participant's avatar -->
<AvatarWrapper
:id="actorId"
:token="participant.roomToken ?? 'new'"
:name="computedName"
:source="actorType"
disableMenu
disableTooltip
:preloadedUserStatus="preloadedUserStatus"
:showUserStatus="showUserStatus" />
<span class="selectable-participant__content">
<span class="selectable-participant__content-name">
{{ computedName }}
</span>
<span
v-if="participantStatus"
class="selectable-participant__content-subname">
{{ participantStatus }}
</span>
</span>
<IconCheck v-if="isBulkSelection" class="selectable-participant__check-icon" :size="20" />
</label>
</template>
<script>
import { t } from '@nextcloud/l10n'
import { computed, inject, ref } from 'vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import AvatarWrapper from '../AvatarWrapper/AvatarWrapper.vue'
import { ATTENDEE } from '../../constants.ts'
import { getPreloadedUserStatus, getStatusMessage } from '../../utils/userStatus.ts'
export default {
name: 'SelectableParticipant',
components: {
AvatarWrapper,
IconCheck,
},
props: {
/**
* The participant object
*/
participant: {
type: Object,
required: true,
},
checked: {
type: Array,
required: true,
},
showUserStatus: {
type: Boolean,
default: true,
},
},
emits: ['update:checked', 'clickParticipant'],
setup(props) {
// Toggles the bulk selection state of this component
const isBulkSelection = inject('bulkParticipantsSelection', false)
// Defines list of locked participants (can not be removed manually
const lockedParticipants = inject('lockedParticipants', ref([]))
const isLocked = computed(() => lockedParticipants.value.some((item) => {
return item.id === props.participant.id && item.source === props.participant.source
}))
return {
isBulkSelection,
isLocked,
}
},
computed: {
modelProxy: {
get() {
return this.checked
},
set(value) {
if (this.isLocked) {
return
}
this.isBulkSelection
? this.$emit('update:checked', value)
: this.$emit('clickParticipant', this.participant)
},
},
value() {
return this.participant.attendeeId || this.participant
},
actorId() {
return this.participant.actorId || this.participant.id
},
actorType() {
return this.participant.actorType || this.participant.source
},
computedName() {
return this.participant.displayName || this.participant.label || t('spreed', 'Guest')
},
preloadedUserStatus() {
return getPreloadedUserStatus(this.participant)
},
participantStatus() {
if (this.actorType === ATTENDEE.ACTOR_TYPE.EMAILS) {
return this.participant.invitedActorId ?? ''
}
return this.participant.shareWithDisplayNameUnique
?? getStatusMessage(this.participant)
},
participantAriaLabel() {
return t('spreed', 'Add participant "{user}"', { user: this.computedName })
},
participantNavigationId() {
if (this.participant.actorType && this.participant.actorId) {
return this.participant.actorType + '_' + this.participant.actorId
} else {
return this.participant.source + '_' + this.participant.id
}
},
},
methods: {
t,
handleEnter(event) {
if (this.isBulkSelection) {
event.target.click()
} else {
this.$emit('clickParticipant', this.participant)
}
},
},
}
</script>
<style lang="scss" scoped>
.selectable-participant {
position: relative;
display: flex;
align-items: center;
gap: calc(2 * var(--default-grid-baseline));
padding: var(--default-grid-baseline);
margin: var(--default-grid-baseline);
border-radius: var(--border-radius-element, 32px);
line-height: 20px;
&, & * {
cursor: pointer;
}
&:hover,
&:focus-within,
&:has(:active),
&:has(:focus-visible) {
background-color: var(--color-background-hover);
}
&:has(input:focus-visible) {
outline: 2px solid var(--color-main-text);
box-shadow: 0 0 0 4px var(--color-main-background);
}
&:has(input:checked) {
background-color: var(--color-primary-light);
&:hover,
&:focus-within,
&:has(:focus-visible),
&:has(:active) {
background-color: var(--color-primary-light-hover);
}
}
&:has(input:checked) &__check-icon {
display: flex;
}
&__checkbox {
position: absolute;
top: 0;
inset-inline-start: 0;
z-index: -1;
opacity: 0;
}
&__content {
display: flex;
flex-direction: column;
align-items: flex-start;
&-name {
font-weight: 500;
}
&-subname {
font-weight: 400;
color: var(--color-text-maxcontrast);
}
}
&__check-icon {
display: none;
margin-inline-start: auto;
width: var(--default-clickable-area);
flex-shrink: 0;
}
}
</style>
@@ -0,0 +1,98 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog
ref="dialog"
:name="dialogTitle"
closeOnClickOutside
size="normal"
@update:open="$emit('close')">
<NewMessage
ref="newMessage"
role="region"
class="send-message-dialog"
:token="token"
:container="modalContainerId"
:aria-label="dialogTitle"
dialog
:broadcast="broadcast"
@submit="handleSubmit" />
</NcDialog>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NewMessage from '../NewMessage/NewMessage.vue'
export default {
name: 'SendMessageDialog',
components: {
NcDialog,
NewMessage,
},
props: {
/**
* The conversation token.
*/
token: {
type: String,
required: true,
},
/**
* The dialog title
*/
dialogTitle: {
type: String,
default: '',
},
/**
* Broadcast messages to all breakout rooms of a given conversation. In
* case this is true, the token needs to be from a conversation that
* has breakout rooms configured.
*/
broadcast: {
type: Boolean,
default: false,
},
},
emits: ['close', 'submit'],
data() {
return {
modalContainerId: null,
}
},
mounted() {
// Postpone render of NewMessage until modal container is mounted
this.modalContainerId = '#' + this.$refs.dialog.$el.querySelector('.modal-container')?.id
this.$nextTick(() => {
this.$refs.newMessage.focusInput()
})
},
methods: {
t,
handleSubmit(event) {
this.$emit('submit', event)
},
},
}
</script>
<style lang="scss" scoped>
.send-message-dialog {
padding-bottom: calc(3 * var(--default-grid-baseline));
}
</style>
+681
View File
@@ -0,0 +1,681 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { Conversation, Participant } from '../types/index.ts'
import { showSuccess } from '@nextcloud/dialogs'
import { n, t } from '@nextcloud/l10n'
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
import { usernameToColor } from '@nextcloud/vue/functions/usernameToColor'
import debounce from 'debounce'
import { computed, onBeforeMount, provide, ref, watch } from 'vue'
import { useStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import IconAccountPlusOutline from 'vue-material-design-icons/AccountPlusOutline.vue'
import IconAccountSearchOutline from 'vue-material-design-icons/AccountSearchOutline.vue'
import IconCalendarBlankOutline from 'vue-material-design-icons/CalendarBlankOutline.vue'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import SelectableParticipant from './BreakoutRoomsEditor/SelectableParticipant.vue'
import CalendarEventSmall from './UIShared/CalendarEventSmall.vue'
import ContactSelectionBubble from './UIShared/ContactSelectionBubble.vue'
import SearchBox from './UIShared/SearchBox.vue'
import StaticDateTime from './UIShared/StaticDateTime.vue'
import TransitionWrapper from './UIShared/TransitionWrapper.vue'
import { ATTENDEE, CONVERSATION } from '../constants.ts'
import { hasTalkFeature, localCapabilities } from '../services/CapabilitiesManager.ts'
import { useGroupwareStore } from '../stores/groupware.ts'
import { convertToUnix, ONE_HOUR_IN_MS } from '../utils/formattedTime.ts'
import { getDisplayNameWithFallback } from '../utils/getDisplayName.ts'
const props = defineProps<{
token: string
container?: string
}>()
const emit = defineEmits<{
(event: 'close'): void
}>()
const isCalendarEnabled = localCapabilities.calendar?.webui ?? false
const hideTriggers = (triggers: string[]) => [...triggers, 'click']
const store = useStore()
const groupwareStore = useGroupwareStore()
const isMobile = useIsMobile()
// Add a visual bulk selection state for SelectableParticipant component
provide('bulkParticipantsSelection', true)
const isFormOpen = ref(false)
const isSelectorOpen = ref(false)
const loading = ref(Object.keys(groupwareStore.calendars).length === 0)
const submitting = ref(false)
const calendars = computed(() => groupwareStore.calendars)
const upcomingEvents = computed(() => {
const now = convertToUnix(Date.now())
return groupwareStore.getAllEvents(props.token)
.sort((a, b) => (a.start && b.start) ? (a.start - b.start) : 0)
.map((event) => {
const start = event.start
? (event.start <= now) ? t('spreed', 'Now') : event.start * 1000
: ''
const color = calendars.value[event.calendarUri]?.color ?? usernameToColor(event.calendarUri).color
const href = isCalendarEnabled ? (event.calendarAppUrl ?? undefined) : undefined
return { ...event, start, color, href }
})
})
type CalendarOption = { value: string, label: string, color: string }
const calendarOptions = computed<CalendarOption[]>(() => groupwareStore.writeableCalendars.map((calendar) => ({
value: calendar.uri,
label: calendar.displayname,
color: calendar.color ?? usernameToColor(calendar.uri).color,
})))
const canScheduleMeeting = computed(() => {
return hasTalkFeature(props.token, 'schedule-meeting') && store.getters.isModerator && calendarOptions.value.length !== 0
&& conversation.value?.type !== CONVERSATION.TYPE.ONE_TO_ONE_FORMER
})
const selectedCalendar = ref<CalendarOption | null>(null)
const selectedDateTimeStart = ref(getCurrentDateInStartOfNthHour(1))
const selectedDateTimeEnd = ref(getCurrentDateInStartOfNthHour(2))
const newMeetingTitle = ref('')
const newMeetingDescription = ref('')
const invalid = ref<string | null>(null)
const invalidHint = computed(() => {
switch (invalid.value) {
case null:
return ''
case 'calendar':
return t('spreed', 'Invalid calendar selected')
case 'start':
return t('spreed', 'Invalid start time selected')
case 'end':
return t('spreed', 'Invalid end time selected')
case 'unknown':
default:
return t('spreed', 'Unknown error occurred')
}
})
const selectAll = ref(true)
const selectedAttendeeIds = ref<number[]>([])
const attendeeHint = computed(() => {
if (!selectedAttendeeIds.value?.length) {
return t('spreed', 'Sending no invitations')
}
const list: Participant[] = selectedParticipants.value.slice(0, 2)
const remainingCount = selectedParticipants.value.length - list.length
const summary = list.map((participant) => getDisplayNameWithFallback(participant.displayName, participant.actorType))
if (remainingCount === 0) {
// Amount is 2 or less
switch (summary.length) {
case 1: {
return t('spreed', '{participant0} will receive an invitation', { participant0: summary[0] }, undefined, {
escape: false,
sanitize: false,
})
}
case 2: {
return t('spreed', '{participant0} and {participant1} will receive invitations', { participant0: summary[0], participant1: summary[1] }, undefined, {
escape: false,
sanitize: false,
})
}
case 0:
default: {
return ''
}
}
} else {
return n('spreed', '{participant0}, {participant1} and %n other will receive invitations', '{participant0}, {participant1} and %n others will receive invitations', remainingCount, { participant0: summary[0], participant1: summary[1] }, {
escape: false,
sanitize: false,
})
}
})
const searchText = ref('')
const isMatch = (string: string = '') => string.toLowerCase().includes(searchText.value.toLowerCase())
const conversation = computed<Conversation | undefined>(() => store.getters.conversation(props.token))
const participants = computed(() => {
if (!conversation.value) {
return []
}
if (isOneToOneConversation.value && store.getters.participantsList(props.token).length === 1) {
// Second participant is not yet added to conversation, need to fake data from conversation object
// We do not have an attendeeId, so 'attendeeIds' in payload should be 'null' (selectAll === true)
return [{ id: conversation.value.name, source: ATTENDEE.ACTOR_TYPE.USERS, displayName: conversation.value.displayName }]
}
return store.getters.participantsList(props.token).filter((participant: Participant) => {
return [ATTENDEE.ACTOR_TYPE.USERS, ATTENDEE.ACTOR_TYPE.EMAILS].includes(participant.actorType)
&& participant.attendeeId !== conversation.value!.attendeeId
})
})
const participantsInitialised = computed(() => store.getters.participantsInitialised(props.token))
const filteredParticipants = computed(() => participants.value.filter((participant: Participant) => {
return isMatch(participant.displayName)
|| (participant.actorType === ATTENDEE.ACTOR_TYPE.USERS && isMatch(participant.actorId))
|| (participant.actorType === ATTENDEE.ACTOR_TYPE.EMAILS && participant.invitedActorId && isMatch(participant.invitedActorId))
}))
const selectedParticipants = computed(() => participants.value
.filter((participant: Participant) => selectedAttendeeIds.value.includes(participant.attendeeId))
.sort((a: Participant, b: Participant) => {
if (a.actorType === ATTENDEE.ACTOR_TYPE.USERS && b.actorType === ATTENDEE.ACTOR_TYPE.EMAILS) {
return -1
} else if (a.actorType === ATTENDEE.ACTOR_TYPE.EMAILS && b.actorType === ATTENDEE.ACTOR_TYPE.USERS) {
return 1
} else if (a.actorType === ATTENDEE.ACTOR_TYPE.EMAILS && b.actorType === ATTENDEE.ACTOR_TYPE.EMAILS
&& (!a.displayName || !b.displayName)) {
return a.displayName ? -1 : 1
}
return 0
}))
const isOneToOneConversation = computed(() => {
return conversation.value?.type === CONVERSATION.TYPE.ONE_TO_ONE
|| conversation.value?.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER
})
const inviteLabel = computed(() => {
return isOneToOneConversation.value
? t('spreed', 'Invite {user}', { user: conversation.value?.displayName ?? '' })
: t('spreed', 'Invite all users and emails in this conversation')
})
const debounceAdjustCurrentTimeRange = debounce(adjustCurrentTimeRange, 500)
onBeforeMount(() => {
getCalendars()
})
watch(isFormOpen, (value) => {
if (!value) {
return
}
// Reset the default form values
selectedCalendar.value = calendarOptions.value.find((o) => o.value === groupwareStore.defaultCalendarUri) ?? null
selectedDateTimeStart.value = getCurrentDateInStartOfNthHour(1)
selectedDateTimeEnd.value = getCurrentDateInStartOfNthHour(2)
newMeetingTitle.value = ''
newMeetingDescription.value = ''
selectedAttendeeIds.value = participants.value.map((participant: Participant) => participant.attendeeId)
searchText.value = ''
selectAll.value = true
invalid.value = null
})
watch([selectedCalendar, selectedDateTimeStart, selectedDateTimeEnd], () => {
invalid.value = null
})
watch(participants, (value) => {
if (selectAll.value) {
selectedAttendeeIds.value = value.map((participant: Participant) => participant.attendeeId)
}
})
watch(selectedDateTimeStart, () => debounceAdjustCurrentTimeRange('end'))
watch(selectedDateTimeEnd, () => debounceAdjustCurrentTimeRange('start'))
/**
* Autocorrect end date, if start date is after end date (or vice versa)
*
* @param direction counterpart to be adjusted
*/
function adjustCurrentTimeRange(direction: 'start' | 'end') {
if (selectedDateTimeStart.value < selectedDateTimeEnd.value) {
// All good, no adjustment needed
return
} else if (direction === 'end') {
// Keep the end time 1 hour ahead
selectedDateTimeEnd.value = new Date(selectedDateTimeStart.value.getTime() + ONE_HOUR_IN_MS)
} else {
// Keep the start time 1 hour behind
selectedDateTimeStart.value = new Date(selectedDateTimeEnd.value.getTime() - ONE_HOUR_IN_MS)
}
}
/**
* Returns Date object with N hours from now at the start of hour
*
* @param hours amount of hours to add
*/
function getCurrentDateInStartOfNthHour(hours: number) {
const date = new Date()
date.setHours(date.getHours() + hours, 0, 0, 0)
return date
}
/**
* Toggle selected attendees
*
* @param value switch value
*/
function toggleAll(value: boolean) {
selectedAttendeeIds.value = value ? participants.value.map((participant: Participant) => participant.attendeeId) : []
}
/**
* Remove selected attendee from contact bubble
*
* @param value switch value
*/
function removeSelectedParticipant(value: Participant) {
selectedAttendeeIds.value = selectedAttendeeIds.value.filter((id) => value.attendeeId !== id)
}
/**
* Check selected attendees
*
* @param value array of ids
*/
function checkSelection(value: number[]) {
selectAll.value = participants.value.length === value.length
}
/**
* Get user's calendars to identify belonging of known and future events
*/
async function getCalendars() {
await groupwareStore.getDefaultCalendarUri()
await groupwareStore.getPersonalCalendars()
loading.value = false
}
/**
* Get user's calendars to identify belonging of known and future events
*/
async function submitNewMeeting() {
if (!selectedCalendar.value) {
invalid.value = 'calendar'
return
}
if (selectedDateTimeStart.value < new Date()) {
invalid.value = 'start'
return
}
if (selectedDateTimeEnd.value < new Date() || selectedDateTimeEnd.value < selectedDateTimeStart.value) {
invalid.value = 'end'
return
}
try {
submitting.value = true
await groupwareStore.scheduleMeeting(props.token, {
calendarUri: selectedCalendar.value.value,
start: convertToUnix(selectedDateTimeStart.value),
end: convertToUnix(selectedDateTimeEnd.value),
title: newMeetingTitle.value || null,
description: newMeetingDescription.value || null,
attendeeIds: selectAll.value ? null : selectedAttendeeIds.value,
})
showSuccess(t('spreed', 'Meeting created'))
isFormOpen.value = false
} catch (error) {
// @ts-expect-error Vue: Property response does not exist
invalid.value = error?.response?.data?.ocs?.data?.error ?? 'unknown'
} finally {
submitting.value = false
}
}
</script>
<template>
<div v-if="conversation">
<NcPopover
:container="container"
:popperHideTriggers="hideTriggers"
:noFocusTrap="!canScheduleMeeting && upcomingEvents.length === 0"
popupRole="dialog">
<template #trigger>
<NcButton
class="upcoming-meeting"
:title="t('spreed', 'Upcoming meetings')"
:aria-label="t('spreed', 'Upcoming meetings')">
<template #icon>
<IconCalendarBlankOutline :size="20" />
</template>
<template v-if="upcomingEvents[0] && !isMobile" #default>
<span class="upcoming-meeting__header">
{{ t('spreed', 'Next meeting') }}
</span>
<StaticDateTime :time="upcomingEvents[0].start" calendar />
</template>
</NcButton>
</template>
<template #default>
<template v-if="!loading && upcomingEvents.length">
<ul class="calendar-events__list">
<CalendarEventSmall
v-for="event in upcomingEvents"
:key="event.uri"
:name="event.summary"
:start="event.start"
:href="event.href"
:color="event.color"
:isRecurring="!!event.recurrenceId" />
</ul>
</template>
<NcEmptyContent v-else class="calendar-events__empty-content">
<template #icon>
<NcLoadingIcon v-if="loading" />
<IconCalendarBlankOutline v-else />
</template>
<template #description>
<p>{{ loading ? t('spreed', 'Loading …') : t('spreed', 'No upcoming meetings') }}</p>
</template>
</NcEmptyContent>
<div v-if="canScheduleMeeting" class="calendar-events__buttons">
<NcButton wide @click="isFormOpen = true">
<template #icon>
<IconPlus :size="20" />
</template>
{{ t('spreed', 'Schedule a meeting') }}
</NcButton>
</div>
</template>
</NcPopover>
<template v-if="canScheduleMeeting">
<NcDialog
id="calendar-meeting"
v-model:open="isFormOpen"
class="calendar-meeting"
:name="t('spreed', 'Schedule a meeting')"
size="normal"
closeOnClickOutside
:container="container">
<NcTextField
v-model="newMeetingTitle"
:label="t('spreed', 'Meeting title')"
labelVisible />
<NcTextArea
v-model="newMeetingDescription"
:label="t('spreed', 'Description')"
resize="vertical"
labelVisible />
<div class="calendar-meeting__flex-wrapper">
<NcDateTimePickerNative
id="schedule_meeting_input"
v-model="selectedDateTimeStart"
:class="{ 'invalid-time': invalid === 'start' }"
:min="new Date()"
:step="300"
:label="t('spreed', 'From')"
type="datetime-local" />
<NcDateTimePickerNative
id="schedule_meeting_input"
v-model="selectedDateTimeEnd"
:class="{ 'invalid-time': invalid === 'end' }"
:min="new Date()"
:step="300"
:label="t('spreed', 'To')"
type="datetime-local" />
</div>
<NcSelect
id="schedule_meeting_select"
v-model="selectedCalendar"
:options="calendarOptions"
:inputLabel="t('spreed', 'Calendar')">
<template #selected-option="option">
<span class="calendar-badge" :style="{ backgroundColor: option.color }" />
{{ option.label }}
</template>
<template #option="option">
<span class="calendar-badge" :style="{ backgroundColor: option.color }" />
{{ option.label }}
</template>
</NcSelect>
<h5 v-if="!isOneToOneConversation" class="calendar-meeting__header">
{{ t('spreed', 'Attendees') }}
</h5>
<div
v-if="!participantsInitialised"
class="calendar-meeting--loading">
<NcLoadingIcon />
{{ t('spreed', 'Loading …') }}
</div>
<p v-else-if="participants.length === 0">
{{ t('spreed', 'No other participants to send invitations to.') }}
</p>
<template v-else>
<NcCheckboxRadioSwitch v-model="selectAll" @update:modelValue="toggleAll">
{{ inviteLabel }}
</NcCheckboxRadioSwitch>
<NcButton v-if="!isOneToOneConversation && !selectAll" variant="tertiary" @click="isSelectorOpen = true">
<template #icon>
<IconAccountPlusOutline :size="20" />
</template>
{{ t('spreed', 'Add attendees') }}
</NcButton>
<p>{{ attendeeHint }}</p>
</template>
<template #actions>
<p v-if="invalidHint" class="calendar-meeting__invalid-hint">
{{ invalidHint }}
</p>
<NcButton
variant="primary"
:disabled="!selectedCalendar || submitting || !!invalid"
@click="submitNewMeeting">
<template #icon>
<NcLoadingIcon v-if="submitting" :size="20" />
<IconCheck v-else :size="20" />
</template>
{{ t('spreed', 'Save') }}
</NcButton>
</template>
</NcDialog>
<NcDialog
v-if="isSelectorOpen"
v-model:open="isSelectorOpen"
:name="t('spreed', 'Add attendees')"
class="calendar-meeting"
closeOnClickOutside
container="#calendar-meeting">
<SearchBox
v-model:value="searchText"
class="calendar-meeting__searchbox"
isFocused
:placeholderText="t('spreed', 'Search participants')"
@abortSearch="searchText = ''" />
<!-- Selected results -->
<TransitionWrapper
v-if="selectedAttendeeIds.length"
class="calendar-meeting__attendees-selected"
name="zoom"
tag="div"
group>
<ContactSelectionBubble
v-for="participant in selectedParticipants"
:key="participant.actorType + participant.actorId"
:participant="participant"
@update="removeSelectedParticipant" />
</TransitionWrapper>
<ul v-if="participantsInitialised && filteredParticipants.length" class="calendar-meeting__attendees">
<SelectableParticipant
v-for="participant in filteredParticipants"
:key="participant.attendeeId"
v-model:checked="selectedAttendeeIds"
:participant="participant"
@update:checked="checkSelection" />
</ul>
<NcEmptyContent
v-else
class="calendar-meeting__empty-content"
:name="!participantsInitialised ? t('spreed', 'Loading …') : t('spreed', 'No results')">
<template #icon>
<NcLoadingIcon v-if="!participantsInitialised" />
<IconAccountSearchOutline v-else />
</template>
</NcEmptyContent>
<template #actions>
<NcButton variant="primary" @click="isSelectorOpen = false">
<template #icon>
<IconCheck :size="20" />
</template>
{{ t('spreed', 'Done') }}
</NcButton>
</template>
</NcDialog>
</template>
</div>
</template>
<style lang="scss" scoped>
.calendar-events {
&__list {
--item-height: calc(2lh + var(--default-grid-baseline) * 3);
display: flex;
flex-direction: column;
margin: calc(var(--default-grid-baseline) / 2);
line-height: 20px;
max-height: calc(4.5 * var(--item-height) + 4 * var(--default-grid-baseline));
max-width: 200px;
overflow-y: auto;
& > * {
margin-inline: calc(var(--default-grid-baseline) / 2);
&:not(:last-child) {
border-bottom: 1px solid var(--color-border-dark);
}
}
}
&__empty-content {
min-width: 150px;
margin-top: calc(var(--default-grid-baseline) * 3);
padding: var(--default-grid-baseline);
}
&__buttons {
padding: var(--default-grid-baseline);
}
}
.calendar-meeting {
--item-height: calc(2lh + var(--default-grid-baseline) * 2);
:deep(.dialog__content) {
display: flex;
flex-direction: column;
margin: calc(var(--default-grid-baseline) / 2);
gap: var(--default-grid-baseline);
}
:deep(.dialog__actions) {
align-items: center;
}
&__header {
margin-block: calc(var(--default-grid-baseline) * 2);
}
&__invalid-hint {
color: var(--color-text-error);
}
&__flex-wrapper {
display: flex;
align-items: center;
gap: calc(var(--default-grid-baseline) * 2);
}
&__searchbox {
margin-inline: var(--default-grid-baseline);
margin-block-end: var(--default-grid-baseline);
width: calc(100% - var(--default-grid-baseline) * 2) !important;
}
&__attendees {
height: calc(5.5 * var(--item-height));
padding-block: var(--default-grid-baseline);
overflow-y: auto;
}
&__attendees-selected {
display: flex;
flex-wrap: wrap;
gap: var(--default-grid-baseline);
border-bottom: 1px solid var(--color-background-darker);
padding: var(--default-grid-baseline) 0;
max-height: 97px;
overflow-y: auto;
flex: 1 0 auto;
align-content: flex-start;
}
&__empty-content {
height: calc(5.5 * var(--item-height));
margin-block: auto !important;
}
&--loading {
display: flex;
align-items: center;
gap: var(--default-grid-baseline);
height: 32px;
}
// Overwrite default NcDateTimePickerNative styles
:deep(.native-datetime-picker) {
width: calc(50% - var(--default-grid-baseline));
margin-bottom: var(--default-grid-baseline);
&.invalid-time input {
--border-width-input: 2px;
border-color: var(--color-border-error);
}
}
}
.upcoming-meeting {
// Overwrite default NcButton styles
:deep(.button-vue__text) {
padding-block: 0;
margin: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 20px;
font-weight: 400;
}
&__header {
font-weight: 500;
}
}
.calendar-badge {
display: inline-block;
width: var(--default-font-size);
height: var(--default-font-size);
margin-inline: calc((var(--default-clickable-area) - var(--default-font-size)) / 2);
border-radius: 50%;
background-color: var(--primary-color);
}
</style>
+482
View File
@@ -0,0 +1,482 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import {
showError,
} from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { useIsMobile } from '@nextcloud/vue/composables/useIsMobile'
import { useResizeObserver } from '@vueuse/core'
import debounce from 'debounce'
import { computed, onMounted, onUnmounted, ref, toValue, useTemplateRef, watch } from 'vue'
import { useStore } from 'vuex'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconFullscreen from 'vue-material-design-icons/Fullscreen.vue'
import IconFullscreenExit from 'vue-material-design-icons/FullscreenExit.vue'
import IconHandBackLeft from 'vue-material-design-icons/HandBackLeft.vue' // Filled for better indication
import IconHandBackLeftOutline from 'vue-material-design-icons/HandBackLeftOutline.vue'
import IconSubtitles from 'vue-material-design-icons/Subtitles.vue'
import IconSubtitlesOutline from 'vue-material-design-icons/SubtitlesOutline.vue'
import IconViewGalleryOutline from 'vue-material-design-icons/ViewGalleryOutline.vue'
import IconViewGridOutline from 'vue-material-design-icons/ViewGridOutline.vue'
import CallButton from '../TopBar/CallButton.vue'
import ReactionMenu from '../TopBar/ReactionMenu.vue'
import TopBarMediaControls from '../TopBar/TopBarMediaControls.vue'
import {
toggleFullscreen,
useDocumentFullscreen,
} from '../../composables/useDocumentFullscreen.ts'
import { useGetToken } from '../../composables/useGetToken.ts'
import { CONVERSATION, PARTICIPANT } from '../../constants.ts'
import { getTalkConfig } from '../../services/CapabilitiesManager.ts'
import { useActorStore } from '../../stores/actor.ts'
import { useBreakoutRoomsStore } from '../../stores/breakoutRooms.ts'
import { useCallViewStore } from '../../stores/callView.ts'
import { useLiveTranscriptionStore } from '../../stores/liveTranscription.ts'
import { localCallParticipantModel, localMediaModel } from '../../utils/webrtc/index.js'
const { isSidebar = false } = defineProps<{
isSidebar: boolean
}>()
const AUTO_LOWER_HAND_THRESHOLD = 3000
const disableKeyboardShortcuts = OCP.Accessibility.disableKeyboardShortcuts()
const store = useStore()
const token = useGetToken()
const actorStore = useActorStore()
const breakoutRoomsStore = useBreakoutRoomsStore()
const isFullscreen = !isSidebar && useDocumentFullscreen()
const callViewStore = useCallViewStore()
const liveTranscriptionStore = useLiveTranscriptionStore()
const isLiveTranscriptionLoading = ref(false)
const bottomBar = useTemplateRef('bottomBar')
const callButtonWithActions = useTemplateRef('callButtonWithActions')
const isMobile = useIsMobile()
const conversation = computed(() => {
return store.getters.conversation(token.value) || store.getters.dummyConversation
})
const supportedReactions = computed(() => getTalkConfig(token.value, 'call', 'supported-reactions') || [])
const hasReactionSupport = computed(() => supportedReactions.value && supportedReactions.value.length > 0)
const canModerate = computed(() => [PARTICIPANT.TYPE.OWNER, PARTICIPANT.TYPE.MODERATOR, PARTICIPANT.TYPE.GUEST_MODERATOR]
.includes(conversation.value.participantType))
const isLiveTranscriptionSupported = computed(() => getTalkConfig(token.value, 'call', 'live-transcription') || false)
const liveTranscriptionButtonLabel = computed(() => {
if (!callViewStore.isLiveTranscriptionEnabled) {
return t('spreed', 'Enable live transcription')
}
return t('spreed', 'Disable live transcription')
})
const isHandRaised = computed(() => localMediaModel.attributes.raisedHand.state === true)
const raiseHandButtonLabel = computed(() => {
if (!isHandRaised.value) {
return disableKeyboardShortcuts
? t('spreed', 'Raise hand')
: t('spreed', 'Raise hand (R)')
}
return disableKeyboardShortcuts
? t('spreed', 'Lower hand')
: t('spreed', 'Lower hand (R)')
})
const fullscreenLabel = computed(() => {
return toValue(isFullscreen)
? t('spreed', 'Exit full screen (F)')
: t('spreed', 'Full screen (F)')
})
const changeViewLabel = computed(() => {
return isGrid.value
? t('spreed', 'Speaker view')
: t('spreed', 'Grid view')
})
const showCallLayoutSwitch = computed(() => !callViewStore.isEmptyCallView)
const isGrid = computed(() => callViewStore.isGrid)
const userIsInBreakoutRoomAndInCall = computed(() => conversation.value.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM)
const COLLAPSIBLE_BUTTONS = ['virtualBackground', 'liveTranscription', 'raiseHand', 'callLayout', 'fullscreen'] as const
type CollapsibleButtons = Record<typeof COLLAPSIBLE_BUTTONS[number], boolean>
const isActionAvailableMask = computed<CollapsibleButtons>(() => ({
fullscreen: !isSidebar,
callLayout: showCallLayoutSwitch.value,
raiseHand: true,
liveTranscription: isLiveTranscriptionSupported.value,
virtualBackground: !isSidebar,
}))
const hidingList = ref<CollapsibleButtons>({ ...isActionAvailableMask.value })
const hasHiddenItems = computed(() => Object.values(hidingList.value).some(Boolean))
const BUTTON_WITH_GAP_WIDTH = 38 // var(--default-clickable-area) + var--default-grid-baseline)
const MINIMAL_MEDIA_CONTROLS_WIDTH = 236 // Minimal width to show media controls properly
/**
* Adjust the layout of the bottom bar based on the available width.
*
*/
function adjustLayout() {
if (!bottomBar.value) {
return
}
// 20px is for side paddings of the bottom bar, 8px is for the gap between the call button and the options
const availableWidth = bottomBar.value.clientWidth - callButtonWithActions.value!.clientWidth - 28
if (availableWidth <= MINIMAL_MEDIA_CONTROLS_WIDTH) {
// Not enough space to show anything, hide all buttons
COLLAPSIBLE_BUTTONS.forEach((button) => {
hidingList.value[button as keyof typeof hidingList.value] = true
})
return
}
const buttonsToRender = Math.floor((availableWidth - MINIMAL_MEDIA_CONTROLS_WIDTH) / BUTTON_WITH_GAP_WIDTH)
// make the first n buttons visible, hide the rest
const buttonsToCollapse = COLLAPSIBLE_BUTTONS.filter((button) => isActionAvailableMask.value[button])
buttonsToCollapse.forEach((button, index) => {
hidingList.value[button] = index >= buttonsToRender
})
}
const debounceAdjustLayout = debounce(adjustLayout, 200)
useResizeObserver(bottomBar, () => {
debounceAdjustLayout()
})
onMounted(() => {
adjustLayout()
})
onUnmounted(() => {
debounceAdjustLayout.clear?.()
})
/**
* Toggle live transcriptions.
*/
async function toggleLiveTranscription() {
if (isLiveTranscriptionLoading.value) {
return
}
isLiveTranscriptionLoading.value = true
if (!callViewStore.isLiveTranscriptionEnabled) {
await enableLiveTranscription()
} else {
await disableLiveTranscription()
}
isLiveTranscriptionLoading.value = false
}
/**
* Enable live transcriptions.
*/
async function enableLiveTranscription() {
// Strictly speaking it would be the responsibility of the components using
// the language metadata to ensure that it is loaded before using it, but
// for simplicity it is done here and enabling the live transcription is
// tied to having said metadata.
try {
await liveTranscriptionStore.loadLiveTranscriptionLanguages()
} catch (exception) {
showError(t('spreed', 'Error when trying to load the available live transcription languages'))
return
}
try {
await callViewStore.enableLiveTranscription(token.value)
} catch (error) {
showError(t('spreed', 'Failed to enable live transcription'))
}
}
/**
* Disable live transcriptions.
*/
async function disableLiveTranscription() {
try {
await callViewStore.disableLiveTranscription(token.value)
} catch (error) {
// Not being able to disable the live transcription is not really
// relevant for the user, as the transcript will be no longer visible in
// the UI anyway, so no error is shown in that case.
}
}
let lowerHandDelay = AUTO_LOWER_HAND_THRESHOLD
let speakingTimestamp: number | null = null
let lowerHandTimeout: ReturnType<typeof setTimeout> | null = null
// Hand raising functionality
/**
* Toggle the hand raised state for the local media model and update the store.
* If the user is in a breakout room, it also handles the request for assistance.
*/
function toggleHandRaised() {
const newState = !isHandRaised.value
localMediaModel.toggleHandRaised(newState)
store.dispatch('setParticipantHandRaised', {
sessionId: actorStore.sessionId,
raisedHand: localMediaModel.attributes.raisedHand,
})
// Handle breakout room assistance requests
if (userIsInBreakoutRoomAndInCall.value && !canModerate.value) {
const hasRaisedHands = Object.keys(store.getters.participantRaisedHandList)
.filter((sessionId) => sessionId !== actorStore.sessionId)
.length !== 0
if (hasRaisedHands) {
return // Assistance is already requested by someone in the room
}
const hasAssistanceRequested = conversation.value.breakoutRoomStatus === CONVERSATION.BREAKOUT_ROOM_STATUS.STATUS_ASSISTANCE_REQUESTED
if (newState && !hasAssistanceRequested) {
breakoutRoomsStore.requestAssistance(token.value)
} else if (!newState && hasAssistanceRequested) {
breakoutRoomsStore.dismissRequestAssistance(token.value)
}
}
}
// Auto-lower hand when speaking
watch(() => localMediaModel.attributes.speaking, (speaking) => {
if (lowerHandTimeout !== null && !speaking) {
lowerHandDelay = Math.max(0, lowerHandDelay - (Date.now() - speakingTimestamp!))
clearTimeout(lowerHandTimeout)
lowerHandTimeout = null
return
}
// User is not speaking OR timeout is already running OR hand is not raised
if (!speaking || lowerHandTimeout !== null || !isHandRaised.value) {
return
}
speakingTimestamp = Date.now()
lowerHandTimeout = setTimeout(() => {
lowerHandTimeout = null
speakingTimestamp = null
lowerHandDelay = AUTO_LOWER_HAND_THRESHOLD
if (isHandRaised.value) {
toggleHandRaised()
}
}, lowerHandDelay)
})
/**
* Switches the call view mode between grid and speaker view.
*/
function changeView() {
callViewStore.setCallViewMode({ token: token.value, isGrid: !isGrid.value, clearLast: false })
callViewStore.setSelectedVideoPeerId(null)
}
// Keyboard shortcuts
useHotKey('r', toggleHandRaised)
</script>
<template>
<div ref="bottomBar" class="bottom-bar" data-theme-dark>
<div v-if="!isSidebar" class="bottom-bar-call-controls">
<!-- Fullscreen -->
<NcButton
v-if="!hidingList.fullscreen"
:aria-label="fullscreenLabel"
:variant="isFullscreen ? 'secondary' : 'tertiary'"
:title="fullscreenLabel"
@click="toggleFullscreen">
<template #icon>
<IconFullscreen v-if="!isFullscreen" :size="20" />
<IconFullscreenExit v-else :size="20" />
</template>
</NcButton>
<!-- Call layout switcher -->
<NcButton
v-if="showCallLayoutSwitch && !hidingList.callLayout"
variant="tertiary"
:aria-label="changeViewLabel"
:title="changeViewLabel"
@click="changeView">
<template #icon>
<IconViewGridOutline v-if="!isGrid" :size="20" />
<IconViewGalleryOutline v-else :size="20" />
</template>
</NcButton>
</div>
<div class="bottom-bar-call-controls">
<!-- Local media controls -->
<TopBarMediaControls
:token="token"
:model="localMediaModel"
:isSidebar="isSidebar"
:hideVirtualBackgroundShortcut="hidingList.virtualBackground"
:localCallParticipantModel="localCallParticipantModel" />
<!-- Reactions menu -->
<ReactionMenu
v-if="hasReactionSupport"
:token="token"
:supportedReactions="supportedReactions"
:localCallParticipantModel="localCallParticipantModel" />
<NcButton
v-if="isLiveTranscriptionSupported && !hidingList.liveTranscription"
:title="liveTranscriptionButtonLabel"
:aria-label="liveTranscriptionButtonLabel"
:variant="callViewStore.isLiveTranscriptionEnabled ? 'secondary' : 'tertiary'"
:disabled="isLiveTranscriptionLoading"
@click="toggleLiveTranscription">
<template #icon>
<NcLoadingIcon
v-if="isLiveTranscriptionLoading"
:size="20" />
<IconSubtitles
v-else-if="callViewStore.isLiveTranscriptionEnabled"
:size="20" />
<IconSubtitlesOutline
v-else
:size="20" />
</template>
</NcButton>
<NcButton
v-if="!isSidebar && !hidingList.raiseHand"
:title="raiseHandButtonLabel"
:aria-label="raiseHandButtonLabel"
:variant="isHandRaised ? 'secondary' : 'tertiary'"
@click="toggleHandRaised">
<!-- The following icon is much bigger than all the others
so we reduce its size -->
<template #icon>
<IconHandBackLeft v-if="isHandRaised" :size="18" />
<IconHandBackLeftOutline v-else :size="18" />
</template>
</NcButton>
</div>
<div ref="callButtonWithActions" class="bottom-bar-options call-options">
<!-- Collapsed actions -->
<NcActions v-if="hasHiddenItems" forceMenu>
<!-- Fullscreen -->
<NcActionButton
v-if="!isSidebar && hidingList.fullscreen"
:aria-label="fullscreenLabel"
:variant="isFullscreen ? 'secondary' : 'tertiary'"
:title="fullscreenLabel"
@click="toggleFullscreen">
<template #icon>
<IconFullscreen v-if="!isFullscreen" :size="20" />
<IconFullscreenExit v-else :size="20" />
</template>
{{ fullscreenLabel }}
</NcActionButton>
<!-- Call layout switcher -->
<NcActionButton
v-if="hidingList.callLayout && showCallLayoutSwitch"
variant="tertiary"
:aria-label="changeViewLabel"
:title="changeViewLabel"
@click="changeView">
<template #icon>
<IconViewGridOutline v-if="!isGrid" :size="20" />
<IconViewGalleryOutline v-else :size="20" />
</template>
{{ changeViewLabel }}
</NcActionButton>
<NcActionButton
v-if="isLiveTranscriptionSupported && hidingList.liveTranscription"
:title="liveTranscriptionButtonLabel"
:aria-label="liveTranscriptionButtonLabel"
:variant="callViewStore.isLiveTranscriptionEnabled ? 'secondary' : 'tertiary'"
:disabled="isLiveTranscriptionLoading"
@click="toggleLiveTranscription">
<template #icon>
<NcLoadingIcon
v-if="isLiveTranscriptionLoading"
:size="20" />
<IconSubtitles
v-else-if="callViewStore.isLiveTranscriptionEnabled"
:size="20" />
<IconSubtitlesOutline
v-else
:size="20" />
</template>
{{ liveTranscriptionButtonLabel }}
</NcActionButton>
<NcActionButton
v-if="!isSidebar && hidingList.raiseHand"
:title="raiseHandButtonLabel"
:aria-label="raiseHandButtonLabel"
:variant="isHandRaised ? 'secondary' : 'tertiary'"
@click="toggleHandRaised">
<!-- The following icon is much bigger than all the others
so we reduce its size -->
<template #icon>
<IconHandBackLeft v-if="isHandRaised" :size="18" />
<IconHandBackLeftOutline v-else :size="18" />
</template>
{{ raiseHandButtonLabel }}
</NcActionButton>
</NcActions>
<CallButton
class="call-button"
:hideText="isSidebar || isMobile"
:isScreensharing="!!localMediaModel.attributes.localScreen" />
</div>
</div>
</template>
<style lang="scss" scoped>
.bottom-bar {
position: absolute;
bottom: 0;
inset-inline: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--wrapper-padding);
z-index: 10;
overflow: hidden;
:deep(.button-vue--tertiary) {
background-color: var(--color-primary-light);
}
}
.bottom-bar-call-controls {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--default-grid-baseline);
}
.bottom-bar-call-controls:not(:has(*)) {
display: none
}
.call-options {
display: flex;
align-items: center;
flex-direction: row;
gap: 4px;
}
</style>
@@ -0,0 +1,88 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import { useStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcModal from '@nextcloud/vue/components/NcModal'
import IconAlertOctagonOutline from 'vue-material-design-icons/AlertOctagonOutline.vue'
import IconRefresh from 'vue-material-design-icons/Refresh.vue'
import { messagePleaseTryToReload } from '../../utils/talkDesktopUtils.ts'
const props = defineProps({
token: {
type: String,
required: true,
},
})
const store = useStore()
const STATUS_ERRORS = {
400: t('spreed', 'Recording consent is required'),
403: t('spreed', 'This conversation is read-only'),
404: t('spreed', 'Conversation not found or not joined'),
412: t('spreed', "Lobby is still active and you're not a moderator"),
} as const
const connectionFailed = computed(() => store.getters.connectionFailed(props.token))
const connectionFailedDialogId = `connection-failed-${props.token}`
const message = computed(() => {
if (!connectionFailed.value) {
return ''
}
const statusCode: keyof typeof STATUS_ERRORS | undefined = connectionFailed.value.meta?.statuscode
if (statusCode && STATUS_ERRORS[statusCode]) {
return STATUS_ERRORS[statusCode]
}
if (connectionFailed.value?.data?.error) {
return connectionFailed.value.data.error
}
return messagePleaseTryToReload
})
/**
* Reset error status in the store
*/
function clearConnectionFailedError() {
store.dispatch('clearConnectionFailed', props.token)
}
/**
* Reload the page to get a valid room object and HPB settings
*/
function reloadApp() {
window.location.reload()
}
</script>
<template>
<NcModal
:labelId="connectionFailedDialogId"
@close="clearConnectionFailedError">
<NcEmptyContent
:name="t('spreed', 'Connection failed')"
:description="message">
<template #icon>
<IconAlertOctagonOutline />
</template>
<template #action>
<NcButton
variant="primary"
@click="reloadApp">
<template #icon>
<IconRefresh />
</template>
{{ t('spreed', 'Reload') }}
</NcButton>
</template>
</NcEmptyContent>
</NcModal>
</template>
+978
View File
@@ -0,0 +1,978 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div id="call-container" :class="callContainerClass">
<ViewerOverlayCallView
v-if="isViewerOverlay"
:token="token"
:model="promotedParticipantModel"
:sharedData="promotedParticipantModel && sharedDatas[promotedParticipantModel.attributes.peerId]"
:screens="screens"
:localSharedData="localSharedData" />
<template v-else>
<EmptyCallView v-if="showEmptyCallView" :isSidebar="isSidebar" />
<div id="videos" :class="{ 'is-sidebar': isSidebar }">
<div
v-if="devMode ? !isGrid : (!isGrid || !callParticipantModels.length)"
class="video__promoted"
:class="{ 'full-page': showFullPage }">
<!-- Selected video override mode -->
<VideoVue
v-if="showSelectedVideo && selectedCallParticipantModel"
:key="`promoted-${selectedVideoPeerId}`"
:token="token"
:model="selectedCallParticipantModel"
:sharedData="sharedDatas[selectedVideoPeerId]"
:showTalkingHighlight="false"
:isOneToOne="isOneToOne"
isGrid
isBig
fitVideo />
<!-- Local Video Override mode (following own video) -->
<LocalVideo
v-else-if="showLocalVideo"
ref="localVideo"
:token="token"
:localMediaModel="localMediaModel"
:localCallParticipantModel="localCallParticipantModel"
:isStripe="false"
:showControls="false"
:isSidebar="false"
isBig
fitVideo />
<!-- Screens -->
<!-- Local screen -->
<ScreenShare
v-else-if="showLocalScreen"
key="screen-local"
:token="token"
:localMediaModel="localMediaModel"
:sharedData="localSharedData"
isBig />
<!-- Remote or selected screen -->
<ScreenShare
v-else-if="(showRemoteScreen || showSelectedScreen) && shownRemoteScreenCallParticipantModel"
:key="`screen-${shownRemoteScreenPeerId}`"
:token="token"
:callParticipantModel="shownRemoteScreenCallParticipantModel"
:sharedData="sharedDatas[shownRemoteScreenPeerId]"
isBig />
<!-- Promoted "autopilot" mode -->
<VideoVue
v-else-if="promotedParticipantModel"
:key="`autopilot-${promotedParticipantModel.attributes.peerId}`"
:token="token"
:model="promotedParticipantModel"
:sharedData="sharedDatas[promotedParticipantModel.attributes.peerId]"
:showTalkingHighlight="false"
isGrid
fitVideo
isBig
:isOneToOne="isOneToOne"
:isSidebar="isSidebar"
@forcePromoteVideo="forcePromotedModel = $event" />
<!-- presenter overlay -->
<PresenterOverlay
v-if="shouldShowPresenterOverlay"
:token="token"
:model="presenterModel"
:sharedData="presenterSharedData"
:isLocalPresenter="showLocalScreen"
:localMediaModel="localMediaModel"
:isCollapsed="!showPresenterOverlay"
@click="toggleShowPresenterOverlay" />
<div
v-else-if="devMode && !isGrid"
class="dev-mode-video--promoted">
<img :alt="placeholderName(6)" :src="placeholderImage(6)">
<VideoBottomBar
:hasShadow="false"
:model="placeholderModel(6)"
:sharedData="placeholderSharedData(6)"
:token="token"
:participantName="placeholderName(6)"
isBig />
</div>
</div>
<!-- Stripe or fullscreen grid depending on `isGrid` -->
<VideosGrid
v-if="!isSidebar"
:isStripe="devMode ? !isGrid : (!isGrid || !callParticipantModels.length)"
:isRecording="isRecording"
:token="token"
:hasPagination="true"
:isOverlap="showFullPage"
:callParticipantModels="callParticipantModels"
:screens="screens"
:localMediaModel="localMediaModel"
:localCallParticipantModel="localCallParticipantModel"
:sharedDatas="sharedDatas"
v-bind="$attrs"
@selectVideo="handleSelectVideo"
@clickLocalVideo="handleClickLocalVideo" />
<ReactionToaster
v-if="supportedReactions?.length"
:token="token"
:supportedReactions="supportedReactions"
:callParticipantModels="callParticipantModels" />
<LiveTranscriptionRenderer
v-if="isLiveTranscriptionEnabled"
:token="token"
:callParticipantModels="callParticipantModels" />
<!-- Local video if sidebar -->
<LocalVideo
v-if="isSidebar && !showLocalVideo"
ref="localVideo"
class="local-video"
:class="{ 'local-video--sidebar': isSidebar }"
:showControls="false"
:fitVideo="true"
:isStripe="true"
:token="token"
:localMediaModel="localMediaModel"
:localCallParticipantModel="localCallParticipantModel"
:isSidebar="isSidebar"
@clickVideo="handleClickLocalVideo" />
</div>
<BottomBar v-if="!isRecording" :isSidebar="isSidebar" />
</template>
</div>
</template>
<script>
import { showMessage } from '@nextcloud/dialogs'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import debounce from 'debounce'
import { provide, ref } from 'vue'
import BottomBar from './BottomBar.vue'
import VideosGrid from './Grid/VideosGrid.vue'
import EmptyCallView from './shared/EmptyCallView.vue'
import LiveTranscriptionRenderer from './shared/LiveTranscriptionRenderer.vue'
import LocalVideo from './shared/LocalVideo.vue'
import PresenterOverlay from './shared/PresenterOverlay.vue'
import ReactionToaster from './shared/ReactionToaster.vue'
import ScreenShare from './shared/ScreenShare.vue'
import VideoBottomBar from './shared/VideoBottomBar.vue'
import VideoVue from './shared/VideoVue.vue'
import ViewerOverlayCallView from './shared/ViewerOverlayCallView.vue'
import { SIMULCAST } from '../../constants.ts'
import BrowserStorage from '../../services/BrowserStorage.js'
import { fetchPeers } from '../../services/callsService.ts'
import { getTalkConfig } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts'
import { useCallViewStore } from '../../stores/callView.ts'
import { useSettingsStore } from '../../stores/settings.ts'
import { satisfyVersion } from '../../utils/satisfyVersion.ts'
import { callParticipantCollection, localCallParticipantModel, localMediaModel } from '../../utils/webrtc/index.js'
import RemoteVideoBlocker from '../../utils/webrtc/RemoteVideoBlocker.js'
import { placeholderImage, placeholderModel, placeholderName, placeholderSharedData } from './Grid/gridPlaceholders.ts'
import { useWakeLock } from './useWakeLock.ts'
const serverVersion = loadState('core', 'config', {}).version ?? '29.0.0.0'
const serverSupportsBackgroundBlurred = satisfyVersion(serverVersion, '29.0.4.0')
export default {
name: 'CallView',
components: {
BottomBar,
EmptyCallView,
VideosGrid,
LiveTranscriptionRenderer,
LocalVideo,
PresenterOverlay,
ReactionToaster,
ScreenShare,
VideoBottomBar,
VideoVue,
ViewerOverlayCallView,
},
props: {
token: {
type: String,
required: true,
},
// Determines whether this component is used in the sidebar
isSidebar: {
type: Boolean,
default: false,
},
// Determines whether this component is used in the recording view
isRecording: {
type: Boolean,
default: false,
},
},
setup() {
// Prevent the screen from turning off
useWakeLock()
// For debug and screenshot purposes. Set to true to enable
const devMode = ref(false)
provide('CallView:devModeEnabled', devMode)
const screenshotMode = ref(false)
provide('CallView:screenshotModeEnabled', screenshotMode)
const settingsStore = useSettingsStore()
// If media settings was not used, we check the global config of default devices state here
if (!settingsStore.showMediaSettings && settingsStore.startWithoutMedia) {
localMediaModel.disableAudio()
localMediaModel.disableVideo()
}
// Fallback ref for versions before v29.0.4
const isBackgroundBlurred = ref(BrowserStorage.getItem('background-blurred') !== 'false')
return {
localMediaModel,
localCallParticipantModel,
callParticipantCollection,
devMode,
callViewStore: useCallViewStore(),
isBackgroundBlurred,
}
},
data() {
return {
screens: [],
sharedDatas: {},
raisedHandUnwatchers: {},
speakingUnwatchers: {},
screenUnwatchers: {},
speakers: [],
localSharedData: {
screenVisible: true,
},
showPresenterOverlay: true,
debounceFetchPeers: () => {},
forcePromotedModel: null,
}
},
computed: {
promotedParticipantModel() {
// Ensure at least one participant is always promoted to show in autopilot
return this.forcePromotedModel
?? this.callParticipantModels.find((callParticipantModel) => this.sharedDatas[callParticipantModel.attributes.peerId].promoted)
?? this.callParticipantModels[0]
},
callParticipantModels() {
return callParticipantCollection.callParticipantModels.filter((callParticipantModel) => !callParticipantModel.attributes.internal || callParticipantModel.attributes.videoAvailable)
},
callParticipantModelsWithScreen() {
return this.callParticipantModels.filter((callParticipantModel) => callParticipantModel.attributes.screen)
},
localScreen() {
return localMediaModel.attributes.localScreen
},
screenSharingActive() {
return this.screens.length > 0
},
isViewerOverlay() {
return this.callViewStore.isViewerOverlay
},
isGrid() {
return this.callViewStore.isGrid && !this.isSidebar
},
selectedVideoPeerId() {
return this.callViewStore.selectedVideoPeerId
},
selectedCallParticipantModel() {
if (!this.showSelectedVideo || !this.selectedVideoPeerId) {
return null
}
return this.callParticipantModels.find((callParticipantModel) => {
return callParticipantModel.attributes.peerId === this.selectedVideoPeerId
})
},
hasSelectedScreen() {
return this.selectedVideoPeerId !== null && this.screens.includes(this.selectedVideoPeerId)
},
hasSelectedVideo() {
return this.selectedVideoPeerId !== null && !this.screens.includes(this.selectedVideoPeerId)
},
isOneToOne() {
return this.callParticipantModels.length === 1
},
showFullPage() {
return this.isOneToOne && !(this.showLocalScreen || this.showRemoteScreen || this.showSelectedScreen)
},
hasLocalVideo() {
return this.localMediaModel.attributes.videoEnabled
},
hasLocalScreen() {
return !!this.localMediaModel.attributes.localScreen
},
hasRemoteScreen() {
return this.callParticipantModelsWithScreen.length > 0
},
// The following conditions determine what to show in the "Big container"
// of the promoted view
// Show selected video (other than local)
showSelectedVideo() {
return this.hasSelectedVideo && !this.showLocalVideo
},
showSelectedScreen() {
return this.hasSelectedScreen && !this.showLocalVideo
},
// Shows the local video if selected
showLocalVideo() {
return this.hasLocalVideo && this.selectedVideoPeerId === 'local'
},
// Show local screen
showLocalScreen() {
return this.hasLocalScreen && this.selectedVideoPeerId === null && this.screens[0] === localCallParticipantModel.attributes.peerId
},
// Show somebody else's screen. This will show the screen of the last
// person that shared it.
showRemoteScreen() {
return this.shownRemoteScreenPeerId !== null && !this.showSelectedVideo && !this.showSelectedScreen
},
shownRemoteScreenPeerId() {
if (!this.screenSharingActive || !this.hasRemoteScreen) {
return null
}
if (this.screens.includes(this.selectedVideoPeerId)) {
return this.selectedVideoPeerId
}
if (!this.hasSelectedScreen) {
return this.screens[0]
}
return null
},
shownRemoteScreenCallParticipantModel() {
if (!this.shownRemoteScreenPeerId) {
return null
}
return this.callParticipantModels.find((callParticipantModel) => {
return callParticipantModel.attributes.peerId === this.shownRemoteScreenPeerId
})
},
shouldShowPresenterOverlay() {
return (this.showLocalScreen && this.hasLocalVideo)
|| ((this.showRemoteScreen || this.showSelectedScreen)
&& (this.shownRemoteScreenCallParticipantModel?.attributes.videoAvailable || this.isModelWithVideo(this.shownRemoteScreenCallParticipantModel)))
},
presenterModel() {
// Prioritize local screen over remote screen, if both are available (as in DOM order)
return this.showLocalScreen ? this.localCallParticipantModel : this.shownRemoteScreenCallParticipantModel
},
presenterSharedData() {
return this.showLocalScreen ? this.localSharedData : this.sharedDatas[this.shownRemoteScreenPeerId]
},
presenterVideoBlockerEnabled() {
return this.sharedDatas[this.shownRemoteScreenPeerId]?.remoteVideoBlocker?.isVideoEnabled()
},
showEmptyCallView() {
return !this.callParticipantModels.length && !this.screenSharingActive && !this.devMode
},
supportedReactions() {
return getTalkConfig(this.token, 'call', 'supported-reactions')
},
/**
* Fallback style for versions before v29.0.4
*/
callContainerClass() {
if (serverSupportsBackgroundBlurred) {
return
}
return this.isBackgroundBlurred ? 'call-container__blurred' : 'call-container__non-blurred'
},
isLiveTranscriptionEnabled() {
return this.callViewStore.isLiveTranscriptionEnabled
},
},
watch: {
'localCallParticipantModel.attributes.peerId': function(newValue, previousValue) {
const index = this.screens.indexOf(previousValue)
if (index !== -1) {
this.screens[index] = newValue
}
},
localScreen(localScreen) {
this._setScreenAvailable(localCallParticipantModel.attributes.peerId, localScreen)
},
callParticipantModels(models) {
this.updateDataFromCallParticipantModels(models)
},
isGrid() {
this.adjustSimulcastQuality()
},
selectedVideoPeerId() {
this.adjustSimulcastQuality()
},
speakers: {
deep: true,
handler() {
this._setPromotedParticipant()
},
},
shownRemoteScreenPeerId(value) {
if (value) {
this._setPromotedParticipant()
}
},
screens: {
deep: true,
handler() {
this._setScreenVisible()
},
},
callParticipantModelsWithScreen(newValue, previousValue) {
// Everytime a new screen is shared, switch to promoted view
if (newValue.length > previousValue.length) {
this.callViewStore.startPresentation(this.token)
} else if (newValue.length === 0 && previousValue.length > 0 && !this.hasLocalScreen && !this.selectedVideoPeerId) {
// last screen share stopped and no selected video, restoring previous state
this.callViewStore.stopPresentation(this.token)
}
},
showLocalScreen(showLocalScreen) {
// Everytime the local screen is shared, switch to promoted view
if (showLocalScreen) {
this.callViewStore.startPresentation(this.token)
} else if (this.callParticipantModelsWithScreen.length === 0 && !this.selectedVideoPeerId) {
// last screen share stopped and no selected video, restoring previous state
this.callViewStore.stopPresentation(this.token)
}
},
hasLocalVideo(newValue) {
if (this.selectedVideoPeerId === 'local') {
if (!newValue) {
this.callViewStore.setSelectedVideoPeerId(null)
}
}
},
presenterVideoBlockerEnabled(value) {
this.showPresenterOverlay = value
},
showEmptyCallView: {
immediate: true,
handler(value) {
this.callViewStore.setIsEmptyCallView(value)
},
},
},
created() {
// Ensure that data is properly initialized before mounting the
// subviews.
this.updateDataFromCallParticipantModels(this.callParticipantModels)
},
mounted() {
this.debounceFetchPeers = debounce(this.fetchPeers, 1500)
EventBus.on('refresh-peer-list', this.debounceFetchPeers)
callParticipantCollection.on('remove', this._lowerHandWhenParticipantLeaves)
subscribe('switch-screen-to-id', this._switchScreenToId)
subscribe('set-background-blurred', this.setBackgroundBlurred)
},
beforeUnmount() {
this.debounceFetchPeers.clear?.()
this.callViewStore.setIsEmptyCallView(true)
EventBus.off('refresh-peer-list', this.debounceFetchPeers)
callParticipantCollection.off('remove', this._lowerHandWhenParticipantLeaves)
unsubscribe('switch-screen-to-id', this._switchScreenToId)
unsubscribe('set-background-blurred', this.setBackgroundBlurred)
},
methods: {
t,
// Placeholder data for devMode and screenshotMode
placeholderImage,
placeholderName,
placeholderModel,
placeholderSharedData,
/**
* Updates data properties that depend on the CallParticipantModels.
*
* The data contains some properties that can not be dynamically
* computed but that depend on the current CallParticipantModels, so
* this function adds and removes elements and watchers as needed based
* on the given CallParticipantModels.
*
* @param {Array} models the array of CallParticipantModels
*/
updateDataFromCallParticipantModels(models) {
const addedModels = models.filter((model) => !this.sharedDatas[model.attributes.peerId])
const removedModelIds = Object.keys(this.sharedDatas).filter((sharedDataId) => models.find((model) => model.attributes.peerId === sharedDataId) === undefined)
removedModelIds.forEach((removedModelId) => {
this.sharedDatas[removedModelId].remoteVideoBlocker.destroy()
delete this.sharedDatas[removedModelId]
this.speakingUnwatchers[removedModelId]()
// Not reactive, but not a problem
delete this.speakingUnwatchers[removedModelId]
this.screenUnwatchers[removedModelId]()
// Not reactive, but not a problem
delete this.screenUnwatchers[removedModelId]
this.raisedHandUnwatchers[removedModelId]()
// Not reactive, but not a problem
delete this.raisedHandUnwatchers[removedModelId]
const index = this.speakers.findIndex((speaker) => speaker.id === removedModelId)
this.speakers.splice(index, 1)
this._setScreenAvailable(removedModelId, false)
})
addedModels.forEach((addedModel) => {
const sharedData = {
promoted: false,
remoteVideoBlocker: new RemoteVideoBlocker(addedModel),
screenVisible: false,
}
this.sharedDatas[addedModel.attributes.peerId] = sharedData
// Not reactive, but not a problem
this.speakingUnwatchers[addedModel.attributes.peerId] = this.$watch(function() {
return addedModel.attributes.speaking
}, function(speaking) {
this._setSpeaking(addedModel.attributes.peerId, speaking)
})
this.speakers.push({
id: addedModel.attributes.peerId,
active: false,
})
// Not reactive, but not a problem
this.screenUnwatchers[addedModel.attributes.peerId] = this.$watch(function() {
return addedModel.attributes.screen
}, function(screen) {
this._setScreenAvailable(addedModel.attributes.peerId, screen)
})
// Not reactive, but not a problem
this.raisedHandUnwatchers[addedModel.attributes.peerId] = this.$watch(function() {
return addedModel.attributes.raisedHand
}, function(raisedHand) {
this._handleParticipantRaisedHand(addedModel, raisedHand)
})
this.adjustSimulcastQualityForParticipant(addedModel)
})
},
_setSpeaking(peerId, speaking) {
if (speaking) {
// Move the speaker to the first element of the list
const index = this.speakers.findIndex((speaker) => speaker.id === peerId)
const speaker = this.speakers[index]
speaker.active = true
this.speakers.splice(index, 1)
this.speakers.unshift(speaker)
return
}
// Set the speaker as not speaking
const index = this.speakers.findIndex((speaker) => speaker.id === peerId)
const speaker = this.speakers[index]
speaker.active = false
// Move the speaker after all the active speakers
if (index === 0) {
this.speakers.shift()
const firstInactiveSpeakerIndex = this.speakers.findIndex((speaker) => !speaker.active)
if (firstInactiveSpeakerIndex === -1) {
this.speakers.push(speaker)
} else {
this.speakers.splice(firstInactiveSpeakerIndex, 0, speaker)
}
}
},
_handleParticipantRaisedHand(callParticipantModel, raisedHand) {
const nickName = callParticipantModel.attributes.name || callParticipantModel.attributes.userId
// sometimes the nick name is not available yet...
if (nickName) {
if (raisedHand?.state) {
showMessage(t('spreed', '{nickName} raised their hand.', { nickName }))
}
} else {
if (raisedHand?.state) {
showMessage(t('spreed', 'A participant raised their hand.'))
}
}
// update in callViewStore
this.$store.dispatch('setParticipantHandRaised', {
sessionId: callParticipantModel.attributes.nextcloudSessionId,
raisedHand,
})
},
_lowerHandWhenParticipantLeaves(callParticipantCollection, callParticipantModel) {
this.$store.dispatch('setParticipantHandRaised', {
sessionId: callParticipantModel.attributes.nextcloudSessionId,
raisedHand: false,
})
},
_setScreenAvailable(id, screen) {
if (screen) {
this.screens.unshift(id)
return
}
const index = this.screens.indexOf(id)
if (index !== -1) {
this.screens.splice(index, 1)
}
},
_setPromotedParticipant() {
let promotedPeerId = null
if (!this.screenSharingActive && this.speakers.length) {
promotedPeerId = this.speakers[0].id
} else if (this.shownRemoteScreenPeerId && this.sharedDatas[this.shownRemoteScreenPeerId]) {
promotedPeerId = this.shownRemoteScreenPeerId
}
// Ensure at least one participant is always promoted to show in autopilot
if (promotedPeerId && this.sharedDatas[promotedPeerId]) {
Object.keys(this.sharedDatas).forEach((peerId) => {
this.sharedDatas[peerId].promoted = false
})
this.sharedDatas[promotedPeerId].promoted = true
}
this.adjustSimulcastQuality()
},
_switchScreenToId(id) {
const index = this.screens.indexOf(id)
if (index === -1) {
return
}
if (this.callViewStore.presentationStarted) {
this.callViewStore.setCallViewMode({
token: this.token,
isGrid: false,
isStripeOpen: false,
clearLast: false,
})
} else {
this.callViewStore.startPresentation(this.token)
}
this.callViewStore.setSelectedVideoPeerId(null)
this.screens.splice(index, 1)
this.screens.unshift(id)
},
_setScreenVisible() {
this.localSharedData.screenVisible = false
Object.values(this.sharedDatas).forEach((sharedData) => {
sharedData.screenVisible = false
})
if (!this.screens.length) {
return
}
if (this.screens[0] === this.localCallParticipantModel.attributes.peerId) {
this.localSharedData.screenVisible = true
return
}
this.sharedDatas[this.screens[0]].screenVisible = true
},
handleSelectVideo(peerId) {
if (this.isSidebar) {
return
}
this.callViewStore.setSelectedVideoPeerId(peerId)
this.callViewStore.setCallViewMode({
token: this.token,
isGrid: false,
isStripeOpen: false,
clearLast: false,
})
},
handleClickLocalVideo() {
// DO nothing if no video
if (!this.hasLocalVideo || this.isSidebar) {
return
}
// Deselect possible selected video
this.callViewStore.setSelectedVideoPeerId('local')
this.callViewStore.setCallViewMode({
token: this.token,
isGrid: false,
isStripeOpen: false,
clearLast: false,
})
},
async fetchPeers() {
// The recording participant does not have a Nextcloud session, so
// it can not fetch the peers. This should not be a problem, as all
// the needed data for the recording should be (eventually)
// available in the signaling data.
if (this.isRecording) {
return
}
const token = this.token
try {
const response = await fetchPeers(token)
this.$store.dispatch('purgePeersStore')
response.data.ocs.data.forEach((peer) => {
this.$store.dispatch('addPeer', {
token,
peer,
})
})
} catch (exception) {
// Just means guests have no name, so don't error …
console.error(exception)
}
},
adjustSimulcastQuality() {
this.callParticipantModels.forEach((callParticipantModel) => {
this.adjustSimulcastQualityForParticipant(callParticipantModel)
})
},
adjustSimulcastQualityForParticipant(callParticipantModel) {
// Always use the high temporal layer, as a low frame rate can look
// bad specially with a low number of participants.
if (this.isGrid) {
callParticipantModel.setSimulcastVideoQuality(SIMULCAST.MEDIUM, SIMULCAST.HIGH)
} else if (this.sharedDatas[callParticipantModel.attributes.peerId].promoted || this.selectedVideoPeerId === callParticipantModel.attributes.peerId) {
callParticipantModel.setSimulcastVideoQuality(SIMULCAST.HIGH, SIMULCAST.HIGH)
} else {
callParticipantModel.setSimulcastVideoQuality(SIMULCAST.LOW, SIMULCAST.HIGH)
}
},
/**
* Fallback method for versions before v29.0.4
*
* @param {boolean} value whether background should be blurred
*/
setBackgroundBlurred(value) {
this.isBackgroundBlurred = value
},
isModelWithVideo(callParticipantModel) {
if (!callParticipantModel) {
return false
}
return callParticipantModel.attributes.videoAvailable
&& this.sharedDatas[callParticipantModel.attributes.peerId].remoteVideoBlocker.isVideoEnabled()
&& (typeof callParticipantModel.attributes.stream === 'object')
},
toggleShowPresenterOverlay() {
if (!this.showLocalScreen && !this.presenterVideoBlockerEnabled) {
this.sharedDatas[this.shownRemoteScreenPeerId].remoteVideoBlocker.setVideoEnabled(true)
} else {
this.showPresenterOverlay = !this.showPresenterOverlay
}
},
},
}
</script>
<style lang="scss" scoped>
@use '../../assets/variables' as *;
#call-container {
width: 100%;
height: 100%;
background-color: $color-call-background;
// Default value has changed since v29.0.4: 'blur(25px)' => 'none'
backdrop-filter: var(--filter-background-blur);
--grid-gap: calc(var(--default-grid-baseline) * 2);
--top-bar-height: 51px;
--wrapper-padding: calc(var(--default-grid-baseline) * 2.5);
--bottom-bar-height: calc(var(--default-clickable-area) + var(--wrapper-padding) * 2);
&.call-container__blurred {
backdrop-filter: blur(25px);
}
&.call-container__non-blurred {
backdrop-filter: none;
}
}
#videos {
position: absolute;
width: 100%;
height: calc(100% - (var(--top-bar-height) + var(--bottom-bar-height)));
top: var(--top-bar-height);
overflow: hidden;
display: flex;
justify-content: space-around;
align-items: flex-end;
flex-direction: column;
padding-inline: var(--wrapper-padding);
&.is-sidebar {
width: 100%;
height: 100%;
inset: 0;
padding: 0;
:deep(.video-container-big) {
width: 100%;
height: 100%;
padding: 0;
border-radius: 0;
}
}
}
.video__promoted {
position: relative;
height: 100%;
width: 100%;
&.full-page {
// force the promoted remote or local video to cover the whole call view
// doesn't affect screen shares, as it's a different MediaStream
position: static;
}
.dev-mode-video--promoted {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
}
.dev-mode-video--promoted img {
position: absolute;
height: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
}
.local-video {
position: absolute;
inset-inline-end: 0;
bottom: 0;
width: 300px;
height: 250px;
&--sidebar {
width: 150px;
height: 100px;
bottom: var(--bottom-bar-height);
margin: var(--default-grid-baseline);
}
}
#videos.hidden {
display: none;
}
:deep(video) {
z-index: 0;
/* default filter for slightly better look */
/* Disabled for now as it causes a huuuuge performance drop.
CPU usage is more than halved without this.
-webkit-filter: contrast(1.1) saturate(1.1) sepia(.1);
filter: contrast(1.1) saturate(1.1) sepia(.1);
*/
vertical-align: top; /* fix white line below video */
}
#videos :deep(video) {
padding: 0;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { generateFilePath } from '@nextcloud/router'
/**
* Mock participant image for placeholders
*
* @param i index
*/
export function placeholderImage(i: number) {
return generateFilePath('spreed', 'docs', 'screenshotplaceholders/placeholder-' + (i % 9) + '.jpeg')
}
/**
* Mock participant name for placeholders
*
* @param i index
* @param showKey show key next to the name
*/
export function placeholderName(i: number, showKey: boolean = false): string {
switch (i % 9) {
case 0:
return 'Sandra McKinney' + (showKey ? ` | ${i}` : '')
case 1:
return 'Chris Wurst' + (showKey ? ` | ${i}` : '')
case 2:
return 'Edeltraut Bobb' + (showKey ? ` | ${i}` : '')
case 3:
return 'Arthur Blitz' + (showKey ? ` | ${i}` : '')
case 4:
return 'Roeland Douma' + (showKey ? ` | ${i}` : '')
case 5:
return 'Vanessa Steg' + (showKey ? ` | ${i}` : '')
case 6:
return 'Emily Grant' + (showKey ? ` | ${i}` : '')
case 7:
return 'Tobias Kaminsky' + (showKey ? ` | ${i}` : '')
case 8:
default:
return 'Adrian Ada' + (showKey ? ` | ${i}` : '')
}
}
/**
* Mock participant model for placeholders
*
* @param i index
*/
export function placeholderModel(i: number) {
return {
attributes: {
audioAvailable: [1, 2, 4, 5, 7, 8].includes(i % 9),
audioEnabled: (i % 9) === 8,
videoAvailable: true,
screen: false,
currentVolume: 0.75,
volumeThreshold: 0.75,
localScreen: false,
raisedHand: {
state: [0, 1, 6].includes(i % 9),
},
},
forceMute: () => {},
on: () => {},
off: () => {},
getWebRtc: () => {
return {
connection: {
getSendVideoIfAvailable: () => {},
},
}
},
}
}
/**
* Mock shared data for placeholders
*/
export function placeholderSharedData() {
return {
videoEnabled: {
isVideoEnabled: () => true,
},
remoteVideoBlocker: {
isVideoEnabled: () => true,
},
screenVisible: false,
}
}
@@ -0,0 +1,260 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
class="empty-call-view"
:class="{
'empty-call-view--sidebar': isSidebar,
'empty-call-view--small': isSmall,
}"
data-theme-dark>
<component :is="emptyCallViewIcon" :size="isSidebar ? 32 : 64" class="empty-call-view__icon" />
<h2>{{ title }}</h2>
<template v-if="!isSmall">
<p v-if="message" class="emptycontent-additional">
{{ message }}
</p>
<NcButton
v-if="showLink"
variant="primary"
@click.stop="handleCopyLink">
{{ t('spreed', 'Copy link') }}
</NcButton>
</template>
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconAccountMultipleOutline from 'vue-material-design-icons/AccountMultipleOutline.vue'
import IconLink from 'vue-material-design-icons/Link.vue'
import IconPhoneOutline from 'vue-material-design-icons/PhoneOutline.vue'
import { useGetToken } from '../../../composables/useGetToken.ts'
import { CONVERSATION, PARTICIPANT } from '../../../constants.ts'
import { copyConversationLinkToClipboard } from '../../../utils/handleUrl.ts'
export default {
name: 'EmptyCallView',
components: {
NcButton,
NcLoadingIcon,
IconAccountMultipleOutline,
IconLink,
IconPhoneOutline,
},
props: {
isGrid: {
type: Boolean,
default: false,
},
isSidebar: {
type: Boolean,
default: false,
},
isSmall: {
type: Boolean,
default: false,
},
},
setup() {
return {
token: useGetToken(),
}
},
computed: {
isConnecting() {
return this.$store.getters.isConnecting(this.token)
},
conversation() {
return this.$store.getters.conversation(this.token)
},
isGroupConversation() {
return this.conversation && this.conversation.type === CONVERSATION.TYPE.GROUP
},
isPublicConversation() {
return this.conversation && this.conversation.type === CONVERSATION.TYPE.PUBLIC
},
isOneToOneConversation() {
return this.conversation?.type === CONVERSATION.TYPE.ONE_TO_ONE
|| this.conversation?.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER
},
isPasswordRequestConversation() {
return this.conversation && this.conversation.objectType === CONVERSATION.OBJECT_TYPE.VIDEO_VERIFICATION
},
isFileConversation() {
return this.conversation && this.conversation.objectType === CONVERSATION.OBJECT_TYPE.FILE
},
isPhoneConversation() {
return this.conversation
&& (this.conversation.objectType === CONVERSATION.OBJECT_TYPE.PHONE_LEGACY
|| this.conversation.objectType === CONVERSATION.OBJECT_TYPE.PHONE_PERSISTENT
|| this.conversation.objectType === CONVERSATION.OBJECT_TYPE.PHONE_TEMPORARY)
},
conversationDisplayName() {
return this.conversation && this.conversation.displayName
},
canInviteOthers() {
return this.conversation && (
this.conversation.participantType === PARTICIPANT.TYPE.OWNER
|| this.conversation.participantType === PARTICIPANT.TYPE.MODERATOR)
},
canInviteOthersInPublicConversations() {
return this.canInviteOthers
|| (this.conversation && this.conversation.participantType === PARTICIPANT.TYPE.GUEST_MODERATOR)
},
emptyCallViewIcon() {
if (this.isConnecting) {
return NcLoadingIcon
} else if (this.isPhoneConversation) {
return IconPhoneOutline
} else {
return this.isPublicConversation ? IconLink : IconAccountMultipleOutline
}
},
title() {
if (this.isConnecting) {
return t('spreed', 'Connecting …')
}
if (this.isPhoneConversation) {
return t('spreed', 'Calling …')
}
if (this.isOneToOneConversation) {
return t('spreed', 'Waiting for {user} to join the call', { user: this.conversationDisplayName })
}
return t('spreed', 'Waiting for others to join the call …')
},
message() {
if (this.isConnecting) {
return ''
}
if (this.isPasswordRequestConversation || this.isFileConversation) {
return ''
}
if (!this.isGroupConversation && !this.isPublicConversation) {
return ''
}
if (this.isGroupConversation && !this.canInviteOthers) {
return ''
}
if (this.isPhoneConversation) {
return ''
}
if (this.isGroupConversation) {
return t('spreed', 'You can invite others in the participant tab of the sidebar')
}
if (this.isPublicConversation && this.canInviteOthersInPublicConversations) {
return t('spreed', 'You can invite others in the participant tab of the sidebar or share this link to invite others!')
}
return t('spreed', 'Share this link to invite others!')
},
showLink() {
return this.isPublicConversation && !this.isPasswordRequestConversation && !this.isFileConversation
},
},
methods: {
t,
handleCopyLink() {
copyConversationLinkToClipboard(this.token)
},
},
}
</script>
<style lang="scss" scoped>
.empty-call-view {
height: 100%;
width: 100%;
position: absolute;
display: flex;
flex-direction: column;
align-content: center;
justify-content: center;
text-align: center;
z-index: 1; // Otherwise the "Copy link" button is not clickable
.icon {
background-size: 64px;
height: 64px;
width: 64px;
margin: 0 auto 15px;
}
button {
margin: 4px auto;
}
&__icon,
h2, p {
color: var(--color-main-text);
}
&--sidebar {
padding-bottom: 16px;
h2, p {
font-size: 90%;
}
.icon {
transform: scale(0.7);
margin-top: 0;
margin-bottom: 0;
}
}
&--small {
border-radius: calc(var(--default-clickable-area) / 2);
background-color: rgba(34, 34, 34, 0.8); /* Copy from the call view */
padding: 8px;
h2 {
font-size: 1rem;
font-weight: normal;
}
.icon {
transform: none;
margin-bottom: 0;
background-size: 32px;
height: 32px;
width: 32px;
}
}
}
</style>
@@ -0,0 +1,435 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
ref="transcript"
class="transcript">
<TranscriptBlock
v-for="item in transcriptBlocks"
ref="transcriptBlocks"
:key="item.id"
:token="token"
:model="item.model"
:chunks="item.chunks"
:rightToLeft="item.rightToLeft" />
</div>
</template>
<script lang="ts">
import type { PropType } from 'vue'
import type { Chunk } from './TranscriptBlock.vue'
import TranscriptBlock from './TranscriptBlock.vue'
import { useLiveTranscriptionStore } from '../../../stores/liveTranscription.ts'
declare module 'vue' {
interface TypeRefs {
transcript: HTMLDivElement
transcriptBlocks: undefined | Array<TranscriptBlock>
}
interface ComponentCustomProperties {
$refs: TypeRefs
}
}
interface CallParticipantModel {
attributes: {
peerId: string
actorId: string | null | undefined
actorType: string | null | undefined
userId: string | null | undefined
name: string | null | undefined
}
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
on: (event: string, handler: (model: CallParticipantModel, ...args: any[]) => void) => void
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
off: (event: string, handler: (model: CallParticipantModel, ...args: any[]) => void) => void
}
interface TranscriptBlockData {
id: number
model: CallParticipantModel
chunks: Array<Chunk>
rightToLeft: boolean
}
interface BlockAndLine {
block: number
line: number
}
type TranscriptBlock = InstanceType<typeof TranscriptBlock>
export default {
name: 'LiveTranscriptionRenderer',
components: {
TranscriptBlock,
},
props: {
/**
* The conversation token
*/
token: {
type: String,
required: true,
},
callParticipantModels: {
type: Array as PropType<Array<CallParticipantModel>>,
required: true,
},
},
setup() {
const liveTranscriptionStore = useLiveTranscriptionStore()
return {
liveTranscriptionStore,
}
},
data() {
return {
registeredModels: {} as { [key: string]: CallParticipantModel },
resizeObserver: null as null | ResizeObserver,
transcriptBlocks: [] as TranscriptBlockData[],
lastScrolledToBlockAndLine: null as null | BlockAndLine,
pendingScrollToBottomLineByLine: undefined as undefined | ReturnType<typeof setTimeout>,
}
},
computed: {
liveTranscriptionLanguages() {
const liveTranscriptionLanguages = this.liveTranscriptionStore.getLiveTranscriptionLanguages()
if (!liveTranscriptionLanguages) {
return {}
}
return liveTranscriptionLanguages
},
},
watch: {
callParticipantModels: {
immediate: true,
handler(models: Array<CallParticipantModel>) {
// Subscribe connected models for transcript events
const addedModels = models.filter((model) => !this.registeredModels[model.attributes.peerId])
addedModels.forEach((addedModel) => {
this.registeredModels[addedModel.attributes.peerId] = addedModel
this.registeredModels[addedModel.attributes.peerId].on('transcript', this.handleTranscript)
})
// Unsubscribe disconnected models
const removedModelIds = Object.keys(this.registeredModels).filter((registeredModelId) => !models.find((model) => model.attributes.peerId === registeredModelId))
removedModelIds.forEach((removedModelId) => {
this.registeredModels[removedModelId].off('transcript', this.handleTranscript)
delete this.registeredModels[removedModelId]
})
},
},
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleResize)
this.resizeObserver.observe(this.$refs.transcript)
},
beforeUnmount() {
Object.keys(this.registeredModels).forEach((modelId) => {
this.registeredModels[modelId].off('transcript', this.handleTranscript)
delete this.registeredModels[modelId]
})
this.resizeObserver!.disconnect()
clearTimeout(this.pendingScrollToBottomLineByLine)
},
methods: {
/**
* Handle resizings of the transcript element.
*
* After the transcript is resized the previous lines might have
* changed. For simplicity, and given that it was probably at the bottom
* or close to it already, rather than trying to keep the same visible
* lines the transcript is just scrolled to the bottom; any pending
* scroll to bottom is also cancelled.
*
* @param entries
* @param observer
*/
handleResize(entries: ResizeObserverEntry[], observer: ResizeObserver) {
if (!this.$refs.transcriptBlocks) {
return
}
for (let i = 0; i < this.$refs.transcriptBlocks.length; i++) {
this.$refs.transcriptBlocks[i].reset()
}
this.$refs.transcript.scrollTo({
top: this.$refs.transcript.scrollHeight,
})
// This should not happen, but just in case
if (!this.lastScrolledToBlockAndLine) {
this.lastScrolledToBlockAndLine = {
block: 0,
line: 0,
}
}
this.lastScrolledToBlockAndLine.block = this.$refs.transcriptBlocks.length - 1
const lastTranscriptBlock = this.$refs.transcriptBlocks[this.lastScrolledToBlockAndLine.block]
const lastTranscriptBlockLineBoundaries = lastTranscriptBlock.getLineBoundaries()
this.lastScrolledToBlockAndLine.line = lastTranscriptBlockLineBoundaries.length - 1
if (this.pendingScrollToBottomLineByLine) {
clearTimeout(this.pendingScrollToBottomLineByLine)
this.pendingScrollToBottomLineByLine = undefined
}
},
/**
* Handle a new received transcript.
*
* The transcript is added to the last block if it comes from the same
* participant, or a new block is added if it comes from another one. A
* new block will be used even for the same participant if the text
* direction changed.
*
* @param model the CallParticipantModel for the participant
* that was transcribed.
* @param message the transcribed message.
* @param languageId the ID of the language of the transcribed
* message.
* @param final true if the transcript will not be updated afterwards,
* false otherwise.
*/
handleTranscript(model: CallParticipantModel, message: string, languageId: string, final: boolean) {
let lastTranscriptBlock = this.transcriptBlocks.at(-1)
const messageIsRightToLeft = this.liveTranscriptionLanguages[languageId]?.metadata.rtl || false
if (lastTranscriptBlock?.model.attributes.peerId !== model.attributes.peerId
|| lastTranscriptBlock?.rightToLeft !== messageIsRightToLeft) {
const transcriptBlock = {
id: lastTranscriptBlock ? lastTranscriptBlock.id + 1 : 0,
model,
chunks: [],
rightToLeft: messageIsRightToLeft,
}
this.transcriptBlocks.push(transcriptBlock)
lastTranscriptBlock = transcriptBlock
}
const newTranscriptChunk = {
message,
languageId,
final,
}
const lastTranscriptChunk = lastTranscriptBlock.chunks.at(-1)
if (!lastTranscriptChunk || lastTranscriptChunk.final) {
lastTranscriptBlock.chunks.push(newTranscriptChunk)
} else {
lastTranscriptBlock.chunks.splice(-1, 1, newTranscriptChunk)
}
this.$nextTick(() => {
this.scrollToBottomLineByLine()
})
},
/**
* Scroll to the bottom, one line at a time, with a small pause at each
* line.
*
* If there are no more lines the no longer visible blocks are removed.
*/
scrollToBottomLineByLine() {
if (this.pendingScrollToBottomLineByLine) {
return
}
if (!this.scrollToNextLine()) {
this.removeNoLongerVisibleTranscriptBlocks()
return
}
this.pendingScrollToBottomLineByLine = setTimeout(() => {
this.pendingScrollToBottomLineByLine = undefined
this.scrollToBottomLineByLine()
}, 2000)
},
/**
* Scroll to the next line after the last visible one.
*
* @return {boolean} true if there was a line to scroll to, false
* otherwise.
*/
scrollToNextLine() {
if (!this.lastScrolledToBlockAndLine) {
this.scrollToBlockAndLine(0, 0)
return true
}
const lastScrolledToBlockLineBoundaries = this.$refs.transcriptBlocks![this.lastScrolledToBlockAndLine.block].getLineBoundaries()
// Fix line number if last chunk was replaced with a shorter text
// that uses less lines.
if (this.lastScrolledToBlockAndLine.line >= lastScrolledToBlockLineBoundaries.length) {
this.lastScrolledToBlockAndLine.line = lastScrolledToBlockLineBoundaries.length - 1
}
if (this.lastScrolledToBlockAndLine.line < lastScrolledToBlockLineBoundaries.length - 1) {
this.scrollToBlockAndLine(this.lastScrolledToBlockAndLine.block, this.lastScrolledToBlockAndLine.line + 1)
return true
}
if (this.lastScrolledToBlockAndLine.block < this.$refs.transcriptBlocks!.length - 1) {
this.scrollToBlockAndLine(this.lastScrolledToBlockAndLine.block + 1, 0)
return true
}
return false
},
/**
* Scroll to the given line in the given block.
*
* The bottom of the line will be aligned with the bottom of the
* transcript element (unless the internal area of the transcript is not
* large enough yet to be scrolled).
*
* @param block the index of the block in the current list of
* blocks.
* @param line the index of the line in the current list of
* lines of the block.
*/
scrollToBlockAndLine(block: number, line: number) {
this.lastScrolledToBlockAndLine = {
block,
line,
}
const transcriptBoundaries = this.$refs.transcript.getBoundingClientRect()
const transcriptTop = transcriptBoundaries.top
const transcriptHeight = transcriptBoundaries.bottom - transcriptBoundaries.top
const scrollToBlockLineBoundaries = this.$refs.transcriptBlocks![block].getLineBoundaries()
const scrollToLineLineBoundaries = scrollToBlockLineBoundaries[line]
const scrollToLineHeight = scrollToLineLineBoundaries.bottom - scrollToLineLineBoundaries.top
const scrollToLineRelativeLineBoundaries = {
top: scrollToLineLineBoundaries.top - transcriptTop,
bottom: scrollToLineLineBoundaries.bottom - transcriptTop,
}
// Align bottom of line with bottom of transcript
const scrollToTop = this.$refs.transcript.scrollTop
+ (scrollToLineRelativeLineBoundaries.top - transcriptHeight)
+ scrollToLineHeight
this.$refs.transcript.scrollTo({
top: scrollToTop,
behavior: 'smooth',
})
},
/**
* Remove all the transcript blocks fully above the top of the
* transcript element.
*/
removeNoLongerVisibleTranscriptBlocks() {
const count = this.getNoLongerVisibleTranscriptBlocksCount()
this.transcriptBlocks.splice(0, count)
this.lastScrolledToBlockAndLine!.block = this.lastScrolledToBlockAndLine!.block - count
// The same scroll position is expected to be automatically kept
// after the elements are removed, so the scroll is not explicitly
// adjusted.
},
/**
* @return {number} the number of no longer visible transcript blocks.
*/
getNoLongerVisibleTranscriptBlocksCount() {
const transcriptTop = this.$refs.transcript.getBoundingClientRect().top
let count = 0
for (let i = 0; i < this.lastScrolledToBlockAndLine!.block; i++) {
if (this.$refs.transcriptBlocks![i].$el.getBoundingClientRect().bottom > transcriptTop) {
return count
}
count++
}
return count
},
},
}
</script>
<style lang="scss" scoped>
.transcript {
/**
* A unitless value in line-height would be a multiplier on the font size,
* but --line-height is used for other properties like max-height that
* require a unit, so the variable needs to be explicitly multiplied by the
* font size.
*/
--line-height: calc(var(--default-font-size) * 2);
position: absolute;
bottom: 20px;
inset-inline: 20%;
line-height: var(--line-height);
max-height: calc(var(--line-height) * 4);
overflow: hidden;
display: flex;
align-items: center;
flex-direction: column;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
backdrop-filter: var(--filter-background-blur);
z-index: 1;
}
@media (height <= calc(var(--line-height) * 8)) {
.transcript {
max-height: calc(var(--line-height) * 2);
}
}
@media (max-width: 768px) {
.transcript {
inset-inline: 10%;
}
}
@media (max-width: 512px) {
.transcript {
inset-inline: 5%;
}
}
</style>
@@ -0,0 +1,400 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="local-audio-control-wrapper">
<NcPopover
ref="popover"
:boundary="boundaryElement"
:showTriggers="[]"
:hideTriggers="['click']"
:autoHide="false"
noFocusTrap
:shown="popupShown">
<template #trigger>
<NcButton
:title="audioButtonTitle"
:variant="audioStreamError ? 'error' : variant"
:aria-label="audioButtonAriaLabel"
:class="{
'no-audio-available': !isAudioAvailable,
'audio-control-button': showDevices,
}"
:disabled="resumeAudioAfterChange"
@click.stop="toggleAudio">
<template #icon>
<VolumeIndicator
:audioPreviewAvailable="isAudioAvailable"
:audioEnabled="showMicrophoneOn || resumeAudioAfterChange"
:currentVolume="model.attributes.currentVolume"
:volumeThreshold="model.attributes.volumeThreshold"
overlayMutedColor="#888888" />
</template>
</NcButton>
</template>
<div class="popover-hint">
<span>{{ speakingWhileMutedWarner?.message }}</span>
</div>
</NcPopover>
<NcActions
v-if="showDevices"
:disabled="!isAudioAllowed && !audioOutputSupported || !!audioStreamError"
class="audio-selector-button"
:class="{
'no-audio-available': !isAudioAvailable,
}"
@open="updateDevices">
<template #icon>
<IconChevronUp :size="16" />
</template>
<template v-if="isAudioAllowed">
<NcActionCaption :name="t('spreed', 'Select a microphone')" />
<NcActionButton
v-for="device in audioInputDevices"
:key="device.deviceId ?? 'none'"
class="audio-selector__action"
type="radio"
:modelValue="audioInputId"
:value="device.deviceId"
:title="device.label"
@click="handleAudioInputIdChange(device.deviceId)">
{{ device.label }}
</NcActionButton>
</template>
<NcActionSeparator v-if="isAudioAllowed && audioOutputSupported" />
<template v-if="audioOutputSupported">
<NcActionCaption :name="t('spreed', 'Select a speaker')" />
<NcActionButton
v-for="device in audioOutputDevices"
:key="device.deviceId ?? 'none'"
class="audio-selector__action"
type="radio"
:modelValue="audioOutputId"
:value="device.deviceId"
:title="device.label"
@click="handleAudioOutputIdChange(device.deviceId)">
{{ device.label }}
</NcActionButton>
</template>
</NcActions>
</div>
</template>
<script>
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { onBeforeUnmount, ref, watch } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCaption from '@nextcloud/vue/components/NcActionCaption'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import IconChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import VolumeIndicator from '../../UIShared/VolumeIndicator.vue'
import { useDevices } from '../../../composables/useDevices.js'
import { PARTICIPANT } from '../../../constants.ts'
import SpeakingWhileMutedWarner from '../../../utils/webrtc/SpeakingWhileMutedWarner.js'
export default {
name: 'LocalAudioControlButton',
components: {
NcActions,
NcActionButton,
NcActionCaption,
NcActionSeparator,
NcButton,
NcPopover,
VolumeIndicator,
IconChevronUp,
},
props: {
conversation: {
type: Object,
required: true,
},
model: {
type: Object,
required: true,
},
disableKeyboardShortcuts: {
type: Boolean,
default: OCP.Accessibility.disableKeyboardShortcuts(),
},
disableMutedWarning: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: 'tertiary-no-background',
},
token: {
type: String,
required: true,
},
showDevices: {
type: Boolean,
default: false,
},
},
expose: ['toggleAudio'],
setup(props) {
const boundaryElement = document.querySelector('.main-view')
const popover = ref(null)
const popupShown = ref(false)
const speakingWhileMutedWarner = !props.disableMutedWarning
? ref(new SpeakingWhileMutedWarner(props.model))
: ref(null)
if (!props.disableMutedWarning) {
watch(() => speakingWhileMutedWarner.value.showPopup, (newValue) => {
popupShown.value = newValue && isVisible(popover.value?.$el)
})
onBeforeUnmount(() => {
speakingWhileMutedWarner.value.destroy()
})
}
const {
devices,
audioInputId,
audioOutputId,
audioStreamError,
updateDevices,
audioOutputSupported,
updatePreferences,
subscribeToDevices,
unsubscribeFromDevices,
} = useDevices()
/* Flag to smoothly toggle the audio while in call */
const resumeAudioAfterChange = ref(false)
/**
* Check if component is visible and not obstructed by others
*
* @param element HTML element
*/
function isVisible(element) {
if (!element) {
return false // Element doesn't exist, therefore - not visible
}
const rect = element.getBoundingClientRect()
return document.elementsFromPoint(rect.left, rect.top)?.[0] === element
}
return {
boundaryElement,
popover,
popupShown,
speakingWhileMutedWarner,
devices,
audioInputId,
audioOutputId,
audioStreamError,
updateDevices,
audioOutputSupported,
updatePreferences,
subscribeToDevices,
unsubscribeFromDevices,
resumeAudioAfterChange,
}
},
computed: {
isAudioAllowed() {
return this.conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_AUDIO
},
isAudioAvailable() {
return this.model.attributes.audioAvailable
},
showMicrophoneOn() {
return this.isAudioAvailable && this.model.attributes.audioEnabled
},
audioButtonTitle() {
if (!this.isAudioAllowed) {
return t('spreed', 'You are not allowed to enable audio')
}
if (!this.isAudioAvailable) {
return t('spreed', 'No audio. Click to select device')
}
if (this.model.attributes.audioEnabled) {
return this.disableKeyboardShortcuts
? t('spreed', 'Mute audio')
: t('spreed', 'Mute audio (M)')
} else {
return this.disableKeyboardShortcuts
? t('spreed', 'Unmute audio')
: t('spreed', 'Unmute audio (M)')
}
},
audioButtonAriaLabel() {
if (!this.isAudioAvailable) {
return t('spreed', 'No audio. Click to select device')
}
return this.model.attributes.audioEnabled
? t('spreed', 'Mute audio')
: t('spreed', 'Unmute audio')
},
audioInputDevices() {
return [
...this.devices.filter((device) => device.kind === 'audioinput')
.map((device) => ({
deviceId: device.deviceId,
label: device.label || device.fallbackLabel,
})),
{ deviceId: null, label: t('spreed', 'None') },
]
},
audioOutputDevices() {
return this.devices.filter((device) => device.kind === 'audiooutput')
.map((device) => ({
deviceId: device.deviceId,
label: device.label || device.fallbackLabel,
}))
},
},
watch: {
isAudioAvailable(newValue) {
if (newValue && this.resumeAudioAfterChange) {
// New track is available, resume audio
this.model.enableAudio()
this.resumeAudioAfterChange = false
}
},
audioInputId(newValue) {
if (!newValue && this.resumeAudioAfterChange) {
this.resumeAudioAfterChange = false
}
},
},
created() {
useHotKey('m', this.toggleAudio)
useHotKey(' ', this.toggleAudio, { push: true })
},
mounted() {
this.subscribeToDevices()
},
beforeUnmount() {
this.unsubscribeFromDevices()
},
methods: {
t,
toggleAudio() {
if (!this.isAudioAllowed || !this.isAudioAvailable) {
emit('talk:media-settings:show')
return
}
if (this.model.attributes.audioEnabled) {
this.model.disableAudio()
} else {
this.model.enableAudio()
}
},
handleAudioInputIdChange(audioInputId) {
if (this.showDevices && this.showMicrophoneOn) {
// If input was changed from bottom bar while active, it should not be muted after track change
this.resumeAudioAfterChange = true
}
this.audioInputId = audioInputId
this.updatePreferences('audioinput')
},
handleAudioOutputIdChange(audioOutputId) {
this.audioOutputId = audioOutputId
this.updatePreferences('audiooutput')
},
},
}
</script>
<style scoped lang="scss">
.no-audio-available {
opacity: .7;
}
.popover-hint {
padding: calc(3 * var(--default-grid-baseline));
max-width: 300px;
text-align: start;
}
.audio-selector-button :deep(.action-item__menutoggle) {
--button-size: var(--clickable-area-small);
height: var(--default-clickable-area);
border-start-start-radius: 2px;
border-end-start-radius: 2px;
}
.local-audio-control-wrapper {
display: flex;
align-items: center;
gap: 1px;
// Overwriting NcButton styles
.audio-control-button {
border-start-end-radius: 2px;
border-end-end-radius: 2px;
}
}
.audio-selector__action {
// Overwriting NcActionButton styles
:deep(.action-button__longtext) {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
padding: 0;
max-width: 350px;
}
:deep(.action-button__longtext-wrapper) {
max-width: 350px;
}
:deep(.action-button__icon) {
width: 0;
margin-inline-start: calc(var(--default-grid-baseline) * 3);
}
:deep(.action-button > span) {
height: var(--default-clickable-area);
}
}
</style>
@@ -0,0 +1,539 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
ref="videoContainer"
class="localVideoContainer"
:class="videoContainerClass"
@mouseover="mouseover = true"
@mouseleave="mouseover = false"
@click="$emit('clickVideo')">
<img
v-if="screenshotModeUrl"
class="dev-mode-video--self videoWrapper"
alt="dev-mode-video--self"
:src="screenshotModeUrl">
<div
v-show="!screenshotModeUrl && localMediaModel.attributes.videoEnabled"
class="videoWrapper"
:style="videoWrapperStyle">
<video
id="localVideo"
ref="video"
disablePictureInPicture="true"
:class="fitVideo ? 'video--fit' : 'video--fill'"
class="video"
@playing="updateVideoAspectRatio" />
<IconAccountOffOutline
v-if="isPresenterOverlay && mouseover"
class="presenter-icon__hide"
:aria-label="t('spreed', 'Hide presenter video')"
:title="t('spreed', 'Hide presenter video')"
:size="32"
@click="$emit('clickPresenter')" />
<NcLoadingIcon
v-if="isNotConnected"
:size="avatarSize / 2"
class="video-loading" />
</div>
<div v-if="!screenshotModeUrl && !localMediaModel.attributes.videoEnabled && !isSidebar" class="avatar-container">
<VideoBackground
v-if="isGrid || isStripe"
:displayName="displayName"
:user="userId" />
<AvatarWrapper
:id="userId"
:token="token"
:name="displayName"
:source="actorStore.actorType"
:size="avatarSize"
:loading="isNotConnected"
disableMenu
disableTooltip />
</div>
<div class="bottom-bar">
<NcButton
v-if="isBig"
variant="tertiary"
class="bottom-bar__button"
@click="handleStopFollowing">
{{ stopFollowingLabel }}
</NcButton>
</div>
</div>
</template>
<script>
import { showError, showInfo, TOAST_PERMANENT_TIMEOUT } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { inject, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconAccountOffOutline from 'vue-material-design-icons/AccountOffOutline.vue'
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
import VideoBackground from './VideoBackground.vue'
import { AVATAR } from '../../../constants.ts'
import { useActorStore } from '../../../stores/actor.ts'
import { useCallViewStore } from '../../../stores/callView.ts'
import attachMediaStream from '../../../utils/attachmediastream.js'
import { ConnectionState } from '../../../utils/webrtc/models/CallParticipantModel.js'
import { placeholderImage } from '../Grid/gridPlaceholders.ts'
export default {
name: 'LocalVideo',
components: {
AvatarWrapper,
IconAccountOffOutline,
NcButton,
VideoBackground,
NcLoadingIcon,
},
props: {
token: {
type: String,
required: true,
},
localMediaModel: {
type: Object,
required: true,
},
localCallParticipantModel: {
type: Object,
required: true,
},
isGrid: {
type: Boolean,
default: false,
},
isStripe: {
type: Boolean,
default: false,
},
fitVideo: {
type: Boolean,
default: false,
},
isSidebar: {
type: Boolean,
default: false,
},
showControls: {
type: Boolean,
default: true,
},
unSelectable: {
type: Boolean,
default: false,
},
isBig: {
type: Boolean,
default: false,
},
isSmall: {
type: Boolean,
default: false,
},
isPresenterOverlay: {
type: Boolean,
default: false,
},
},
emits: ['clickVideo', 'clickPresenter'],
setup() {
const devMode = inject('CallView:devModeEnabled', ref(false))
const screenshotMode = inject('CallView:screenshotModeEnabled', ref(false))
return {
devMode,
screenshotMode,
callViewStore: useCallViewStore(),
actorStore: useActorStore(),
}
},
data() {
return {
notificationHandle: null,
videoAspectRatio: null,
containerAspectRatio: null,
resizeObserver: null,
mouseover: false,
}
},
computed: {
stopFollowingLabel() {
return t('spreed', 'Back')
},
isNotConnected() {
// When there is no sender participant (when the MCU is not used, or
// if it is used but no peer object has been set yet) the local
// video is shown as connected.
return this.localCallParticipantModel.attributes.peerNeeded
&& this.localCallParticipantModel.attributes.connectionState !== ConnectionState.CONNECTED && this.localCallParticipantModel.attributes.connectionState !== ConnectionState.COMPLETED
},
videoContainerClass() {
return {
'not-connected': this.isNotConnected,
'video-container-grid': this.isGrid,
'video-container-stripe': this.isStripe,
'video-container-big': this.isBig,
'video-container-small': this.isSmall,
presenter: this.isPresenterOverlay && this.mouseover,
'presenter-overlay': this.isPresenterOverlay,
'hover-shadow': this.isSelectable && this.mouseover,
speaking: this.localMediaModel.attributes.speaking,
}
},
videoWrapperStyle() {
if (!this.containerAspectRatio || !this.videoAspectRatio || !this.isBig || this.isGrid) {
return
}
return (this.containerAspectRatio > this.videoAspectRatio)
? `width: ${this.$refs.videoContainer.clientHeight * this.videoAspectRatio}px`
: `height: ${this.$refs.videoContainer.clientWidth / this.videoAspectRatio}px`
},
userId() {
return this.actorStore.userId
},
displayName() {
return this.actorStore.displayName
},
avatarSize() {
if (this.isStripe || (!this.isBig && !this.isGrid)) {
return AVATAR.SIZE.LARGE
} else if (!this.containerAspectRatio) {
return AVATAR.SIZE.FULL
} else {
return Math.min(AVATAR.SIZE.FULL, this.$refs.videoContainer.clientHeight / 2, this.$refs.videoContainer.clientWidth / 2)
}
},
localStreamVideoError() {
return this.localMediaModel.attributes.localStream && this.localMediaModel.attributes.localStreamRequestVideoError
},
hasLocalVideo() {
return this.localMediaModel.attributes.videoEnabled
},
isSelected() {
return this.callViewStore.selectedVideoPeerId === 'local'
},
isSelectable() {
return !this.unSelectable && !this.isSidebar && this.hasLocalVideo && this.callViewStore.selectedVideoPeerId !== 'local'
},
screenshotModeUrl() {
return this.screenshotMode ? placeholderImage(8) : ''
},
},
watch: {
localCallParticipantModel: {
immediate: true,
handler(localCallParticipantModel, oldLocalCallParticipantModel) {
if (oldLocalCallParticipantModel) {
oldLocalCallParticipantModel.off('forcedMute', this._handleForcedMute)
}
if (localCallParticipantModel) {
localCallParticipantModel.on('forcedMute', this._handleForcedMute)
}
},
},
'localMediaModel.attributes.localStream': function(localStream) {
this._setLocalStream(localStream)
},
localStreamVideoError: {
immediate: true,
handler(error) {
if (error) {
if (error.name === 'NotAllowedError') {
this.notificationHandle = showError(t('spreed', 'Access to camera was denied'))
} else if (error.name === 'NotReadableError' || error.name === 'AbortError') {
// when camera in use, Chrome gives NotReadableError, Firefox gives AbortError
this.notificationHandle = showError(t('spreed', 'Error while accessing camera: It is likely in use by another program'), {
timeout: TOAST_PERMANENT_TIMEOUT,
})
} else {
console.error('Error while accessing camera: ', error.message, error.name)
this.notificationHandle = showError(t('spreed', 'Error while accessing camera'), {
timeout: TOAST_PERMANENT_TIMEOUT,
})
}
}
},
},
},
mounted() {
// Set initial state
this._setLocalStream(this.localMediaModel.attributes.localStream)
if (this.isBig || this.isGrid) {
this.resizeObserver = new ResizeObserver(this.updateContainerAspectRatio)
this.resizeObserver.observe(this.$refs.videoContainer)
}
},
beforeUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
unmounted() {
if (this.notificationHandle) {
this.notificationHandle.hideToast()
}
if (this.localCallParticipantModel) {
this.localCallParticipantModel.off('forcedMute', this._handleForcedMute)
}
},
methods: {
t,
_handleForcedMute() {
// The default toast selector is "body-user", but as this toast can
// be shown to guests too, a generic selector valid both for logged-in
// users and guests needs to be used instead (undefined selects
// the body element).
showInfo(t('spreed', 'You have been muted by a moderator'), { selector: undefined })
},
_setLocalStream(localStream) {
if (!localStream) {
// Do not clear the srcObject of the video element, just leave
// the previous stream as a frozen image.
return
}
const options = {
autoplay: true,
mirror: true,
muted: true,
}
attachMediaStream(localStream, this.$refs.video, options)
},
handleStopFollowing() {
this.callViewStore.setSelectedVideoPeerId(null)
this.callViewStore.stopPresentation(this.token)
},
updateContainerAspectRatio([{ target }]) {
this.containerAspectRatio = target.clientWidth / target.clientHeight
},
updateVideoAspectRatio() {
if (!this.isBig) {
return
}
this.videoAspectRatio = this.localMediaModel.attributes.localStream.getVideoTracks()?.[0].getSettings().aspectRatio
// Fallback for Firefox
?? this.$refs.video.videoWidth / this.$refs.video.videoHeight
},
},
}
</script>
<style lang="scss" scoped>
.not-connected {
video,
.avatar-container {
opacity: 0.5;
}
}
// Always display the local video in the last row
.localVideoContainer {
grid-row-end: -1;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
z-index: 1;
}
.video-container-grid {
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.video-container-stripe:not(.local-video--sidebar) {
// aspect-ratio is set according to the maximum video resolution after applying constraints (720*540)
--aspect-ratio: 1.33333;
--stripe-height: 242px;
position: relative;
flex: 0 0 calc(var(--aspect-ratio) * var(--stripe-height));
overflow: hidden;
display: flex;
flex-direction: column;
margin-top: auto;
height: var(--stripe-height) !important;
}
.video-container-big {
position: absolute;
width: calc(100% - var(--grid-gap) * 2);
height: 100%;
display: flex;
flex-direction: column;
& .videoWrapper {
margin: auto;
}
}
.video-container-small {
border-radius: var(--border-radius-large);
}
.videoWrapper,
.video {
height: 100%;
width: 100%;
}
.video-loading {
position: absolute;
top: 0;
inset-inline-end: 0;
height: 100%;
width: 100%;
}
.video--fit {
/* Fit the frame */
object-fit: contain;
}
.video--fill {
/* Fill the frame */
object-fit: cover;
}
.avatar-container {
margin: auto;
}
.presenter-overlay,
.presenter-overlay * {
border-radius: 50%;
}
.localVideoContainer::after {
position: absolute;
height: 100%;
width: 100%;
top: 0;
inset-inline-start: 0;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.presenter-overlay::after {
border-radius: 50%;
z-index: 1;
}
.hover-shadow::after {
content: '';
box-shadow: inset 0 0 0 3px white;
cursor: pointer;
}
.speaking::after {
content: '';
box-shadow: inset 0 0 0 2px white;
}
.bottom-bar {
position: absolute;
bottom: 0;
width: 100%;
padding: 0 calc(var(--default-grid-baseline) * 3);
padding-bottom: calc(var(--default-grid-baseline) * 2);
display: flex;
justify-content: center;
align-items: center;
&--big {
justify-content: center;
}
& &__button {
opacity: 0.8;
background-color: var(--color-background-dark);
&:hover,
&:focus {
opacity: 1;
}
}
}
.dev-mode-video--self {
object-fit: cover !important;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
.presenter-overlay & {
border-radius: 50%;
}
}
.presenter-icon__hide {
position: absolute;
color: white;
inset-inline-start: calc(50% - var(--default-clickable-area) / 2);
top: calc(100% - var(--default-grid-baseline) - var(--default-clickable-area));
opacity: 0.7;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
padding: 6px;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
z-index: 2; // Above video and its border
&:hover {
cursor: pointer;
opacity: 0.9;
}
}
</style>
@@ -0,0 +1,306 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="local-video-control-wrapper">
<NcButton
:title="videoButtonTitle"
:variant="videoStreamError ? 'error' : variant"
:aria-label="videoButtonAriaLabel"
:class="{
'no-video-available': !isVideoAvailable,
'video-control-button': showDevices,
}"
:disabled="resumeVideoAfterChange"
@click.stop="toggleVideo">
<template #icon>
<IconVideo v-if="showVideoOn || resumeVideoAfterChange" :size="20" />
<IconVideoOffOutline v-else :size="20" />
</template>
</NcButton>
<NcActions
v-if="showDevices"
:disabled="!isVideoAvailable || !isVideoAllowed || !!videoStreamError"
class="video-selector-button"
@open="updateDevices">
<template #icon>
<IconChevronUp :size="16" />
</template>
<NcActionCaption :name="t('spreed', 'Select a video device')" />
<NcActionButton
v-for="device in videoDevices"
:key="device.deviceId ?? 'none'"
class="video-selector__action"
type="radio"
:modelValue="videoInputId"
:value="device.deviceId"
:title="device.label"
@click="handleVideoInputIdChange(device.deviceId)">
{{ device.label }}
</NcActionButton>
</NcActions>
</div>
</template>
<script>
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { useHotKey } from '@nextcloud/vue/composables/useHotKey'
import { ref } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionCaption from '@nextcloud/vue/components/NcActionCaption'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import IconVideo from 'vue-material-design-icons/Video.vue' // Filled for better indication
import IconVideoOffOutline from 'vue-material-design-icons/VideoOffOutline.vue'
import { useDevices } from '../../../composables/useDevices.js'
import { PARTICIPANT } from '../../../constants.ts'
export default {
name: 'LocalVideoControlButton',
components: {
NcActions,
NcActionButton,
NcActionCaption,
NcButton,
IconChevronUp,
IconVideo,
IconVideoOffOutline,
},
props: {
conversation: {
type: Object,
required: true,
},
model: {
type: Object,
required: true,
},
disableKeyboardShortcuts: {
type: Boolean,
default: OCP.Accessibility.disableKeyboardShortcuts(),
},
variant: {
type: String,
default: 'tertiary-no-background',
},
token: {
type: String,
required: true,
},
showDevices: {
type: Boolean,
default: false,
},
},
setup(props) {
const {
devices,
videoInputId,
videoStreamError,
updateDevices,
updatePreferences,
subscribeToDevices,
unsubscribeFromDevices,
} = useDevices()
/* Flag to smoothly toggle the video while in call */
const resumeVideoAfterChange = ref(false)
return {
devices,
videoInputId,
videoStreamError,
updateDevices,
updatePreferences,
subscribeToDevices,
unsubscribeFromDevices,
resumeVideoAfterChange,
}
},
computed: {
isVideoAllowed() {
return this.conversation.permissions & PARTICIPANT.PERMISSIONS.PUBLISH_VIDEO
},
isVideoAvailable() {
return this.model.attributes.videoAvailable
},
showVideoOn() {
return this.isVideoAvailable && this.model.attributes.videoEnabled
},
videoButtonTitle() {
if (!this.isVideoAllowed) {
return t('spreed', 'You are not allowed to enable video')
}
if (!this.isVideoAvailable) {
return t('spreed', 'No video. Click to select device')
}
if (this.model.attributes.videoEnabled) {
return this.disableKeyboardShortcuts
? t('spreed', 'Disable video')
: t('spreed', 'Disable video (V)')
}
if (!this.model.getWebRtc() || !this.model.getWebRtc().connection || this.model.getWebRtc().connection.getSendVideoIfAvailable()) {
return this.disableKeyboardShortcuts
? t('spreed', 'Enable video')
: t('spreed', 'Enable video (V)')
}
return this.disableKeyboardShortcuts
? t('spreed', 'Enable video - Your connection will be briefly interrupted when enabling the video for the first time')
: t('spreed', 'Enable video (V) - Your connection will be briefly interrupted when enabling the video for the first time')
},
videoButtonAriaLabel() {
if (!this.isVideoAvailable) {
return t('spreed', 'No video. Click to select device')
}
if (this.model.attributes.videoEnabled) {
return t('spreed', 'Disable video')
}
if (!this.model.getWebRtc() || !this.model.getWebRtc().connection || this.model.getWebRtc().connection.getSendVideoIfAvailable()) {
return t('spreed', 'Enable video')
}
return t('spreed', 'Enable video. Your connection will be briefly interrupted when enabling the video for the first time')
},
videoDevices() {
return [
...this.devices.filter((device) => device.kind === 'videoinput')
.map((device) => ({
deviceId: device.deviceId,
label: device.label || device.fallbackLabel,
})),
{ deviceId: null, label: t('spreed', 'None') },
]
},
},
watch: {
isVideoAvailable(newValue) {
if (newValue && this.resumeVideoAfterChange) {
// New track is available, resume video
this.model.enableVideo()
this.resumeVideoAfterChange = false
}
},
videoInputId(newValue) {
if (!newValue && this.resumeVideoAfterChange) {
this.resumeVideoAfterChange = false
}
},
},
created() {
useHotKey('v', this.toggleVideo)
},
mounted() {
this.subscribeToDevices()
},
beforeUnmount() {
this.unsubscribeFromDevices()
},
methods: {
t,
toggleVideo() {
if (!this.isVideoAllowed || !this.isVideoAvailable) {
emit('talk:media-settings:show')
return
}
if (this.model.attributes.videoEnabled) {
this.model.disableVideo()
} else {
this.model.enableVideo()
}
},
handleVideoInputIdChange(videoInputId) {
if (this.showDevices && this.showVideoOn) {
// If input was changed from bottom bar while active, it should not be muted after track change
this.resumeVideoAfterChange = true
}
this.videoInputId = videoInputId
this.updatePreferences('videoinput')
},
},
}
</script>
<style scoped lang="scss">
.no-video-available {
opacity: .7;
}
.video-selector-button :deep(.action-item__menutoggle) {
--button-size: var(--clickable-area-small);
height: var(--default-clickable-area);
border-start-start-radius: 2px;
border-end-start-radius: 2px;
}
.local-video-control-wrapper {
display: flex;
align-items: center;
gap: 1px;
// Overwriting NcButton styles
.video-control-button {
border-start-end-radius: 2px;
border-end-end-radius: 2px;
}
}
.video-selector__action {
// Overwriting NcActionButton styles
:deep(.action-button__longtext) {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
padding: 0;
max-width: 350px;
}
:deep(.action-button__longtext-wrapper) {
max-width: 350px;
}
:deep(.action-button__icon) {
width: 0;
margin-inline-start: calc(var(--default-grid-baseline) * 3);
}
:deep(.action-button > span) {
height: var(--default-clickable-area);
}
}
</style>
@@ -0,0 +1,220 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div ref="presenterOverlayContainer" class="presenter-overlay__container">
<VueDraggableResizable
v-if="!isCollapsed"
ref="presenterOverlay"
parent
class="presenter-overlay"
:resizable="false"
:h="presenterOverlaySize"
:w="presenterOverlaySize"
:x="isDirectionRTL ? parentWidth - presenterOverlaySize - 10 : 10"
:y="10"
@dragging="isDragging = true"
@dragstop="isDragging = false">
<LocalVideo
v-if="isLocalPresenter"
class="presenter-overlay__video"
:token="token"
:localMediaModel="localMediaModel"
:localCallParticipantModel="model"
isPresenterOverlay
unSelectable
hideBottomBar
@clickPresenter="$emit('click')" />
<VideoVue
v-else
:token="token"
:class="{ dragging: isDragging }"
class="presenter-overlay__video"
:model="model"
:sharedData="sharedData"
isPresenterOverlay
unSelectable
hideBottomBar
@clickPresenter="$emit('click')" />
</VueDraggableResizable>
<!-- presenter button when presenter overlay is collapsed -->
<NcButton
v-else
:aria-label="t('spreed', 'Show presenter')"
:title="t('spreed', 'Show presenter')"
class="presenter-overlay--collapsed"
variant="tertiary-no-background"
@click="$emit('click')">
<template #icon>
<AccountBox fillColor="#ffffff" :size="20" />
</template>
</NcButton>
</div>
</template>
<script>
import { isRTL, t } from '@nextcloud/l10n'
import { ref } from 'vue'
import VueDraggableResizable from 'vue-draggable-resizable'
import NcButton from '@nextcloud/vue/components/NcButton'
import AccountBox from 'vue-material-design-icons/AccountBoxOutline.vue'
import LocalVideo from './LocalVideo.vue'
import VideoVue from './VideoVue.vue'
const isDirectionRTL = isRTL()
export default {
name: 'PresenterOverlay',
components: {
AccountBox,
VueDraggableResizable,
NcButton,
LocalVideo,
VideoVue,
},
props: {
token: {
type: String,
required: true,
},
model: {
type: Object,
required: true,
},
sharedData: {
type: Object,
required: true,
},
isCollapsed: {
type: Boolean,
required: true,
},
isLocalPresenter: {
type: Boolean,
default: false,
},
localMediaModel: {
type: Object,
default: null,
},
},
emits: ['click'],
setup() {
const parentWidth = ref(document.getElementById('videos').getBoundingClientRect().width)
return {
parentWidth,
isDirectionRTL,
}
},
data() {
return {
resizeObserver: null,
presenterOverlaySize: 128,
isDragging: false,
}
},
mounted() {
this.resizeObserver = new ResizeObserver(this.updateSize)
this.resizeObserver.observe(this.$refs.presenterOverlayContainer)
},
beforeUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
methods: {
t,
updateSize() {
if (!this.$refs.presenterOverlay) {
// overlay is collapsed, do not process size update
return
}
// Size should be proportionate to the screen share size
const newSize = Math.round(this.$refs.presenterOverlayContainer.clientWidth * 0.1)
this.presenterOverlaySize = Math.min(Math.max(newSize, 100), 242)
// FIXME: inner method should be triggered to re-parent element
this.$refs.presenterOverlay.checkParentSize()
// FIXME: if it stays out of bounds (right and bottom), bring it back
// FIXME: should consider RTL
if (this.$refs.presenterOverlay.right < 0 && this.$refs.presenterOverlay.parentWidth > this.presenterOverlaySize) {
this.$refs.presenterOverlay.moveHorizontally(this.$refs.presenterOverlay.parentWidth - this.presenterOverlaySize)
}
if (this.$refs.presenterOverlay.bottom < 0 && this.$refs.presenterOverlay.parentHeight > this.presenterOverlaySize) {
this.$refs.presenterOverlay.moveVertically(this.$refs.presenterOverlay.parentHeight - this.presenterOverlaySize)
}
},
},
}
</script>
<style lang="scss" scoped>
.presenter-overlay__container {
position: absolute;
inset: 0;
// Make container transparent to user events
pointer-events: none;
& > * {
pointer-events: auto;
}
}
.presenter-overlay__video {
position: relative;
--max-size: 242px;
--min-size: 100px;
max-width: var(--max-size);
max-height: var(--max-size);
min-width: var(--min-size);
min-height: var(--min-size);
z-index: 10;
aspect-ratio: 1;
&:hover {
cursor: grab;
}
&.dragging {
cursor: grabbing;
}
}
.presenter-overlay--collapsed {
position: absolute !important;
opacity: .7;
bottom: calc(var(--default-clickable-area) + var(--default-grid-baseline));
inset-inline-end: var(--grid-gap);
#call-container:hover & {
background-color: rgba(0, 0, 0, 0.1) !important;
&:hover,
&:focus {
opacity: 1;
background-color: rgba(0, 0, 0, 0.2) !important;
}
}
}
:deep(div) {
// prevent default cursor
cursor: inherit;
}
</style>
@@ -0,0 +1,305 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<ul class="toaster">
<li
v-for="toast in toasts"
:key="toast.seed"
class="toast"
:style="styled(toast.name, toast.seed)">
<img
v-if="toast.reactionURL"
class="toast__reaction-img"
:src="toast.reactionURL"
:alt="toast.reaction"
width="34"
height="34">
<span v-else class="toast__reaction">
{{ toast.reaction }}
</span>
<span class="toast__name">
{{ toast.name }}
</span>
</li>
</ul>
</template>
<script>
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { imagePath } from '@nextcloud/router'
import { usernameToColor } from '@nextcloud/vue/functions/usernameToColor'
import Hex from 'crypto-js/enc-hex.js'
import SHA1 from 'crypto-js/sha1.js'
import { useActorStore } from '../../../stores/actor.ts'
import { useGuestNameStore } from '../../../stores/guestName.ts'
const reactions = {
'❤️': 'Heart.gif',
'🎉': 'Party.gif',
'👏': 'Clap.gif',
'👋': 'Wave.gif',
'👍': 'Thumbs-up.gif',
'👎': 'Thumbs-down.gif',
'🔥': 'Fire.gif',
'😂': 'Joy.gif',
'🤩': 'Star-struck.gif',
'🤔': 'Thinking-face.gif',
'😲': 'Surprised.gif',
'😥': 'Concerned.gif',
}
const ANIMATION_LENGTH = 2_000
const TOAST_INTERVAL = 500
// Timestamp of when the next reaction should be shown in UI
let nextProcessedTimestamp = 0
export default {
name: 'ReactionToaster',
props: {
/**
* The conversation token
*/
token: {
type: String,
required: true,
},
/**
* Supported reactions
*/
supportedReactions: {
type: Array,
validator: (prop) => prop.every((e) => typeof e === 'string'),
required: true,
},
callParticipantModels: {
type: Array,
required: true,
},
},
setup() {
const guestNameStore = useGuestNameStore()
return {
guestNameStore,
actorStore: useActorStore(),
}
},
data() {
return {
registeredModels: {},
reactionsQueue: [],
intervalId: null,
toasts: [],
}
},
computed: {
participants() {
return this.$store.getters.participantsList(this.token)
},
},
watch: {
callParticipantModels: {
handler(models) {
// subscribe connected models for reaction signals
const addedModels = models.filter((model) => !this.registeredModels[model.attributes.peerId])
addedModels.forEach((addedModel) => {
this.registeredModels[addedModel.attributes.peerId] = addedModel
this.registeredModels[addedModel.attributes.peerId].on('reaction', this.handleReaction)
})
// unsubscribe disconnected models
const removedModelIds = Object.keys(this.registeredModels).filter((registeredModelId) => !models.find((model) => model.attributes.peerId === registeredModelId))
removedModelIds.forEach((removedModelId) => {
this.registeredModels[removedModelId].off('reaction', this.handleReaction)
delete this.registeredModels[removedModelId]
})
},
immediate: true,
},
},
mounted() {
this.intervalId = setInterval(this.processReactionsQueue, TOAST_INTERVAL)
subscribe('send-reaction', this.handleOwnReaction)
},
beforeUnmount() {
clearInterval(this.intervalId)
unsubscribe('send-reaction', this.handleOwnReaction)
Object.keys(this.registeredModels).forEach((modelId) => {
this.registeredModels[modelId].off('reaction', this.handleReaction)
delete this.registeredModels[modelId]
})
},
methods: {
t,
handleOwnReaction({ model, reaction }) {
this.handleReaction(model, reaction, true)
},
handleReaction(model, reaction, isLocalModel = false) {
// prevent spamming to queue from a single account (unless in debug mode)
if (!OC.debug && this.reactionsQueue.some((item) => item.id === model.attributes.peerId)) {
return
}
// prevent receiving anything rather than defined reactions in capabilities
if (!this.supportedReactions.includes(reaction)) {
return
}
this.reactionsQueue.push({
id: model.attributes.peerId,
reaction,
reactionURL: this.getReactionURL(reaction),
name: isLocalModel
? this.actorStore.displayName || t('spreed', 'Guest')
: this.getParticipantName(model),
seed: Math.random(),
timestamp: Date.now(),
})
},
processReactionsQueue() {
if (this.reactionsQueue.length === 0) {
return
}
// Prevent spamming with reactions, if tab was suspended
const now = Date.now()
if (now < nextProcessedTimestamp) {
return
}
// Discard reactions, that should have been fired long ago
if (this.reactionsQueue.at(0).timestamp < now - 30_000) {
this.reactionsQueue = this.reactionsQueue.filter((reaction) => reaction.timestamp >= now - 30_000)
if (this.reactionsQueue.length === 0) {
return
}
}
nextProcessedTimestamp = now + TOAST_INTERVAL
// Move reactions from queue to visible array
this.toasts.push(this.reactionsQueue.shift())
// Delete reactions from array after animation ends
setTimeout(() => {
this.toasts.shift()
}, ANIMATION_LENGTH)
},
getParticipantName(model) {
const { name, nextcloudSessionId } = model.attributes
if (name) {
return name
}
const participant = this.participants.find((participant) => participant.sessionIds.includes(nextcloudSessionId))
if (participant?.displayName) {
return participant.displayName
}
return this.guestNameStore.getGuestName(this.token, Hex.stringify(SHA1(nextcloudSessionId)))
},
getReactionURL(emoji) {
return reactions[emoji]
? imagePath('spreed', 'emojis/' + reactions[emoji])
: undefined
},
styled(name, seed) {
const color = usernameToColor(name)
return {
'--background-color': `rgb(${color.r}, ${color.g}, ${color.b})`,
'--animation-length': `${ANIMATION_LENGTH + 300}ms`,
'--horizontal-offset': `${10 + 20 * seed}%`,
'--vertical-offset': 30 + 5 * seed,
}
},
},
}
</script>
<style lang="scss" scoped>
.toaster {
position: absolute;
bottom: 20px;
inset-inline-start: 0;
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
z-index: 1;
}
.toast {
position: absolute;
bottom: 0;
inset-inline-start: var(--horizontal-offset, 0);
display: flex;
align-items: center;
gap: 8px;
animation: toast-floating var(--animation-length) linear;
&__reaction {
font-size: 250%;
line-height: 100%;
@media only screen and (max-width: 1920px) {
& {
font-size: 150%;
}
&-img {
width: 30px;
height: 30px;
}
}
}
&__name {
padding: 8px 12px;
border-radius: 6px;
line-height: 100%;
white-space: nowrap;
color: #ffffff;
background-color: var(--background-color);
box-shadow: 1px 1px 4px var(--color-box-shadow);
}
}
@keyframes toast-floating {
0% {
transform: translateY(0);
opacity: 0;
}
5% {
transform: translateY(calc(-0.05 * var(--vertical-offset) * 1vh));
opacity: 1;
}
50% {
transform: translateY(calc(-0.5 * var(--vertical-offset) * 1vh));
opacity: 1;
}
100% {
transform: translateY(calc(-1 * var(--vertical-offset) * 1vh));
opacity: 0;
}
}
</style>
@@ -0,0 +1,274 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
:id="screenContainerId"
class="screenContainer"
@dblclick.capture="onDoubleClick">
<video
v-show="(localMediaModel && localMediaModel.attributes.localScreen) || (callParticipantModel && callParticipantModel.attributes.screen)"
ref="screen"
:disablePictureInPicture="!isBig ? 'true' : 'false'"
class="screen"
:class="screenClass" />
<VideoBottomBar
v-if="isBig"
:token="token"
:sharedData="sharedData"
isBig
isScreen
:model="model"
:participantName="remoteParticipantName" />
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import Hex from 'crypto-js/enc-hex.js'
import SHA1 from 'crypto-js/sha1.js'
import panzoom from 'panzoom'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import VideoBottomBar from './VideoBottomBar.vue'
import { useGuestNameStore } from '../../../stores/guestName.ts'
import attachMediaStream from '../../../utils/attachmediastream.js'
const ZOOM_MIN = 1
const ZOOM_FACTOR = 4
const ZOOM_MAX = 8
export default {
name: 'ScreenShare',
components: {
VideoBottomBar,
},
props: {
token: {
type: String,
required: true,
},
localMediaModel: {
type: Object,
default: null,
},
callParticipantModel: {
type: Object,
default: null,
},
sharedData: {
type: Object,
required: true,
},
isBig: {
type: Boolean,
default: false,
},
},
setup(props) {
const guestNameStore = useGuestNameStore()
const screen = ref(null)
const instance = ref(null)
const instanceTransform = ref({ x: 0, y: 0, scale: 1 })
const instanceGrabbing = ref(false)
const screenClass = computed(() => {
if (!props.isBig) {
return ['screen--fill']
} else {
return [
'screen--fit',
instanceTransform.value.scale === 1
? 'screen--magnify'
: (instanceGrabbing.value ? 'screen--grabbing' : 'screen--grab'),
]
}
})
onMounted(() => {
if (props.isBig) {
instance.value = panzoom(screen.value, {
minZoom: ZOOM_MIN,
maxZoom: ZOOM_MAX,
bounds: true,
boundsPadding: 1,
})
instance.value.on('zoom', (instance) => {
instanceTransform.value = instance.getTransform()
})
instance.value.on('panstart', () => {
instanceGrabbing.value = true
})
instance.value.on('panend', () => {
instanceGrabbing.value = false
})
}
})
onBeforeUnmount(() => {
instance.value?.dispose()
})
/**
* Overriding method to handle double click event on screen share
*
* @param event Double click event
*/
function onDoubleClick(event) {
if (!instance.value) {
return
}
// panzoom library puts a listener on parent element
event.preventDefault()
event.stopPropagation()
// Calculate the offset of the click event from the top left corner of screen container
const screenContainerRect = event.currentTarget.getBoundingClientRect()
const offsetX = event.clientX - screenContainerRect.left
const offsetY = event.clientY - screenContainerRect.top
if (instanceTransform.value.scale === 1) {
// Zoom in the click point with specified zoom factor
instance.value.smoothZoom(offsetX, offsetY, ZOOM_FACTOR)
} else {
// Zoom out (0 is set to ensure the zoom is reset to 1)
instance.value.smoothZoomAbs(offsetX, offsetY, 0)
}
}
return {
guestNameStore,
screen,
screenClass,
onDoubleClick,
}
},
computed: {
model() {
if (this.callParticipantModel) {
return this.callParticipantModel
}
return this.localMediaModel
},
screenContainerId() {
if (this.localMediaModel) {
return 'localScreenContainer'
}
return 'container_' + this.callParticipantModel.attributes.peerId + '_screen_incoming'
},
remoteSessionHash() {
if (!this.callParticipantModel) {
return null
}
return Hex.stringify(SHA1(this.callParticipantModel.attributes.nextcloudSessionId))
},
remoteParticipantName() {
if (!this.callParticipantModel) {
return t('spreed', 'You')
}
let remoteParticipantName = this.callParticipantModel.attributes.name
// The name is undefined and not shown until a connection is made
// for registered users, so do not fall back to the guest name in
// the store either until the connection was made.
if (!this.callParticipantModel.attributes.userId && !remoteParticipantName && remoteParticipantName !== undefined) {
remoteParticipantName = this.guestNameStore.getGuestName(
this.token,
this.remoteSessionHash,
)
}
return remoteParticipantName
},
},
watch: {
'localMediaModel.attributes.localScreen': function(localScreen) {
this._setScreen(localScreen)
},
'callParticipantModel.attributes.screen': function(screen) {
this._setScreen(screen)
},
},
mounted() {
// Set initial state
if (this.localMediaModel) {
this._setScreen(this.localMediaModel.attributes.localScreen)
} else {
this._setScreen(this.callParticipantModel.attributes.screen)
}
},
methods: {
t,
_setScreen(screen) {
if (!screen) {
this.$refs.screen.srcObject = null
return
}
// The audio is played using an audio element in the model to be
// able to hear it even if there is no view for it.
attachMediaStream(screen, this.$refs.screen)
this.$refs.screen.muted = true
},
},
}
</script>
<style lang="scss" scoped>
.screenContainer {
width: 100%;
height: 100%;
}
.screen {
width: 100%;
height: 100%;
position: absolute;
top: 0;
inset-inline-start: 0;
&--fit {
object-fit: contain;
}
&--fill {
object-fit: cover;
}
&--magnify {
cursor: zoom-in;
}
&--grab {
cursor: grab;
}
&--grabbing {
cursor: grabbing;
}
}
</style>
@@ -0,0 +1,194 @@
/*
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, test } from 'vitest'
import TranscriptBlock from './TranscriptBlock.vue'
describe('TranscriptBlock.vue', () => {
describe('remove last chunk from lines', () => {
let wrapper
let lines
beforeEach(() => {
wrapper = shallowMount(TranscriptBlock, {
props: {
token: 'theToken',
model: {
attributes: {
peerId: 'thePeerId',
actorId: 'theActorId',
actorType: 'theActorType',
userId: 'theUserId',
name: 'The user name',
},
},
chunks: [],
rightToLeft: false,
},
})
lines = wrapper.vm.$data.lines
})
test('no lines', () => {
wrapper.vm.removeLastChunkFromLines()
expect(lines.length).toBe(0)
})
test('single line with single chunk', () => {
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 42,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines.length).toBe(0)
})
test('single line with several chunks', () => {
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 42,
lastChunkIndex: 107,
},
])
})
test('several lines with single chunk', () => {
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 42,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines.length).toBe(0)
})
describe('several lines with several chunks', () => {
test('last chunk filling last line', () => {
lines.push({
firstChunkIndex: 23,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 23,
lastChunkIndex: 42,
},
])
})
test('last chunk filling several lines', () => {
lines.push({
firstChunkIndex: 23,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 23,
lastChunkIndex: 42,
},
])
})
test('last chunk partially in last line', () => {
lines.push({
firstChunkIndex: 23,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 23,
lastChunkIndex: 42,
},
{
firstChunkIndex: 42,
lastChunkIndex: 107,
},
])
})
test('last chunk partially in several lines', () => {
lines.push({
firstChunkIndex: 23,
lastChunkIndex: 42,
})
lines.push({
firstChunkIndex: 42,
lastChunkIndex: 108,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
lines.push({
firstChunkIndex: 108,
lastChunkIndex: 108,
})
wrapper.vm.removeLastChunkFromLines()
expect(lines).toEqual([
{
firstChunkIndex: 23,
lastChunkIndex: 42,
},
{
firstChunkIndex: 42,
lastChunkIndex: 107,
},
])
})
})
})
})
@@ -0,0 +1,416 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
class="transcript-block"
:style="transcriptBlockStyle">
<div class="transcript-block__avatar">
<AvatarWrapper
:id="actorId"
:token="token"
:name="actorDisplayName"
:source="actorType"
:size="AVATAR.SIZE.SMALL"
:disableMenu="true" />
</div>
<div class="transcript-block__text">
<p class="transcript-block__author">
{{ actorInfo }}
</p>
<p
ref="chunksWrapper"
class="transcript-block__chunks">
<span
v-for="(item, index) in chunksWithSeparator"
ref="chunks"
:key="index"
:lang="item.languageId">
{{ item.message }}
</span>
</p>
</div>
</div>
</template>
<script lang="ts">
import type { PropType, StyleValue } from 'vue'
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
import { ATTENDEE, AVATAR } from '../../../constants.ts'
import { useLiveTranscriptionStore } from '../../../stores/liveTranscription.ts'
import { getDisplayNameWithFallback } from '../../../utils/getDisplayName.ts'
declare module 'vue' {
interface TypeRefs {
chunksWrapper: HTMLParagraphElement
chunks: undefined | Array<HTMLSpanElement>
}
interface ComponentCustomProperties {
$refs: TypeRefs
}
}
interface CallParticipantModel {
attributes: {
peerId: string
actorId: string | null | undefined
actorType: string | null | undefined
userId: string | null | undefined
name: string | null | undefined
}
}
interface Chunk {
message: string
languageId: string
final: boolean
}
interface ChunkElementData {
message: string
languageId: string
}
export type {
Chunk,
}
export default {
name: 'TranscriptBlock',
components: {
AvatarWrapper,
},
props: {
/**
* The conversation token.
*/
token: {
type: String,
required: true,
},
/**
* The CallParticipantModel for the participant being transcribed.
*/
model: {
type: Object as PropType<CallParticipantModel>,
required: true,
},
/**
* The transcript chunks.
*/
chunks: {
type: Array as PropType<Array<Chunk>>,
required: true,
},
/**
* Whether the transcript is written right to left.
*/
rightToLeft: {
type: Boolean,
required: false,
default: false,
},
},
setup() {
const liveTranscriptionStore = useLiveTranscriptionStore()
return {
liveTranscriptionStore,
}
},
data() {
return {
AVATAR,
resizeObserver: null as null | ResizeObserver,
lines: [] as Array<{
firstChunkIndex: number
lastChunkIndex: number
}>,
}
},
computed: {
transcriptBlockStyle() {
return {
direction: this.rightToLeft ? 'rtl' : 'ltr',
} as StyleValue
},
actorId() {
return this.model.attributes.actorId || ''
},
actorType() {
return this.model.attributes.actorType || ''
},
actorDisplayName() {
return this.model.attributes.name || ''
},
actorDisplayNameWithFallback() {
return getDisplayNameWithFallback(this.actorDisplayName, this.actorType)
},
remoteServer() {
return this.actorType === ATTENDEE.ACTOR_TYPE.FEDERATED_USERS
? '(' + this.actorId.split('@').pop() + ')'
: ''
},
actorInfo() {
return [this.actorDisplayNameWithFallback, this.remoteServer]
.filter((value) => value).join(' ')
},
liveTranscriptionLanguages() {
const liveTranscriptionLanguages = this.liveTranscriptionStore.getLiveTranscriptionLanguages()
if (!liveTranscriptionLanguages) {
return {}
}
return liveTranscriptionLanguages
},
chunksWithSeparator() {
const chunksWithSeparator = [] as Array<ChunkElementData>
if (!this.chunks.length) {
return chunksWithSeparator
}
// The returned languageId is a BCP 47 language tag (to be used in
// the HTML "lang" attribute), but the language and region may be
// separated by "_" in the language metadata, so it needs to be
// replaced by "-".
chunksWithSeparator.push({
message: this.chunks[0].message,
languageId: this.chunks[0].languageId.replace('_', '-'),
})
for (let i = 1; i < this.chunks.length; i++) {
const separator = this.getSeparatorBetweenChunks(this.chunks[i - 1], this.chunks[i])
chunksWithSeparator.push({
message: separator + this.chunks[i].message,
languageId: this.chunks[i].languageId.replace('_', '-'),
})
}
return chunksWithSeparator
},
},
mounted() {
this.resizeObserver = new ResizeObserver(this.handleChunksWrapperResize)
this.resizeObserver.observe(this.$refs.chunksWrapper)
},
beforeUnmount() {
this.resizeObserver!.disconnect()
},
methods: {
reset() {
this.lines = []
this.$refs.chunksWrapper!.style.removeProperty('min-height')
},
handleChunksWrapperResize(entries: ResizeObserverEntry[], observer: ResizeObserver) {
if (!this.$refs.chunksWrapper) {
return
}
const height = parseFloat(window.getComputedStyle(this.$refs.chunksWrapper).getPropertyValue('height'))
const minHeight = parseFloat(window.getComputedStyle(this.$refs.chunksWrapper).getPropertyValue('min-height'))
if (height > minHeight || Number.isNaN(minHeight)) {
this.$refs.chunksWrapper.style.setProperty('min-height', `${height}px`)
}
},
removeLastChunkFromLines() {
if (!this.lines.length) {
return
}
const lastKnownChunkIndex = this.lines.at(-1)!.lastChunkIndex
while (this.lines.length && this.lines.at(-1)!.firstChunkIndex === this.lines.at(-1)!.lastChunkIndex) {
this.lines.splice(-1, 1)
}
if (this.lines.length && this.lines.at(-1)!.lastChunkIndex === lastKnownChunkIndex) {
this.lines.at(-1)!.lastChunkIndex--
}
},
updateLines() {
if (!this.$refs.chunks || !this.$refs.chunks.length) {
return
}
// Remove information of last chunk to regenerate it, as it could
// have been updated and thus its lines could have changed.
this.removeLastChunkFromLines()
if (!this.lines.length) {
const firstChunkClientRectsLength = this.$refs.chunks[0].getClientRects().length
// If there is a single chunk and it has several bounding
// rectangles each rectangle will be in its own line.
for (let i = 0; i < firstChunkClientRectsLength; i++) {
this.lines.push({
firstChunkIndex: 0,
lastChunkIndex: 0,
})
}
}
const lastKnownChunkIndex = this.lines.at(-1)!.lastChunkIndex
if (lastKnownChunkIndex >= this.$refs.chunks.length - 1) {
return
}
let lastKnownChunkElement = this.$refs.chunks[lastKnownChunkIndex]
let lastKnownChunkElementTop = lastKnownChunkElement.getClientRects()[lastKnownChunkElement.getClientRects().length - 1].top
for (let i = lastKnownChunkIndex + 1; i < this.$refs.chunks.length; i++) {
const nextChunkElement = this.$refs.chunks[i]
const nextChunkElementClientRects = nextChunkElement.getClientRects()
const nextChunkElementTop = nextChunkElementClientRects[0].top
// If the first bounding rectangle has the same top value as the
// last known one they will be in the same line. Otherwise it
// will be in a new line.
if (nextChunkElementTop === lastKnownChunkElementTop) {
this.lines.at(-1)!.lastChunkIndex = i
} else {
this.lines.push({
firstChunkIndex: i,
lastChunkIndex: i,
})
}
// Any bounding rectangle after the first one will be in its own
// line.
for (let j = 1; j < nextChunkElementClientRects.length; j++) {
this.lines.push({
firstChunkIndex: i,
lastChunkIndex: i,
})
}
lastKnownChunkElement = nextChunkElement
lastKnownChunkElementTop = lastKnownChunkElement.getClientRects()[lastKnownChunkElement.getClientRects().length - 1].top
}
},
getLineBoundaries() {
this.updateLines()
const lineHeight = parseFloat(window.getComputedStyle(this.$el).getPropertyValue('line-height'))
let clientRectIndex = 0
return this.lines.map((line, index) => {
const clientRectsOfLastChunkInLine = this.$refs.chunks![line.lastChunkIndex].getClientRects()
if (index > 0 && line.lastChunkIndex === this.lines[index - 1].lastChunkIndex) {
clientRectIndex++
} else {
clientRectIndex = 0
}
const currentClientRectsOfLastChunkInLine = clientRectsOfLastChunkInLine[clientRectIndex]
// Chunks are shown as inline spans, which do not have the full
// line height. The spans are vertically centered on the line,
// so there is the same extra space at the top and at the
// bottom.
const chunkHeight = currentClientRectsOfLastChunkInLine.bottom - currentClientRectsOfLastChunkInLine.top
const chunkToLineHeightDifference = lineHeight - chunkHeight
return {
top: currentClientRectsOfLastChunkInLine.top - (chunkToLineHeightDifference / 2),
bottom: currentClientRectsOfLastChunkInLine.bottom + (chunkToLineHeightDifference / 2),
}
})
},
getSeparatorBetweenChunks(chunk1: Chunk, chunk2: Chunk) {
if (chunk1.languageId !== chunk2.languageId) {
return ' '
}
if (this.liveTranscriptionLanguages[chunk1.languageId]?.metadata) {
return this.liveTranscriptionLanguages[chunk1.languageId].metadata.separator
}
return ' '
},
},
}
</script>
<style lang="scss" scoped>
.transcript-block {
display: flex;
flex-direction: row;
align-items: flex-start;
align-self: end;
width: 100%;
background-color: rgba(34, 34, 34, 0.8);
color: white;
&__avatar {
position: sticky;
top: 0;
padding: calc(2 * var(--default-grid-baseline));
margin-top: calc(2 * var(--default-grid-baseline));
}
&__text {
display: flex;
flex-direction: column;
padding-inline-end: var(--default-grid-baseline);
}
&__author {
color: var(--color-text-maxcontrast);
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Move the author closer to the chunks while keeping the line height,
* but not too much to avoid "overflowing" the line (which could
* partially show the author after scrolling to another line).
*/
margin-top: 4px;
margin-bottom: -4px;
}
&__chunks {
&::first-letter {
text-transform: capitalize;
}
}
}
</style>
@@ -0,0 +1,61 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="video-background" :style="{ 'background-color': backgroundColor }" />
</template>
<script>
import { usernameToColor } from '@nextcloud/vue/functions/usernameToColor'
export default {
name: 'VideoBackground',
props: {
displayName: {
type: String,
default: null,
},
user: {
type: String,
default: '',
},
},
computed: {
backgroundColor() {
// If the prop is empty. We're not checking for the default value
// because the user's displayName might be '?'
if (!this.displayName) {
return 'var(--color-text-maxcontrast)'
} else {
const color = usernameToColor(this.displayName)
return `rgb(${color.r}, ${color.g}, ${color.b})`
}
},
},
}
</script>
<style lang="scss" scoped>
.video-background {
position: absolute;
inset-inline-start: 0;
top: 0;
height: 100%;
width: 100%;
&::after {
content: ' ';
background-color: rgba(0, 0, 0, 0.12);
position: absolute;
width: 100%;
height: 100%;
top: 0;
inset-inline-start: 0;
}
}
</style>
@@ -0,0 +1,391 @@
/*
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { mount } from '@vue/test-utils'
import { cloneDeep } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { createStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconHandBackLeft from 'vue-material-design-icons/HandBackLeft.vue'
import IconVideo from 'vue-material-design-icons/Video.vue'
import IconVideoOffOutline from 'vue-material-design-icons/VideoOffOutline.vue'
import VideoBottomBar from './VideoBottomBar.vue'
import { CONVERSATION, PARTICIPANT } from '../../../constants.ts'
import storeConfig from '../../../store/storeConfig.js'
import { useActorStore } from '../../../stores/actor.ts'
import { useCallViewStore } from '../../../stores/callView.ts'
import { findNcButton } from '../../../test-helpers.js'
import { ConnectionState } from '../../../utils/webrtc/models/CallParticipantModel.js'
vi.mock('@nextcloud/event-bus', () => ({
emit: vi.fn(),
subscribe: vi.fn(),
}))
describe('VideoBottomBar.vue', () => {
const TOKEN = 'XXTOKENXX'
const PARTICIPANT_NAME = 'John Doe'
const PEER_ID = 'peer-id'
const USER_ID = 'user-id-1'
let store
let callViewStore
let testStoreConfig
let componentProps
let conversationProps
let actorStore
const audioIndicatorAriaLabels = [t('spreed', 'Mute'), t('spreed', 'Muted')]
const videoIndicatorAriaLabels = [t('spreed', 'Disable video'), t('spreed', 'Enable video')]
const screenSharingAriaLabel = t('spreed', 'Show screen')
const followingButtonAriaLabel = t('spreed', 'Stop following')
beforeEach(() => {
setActivePinia(createPinia())
callViewStore = useCallViewStore()
actorStore = useActorStore()
conversationProps = {
token: TOKEN,
lastCommonReadMessage: 0,
type: CONVERSATION.TYPE.GROUP,
participantType: PARTICIPANT.TYPE.OWNER,
readOnly: CONVERSATION.STATE.READ_WRITE,
}
componentProps = {
token: TOKEN,
model: {
attributes: {
connectionState: ConnectionState.CONNECTED,
raisedHand: {
state: false,
},
audioAvailable: true,
videoAvailable: true,
screen: true,
speaking: false,
peerId: PEER_ID,
},
forceMute: vi.fn(),
},
participantName: PARTICIPANT_NAME,
sharedData: {
remoteVideoBlocker: {
isVideoEnabled: vi.fn().mockReturnValue(true),
setVideoEnabled: vi.fn(),
},
},
}
testStoreConfig = cloneDeep(storeConfig)
testStoreConfig.modules.conversationsStore.getters.conversation = vi.fn().mockReturnValue((token) => conversationProps)
actorStore.userId = USER_ID
store = createStore(testStoreConfig)
})
afterEach(() => {
vi.clearAllMocks()
})
/**
* Shared function to mount component
*/
function mountVideoBottomBar(props) {
return mount(VideoBottomBar, {
global: {
plugins: [store],
},
props,
})
}
describe('unit tests', () => {
describe('render component', () => {
test('component renders properly', async () => {
const wrapper = mountVideoBottomBar(componentProps)
expect(wrapper.exists()).toBeTruthy()
expect(wrapper.classes('wrapper')).toBeDefined()
})
test('component has class "wrapper--big" for main view', async () => {
componentProps.isBig = true
const wrapper = mountVideoBottomBar(componentProps)
expect(wrapper.exists()).toBeTruthy()
expect(wrapper.classes('wrapper--big')).toBeDefined()
})
test('component renders all indicators by default', async () => {
const wrapper = mountVideoBottomBar(componentProps)
const indicators = wrapper.findAllComponents(NcButton)
expect(indicators).toHaveLength(3)
})
test('component does not render indicators for ScreenShare.vue component', async () => {
componentProps.isScreen = true
const wrapper = mountVideoBottomBar(componentProps)
const indicators = wrapper.findAllComponents(NcButton)
expect(indicators).toHaveLength(0)
})
test('component does not show indicators after video overlay is off', async () => {
const wrapper = mountVideoBottomBar(componentProps)
componentProps.showVideoOverlay = false
await wrapper.setProps(cloneDeep(componentProps))
const indicators = wrapper.findAllComponents(NcButton)
indicators.forEach((indicator) => {
expect(indicator.isVisible()).toBeFalsy()
})
})
test('component does not render anything when used in sidebar', async () => {
componentProps.isSidebar = true
const wrapper = mountVideoBottomBar(componentProps)
const participantName = wrapper.find('.participant-name')
expect(participantName.exists()).toBeFalsy()
const indicators = wrapper.findAllComponents(NcButton)
expect(indicators).toHaveLength(0)
})
})
describe('render participant name', () => {
test('name is shown by default', () => {
const wrapper = mountVideoBottomBar(componentProps)
const participantName = wrapper.find('.participant-name')
expect(participantName.isVisible()).toBeTruthy()
expect(participantName.text()).toBe(PARTICIPANT_NAME)
})
test('name is not shown if all checks are falsy', () => {
componentProps.showVideoOverlay = false
const wrapper = mountVideoBottomBar(componentProps)
const participantName = wrapper.find('.participant-name')
expect(participantName.isVisible()).toBeFalsy()
})
})
describe('render indicators', () => {
describe('connection failed indicator', () => {
test('indicator is not shown by default, other indicators are visible', () => {
componentProps.model.attributes.raisedHand.state = true
const wrapper = mountVideoBottomBar(componentProps)
const iceFailedIndicator = wrapper.findComponent(IconAlertCircleOutline)
expect(iceFailedIndicator.exists()).toBeFalsy()
const raiseHandIndicator = wrapper.findComponent(IconHandBackLeft)
expect(raiseHandIndicator.exists()).toBeTruthy()
const indicators = wrapper.findAllComponents(NcButton)
indicators.forEach((indicator) => {
expect(indicator.isVisible()).toBeTruthy()
})
})
test('indicator is shown when model prop is true, other indicators are hidden', () => {
componentProps.model.attributes.raisedHand.state = true
componentProps.model.attributes.connectionState = ConnectionState.FAILED_NO_RESTART
const wrapper = mountVideoBottomBar(componentProps)
const iceFailedIndicator = wrapper.findComponent(IconAlertCircleOutline)
expect(iceFailedIndicator.exists()).toBeTruthy()
const raiseHandIndicator = wrapper.findComponent(IconHandBackLeft)
expect(raiseHandIndicator.exists()).toBeFalsy()
const indicators = wrapper.findAllComponents(NcButton)
indicators.forEach((indicator) => {
expect(indicator.isVisible()).toBeFalsy()
})
})
})
describe('raise hand indicator', () => {
test('indicator is not shown by default', () => {
const wrapper = mountVideoBottomBar(componentProps)
const raiseHandIndicator = wrapper.findComponent(IconHandBackLeft)
expect(raiseHandIndicator.exists()).toBeFalsy()
})
test('indicator is shown when model prop is true', () => {
componentProps.model.attributes.raisedHand.state = true
const wrapper = mountVideoBottomBar(componentProps)
const raiseHandIndicator = wrapper.findComponent(IconHandBackLeft)
expect(raiseHandIndicator.exists()).toBeTruthy()
})
})
})
describe('render buttons', () => {
describe('audio indicator', () => {
test('button is rendered properly', () => {
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.exists()).toBeTruthy()
})
test('button is visible for moderators when audio is available', () => {
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.isVisible()).toBeTruthy()
})
test('button is not rendered for non-moderators when audio is available', () => {
conversationProps.participantType = PARTICIPANT.TYPE.USER
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.exists()).toBeFalsy()
})
test('button is visible for everyone when audio is unavailable', () => {
conversationProps.participantType = PARTICIPANT.TYPE.USER
componentProps.model.attributes.audioAvailable = false
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.isVisible()).toBeTruthy()
})
test('button is enabled for moderators', () => {
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.attributes('disabled')).toBeUndefined()
})
test('button is disabled when audio is unavailable', () => {
componentProps.model.attributes.audioAvailable = false
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
expect(audioIndicator.attributes('disabled')).toBeDefined()
})
test('method is called after click', async () => {
const wrapper = mountVideoBottomBar(componentProps)
const audioIndicator = findNcButton(wrapper, audioIndicatorAriaLabels)
await audioIndicator.trigger('click')
expect(wrapper.vm.$props.model.forceMute).toHaveBeenCalled()
})
})
describe('video indicator', () => {
test('button is rendered properly', () => {
const wrapper = mountVideoBottomBar(componentProps)
const videoIndicator = findNcButton(wrapper, videoIndicatorAriaLabels)
expect(videoIndicator.exists()).toBeTruthy()
})
test('button is visible when video is available', () => {
const wrapper = mountVideoBottomBar(componentProps)
const videoIndicator = findNcButton(wrapper, videoIndicatorAriaLabels)
expect(videoIndicator.isVisible()).toBeTruthy()
})
test('button is not rendered when video is unavailable', () => {
componentProps.model.attributes.videoAvailable = false
const wrapper = mountVideoBottomBar(componentProps)
const videoIndicator = findNcButton(wrapper, videoIndicatorAriaLabels)
expect(videoIndicator.exists()).toBeFalsy()
})
test('button shows proper icon if video is enabled', () => {
const wrapper = mountVideoBottomBar(componentProps)
const videoOnIcon = wrapper.findComponent(IconVideo)
expect(videoOnIcon.exists()).toBeTruthy()
})
test('button shows proper icon if video is blocked', () => {
componentProps.sharedData.remoteVideoBlocker.isVideoEnabled.mockReturnValue(false)
const wrapper = mountVideoBottomBar(componentProps)
const videoOffIcon = wrapper.findComponent(IconVideoOffOutline)
expect(videoOffIcon.exists()).toBeTruthy()
})
test('method is called after click', async () => {
const wrapper = mountVideoBottomBar(componentProps)
const videoIndicator = findNcButton(wrapper, videoIndicatorAriaLabels)
await videoIndicator.trigger('click')
expect(wrapper.vm.$props.sharedData.remoteVideoBlocker.setVideoEnabled).toHaveBeenCalled()
expect(wrapper.vm.$props.sharedData.remoteVideoBlocker.setVideoEnabled).toHaveBeenCalledWith(false)
})
})
describe('screen sharing indicator', () => {
test('button is rendered properly', () => {
const wrapper = mountVideoBottomBar(componentProps)
const screenSharingIndicator = findNcButton(wrapper, screenSharingAriaLabel)
expect(screenSharingIndicator.exists()).toBeTruthy()
})
test('button is visible when screen is available', () => {
const wrapper = mountVideoBottomBar(componentProps)
const screenSharingIndicator = findNcButton(wrapper, screenSharingAriaLabel)
expect(screenSharingIndicator.isVisible()).toBeTruthy()
})
test('button is not rendered when screen is unavailable', () => {
componentProps.model.attributes.screen = false
const wrapper = mountVideoBottomBar(componentProps)
const screenSharingIndicator = findNcButton(wrapper, screenSharingAriaLabel)
expect(screenSharingIndicator.exists()).toBeFalsy()
})
test('component emits peer id after click', async () => {
const wrapper = mountVideoBottomBar(componentProps)
const screenSharingIndicator = findNcButton(wrapper, screenSharingAriaLabel)
await screenSharingIndicator.trigger('click')
expect(emit).toHaveBeenCalledWith('switch-screen-to-id', PEER_ID)
})
})
describe('following button', () => {
test('button is not rendered for participants by default', () => {
const wrapper = mountVideoBottomBar(componentProps)
const followingButton = findNcButton(wrapper, followingButtonAriaLabel)
expect(followingButton.exists()).toBeFalsy()
})
test('button is not rendered for main speaker by default', () => {
componentProps.isBig = true
const wrapper = mountVideoBottomBar(componentProps)
const followingButton = findNcButton(wrapper, followingButtonAriaLabel)
expect(followingButton.exists()).toBeFalsy()
})
test('button is rendered when source is selected', () => {
callViewStore.setSelectedVideoPeerId(PEER_ID)
componentProps.isBig = true
const wrapper = mountVideoBottomBar(componentProps)
const followingButton = findNcButton(wrapper, followingButtonAriaLabel)
expect(followingButton.exists()).toBeTruthy()
})
test('method is called after click', async () => {
callViewStore.setSelectedVideoPeerId(PEER_ID)
callViewStore.startPresentation(TOKEN)
expect(callViewStore.selectedVideoPeerId).toBe(PEER_ID)
expect(callViewStore.presentationStarted).toBeTruthy()
componentProps.isBig = true
const wrapper = mountVideoBottomBar(componentProps)
const followingButton = findNcButton(wrapper, followingButtonAriaLabel)
await followingButton.trigger('click')
expect(callViewStore.selectedVideoPeerId).toBe(null)
expect(callViewStore.presentationStarted).toBeFalsy()
})
})
})
})
})
@@ -0,0 +1,410 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
class="wrapper"
:class="{ 'wrapper--big': isBig }"
@mouseover.stop="mouseover = true"
@mouseleave.stop="mouseover = false">
<div v-if="showRaiseHandIndicator" class="status-indicator raiseHandIndicator">
<IconHandBackLeft :size="18" fillColor="#ffffff" />
</div>
<div v-if="!isSidebar" class="bottom-bar">
<div
v-show="showParticipantName"
class="participant-name"
:class="{
'participant-name--active': isCurrentlyActive,
'participant-name--has-shadow': hasShadow,
}">
{{ participantName }}
</div>
<div
v-if="!isScreen"
v-show="showVideoOverlay"
class="media-indicators">
<NcButton
v-if="showAudioIndicator"
:title="audioButtonTitle"
:aria-label="audioButtonTitle"
class="audioIndicator"
variant="tertiary-no-background"
:disabled="isAudioButtonDisabled"
@click.stop="forceMute">
<template #icon>
<IconMicrophone v-if="model.attributes.audioAvailable" :size="20" />
<NcIconSvgWrapper v-else :svg="IconMicrophoneOffOutline" :size="20" />
</template>
</NcButton>
<NcButton
v-if="showVideoIndicator"
:title="videoButtonTitle"
:aria-label="videoButtonTitle"
class="videoIndicator"
variant="tertiary-no-background"
@click.stop="toggleVideo">
<template #icon>
<IconVideo v-if="isRemoteVideoEnabled" :size="20" />
<IconVideoOffOutline v-else :size="20" />
</template>
</NcButton>
<NcButton
v-if="showScreenSharingIndicator"
:title="t('spreed', 'Show screen')"
:aria-label="t('spreed', 'Show screen')"
class="screenSharingIndicator"
:class="{ 'screen-visible': sharedData.screenVisible }"
variant="tertiary-no-background"
@click.stop="switchToScreen">
<template #icon>
<IconMonitor :size="20" />
</template>
</NcButton>
<div
v-if="connectionStateFailedNoRestart"
class="status-indicator iceFailedIndicator">
<IconAlertCircleOutline :size="20" />
</div>
</div>
<NcButton
v-if="showStopFollowingButton"
class="following-button"
variant="tertiary"
@click="handleStopFollowing">
{{ t('spreed', 'Stop following') }}
</NcButton>
</div>
</div>
</template>
<script>
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue'
import IconHandBackLeft from 'vue-material-design-icons/HandBackLeft.vue' // Filled for better indication
import IconMicrophone from 'vue-material-design-icons/Microphone.vue' // Filled for better indication
import IconMonitor from 'vue-material-design-icons/Monitor.vue'
import IconVideo from 'vue-material-design-icons/Video.vue' // Filled for better indication
import IconVideoOffOutline from 'vue-material-design-icons/VideoOffOutline.vue'
import IconMicrophoneOffOutline from '../../../../img/material-icons/microphone-off-outline.svg?raw'
import { PARTICIPANT } from '../../../constants.ts'
import { useActorStore } from '../../../stores/actor.ts'
import { useCallViewStore } from '../../../stores/callView.ts'
import { ConnectionState } from '../../../utils/webrtc/models/CallParticipantModel.js'
export default {
name: 'VideoBottomBar',
components: {
IconAlertCircleOutline,
IconHandBackLeft,
IconMicrophone,
IconMonitor,
IconVideo,
IconVideoOffOutline,
NcButton,
NcIconSvgWrapper,
},
inheritAttrs: false,
props: {
token: {
type: String,
required: true,
},
isSidebar: {
type: Boolean,
default: false,
},
hasShadow: {
type: Boolean,
default: false,
},
isBig: {
type: Boolean,
default: false,
},
participantName: {
type: String,
default: '',
},
showVideoOverlay: {
type: Boolean,
default: true,
},
model: {
type: Object,
required: true,
},
sharedData: {
type: Object,
required: true,
},
// True if the bottom bar is used in the screen component
isScreen: {
type: Boolean,
default: false,
},
// The current promoted participant
isPromoted: {
type: Boolean,
default: false,
},
// Is the current selected participant
isSelected: {
type: Boolean,
default: false,
},
},
emits: ['bottomBarHover'],
setup() {
return {
IconMicrophoneOffOutline,
callViewStore: useCallViewStore(),
actorStore: useActorStore(),
}
},
data() {
return {
mouseover: false,
}
},
computed: {
connectionStateFailedNoRestart() {
return this.model.attributes.connectionState === ConnectionState.FAILED_NO_RESTART
},
// Common indicators
showRaiseHandIndicator() {
return !this.connectionStateFailedNoRestart && this.model.attributes.raisedHand.state
},
showStopFollowingButton() {
return this.isBig && this.callViewStore.selectedVideoPeerId !== null
},
// Audio indicator
showAudioIndicator() {
return !this.connectionStateFailedNoRestart && !this.isAudioButtonHidden
},
isAudioButtonHidden() {
return this.model.attributes.audioAvailable && !this.canFullModerate
},
isAudioButtonDisabled() {
return !this.model.attributes.audioAvailable || !this.canFullModerate
},
audioButtonTitle() {
return this.model.attributes.audioAvailable
? t('spreed', 'Mute')
: t('spreed', 'Muted')
},
// Video indicator
showVideoIndicator() {
return !this.connectionStateFailedNoRestart && this.model.attributes.videoAvailable
},
isRemoteVideoEnabled() {
return this.sharedData.remoteVideoBlocker?.isVideoEnabled()
},
isRemoteVideoBlocked() {
return this.sharedData.remoteVideoBlocker && !this.sharedData.remoteVideoBlocker.isVideoEnabled()
},
videoButtonTitle() {
return this.isRemoteVideoEnabled
? t('spreed', 'Disable video')
: t('spreed', 'Enable video')
},
// ScreenSharing indicator
showScreenSharingIndicator() {
return !this.connectionStateFailedNoRestart && this.model.attributes.screen
},
// Name indicator
isCurrentlyActive() {
return this.isSelected || this.model.attributes.speaking
},
showParticipantName() {
return !this.model.attributes.videoAvailable || this.isRemoteVideoBlocked
|| this.showVideoOverlay || this.isPromoted || this.isCurrentlyActive
},
// Moderator rights
participantType() {
return this.$store.getters.conversation(this.token)?.participantType
|| (this.actorStore.isLoggedIn
? PARTICIPANT.TYPE.USER
: PARTICIPANT.TYPE.GUEST)
},
canFullModerate() {
return this.participantType === PARTICIPANT.TYPE.OWNER || this.participantType === PARTICIPANT.TYPE.MODERATOR
},
},
watch: {
mouseover(value) {
if (!this.isBig) {
return
}
this.$emit('bottomBarHover', value)
},
},
methods: {
t,
forceMute() {
this.model.forceMute()
},
toggleVideo() {
this.sharedData.remoteVideoBlocker.setVideoEnabled(!this.isRemoteVideoEnabled)
},
switchToScreen() {
if (!this.sharedData.screenVisible || !this.isBig) {
emit('switch-screen-to-id', this.model.attributes.peerId)
}
},
handleStopFollowing() {
this.callViewStore.stopPresentation(this.token)
this.callViewStore.setSelectedVideoPeerId(null)
},
},
}
</script>
<style lang="scss" scoped>
.wrapper {
display: flex;
align-items: center;
position: absolute;
bottom: 0;
width: 100%;
padding: 0 calc(var(--default-grid-baseline) * 2) var(--default-grid-baseline);
z-index: 1;
&--big {
justify-content: center;
margin: 0 var(--default-clickable-area); // grid collapse button
width: calc(100% - var(--default-clickable-area) * 2);
& .bottom-bar {
width: unset;
padding: var(--default-grid-baseline);
&:hover {
background-color: rgba(0, 0, 0, 0.2);
border-radius: var(--border-radius-large);
}
}
& .participant-name {
margin-inline-end: 0;
}
}
}
.bottom-bar {
display: flex;
align-items: center;
gap: var(--default-grid-baseline);
width: 100%;
min-height: var(--default-clickable-area);
& .media-indicators {
display: flex;
}
& .following-button {
opacity: 0.8;
background-color: var(--color-background-dark);
&:hover,
&:focus {
opacity: 1;
}
}
}
.participant-name {
color: white;
margin-block: 0px;
margin-inline: 8px auto;
position: relative;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
filter: drop-shadow(1px 1px 4px var(--color-box-shadow));
&--active {
font-weight: bold;
}
&--has-shadow {
text-shadow: 0 0 4px rgba(0, 0, 0, .8);
}
}
.status-indicator {
width: var(--default-clickable-area);
height: var(--default-clickable-area);
display: flex;
align-items: center;
justify-content: center;
}
.iceFailedIndicator {
opacity: .8 !important;
}
.audioIndicator,
.videoIndicator,
.screenSharingIndicator,
.iceFailedIndicator {
color: #ffffff !important;
}
.audioIndicator[disabled],
.videoIndicator {
opacity: .7;
}
.videoIndicator {
&:hover,
&:focus {
opacity: 1;
}
}
</style>
File diff suppressed because it is too large Load Diff
+789
View File
@@ -0,0 +1,789 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
v-show="!placeholderForPromoted || sharedData.promoted"
:id="(placeholderForPromoted ? 'placeholder-' : '') + 'container_' + peerId + '_video_incoming'"
ref="videoContainer"
class="video-container"
:class="containerClass"
@mouseover="mouseover = true"
@mouseleave="mouseover = false"
@click="$emit('clickVideo')">
<div
v-show="showVideo"
:class="videoWrapperClass"
class="videoWrapper"
:style="videoWrapperStyle">
<video
ref="video"
:disablePictureInPicture="!isBig"
:class="fitVideo ? 'video--fit' : 'video--fill'"
class="video"
@playing="updateVideoAspectRatio" />
<IconAccountOffOutline
v-if="isPresenterOverlay && mouseover"
class="presenter-icon__hide"
:aria-label="t('spreed', 'Hide presenter video')"
:title="t('spreed', 'Hide presenter video')"
:size="32"
@click="$emit('clickPresenter')" />
<NcLoadingIcon
v-if="isLoading"
:size="avatarSize / 2"
class="video-loading" />
<img
v-if="screenshotModeUrl && isPresenterOverlay"
class="dev-mode-video--presenter"
alt="dev-mode-video--presenter"
:src="screenshotModeUrl">
</div>
<ScreenShare
v-if="showSharedScreen"
:isBig="isBig"
:token="token"
:callParticipantModel="model"
:sharedData="sharedData" />
<div
v-if="showBackgroundAndAvatar"
class="avatar-container">
<VideoBackground :displayName="displayName" :user="participantUserId" />
<AvatarWrapper
:id="participantUserId"
:token="token"
:name="displayName"
:source="participantActorType"
:size="avatarSize"
:loading="isLoading"
disableMenu
disableTooltip />
</div>
<div
v-if="showPlaceholderForPromoted"
class="placeholder-for-promoted">
<IconAccountCircleOutline v-if="isPromoted || isSelected" fillColor="#FFFFFF" :size="64" />
</div>
<div
v-if="connectionMessage"
:class="connectionMessageClass"
class="connection-message">
{{ connectionMessage }}
</div>
<slot v-if="!hideBottomBar" name="bottomBar">
<VideoBottomBar
:hasShadow="hasVideo"
:participantName="participantName"
v-bind="$props"
@bottomBarHover="handleHoverEvent" />
</slot>
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import Hex from 'crypto-js/enc-hex.js'
import SHA1 from 'crypto-js/sha1.js'
import { inject, ref } from 'vue'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconAccountCircleOutline from 'vue-material-design-icons/AccountCircleOutline.vue'
import IconAccountOffOutline from 'vue-material-design-icons/AccountOffOutline.vue'
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
import ScreenShare from './ScreenShare.vue'
import VideoBackground from './VideoBackground.vue'
import VideoBottomBar from './VideoBottomBar.vue'
import { ATTENDEE, AVATAR } from '../../../constants.ts'
import { EventBus } from '../../../services/EventBus.ts'
import { useCallViewStore } from '../../../stores/callView.ts'
import { useGuestNameStore } from '../../../stores/guestName.ts'
import attachMediaStream from '../../../utils/attachmediastream.js'
import { getDisplayNameWithFallback } from '../../../utils/getDisplayName.ts'
import { ConnectionState } from '../../../utils/webrtc/models/CallParticipantModel.js'
import { placeholderImage } from '../Grid/gridPlaceholders.ts'
export default {
name: 'VideoVue',
components: {
AvatarWrapper,
VideoBackground,
ScreenShare,
VideoBottomBar,
NcLoadingIcon,
// icons
IconAccountCircleOutline,
IconAccountOffOutline,
},
props: {
token: {
type: String,
required: true,
},
placeholderForPromoted: {
type: Boolean,
default: false,
},
model: {
type: Object,
required: true,
},
sharedData: {
type: Object,
required: true,
},
showVideoOverlay: {
type: Boolean,
default: true,
},
isGrid: {
type: Boolean,
default: false,
},
fitVideo: {
type: Boolean,
default: false,
},
isPresenterOverlay: {
type: Boolean,
default: false,
},
isBig: {
type: Boolean,
default: false,
},
// True if this video component is used in the promoted view's video stripe
isStripe: {
type: Boolean,
default: false,
},
// The current promoted participant
isPromoted: {
type: Boolean,
default: false,
},
// Is the current selected participant
isSelected: {
type: Boolean,
default: false,
},
// True when this component is used as main video in the sidebar
isSidebar: {
type: Boolean,
default: false,
},
// True when this video component is used in one to one conversations
isOneToOne: {
type: Boolean,
default: false,
},
unSelectable: {
type: Boolean,
default: false,
},
hideBottomBar: {
type: Boolean,
default: false,
},
},
emits: ['clickVideo', 'clickPresenter', 'forcePromoteVideo'],
setup() {
const screenshotMode = inject('CallView:screenshotModeEnabled', ref(false))
return {
callViewStore: useCallViewStore(),
guestNameStore: useGuestNameStore(),
screenshotMode,
}
},
data() {
return {
videoAspectRatio: null,
containerAspectRatio: null,
resizeObserver: null,
mouseover: false,
}
},
computed: {
videoWrapperStyle() {
if (!this.containerAspectRatio || !this.videoAspectRatio || !this.isBig || this.isGrid) {
return
}
return (this.containerAspectRatio > this.videoAspectRatio)
? `width: ${this.$refs.videoContainer.clientHeight * this.videoAspectRatio}px`
: `height: ${this.$refs.videoContainer.clientWidth / this.videoAspectRatio}px`
},
isSelectable() {
if (this.isStripe) {
return !this.isSelected
} else {
return true
}
},
wasConnectedAtLeastOnce() {
return this.model.attributes.connectedAtLeastOnce
},
isConnected() {
return this.model.attributes.connectionState === ConnectionState.CONNECTED || this.model.attributes.connectionState === ConnectionState.COMPLETED
},
isLoading() {
return !this.isConnected && this.model.attributes.connectionState !== ConnectionState.FAILED_NO_RESTART
},
isDisconnected() {
return this.model.attributes.connectionState !== ConnectionState.NEW && this.model.attributes.connectionState !== ConnectionState.CHECKING
&& this.model.attributes.connectionState !== ConnectionState.CONNECTED && this.model.attributes.connectionState !== ConnectionState.COMPLETED
},
/**
* Whether the connection to the participant is being tried again.
*
* The initial connection to the participant is excluded.
*
* A "failed" connection state will trigger a reconnection, but that may
* not immediately change the "negotiating" or "connecting" attributes
* (for example, while the new offer requested to the HPB was not
* received yet). Similarly, both "negotiating" and "connecting" need to
* be checked, as the negotiation will start before the connection
* attempt is started.
*
* If the negotiation is done while there is still a connection it is
* not regarded as reconnecting, as in that case it is a renegotiation
* to update the current connection.
*/
isReconnecting() {
return this.model.attributes.connectionState === ConnectionState.FAILED
|| (!this.model.attributes.initialConnection
&& ((this.model.attributes.negotiating && !this.isConnected) || this.model.attributes.connecting))
},
isNoLongerTryingToReconnect() {
return this.model.attributes.connectionState === ConnectionState.FAILED_NO_RESTART
},
connectionMessage() {
if (!this.wasConnectedAtLeastOnce && this.isNoLongerTryingToReconnect) {
return t('spreed', 'Connection could not be established …')
}
if (this.isNoLongerTryingToReconnect) {
return t('spreed', 'Connection was lost and could not be re-established …')
}
if (!this.wasConnectedAtLeastOnce && this.isReconnecting) {
return t('spreed', 'Connection could not be established. Trying again …')
}
if (this.isReconnecting) {
return t('spreed', 'Connection lost. Trying to reconnect …')
}
if (this.isDisconnected) {
return t('spreed', 'Connection problems …')
}
return null
},
containerClass() {
return {
'videoContainer-dummy': this.placeholderForPromoted,
'not-connected': !this.placeholderForPromoted && !this.isConnected,
speaking: !this.placeholderForPromoted && this.isSpeaking && !this.isBig,
hover: this.mouseover && !this.unSelectable && !this.isBig,
promoted: !this.placeholderForPromoted && this.sharedData.promoted && !this.isGrid,
presenter: this.isPresenterOverlay && this.mouseover,
'video-container-grid': this.isGrid,
'video-container-big': this.isBig,
'one-to-one': this.isOneToOne,
'presenter-overlay': this.isPresenterOverlay,
}
},
videoWrapperClass() {
return {
'presenter-overlay': this.isPresenterOverlay,
}
},
avatarSize() {
if (this.isStripe || (!this.isBig && !this.isGrid)) {
return AVATAR.SIZE.LARGE
} else if (!this.containerAspectRatio) {
return AVATAR.SIZE.FULL
} else {
return Math.min(AVATAR.SIZE.FULL, this.$refs.videoContainer.clientHeight / 2, this.$refs.videoContainer.clientWidth / 2)
}
},
connectionMessageClass() {
return {
'below-avatar': this.showBackgroundAndAvatar,
}
},
sessionHash() {
return Hex.stringify(SHA1(this.nextcloudSessionId))
},
peerData() {
let peerData = this.$store.getters.getPeer(this.token, this.nextcloudSessionId, this.model.attributes.userId)
if (!peerData.actorId) {
EventBus.emit('refresh-peer-list')
peerData = {
actorType: '',
actorId: '',
displayName: '',
}
}
return peerData
},
participant() {
/**
* This only works for logged-in users. Guests can not load the data
* via the participant list
*/
return this.$store.getters.findParticipant(this.token, {
sessionId: this.nextcloudSessionId,
}) || {}
},
participantActorType() {
if (this.model.attributes.actorType) {
return this.model.attributes.actorType
} else if (this.participant?.actorType) {
return this.participant.actorType
} else if (this.peerData?.actorType) {
return this.peerData.actorType
} else {
return this.participantUserId
? ATTENDEE.ACTOR_TYPE.USERS
: ATTENDEE.ACTOR_TYPE.GUESTS
}
},
participantUserId() {
if (this.model.attributes.actorId) {
return this.model.attributes.actorId
}
if (this.model.attributes.userId) {
return this.model.attributes.userId
}
// Check data from participant list
if (this.participant?.actorType) {
if (this.participant?.actorType === ATTENDEE.ACTOR_TYPE.USERS && this.participant?.actorId) {
return this.participant.actorId
}
// Not a user
return null
}
// Fallback to CallController::getPeers() endpoint
if (this.peerData.actorType === ATTENDEE.ACTOR_TYPE.USERS
|| this.peerData.actorType === ATTENDEE.ACTOR_TYPE.FEDERATED_USERS) {
return this.peerData.actorId
}
return null
},
displayName() {
if (this.model.attributes.name) {
return this.model.attributes.name
}
if (this.participant?.displayName) {
return this.participant.displayName
}
let participantName = this.model.attributes.name
// The name is undefined and not shown until a connection is made
// for registered users, so do not fall back to the guest name in
// the store either until the connection was made.
if (!this.model.attributes.userId && !participantName && participantName !== undefined) {
participantName = this.guestNameStore.getGuestName(
this.token,
this.sessionHash,
)
}
if (!participantName) {
participantName = this.peerData.displayName
}
return participantName?.trim() ?? ''
},
participantName() {
return getDisplayNameWithFallback(this.displayName, this.participantActorType)
},
isSpeaking() {
return this.model.attributes.speaking
},
hasVideo() {
return !this.model.attributes.videoBlocked
&& this.model.attributes.videoAvailable
&& this.sharedData.remoteVideoBlocker.isVideoEnabled() && (typeof this.model.attributes.stream === 'object')
},
hasSelectedVideo() {
return this.callViewStore.selectedVideoPeerId !== null
},
hasSharedScreen() {
return this.model.attributes.screen
},
isSharedScreenPromoted() {
return this.sharedData.screenVisible && (!this.hasSelectedVideo || this.isSelected)
},
showSharedScreen() {
// Big screen
if (this.isBig) {
// Always show shared screen if there's one
return this.hasSharedScreen
// Stripe
} else if (this.isStripe) {
if (this.isSharedScreenPromoted) {
return false
} else {
// Show the shared screen if not selected or promoted
return !((this.isSelected) ? this.isSelected : this.isPromoted) && this.hasSharedScreen
}
// Grid
} else {
// Always show shared screen if there's one
return this.hasSharedScreen && !this.isPresenterOverlay
}
},
showVideo() {
// Screenshare have higher priority so return false if screenshare
// is shown
if (this.hasSharedScreen) {
return (!this.showSharedScreen && this.hasVideo && !this.isSelected) || this.isPresenterOverlay
} else {
if (this.isStripe) {
if (this.hasSelectedVideo) {
return !this.isSelected && this.hasVideo
} else {
return !this.isPromoted && this.hasVideo
}
} else {
return this.hasVideo
}
}
},
showPlaceholderForPromoted() {
if (this.isStripe) {
if (this.showVideo || this.showSharedScreen) {
return false
} else if (this.hasSelectedVideo) {
return this.isSelected
} else {
return this.isPromoted
}
} else {
return false
}
},
showBackgroundAndAvatar() {
if (this.showSharedScreen || this.showVideo || this.showPlaceholderForPromoted) {
return false
} else {
return true
}
},
peerId() {
return this.model.attributes.peerId
},
nextcloudSessionId() {
return this.model.attributes.nextcloudSessionId
},
screenshotModeUrl() {
return this.screenshotMode ? placeholderImage(6) : ''
},
},
watch: {
'model.attributes.stream': function(stream) {
this._setStream(stream)
},
isSelected(bool) {
if (bool) {
this.mouseover = false
}
},
},
mounted() {
this.sharedData.remoteVideoBlocker.increaseVisibleCounter()
// Set initial state
this._setStream(this.model.attributes.stream)
if (this.isBig || this.isGrid) {
this.resizeObserver = new ResizeObserver(this.updateContainerAspectRatio)
this.resizeObserver.observe(this.$refs.videoContainer)
}
},
beforeUnmount() {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
},
unmounted() {
this.sharedData.remoteVideoBlocker.decreaseVisibleCounter()
},
methods: {
t,
_setStream(stream) {
if (!stream) {
// Do not clear the srcObject of the video element, just leave
// the previous stream as a frozen image.
return
}
// The audio is played using an audio element in the model to be
// able to hear it even if there is no view for it. Moreover, if
// there is a video track Chromium does not play audio in a video
// element until the video track starts to play; an audio element is
// thus needed to play audio when the remote peer starts with the
// camera available but disabled.
attachMediaStream(stream, this.$refs.video)
this.$refs.video.muted = true
// At least Firefox, Opera and Edge move the video to a wrong
// position instead of keeping it unchanged when
// "transform: scaleX(1)" is used ("transform: scaleX(-1)" is fine);
// as it should have no effect the transform is removed.
if (this.$refs.video.style.transform === 'scaleX(1)') {
this.$refs.video.style.transform = ''
}
},
updateContainerAspectRatio([{ target }]) {
this.containerAspectRatio = target.clientWidth / target.clientHeight
},
updateVideoAspectRatio() {
if (!this.isBig) {
return
}
this.videoAspectRatio = this.model.attributes.stream.getVideoTracks()?.[0].getSettings().aspectRatio
// Fallback for Firefox
?? this.$refs.video.videoWidth / this.$refs.video.videoHeight
},
handleHoverEvent(value) {
this.$emit('forcePromoteVideo', value ? this.model : null)
},
},
}
</script>
<style lang="scss" scoped>
.not-connected {
video,
.avatar-container {
opacity: 0.5;
}
}
.video-container-grid {
position: relative;
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.video-container-big {
position: absolute;
&.one-to-one {
width: calc(100% - var(--wrapper-padding) * 2);
}
& .videoWrapper {
margin: auto;
}
}
.avatar-container {
width: 100%;
height: 100%;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
.placeholder-for-promoted {
background: radial-gradient(146.1% 146.1% at 50% 50%, #333333 0%, #858585 100%);
width: 100%;
height: 100%;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.videoWrapper,
.video {
height: 100%;
width: 100%;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.videoWrapper.presenter-overlay {
& > video {
border-radius: 50%;
}
& > .dev-mode-video--presenter {
position: absolute;
top: 0;
inset-inline-start: 0;
height: 100%;
width: 100%;
object-fit: cover;
border-radius: 50%;
}
}
.video-loading {
position: absolute;
top: 0;
inset-inline-end: 0;
height: 100%;
width: 100%;
}
.video--fit {
/* Fit the frame */
object-fit: contain;
}
.video--fill {
/* Fill the frame */
object-fit: cover;
}
.connection-message {
width: 100%;
position: absolute;
top: calc(50% + 50px);
text-align: center;
z-index: 1;
color: white;
filter: drop-shadow(1px 1px 4px var(--color-box-shadow));
&.below-avatar {
top: calc(50% + 80px);
}
}
.video-container::after {
position: absolute;
height: 100%;
width: 100%;
top: 0;
inset-inline-start: 0;
border-radius: var(--border-radius-element, calc(var(--default-clickable-area) / 2));
}
.video-container.presenter-overlay::after {
border-radius: 50%;
z-index: 1;
}
.video-container.speaking::after {
content: '';
box-shadow: inset 0 0 0 2px white;
}
.video-container.hover::after {
content: '';
box-shadow: inset 0 0 0 3px white;
cursor: pointer;
}
.presenter-icon__hide {
position: absolute;
color: white;
inset-inline-start: calc(50% - var(--default-clickable-area) / 2);
top: calc(100% - var(--default-grid-baseline) - var(--default-clickable-area));
opacity: 0.7;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
padding: 6px;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
z-index: 2; // Above video and its border
&:hover {
cursor: pointer;
opacity: 0.9;
}
}
</style>
@@ -0,0 +1,343 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div ref="ghost" class="viewer-overlay-ghost">
<Teleport to="body">
<!-- Add .app-talk to use Talk icon classes outside of #content-vue -->
<div
class="viewer-overlay app-talk"
:style="computedStyle">
<div
class="viewer-overlay__collapse"
:class="{ collapsed: isCollapsed }">
<NcButton
variant="secondary"
class="viewer-overlay__button"
:aria-label="
isCollapsed ? t('spreed', 'Collapse') : t('spreed', 'Expand')
"
@click.stop="isCollapsed = !isCollapsed">
<template #icon>
<ChevronDown v-if="!isCollapsed" :size="20" />
<ChevronUp v-else :size="20" />
</template>
</NcButton>
</div>
<TransitionWrapper name="slide-down">
<div
v-show="!isCollapsed"
class="viewer-overlay__video-container"
tabindex="0"
@click="maximize">
<div class="video-overlay__top-bar">
<NcButton
variant="secondary"
class="viewer-overlay__button"
:aria-label="t('spreed', 'Expand')"
@click.stop="maximize">
<template #icon>
<ArrowExpand :size="20" />
</template>
</NcButton>
</div>
<!-- local screen -->
<ScreenShare
v-if="showLocalScreen"
:token="token"
:localMediaModel="localModel"
:sharedData="localSharedData" />
<!-- remote screen -->
<ScreenShare
v-else-if="model && screens[model.attributes.peerId]"
:token="token"
:callParticipantModel="model"
:sharedData="sharedData" />
<VideoVue
v-else-if="model"
class="viewer-overlay__video"
:token="token"
:model="model"
:sharedData="sharedData"
isGrid
unSelectable
hideBottomBar
@clickVideo="maximize">
<template #bottomBar />
</VideoVue>
<EmptyCallView v-else isSmall />
<LocalVideo
v-if="localModel.attributes.videoEnabled"
class="viewer-overlay__local-video"
:token="token"
:showControls="false"
:localMediaModel="localModel"
:localCallParticipantModel="localCallParticipantModel"
isSmall
unSelectable />
<div class="viewer-overlay__bottom-bar">
<LocalAudioControlButton
class="viewer-overlay__button"
:token="token"
:conversation="conversation"
:model="localModel"
variant="secondary"
disableKeyboardShortcuts />
<LocalVideoControlButton
class="viewer-overlay__button"
:token="token"
:conversation="conversation"
:model="localModel"
variant="secondary"
disableKeyboardShortcuts />
</div>
</div>
</TransitionWrapper>
</div>
</Teleport>
</div>
</template>
<script>
import { isRTL, t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import ArrowExpand from 'vue-material-design-icons/ArrowExpand.vue'
import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import TransitionWrapper from '../../UIShared/TransitionWrapper.vue'
import EmptyCallView from './EmptyCallView.vue'
import LocalAudioControlButton from './LocalAudioControlButton.vue'
import LocalVideo from './LocalVideo.vue'
import LocalVideoControlButton from './LocalVideoControlButton.vue'
import ScreenShare from './ScreenShare.vue'
import VideoVue from './VideoVue.vue'
import { useCallViewStore } from '../../../stores/callView.ts'
import { localCallParticipantModel, localMediaModel } from '../../../utils/webrtc/index.js'
export default {
name: 'ViewerOverlayCallView',
components: {
EmptyCallView,
LocalAudioControlButton,
LocalVideoControlButton,
ScreenShare,
LocalVideo,
ChevronUp,
ChevronDown,
NcButton,
TransitionWrapper,
VideoVue,
ArrowExpand,
},
props: {
token: {
type: String,
required: true,
},
// Promoted participant model
model: {
type: Object,
required: false,
default: null,
},
sharedData: {
type: Object,
required: false,
default: null,
},
localModel: {
type: Object,
required: false,
default: () => localMediaModel,
},
localCallParticipantModel: {
type: Object,
required: false,
default: () => localCallParticipantModel,
},
localSharedData: {
type: Object,
required: true,
default: () => {},
},
screens: {
type: Array,
required: false,
default: () => [],
},
},
setup() {
return {
callViewStore: useCallViewStore(),
}
},
data() {
return {
isCollapsed: false,
observer: null,
position: {
right: 0,
left: 0,
bottom: 0,
},
}
},
computed: {
conversation() {
return this.$store.getters.conversation(this.token)
},
hasLocalScreen() {
return !!this.localModel.attributes.localScreen
},
showLocalScreen() {
return this.hasLocalScreen && this.screens[0] === localCallParticipantModel.attributes.peerId
},
computedStyle() {
return {
[isRTL() ? 'left' : 'right']: this.position[isRTL() ? 'left' : 'right'] + 'px',
bottom: this.position.bottom + 'px',
}
},
},
mounted() {
this.updatePosition()
this.observer = new ResizeObserver(this.updatePosition)
this.observer.observe(this.$refs.ghost)
},
beforeUnmount() {
this.observer.disconnect()
},
methods: {
t,
maximize() {
if (OCA.Viewer) {
OCA.Viewer.close()
}
this.callViewStore.setIsViewerOverlay(false)
},
updatePosition() {
const { left, right, bottom } = this.$refs.ghost.getBoundingClientRect()
if (isRTL()) {
this.position.left = left
} else {
this.position.right = window.innerWidth - right
}
this.position.bottom = window.innerHeight - bottom
},
},
}
</script>
<style lang="scss" scoped>
.viewer-overlay-ghost {
position: absolute;
bottom: 8px;
inset-inline: 0 8px;
}
.viewer-overlay {
--aspect-ratio: calc(3 / 4);
--width: 20vw;
--min-width: 250px;
--max-width: 400px;
position: absolute;
width: var(--width);
min-width: var(--min-width);
max-width: var(--max-width);
min-height: calc(var(--default-clickable-area) + 8px);
z-index: 11000;
}
.viewer-overlay * {
box-sizing: border-box;
}
.viewer-overlay__collapse {
position: absolute;
top: 8px;
inset-inline-end: 8px;
z-index: 100;
}
.viewer-overlay__button {
opacity: 0.8;
&:active,
&:hover,
&:focus {
opacity: 0.9;
}
}
.video-overlay__top-bar {
position: absolute;
top: 8px;
inset-inline-start: 8px;
z-index: 100;
}
.viewer-overlay__bottom-bar {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
position: absolute;
bottom: 0;
width: 100%;
padding: 0 12px 8px 12px;
z-index: 1;
}
.viewer-overlay__video-container {
width: 100%;
height: calc(var(--width) * var(--aspect-ratio));
min-height: calc(var(--min-width) * var(--aspect-ratio));
max-height: calc(var(--max-width) * var(--aspect-ratio));
/* Note: because of transition it always has position absolute on animation */
bottom: 0;
inset-inline-end: 0;
}
.viewer-overlay__local-video {
position: absolute;
bottom: 8px;
inset-inline-end: 8px;
width: 25%;
height: 25%;
overflow: hidden;
}
.viewer-overlay__video {
position: relative;
height: 100%;
}
:deep(.screen) {
border-radius: calc(var(--default-clickable-area) / 4);
}
</style>
+29
View File
@@ -0,0 +1,29 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { onUnmounted } from 'vue'
/**
* Request a wake lock to prevent the screen from turning off
*/
export function useWakeLock() {
if (!('wakeLock' in navigator)) {
return
}
const wakeLockRequest = navigator.wakeLock
.request('screen')
.catch(() => {
// Web Lock is not available, e.g. battery saving mode is enabled
// Ignoring
})
onUnmounted(async () => {
// Component unmount could happen before the WakeLock request is resolved
// Wait for the WakeLock request before releasing it
const wakeLock = await wakeLockRequest
wakeLock?.release()
})
}
+285
View File
@@ -0,0 +1,285 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
class="chatView"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDropFiles">
<GuestWelcomeWindow v-if="showGuestWelcomeWindow" :token="token" />
<div class="messages-list-dragover-wrapper">
<TransitionWrapper name="slide-up" mode="out-in">
<NcEmptyContent
v-show="isDraggingOver"
:name="dropHintText"
class="dragover">
<template #icon>
<NcIconSvgWrapper v-if="!isGuest && !isReadOnly" :svg="IconFileUpload" />
<IconAccountOutline v-else-if="isGuest" />
<IconAlertOctagonOutline v-else-if="isReadOnly" />
</template>
</NcEmptyContent>
</TransitionWrapper>
<ThreadHeader v-if="isSidebar && threadId" standalone />
<MessagesList
v-model:isChatScrolledToBottom="isChatScrolledToBottom"
role="region"
:aria-label="t('spreed', 'Conversation messages')"
:token="token"
:isVisible="isVisible" />
</div>
<div class="scroll-to-bottom">
<TransitionWrapper name="fade">
<NcButton
v-show="!isChatScrolledToBottom && !isLoadingChat"
variant="secondary"
:aria-label="t('spreed', 'Scroll to bottom')"
:title="t('spreed', 'Scroll to bottom')"
class="scroll-to-bottom__button"
@click="scrollToBottom">
<template #icon>
<IconChevronDoubleDown :size="20" />
</template>
</NcButton>
</TransitionWrapper>
</div>
<!-- Input field -->
<NewMessage
role="region"
:token="token"
hasTypingIndicator
:aria-label="t('spreed', 'Post message')" />
<!-- File upload dialog -->
<NewMessageUploadEditor />
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import { provide } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import IconAccountOutline from 'vue-material-design-icons/AccountOutline.vue'
import IconAlertOctagonOutline from 'vue-material-design-icons/AlertOctagonOutline.vue'
import IconChevronDoubleDown from 'vue-material-design-icons/ChevronDoubleDown.vue'
import GuestWelcomeWindow from './GuestWelcomeWindow.vue'
import MessagesList from './MessagesList/MessagesList.vue'
import NewMessage from './NewMessage/NewMessage.vue'
import NewMessageUploadEditor from './NewMessage/NewMessageUploadEditor.vue'
import ThreadHeader from './RightSidebar/Threads/ThreadHeader.vue'
import TransitionWrapper from './UIShared/TransitionWrapper.vue'
import IconFileUpload from '../../img/material-icons/file-upload.svg?raw'
import { useGetThreadId } from '../composables/useGetThreadId.ts'
import { useGetToken } from '../composables/useGetToken.ts'
import { CONVERSATION, PARTICIPANT } from '../constants.ts'
import { getTalkConfig } from '../services/CapabilitiesManager.ts'
import { EventBus } from '../services/EventBus.ts'
import { useActorStore } from '../stores/actor.ts'
import { useChatExtrasStore } from '../stores/chatExtras.ts'
import { useSettingsStore } from '../stores/settings.ts'
export default {
name: 'ChatView',
components: {
ThreadHeader,
NcButton,
NcEmptyContent,
NcIconSvgWrapper,
MessagesList,
NewMessage,
NewMessageUploadEditor,
TransitionWrapper,
GuestWelcomeWindow,
// icons
IconAccountOutline,
IconAlertOctagonOutline,
IconChevronDoubleDown,
},
props: {
isVisible: {
type: Boolean,
default: true,
},
isSidebar: {
type: Boolean,
default: false,
},
},
setup(props) {
provide('chatView:isSidebar', props.isSidebar)
return {
IconFileUpload,
token: useGetToken(),
threadId: useGetThreadId(),
chatExtrasStore: useChatExtrasStore(),
actorStore: useActorStore(),
settingsStore: useSettingsStore(),
}
},
data() {
return {
isChatScrolledToBottom: false,
isDraggingOver: false,
}
},
computed: {
isGuest() {
return this.actorStore.isActorGuest
},
isGuestWithoutDisplayName() {
return this.isGuest && !this.actorStore.displayName
},
canUploadFiles() {
return getTalkConfig(this.token, 'attachments', 'allowed') && this.actorStore.userId
&& this.settingsStore.attachmentFolderFreeSpace !== 0
&& (this.conversation.permissions & PARTICIPANT.PERMISSIONS.CHAT)
&& !this.conversation.remoteServer // no attachments support in federated conversations
},
isDragAndDropBlocked() {
return this.chatExtrasStore.getMessageIdToEdit(this.token) !== undefined || !this.canUploadFiles
},
dropHintText() {
if (this.isGuest) {
return t('spreed', 'You need to be logged in to upload files')
} else if (this.isReadOnly) {
return t('spreed', 'This conversation is read-only')
} else {
return t('spreed', 'Drop your files to upload')
}
},
isReadOnly() {
if (this.conversation) {
return this.conversation.readOnly === CONVERSATION.STATE.READ_ONLY
} else {
return undefined
}
},
conversation() {
return this.$store.getters.conversation(this.token)
},
isLoadingChat() {
return !this.$store.getters.isMessagesListPopulated(this.token)
},
showGuestWelcomeWindow() {
return this.isGuestWithoutDisplayName
&& !this.conversation.hasCall
&& !this.conversation.objectType !== CONVERSATION.OBJECT_TYPE.VIDEO_VERIFICATION
},
},
methods: {
t,
handleDragOver(event) {
if (event.dataTransfer.types.includes('Files') && !this.isDragAndDropBlocked) {
this.isDraggingOver = true
}
},
handleDragLeave(event) {
if (!event.currentTarget.contains(event.relatedTarget)) {
this.isDraggingOver = false
}
},
handleDropFiles(event) {
if (!this.isDraggingOver || this.isDragAndDropBlocked) {
return
}
// Restore non dragover state
this.isDraggingOver = false
// Stop the executin if the user is a guest
if (this.isGuest || this.isReadOnly) {
return
}
// Get the files from the event
const files = Object.values(event.dataTransfer.files)
// Create a unique id for the upload operation
const uploadId = new Date().getTime()
// Uploads and shares the files
this.$store.dispatch('initialiseUpload', { files, token: this.token, threadId: this.threadId, uploadId })
},
scrollToBottom() {
if (this.$route.hash) {
// Reset the hash from focused message id (but keep the thread id)
// Scrolling will be handled by the useGetMessages composable
this.$router.replace({ query: this.$route.query, hash: '' })
} else {
// If the hash is already empty, simply scroll to the bottom
EventBus.emit('set-context-id-to-bottom')
EventBus.emit('scroll-chat-to-bottom', { smooth: false, force: true })
}
},
},
}
</script>
<style lang="scss" scoped>
.chatView {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
}
.messages-list-dragover-wrapper {
position: relative;
flex: 1 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.dragover {
position: absolute;
inset: 5%;
background: var(--color-primary-element-light);
z-index: 11;
display: flex;
box-shadow: 0 0 36px var(--color-box-shadow);
border-radius: var(--border-radius);
opacity: 90%;
pointer-events: none;
}
.scroll-to-bottom {
position: relative;
height: 0;
&__button {
position: absolute !important;
bottom: 8px;
inset-inline-end: 24px;
z-index: 2;
}
}
</style>
+278
View File
@@ -0,0 +1,278 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div
class="conversation-icon"
:style="{ '--icon-size': `${size}px` }"
:class="[themeClass, { offline: offline }]">
<template v-if="!isOneToOne">
<div
v-if="iconClass"
class="avatar icon"
:class="iconClass" />
<!-- img is used here instead of NcAvatar to explicitly set key required to avoid glitching in virtual scrolling -->
<img
v-else
:key="avatarUrl"
:src="avatarUrl"
:width="size"
:height="size"
:alt="item.displayName"
class="avatar icon"
@error="onError">
<span
v-if="!hideUserStatus && conversationType"
class="conversation-icon__type"
role="img"
aria-hidden="false"
:aria-label="conversationType.label">
<component :is="conversationType.icon" :size="size * 0.3" />
</span>
</template>
<!-- NcAvatar doesn't fully support props update and works only for 1 user -->
<!-- Using key on NcAvatar forces NcAvatar re-mount and solve the problem, could not really optimal -->
<!-- TODO: Check if props update support in NcAvatar is more performant -->
<NcAvatar
v-else
:key="item.token + (isDarkTheme ? '-dark' : '-light')"
:size="size"
:user="item.name"
:disableMenu="disableMenu"
:displayName="item.displayName"
:preloadedUserStatus="preloadedUserStatus"
:hideStatus="hideUserStatus"
:verboseStatus="showUserOnlineStatus"
class="conversation-icon__avatar" />
<div v-if="showCall" class="overlap-icon">
<IconVideo :size="size * 0.5" fillColor="#E9322D" />
<span class="hidden-visually">{{ t('spreed', 'Call in progress') }}</span>
</div>
<div v-else-if="showFavorite" class="overlap-icon">
<IconStar :size="size * 0.5" fillColor="#FFCC00" />
<span class="hidden-visually">{{ t('spreed', 'Favorite') }}</span>
</div>
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import { useIsDarkTheme } from '@nextcloud/vue/composables/useIsDarkTheme'
import { ref } from 'vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import IconLink from 'vue-material-design-icons/Link.vue'
import IconStar from 'vue-material-design-icons/Star.vue' // Filled for better indication
import IconVideo from 'vue-material-design-icons/Video.vue' // Filled for better indication
import IconWeb from 'vue-material-design-icons/Web.vue'
import { AVATAR, CONVERSATION } from '../constants.ts'
import { getConversationAvatarOcsUrl } from '../services/avatarService.ts'
import { hasTalkFeature } from '../services/CapabilitiesManager.ts'
import { getFallbackIconClass } from '../utils/conversation.ts'
import { getPreloadedUserStatus } from '../utils/userStatus.ts'
const supportsAvatar = hasTalkFeature('local', 'avatar')
export default {
name: 'ConversationIcon',
components: {
IconStar,
IconVideo,
NcAvatar,
},
props: {
/**
* Allow to hide the favorite icon, e.g. on mentions
*/
hideFavorite: {
type: Boolean,
default: true,
},
hideCall: {
type: Boolean,
default: true,
},
disableMenu: {
type: Boolean,
default: true,
},
hideUserStatus: {
type: Boolean,
default: false,
},
showUserOnlineStatus: {
type: Boolean,
default: false,
},
item: {
type: Object,
default() {
return {
objectType: '',
type: 0,
displayName: '',
isFavorite: false,
}
},
},
/**
* Reduces the opacity of the icon if true
*/
offline: {
type: Boolean,
default: false,
},
size: {
type: Number,
default: AVATAR.SIZE.DEFAULT,
},
},
setup() {
const isDarkTheme = useIsDarkTheme()
const failed = ref(false)
/**
* If avatar image failed to load, toggle value to provide a fallback
*/
function onError() {
failed.value = true
}
return {
isDarkTheme,
failed,
onError,
}
},
computed: {
showCall() {
return !this.hideCall && this.item.hasCall
},
showFavorite() {
return !this.hideFavorite && this.item.isFavorite
},
preloadedUserStatus() {
if (this.hideUserStatus) {
return undefined
}
return getPreloadedUserStatus(this.item)
},
canRequestAvatar() {
if (!supportsAvatar || this.item.isDummyConversation) {
return false
}
// Endpoint limited with #RequireParticipantOrLoggedInAndListedConversation
return this.item.attendeeId || this.item.listable !== CONVERSATION.LISTABLE.NONE
},
iconClass() {
return getFallbackIconClass(this.item, this.failed || !this.canRequestAvatar)
},
themeClass() {
return `conversation-icon--${this.isDarkTheme ? 'dark' : 'bright'}`
},
isOneToOne() {
return this.item.type === CONVERSATION.TYPE.ONE_TO_ONE
},
conversationType() {
if (this.item.remoteServer) {
return { key: 'federated', icon: IconWeb, label: t('spreed', 'Federated conversation') }
} else if (this.item.type === CONVERSATION.TYPE.PUBLIC) {
return { key: 'public', icon: IconLink, label: t('spreed', 'Public conversation') }
}
return null
},
avatarUrl() {
if (!this.canRequestAvatar) {
return undefined
}
return getConversationAvatarOcsUrl(this.item.token, this.isDarkTheme, this.item.avatarVersion)
},
},
methods: {
t,
},
}
</script>
<style lang="scss" scoped>
.conversation-icon {
width: var(--icon-size);
height: var(--icon-size);
position: relative;
.avatar.icon {
display: block;
width: var(--icon-size);
height: var(--icon-size);
line-height: var(--icon-size);
background-size: calc(var(--icon-size) / 2);
background-color: var(--color-text-maxcontrast-default);
&.icon-changelog {
background-size: cover !important;
}
}
img.avatar.icon {
background-color: transparent;
}
&--dark .avatar.icon {
background-color: #3B3B3B;
}
&__type {
position: absolute;
inset-inline-end: -2px;
bottom: -2px;
display: flex;
align-content: center;
justify-content: center;
height: clamp(10px, 40%, 18px);
width: clamp(10px, 40%, 18px);
border: 1px solid var(--color-main-background);
background-color: var(--color-main-background);
color: var(--color-main-text);
border-radius: 50%;
}
.overlap-icon {
position: absolute;
top: 0;
inset-inline-start: calc(var(--icon-size) * 0.7);
line-height: 100%;
display: inline-block;
vertical-align: middle;
}
}
.offline {
opacity: .4;
}
</style>
@@ -0,0 +1,124 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="conversation-ban__settings">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Banned users') }}
</h4>
<div class="app-settings-section__hint">
{{ t('spreed', 'Manage the list of banned users in this conversation.') }}
</div>
<NcButton @click="modal = true">
{{ t('spreed', 'Manage bans') }}
</NcButton>
<NcDialog
v-model:open="modal"
:name="t('spreed', 'Banned users')"
size="normal"
closeOnClickOutside
container=".conversation-ban__settings">
<div class="conversation-ban__content">
<ul v-if="banList.length" class="conversation-ban__list">
<BannedItem
v-for="ban in banList"
:key="ban.id"
:ban="ban"
@unbanParticipant="handleUnban(ban.id)" />
</ul>
<NcEmptyContent v-else>
<template #icon>
<NcLoadingIcon v-if="isLoading" />
<IconAccountCancelOutline v-else />
</template>
<template #description>
<p>{{ isLoading ? t('spreed', 'Loading …') : t('spreed', 'No banned users') }}</p>
</template>
</NcEmptyContent>
</div>
</NcDialog>
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import IconAccountCancelOutline from 'vue-material-design-icons/AccountCancelOutline.vue'
import BannedItem from './BannedItem.vue'
import { getConversationBans, unbanActor } from '../../../services/banService.ts'
export default {
name: 'BanSettings',
components: {
NcButton,
NcDialog,
NcEmptyContent,
NcLoadingIcon,
BannedItem,
// Icons
IconAccountCancelOutline,
},
props: {
token: {
type: String,
required: true,
},
},
data() {
return {
banList: [],
isLoading: true,
modal: false,
}
},
watch: {
modal(value) {
if (value) {
this.getList()
}
},
},
methods: {
t,
async getList() {
this.isLoading = true
const response = await getConversationBans(this.token)
this.banList = response.data.ocs.data
this.isLoading = false
},
async handleUnban(id) {
await unbanActor(this.token, id)
this.banList = this.banList.filter((ban) => ban.id !== id)
},
},
}
</script>
<style lang="scss" scoped>
.conversation-ban {
&__content {
min-height: 200px;
}
&__list {
overflow: auto;
height: calc(100% - 45px - 12px);
padding: calc(var(--default-grid-baseline) * 2);
}
}
</style>
@@ -0,0 +1,102 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<li :key="ban.id" class="ban-item">
<div class="ban-item__header">
<span class="ban-item__caption">{{ ban.bannedDisplayName }}</span>
<div class="ban-item__buttons">
<NcButton variant="tertiary" @click="showDetails = !showDetails">
{{ showDetails ? t('spreed', 'Hide details') : t('spreed', 'Show details') }}
</NcButton>
<NcButton @click="$emit('unbanParticipant')">
{{ t('spreed', 'Unban') }}
</NcButton>
</div>
</div>
<ul v-if="showDetails" class="ban-item__hint">
<li v-for="(item, index) in banInfo" :key="index">
<strong>{{ item.label }}</strong>
<span>{{ item.value }}</span>
</li>
</ul>
</li>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import { formatDateTime } from '../../../utils/formattedTime.ts'
export default {
name: 'BannedItem',
components: {
NcButton,
},
props: {
ban: {
type: Object,
required: true,
},
},
emits: ['unbanParticipant'],
data() {
return {
showDetails: false,
}
},
computed: {
banInfo() {
return [
// TRANSLATORS name of a moderator who banned a participant
{ label: t('spreed', 'Banned by:'), value: this.ban.moderatorDisplayName },
// TRANSLATORS Date and time of ban creation
{ label: t('spreed', 'Date:'), value: formatDateTime(this.ban.bannedTime * 1000, 'shortDateWithTime') },
// TRANSLATORS Internal note for moderators, usually a reason for this ban
{ label: t('spreed', 'Note:'), value: this.ban.internalNote },
]
},
},
methods: {
t,
},
}
</script>
<style lang="scss" scoped>
.ban-item {
padding: 4px 0;
&:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
}
&__caption {
font-weight: bold;
}
&__hint {
word-wrap: break-word;
color: var(--color-text-maxcontrast);
margin-bottom: 4px;
}
&__buttons {
display: flex;
}
}
</style>
@@ -0,0 +1,170 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-if="isCalendarEnabled && canFullModerate && isEventConversation" class="app-settings-section__hint" v-html="calendarHint" />
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Name') }}
</h4>
<EditableTextField
:editable="canFullModerate && !isEventConversation"
:initialText="conversationName"
:editing="isEditingName"
:loading="isNameLoading"
:placeholder="t('spreed', 'Enter a name for this conversation')"
:edit-button-aria-label="t('spreed', 'Edit conversation name')"
:maxLength="CONVERSATION.MAX_NAME_LENGTH"
@submitText="handleUpdateName"
@update:editing="handleEditName" />
<template v-if="!isOneToOne">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Description') }}
</h4>
<EditableTextField
:editable="canFullModerate && !isEventConversation"
:initialText="description"
:editing="isEditingDescription"
:loading="isDescriptionLoading"
:edit-button-aria-label="t('spreed', 'Edit conversation description')"
:placeholder="t('spreed', 'Enter a description for this conversation')"
:maxLength="maxDescriptionLength"
multiline
useMarkdown
@submitText="handleUpdateDescription"
@update:editing="handleEditDescription" />
</template>
<template v-if="supportsAvatar">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Picture') }}
</h4>
<ConversationAvatarEditor
:conversation="conversation"
:editable="canFullModerate" />
</template>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import EditableTextField from '../UIShared/EditableTextField.vue'
import ConversationAvatarEditor from './ConversationAvatarEditor.vue'
import { CONVERSATION } from '../../constants.ts'
import { getTalkConfig, hasTalkFeature, localCapabilities } from '../../services/CapabilitiesManager.ts'
const isCalendarEnabled = localCapabilities.calendar?.webui ?? false
const supportsAvatar = hasTalkFeature('local', 'avatar')
const maxDescriptionLength = getTalkConfig('local', 'conversations', 'description-length') || 500
export default {
name: 'BasicInfo',
components: {
EditableTextField,
ConversationAvatarEditor,
},
props: {
conversation: {
type: Object,
required: true,
},
canFullModerate: {
type: Boolean,
required: true,
},
},
setup() {
return {
isCalendarEnabled,
supportsAvatar,
CONVERSATION,
maxDescriptionLength,
}
},
data() {
return {
isEditingDescription: false,
isDescriptionLoading: false,
isEditingName: false,
isNameLoading: false,
}
},
computed: {
isOneToOne() {
return this.conversation.type === CONVERSATION.TYPE.ONE_TO_ONE
|| this.conversation.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER
},
conversationName() {
return this.conversation.displayName
},
description() {
return this.conversation.description
},
token() {
return this.conversation.token
},
calendarHint() {
return t('spreed', 'You can change the title and the description in {linkstart}Calendar ↗{linkend}.')
.replace('{linkstart}', `<a target="_blank" rel="noreferrer nofollow" class="external" href="${generateUrl('apps/calendar')}">`)
.replace('{linkend}', '</a>')
},
isEventConversation() {
return this.conversation.objectType === CONVERSATION.OBJECT_TYPE.EVENT
},
},
methods: {
t,
async handleUpdateName(name) {
this.isNameLoading = true
try {
await this.$store.dispatch('setConversationName', {
token: this.token,
name,
})
this.isEditingName = false
} catch (error) {
console.error('Error while setting conversation name', error)
showError(t('spreed', 'Error while updating conversation name'))
}
this.isNameLoading = false
},
handleEditName(payload) {
this.isEditingName = payload
},
async handleUpdateDescription(description) {
this.isDescriptionLoading = true
try {
await this.$store.dispatch('setConversationDescription', {
token: this.token,
description,
})
this.isEditingDescription = false
} catch (error) {
console.error('Error while setting conversation description', error)
showError(t('spreed', 'Error while updating conversation description'))
}
this.isDescriptionLoading = false
},
handleEditDescription(payload) {
this.isEditingDescription = payload
},
},
}
</script>
@@ -0,0 +1,193 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="bots-settings">
<p class="bots-settings__hint">
{{ botsSettingsDescription }}
</p>
<ul v-if="bots.length">
<li
v-for="bot in bots"
:key="bot.id"
class="bots-settings__item">
<div class="bots-settings__item-info">
<span class="bots-settings__item-name">
{{ bot.name }}
</span>
<span class="bots-settings__item-description">
{{ bot.description ?? t('spreed', 'Description is not provided') }}
</span>
<NcNoteCard v-if="isBotUnavailable(bot)" type="warning">
<template #icon>
<IconCancel :size="20" />
</template>
{{ t('spreed', 'The bot is not available anymore') }}
</NcNoteCard>
</div>
<div v-if="isLoading[bot.id]" class="bots-settings__item-loader icon icon-loading-small" />
<NcButton
class="bots-settings__item-button"
:variant="buttonType(bot)"
:disabled="isBotLocked(bot) || isLoading[bot.id]"
@click="toggleBotState(bot)">
{{ toggleButtonTitle(bot) }}
</NcButton>
</li>
</ul>
</div>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import IconCancel from 'vue-material-design-icons/Cancel.vue'
import { BOT } from '../../constants.ts'
import { useBotsStore } from '../../stores/bots.ts'
export default {
name: 'BotsSettings',
components: {
NcButton,
NcNoteCard,
IconCancel,
},
props: {
/**
* The conversation's token
*/
token: {
type: String,
required: true,
},
},
setup() {
const botsStore = useBotsStore()
return {
botsStore,
}
},
data() {
return {
isLoading: {},
}
},
computed: {
bots() {
return this.botsStore.getConversationBots(this.token)
},
botsSettingsDescription() {
return this.bots.length
? t('spreed', 'The following bots can be enabled in this conversation. Reach out to your administration to get more bots installed on this server.')
: t('spreed', 'No bots are installed on this server. Reach out to your administration to get bots installed on this server.')
},
},
async created() {
(await this.botsStore.loadConversationBots(this.token)).forEach((id) => {
this.isLoading[id] = false
})
},
methods: {
t,
isBotLocked(bot) {
return bot.state === BOT.STATE.NO_SETUP
},
isBotUnavailable(bot) {
return bot.state === BOT.STATE.UNAVAILABLE
},
async toggleBotState(bot) {
if (this.isBotLocked(bot)) {
return
}
this.isLoading[bot.id] = true
await this.botsStore.toggleBotState(this.token, bot)
this.isLoading[bot.id] = false
},
buttonType(bot) {
if (this.isBotUnavailable(bot)) {
return 'warning'
}
return bot.state === BOT.STATE.ENABLED ? 'primary' : 'secondary'
},
toggleButtonTitle(bot) {
if (this.isBotUnavailable(bot)) {
return t('spreed', 'Disable')
}
if (this.isBotLocked(bot)) {
return t('spreed', 'Enabled')
}
return bot.state === BOT.STATE.ENABLED ? t('spreed', 'Disable') : t('spreed', 'Enable')
},
},
}
</script>
<style lang="scss" scoped>
.bots-settings {
&__hint {
margin-bottom: calc(var(--default-grid-baseline) * 4);
color: var(--color-text-maxcontrast);
}
&__item {
display: flex;
justify-content: space-between;
align-items: flex-start;
&:not(:last-child) {
margin-bottom: calc(var(--default-grid-baseline) * 4);
}
&-info {
display: flex;
flex-direction: column;
max-width: 80%;
}
&-name {
font-size: var(--default-font-size);
font-weight: bold;
color: var(--color-main-text);
}
&-description {
font-size: var(--default-font-size);
color: var(--color-text-maxcontrast);
}
&-loader {
width: var(--default-clickable-area);
height: var(--default-clickable-area);
display: flex;
justify-content: center;
align-items: center;
margin-inline-start: auto;
}
&-button {
flex-shrink: 0;
}
}
}
</style>
@@ -0,0 +1,147 @@
<!--
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="breakout-rooms-settings">
<p class="breakout-rooms-settings__hint">
{{ hintText }}
</p>
<NcButton
v-if="hasBreakoutRooms"
variant="secondary"
@click="showParticipantsEditor = true">
<template #icon>
<IconDotsCircle :size="20" />
</template>
{{ t('spreed', 'Manage breakout rooms') }}
</NcButton>
<NcButton
v-else
variant="secondary"
@click="openBreakoutRoomsEditor">
<template #icon>
<IconDotsCircle :size="20" />
</template>
{{ t('spreed', 'Set up breakout rooms for this conversation') }}
</NcButton>
</div>
<!-- Breakout rooms editor -->
<BreakoutRoomsEditor
v-if="showBreakoutRoomsEditor"
container=".breakout-rooms-settings"
:token="token"
@close="showBreakoutRoomsEditor = false" />
<!-- Participants editor -->
<NcModal
v-if="showParticipantsEditor"
container=".breakout-rooms-settings"
labelId="breakout-rooms-settings-editor"
@close="showParticipantsEditor = false">
<div class="breakout-rooms-settings__editor">
<h2 id="breakout-rooms-settings-editor" class="nc-dialog-alike-header">
{{ t('spreed', 'Manage breakout rooms') }}
</h2>
<BreakoutRoomsParticipantsEditor
:token="token"
:breakoutRooms="breakoutRooms"
@close="showParticipantsEditor = false" />
</div>
</NcModal>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcModal from '@nextcloud/vue/components/NcModal'
import IconDotsCircle from 'vue-material-design-icons/DotsCircle.vue'
import BreakoutRoomsEditor from '../BreakoutRoomsEditor/BreakoutRoomsEditor.vue'
import BreakoutRoomsParticipantsEditor from '../BreakoutRoomsEditor/BreakoutRoomsParticipantsEditor.vue'
import { CONVERSATION } from '../../constants.ts'
import { useBreakoutRoomsStore } from '../../stores/breakoutRooms.ts'
export default {
name: 'BreakoutRoomsSettings',
components: {
NcButton,
NcModal,
BreakoutRoomsEditor,
BreakoutRoomsParticipantsEditor,
IconDotsCircle,
},
props: {
/**
* The conversation's token
*/
token: {
type: String,
required: true,
},
},
setup() {
const breakoutRoomsStore = useBreakoutRoomsStore()
return {
breakoutRoomsStore,
}
},
data() {
return {
showBreakoutRoomsEditor: false,
showParticipantsEditor: false,
}
},
computed: {
hintText() {
return t('spreed', 'Breakout rooms') // FIXME
},
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
hasBreakoutRooms() {
return this.conversation.breakoutRoomMode !== CONVERSATION.BREAKOUT_ROOM_MODE.NOT_CONFIGURED
},
breakoutRooms() {
return this.breakoutRoomsStore.breakoutRooms(this.token)
},
},
created() {
if (this.hasBreakoutRooms) {
this.breakoutRoomsStore.fetchBreakoutRoomsParticipants(this.token)
}
},
methods: {
t,
openBreakoutRoomsEditor() {
this.showBreakoutRoomsEditor = true
},
},
}
</script>
<style lang="scss" scoped>
.breakout-rooms-settings {
&__hint {
margin-bottom: calc(var(--default-grid-baseline) * 2);
color: var(--color-text-maxcontrast);
}
&__editor {
height: 100%;
padding: 20px;
}
}
</style>
@@ -0,0 +1,447 @@
<!--
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<section id="vue-avatar-section">
<div class="avatar__container">
<div v-if="!showCropper" class="avatar__preview">
<div
v-if="emojiAvatar"
class="avatar__preview-emoji"
:class="themeClass"
:style="{ 'background-color': backgroundColor }">
{{ emojiAvatar }}
</div>
<ConversationIcon
v-else-if="!loading"
:item="conversation"
:size="AVATAR.SIZE.EXTRA_LARGE"
hideUserStatus />
<div v-else class="icon-loading" />
</div>
<VueCropper
v-show="showCropper"
ref="cropper"
class="avatar__cropper"
v-bind="cropperOptions" />
<div v-if="editable" class="avatar__controls">
<div class="avatar__buttons">
<!-- Set emoji as avatar -->
<template v-if="!showCropper">
<NcEmojiPicker :perLine="5" container="#vue-avatar-section" @select="setEmoji">
<NcButton
:title="t('spreed', 'Set emoji as conversation picture')"
:aria-label="t('spreed', 'Set emoji as conversation picture')">
<template #icon>
<IconEmoticonOutline :size="20" />
</template>
</NcButton>
</NcEmojiPicker>
<NcColorPicker
v-if="emojiAvatar"
v-model="backgroundColor"
advancedFields
container="#vue-avatar-section">
<NcButton
:title="t('spreed', 'Set background color for conversation picture')"
:aria-label="t('spreed', 'Set background color for conversation picture')">
<template #icon>
<IconPaletteOutline :size="20" />
</template>
</NcButton>
</NcColorPicker>
</template>
<!-- Set picture as avatar -->
<NcButton
:title="t('spreed', 'Upload conversation picture')"
:aria-label="t('spreed', 'Upload conversation picture')"
@click="activateLocalFilePicker">
<template #icon>
<NcIconSvgWrapper :svg="IconFileUpload" :size="20" />
</template>
</NcButton>
<NcButton
:title="t('spreed', 'Choose conversation picture from files')"
:aria-label="t('spreed', 'Choose conversation picture from files')"
@click="showFilePicker">
<template #icon>
<IconFolder :size="20" />
</template>
</NcButton>
<!-- Remove existing avatar -->
<NcButton
v-if="hasAvatar"
:title="t('spreed', 'Remove conversation picture')"
:aria-label="t('spreed', 'Remove conversation picture')"
@click="removeAvatar">
<template #icon>
<IconTrashCanOutline :size="20" />
</template>
</NcButton>
</div>
<span class="avatar__warning">
{{ t('spreed', 'The file must be a PNG or JPG') }}
</span>
<input
:id="inputId"
ref="input"
type="file"
:accept="validMimeTypes.join(',')"
@change="onChange">
<div v-if="showControls" class="avatar__buttons">
<NcButton @click="cancel">
{{ t('spreed', 'Cancel') }}
</NcButton>
<NcButton
v-if="!controlled"
variant="primary"
@click="saveAvatar">
{{ t('spreed', 'Set picture') }}
</NcButton>
</div>
</div>
</div>
</section>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { getFilePickerBuilder } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { useIsDarkTheme } from '@nextcloud/vue/composables/useIsDarkTheme'
import VueCropper from 'vue-cropperjs'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcColorPicker from '@nextcloud/vue/components/NcColorPicker'
import NcEmojiPicker from '@nextcloud/vue/components/NcEmojiPicker'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import IconEmoticonOutline from 'vue-material-design-icons/EmoticonOutline.vue'
import IconFolder from 'vue-material-design-icons/Folder.vue' // Filled as in Files app icon
import IconPaletteOutline from 'vue-material-design-icons/PaletteOutline.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import ConversationIcon from '../ConversationIcon.vue'
import IconFileUpload from '../../../img/material-icons/file-upload.svg?raw'
import { AVATAR } from '../../constants.ts'
import 'cropperjs/dist/cropper.css'
const validMimeTypes = ['image/png', 'image/jpeg']
export default {
name: 'ConversationAvatarEditor',
components: {
ConversationIcon,
NcButton,
NcColorPicker,
NcEmojiPicker,
NcIconSvgWrapper,
VueCropper,
// Icons
IconTrashCanOutline,
IconEmoticonOutline,
IconFolder,
IconPaletteOutline,
},
props: {
conversation: {
type: Object,
required: true,
},
/**
* Shows or hides the editing buttons.
*/
editable: {
type: Boolean,
default: false,
},
/**
* Force component to emit signals and be used from parent components
*/
controlled: {
type: Boolean,
default: false,
},
},
emits: ['avatarEdited'],
expose: ['saveAvatar', 'getPictureFormData', 'emojiAvatar', 'backgroundColor'],
setup() {
const isDarkTheme = useIsDarkTheme()
return {
IconFileUpload,
isDarkTheme,
AVATAR,
validMimeTypes,
}
},
data() {
return {
showCropper: false,
loading: false,
cropperOptions: {
aspectRatio: 1,
viewMode: 1,
guides: false,
center: false,
highlight: false,
autoCropArea: 1,
minContainerWidth: 300,
minContainerHeight: 300,
},
backgroundColor: '',
emojiAvatar: '',
}
},
computed: {
inputId() {
return `account-property-${this.conversation.displayName}`
},
hasAvatar() {
return this.conversation.isCustomAvatar
},
themeClass() {
return `avatar__preview-emoji--${this.isDarkTheme ? 'dark' : 'bright'}`
},
showControls() {
return this.editable && (this.showCropper || this.emojiAvatar)
},
},
watch: {
showCropper(value) {
if (this.controlled) {
this.$emit('avatarEdited', value)
}
},
emojiAvatar(value) {
if (this.controlled) {
this.$emit('avatarEdited', !!value)
}
},
},
methods: {
t,
activateLocalFilePicker() {
// Set to null so that selecting the same file will trigger the change event
this.$refs.input.value = null
this.$refs.input.click()
},
onChange(e) {
this.loading = true
const file = e.target.files[0]
if (!this.validMimeTypes.includes(file.type)) {
showError(t('spreed', 'Please select a valid PNG or JPG file'))
this.cancel()
return
}
const reader = new FileReader()
reader.onload = (e) => {
this.$refs.cropper.replace(e.target.result)
this.showCropper = true
}
reader.readAsDataURL(file)
},
async showFilePicker() {
const filePicker = getFilePickerBuilder(t('spreed', 'Choose your conversation picture'))
.setContainer('#vue-avatar-section')
.setMultiSelect(false)
.addMimeTypeFilter('image/png')
.addMimeTypeFilter('image/jpeg') // FIXME upstream: pass as array
.addButton({
label: t('spreed', 'Choose'),
callback: (nodes) => this.handleFileChoose(nodes),
variant: 'primary',
})
.build()
await filePicker.pickNodes()
},
async handleFileChoose(nodes) {
const fileid = nodes[0]?.fileid
if (!fileid) {
return
}
try {
const tempAvatar = generateUrl(`/core/preview?fileId=${fileid}&x=512&y=512&a=1`)
this.$refs.cropper.replace(tempAvatar)
this.showCropper = true
} catch (e) {
showError(t('spreed', 'Error setting conversation picture'))
this.cancel()
}
},
setEmoji(emoji) {
this.emojiAvatar = emoji
},
async saveAvatar() {
this.loading = true
try {
if (this.emojiAvatar) {
await this.saveEmojiAvatar()
} else {
await this.savePictureAvatar()
}
} catch (error) {
showError(t('spreed', 'Could not set the conversation picture: {error}', { error: error.message }))
this.cancel()
} finally {
this.loading = false
}
},
async saveEmojiAvatar() {
await this.$store.dispatch('setConversationEmojiAvatarAction', {
token: this.conversation.token,
emoji: this.emojiAvatar,
color: this.backgroundColor ? this.backgroundColor.slice(1) : null,
})
this.emojiAvatar = ''
this.backgroundColor = ''
},
async getPictureFormData() {
const canvasData = this.$refs.cropper.getCroppedCanvas()
const scaleFactor = canvasData.width > 512 ? 512 / canvasData.width : 1
const blob = await new Promise((resolve, reject) => {
this.$refs.cropper.scale(scaleFactor, scaleFactor).getCroppedCanvas()
.toBlob((blob) => blob === null
? reject(new Error(t('spreed', 'Error cropping conversation picture')))
: resolve(blob))
})
const formData = new FormData()
formData.append('file', blob)
return formData
},
async savePictureAvatar() {
this.showCropper = false
const file = await this.getPictureFormData()
await this.$store.dispatch('setConversationAvatarAction', {
token: this.conversation.token,
file,
})
},
async removeAvatar() {
this.loading = true
try {
await this.$store.dispatch('deleteConversationAvatarAction', {
token: this.conversation.token,
})
} catch (e) {
showError(t('spreed', 'Error removing conversation picture'))
} finally {
this.loading = false
}
},
cancel() {
this.showCropper = false
this.loading = false
this.emojiAvatar = ''
this.backgroundColor = ''
},
},
}
</script>
<style lang="scss" scoped>
section {
grid-row: 1/3;
}
.avatar {
&__container {
display: flex;
flex-flow: row wrap;
justify-content: center;
align-items: flex-start;
gap: 16px;
}
&__controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
&__warning {
color: var(--color-text-maxcontrast);
}
&__preview {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: 300px;
height: 180px;
padding: 0 60px;
&-emoji {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
padding-bottom: 6px;
border-radius: 100%;
background-color: var(--color-text-maxcontrast);
font-size: 575%;
line-height: 100%;
&--dark {
background-color: #3B3B3B;
}
}
}
&__buttons {
display: flex;
gap: 10px;
}
&__cropper {
width: 300px;
height: 300px;
overflow: hidden;
&:deep(.cropper-view-box) {
border-radius: 50%;
}
}
}
input[type="file"] {
display: none;
}
</style>
@@ -0,0 +1,266 @@
<!--
- SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="conversation-permissions-editor">
<div class="app-settings-section__hint">
{{ t('spreed', 'Edit the default permissions for participants in this conversation. These settings do not affect moderators.') }}
</div>
<NcNoteCard
type="warning"
:text="t('spreed', 'Every time permissions are modified in this section, custom permissions previously assigned to individual participants will be lost.')" />
<!-- All permissions -->
<div class="conversation-permissions-editor__setting">
<NcCheckboxRadioSwitch
v-model="radioValue"
:disabled="loading"
value="all"
name="permission_radio"
type="radio"
@update:modelValue="handleSubmitPermissions">
{{ t('spreed', 'All permissions') }}
</NcCheckboxRadioSwitch>
<span v-show="loading && radioValue === 'all'" class="icon-loading-small" />
</div>
<p class="conversation-permissions-editor__hint">
{{ t('spreed', 'Participants have permissions to start a call, join a call, enable audio and video, and share screen.') }}
</p>
<!-- No permissions -->
<div class="conversation-permissions-editor__setting">
<NcCheckboxRadioSwitch
v-model="radioValue"
value="restricted"
:disabled="loading"
name="permission_radio"
type="radio"
@update:modelValue="handleSubmitPermissions">
{{ t('spreed', 'Restricted') }}
</NcCheckboxRadioSwitch>
<span v-show="loading && radioValue === 'restricted'" class="icon-loading-small" />
</div>
<p class="conversation-permissions-editor__hint">
{{ t('spreed', 'Participants can join calls, but cannot enable audio nor video nor share screen until a moderator manually grants them permissions.') }}
</p>
<!-- Advanced permissions -->
<div class="conversation-permissions-editor__setting--advanced">
<NcCheckboxRadioSwitch
v-model="radioValue"
value="advanced"
:disabled="loading"
name="permission_radio"
type="radio"
@update:modelValue="showPermissionsEditor = true">
{{ t('spreed', 'Advanced permissions') }}
</NcCheckboxRadioSwitch>
<!-- Edit advanced permissions -->
<NcButton
v-show="showEditButton"
class="edit-button"
variant="tertiary"
:aria-label="t('spreed', 'Edit permissions')"
@click="showPermissionsEditor = true">
<template #icon>
<IconPencilOutline :size="20" />
</template>
</NcButton>
</div>
<PermissionEditor
v-if="showPermissionsEditor"
:conversationName="conversationName"
:permissions="conversationPermissions"
:loading="loading"
nestedContainer=".conversation-permissions-editor"
@close="handleClosePermissionsEditor"
@submit="handleSubmitPermissions" />
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import IconPencilOutline from 'vue-material-design-icons/PencilOutline.vue'
import PermissionEditor from '../PermissionsEditor/PermissionsEditor.vue'
import { PARTICIPANT } from '../../constants.ts'
const PERMISSIONS = PARTICIPANT.PERMISSIONS
export default {
name: 'ConversationPermissionsSettings',
components: {
PermissionEditor,
NcButton,
NcCheckboxRadioSwitch,
NcNoteCard,
IconPencilOutline,
},
props: {
token: {
type: String,
default: null,
},
},
data() {
return {
showPermissionsEditor: false,
isEditingPermissions: false,
loading: false,
radioValue: '',
}
},
computed: {
/**
* The participant's name.
*/
conversationName() {
return this.$store.getters.conversation(this.token).name
},
/**
* The current conversation permissions.
*/
conversationPermissions() {
return this.$store.getters.conversation(this.token).defaultPermissions
},
/**
* Hides and shows the edit button for advanced permissions.
*/
showEditButton() {
return this.radioValue === 'advanced' && !this.showPermissionsEditor
},
},
mounted() {
/**
* Set the initial radio value.
*/
this.setCurrentRadioValue()
},
methods: {
t,
/**
* Binary sum all the permissions and make the request to change them.
*
* @param {string | number} value - The permissions value, which is a
* string (e.g. 'restricted' or 'all') unless this method is called by
* the click event emitted by the `permissionsEditor` component, in
* which case it's a number indicating the permissions value.
*/
async handleSubmitPermissions(value) {
let permissions
// Compute the permissions value
switch (value) {
case 'all':
permissions = PERMISSIONS.MAX_DEFAULT
break
case 'restricted':
permissions = PERMISSIONS.CALL_JOIN
break
default:
permissions = value
}
this.loading = true
// Make the store call
try {
await this.$store.dispatch('setConversationPermissions', {
token: this.token,
permissions,
})
showSuccess(t('spreed', 'Default permissions modified for {conversationName}', { conversationName: this.conversationName }, { escape: false, sanitize: false }))
// Modify the radio buttons value
this.radioValue = this.getPermissionRadioValue(permissions)
this.showPermissionsEditor = false
} catch (error) {
console.debug(error)
showError(t('spreed', 'Could not modify default permissions for {conversationName}', { conversationName: this.conversationName }, { escape: false, sanitize: false }))
// Go back to the previous radio value
this.radioValue = this.getPermissionRadioValue(this.conversationPermissions)
} finally {
this.loading = false
}
},
/**
* Get the radio button string value given a permission number.
*
* @param {number} value - The permissions value.
*/
getPermissionRadioValue(value) {
switch (value) {
case PERMISSIONS.MAX_DEFAULT:
case PERMISSIONS.MAX_CUSTOM:
return 'all'
case PERMISSIONS.CALL_JOIN:
case PERMISSIONS.CALL_JOIN | PERMISSIONS.CUSTOM:
return 'restricted'
default:
return 'advanced'
}
},
/**
* Set the radio value that corresponds to the current default
* permissions in the store.
*/
setCurrentRadioValue() {
this.radioValue = this.getPermissionRadioValue(this.conversationPermissions)
},
/**
* Hides the modal and resets conversation permissions to the previous
* value.
*/
handleClosePermissionsEditor() {
this.showPermissionsEditor = false
this.setCurrentRadioValue()
},
},
}
</script>
<style lang="scss" scoped>
:deep(.mx-input) {
margin: 0;
}
.conversation-permissions-editor {
&__setting {
display: flex;
justify-content: space-between;
&--advanced {
display: flex;
justify-content: flex-start;
}
}
}
.edit-button {
margin-inline-start: 16px;
}
.conversation-permissions-editor__hint {
color: var(--color-text-maxcontrast);
margin-bottom: 16px;
}
</style>
@@ -0,0 +1,341 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcAppSettingsDialog
id="conversation-settings-container"
:aria-label="t('spreed', 'Conversation settings')"
:name="t('spreed', 'Conversation settings')"
:open="showSettings"
showNavigation
legacy
noVersion
@update:open="handleHideSettings">
<NcAppSettingsSection
id="basic-info"
:name="t('spreed', 'Basic Info')">
<BasicInfo
:conversation="conversation"
:canFullModerate="canFullModerate" />
</NcAppSettingsSection>
<template v-if="!isBreakoutRoom">
<!-- Notifications settings and devices preview screen -->
<NcAppSettingsSection
v-if="!isNoteToSelf && !isOneToOneFormer"
id="notifications"
:name="t('spreed', 'Personal')">
<NotificationsSettings v-if="!isGuest" :conversation="conversation" />
</NcAppSettingsSection>
<NcAppSettingsSection
id="conversation-settings"
:name="selfIsOwnerOrModerator ? t('spreed', 'Moderation') : t('spreed', 'Setup overview')">
<ListableSettings v-if="!isNoteToSelf && !isGuest && !isOneToOne" :token="token" :canModerate="canFullModerate" />
<MentionsSettings v-if="!isNoteToSelf && !isOneToOne" :token="token" :canModerate="canFullModerate" />
<LinkShareSettings v-if="!isNoteToSelf" :token="token" :canModerate="canFullModerate" />
<RecordingConsentSettings v-if="!isNoteToSelf && !isOneToOneFormer && recordingConsentAvailable" :token="token" :canModerate="selfIsOwnerOrModerator" />
<ExpirationSettings v-if="!isOneToOneFormer && hasMessageExpirationFeature" :token="token" :canModerate="selfIsOwnerOrModerator" />
<BanSettings v-if="supportBanV1 && canFullModerate" :token="token" />
</NcAppSettingsSection>
<!-- Meeting: lobby and sip -->
<NcAppSettingsSection
v-if="canFullModerate && !isNoteToSelf"
id="meeting"
:name="meetingHeader">
<LobbySettings :token="token" />
<SipSettings v-if="canUserEnableSIP" />
</NcAppSettingsSection>
<!-- Conversation permissions -->
<NcAppSettingsSection
v-if="canFullModerate && !isNoteToSelf"
id="permissions"
:name="t('spreed', 'Permissions')">
<ConversationPermissionsSettings :token="token" />
</NcAppSettingsSection>
<!-- Live transcription -->
<NcAppSettingsSection
v-if="canConfigureLiveTranscription"
id="live-transcription"
:name="t('spreed', 'Live transcription')">
<LiveTranscriptionSettings :token="token" />
</NcAppSettingsSection>
<!-- Breakout rooms -->
<NcAppSettingsSection
v-if="canConfigureBreakoutRooms"
id="breakout-rooms"
:name="t('spreed', 'Breakout Rooms')">
<BreakoutRoomsSettings :token="token" />
</NcAppSettingsSection>
<!-- Matterbridge settings -->
<NcAppSettingsSection
v-if="canFullModerate && matterbridgeEnabled"
id="matterbridge"
:name="t('spreed', 'Matterbridge')">
<MatterbridgeSettings />
</NcAppSettingsSection>
<!-- Bots settings -->
<NcAppSettingsSection
v-if="selfIsOwnerOrModerator && supportBotsV1"
id="bots"
:name="t('spreed', 'Bots')">
<BotsSettings :token="token" />
</NcAppSettingsSection>
<!-- Destructive actions -->
<NcAppSettingsSection
v-if="canLeaveConversation || canDeleteConversation"
id="dangerzone"
:name="t('spreed', 'Danger zone')">
<LockingSettings v-if="canFullModerate && !isNoteToSelf" :token="token" />
<template v-if="supportsArchive">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Archive conversation') }}
</h4>
<p class="app-settings-section__hint">
{{ t('spreed', 'Archived conversations are hidden from the conversation list by default. However, they will still appear when you search for the conversation name or access a list of archived conversations.') }}
</p>
<NcCheckboxRadioSwitch
type="switch"
:modelValue="isArchived"
@update:modelValue="toggleArchiveConversation">
{{ t('spreed', 'Archive conversation') }}
</NcCheckboxRadioSwitch>
</template>
<DangerZone
:conversation="conversation"
:canLeaveConversation="canLeaveConversation"
:canDeleteConversation="canDeleteConversation" />
</NcAppSettingsSection>
</template>
</NcAppSettingsDialog>
</template>
<script>
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { ref } from 'vue'
import NcAppSettingsDialog from '@nextcloud/vue/components/NcAppSettingsDialog'
import NcAppSettingsSection from '@nextcloud/vue/components/NcAppSettingsSection'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import BanSettings from './BanSettings/BanSettings.vue'
import BasicInfo from './BasicInfo.vue'
import BotsSettings from './BotsSettings.vue'
import BreakoutRoomsSettings from './BreakoutRoomsSettings.vue'
import ConversationPermissionsSettings from './ConversationPermissionsSettings.vue'
import DangerZone from './DangerZone.vue'
import ExpirationSettings from './ExpirationSettings.vue'
import LinkShareSettings from './LinkShareSettings.vue'
import ListableSettings from './ListableSettings.vue'
import LiveTranscriptionSettings from './LiveTranscriptionSettings.vue'
import LobbySettings from './LobbySettings.vue'
import LockingSettings from './LockingSettings.vue'
import MatterbridgeSettings from './Matterbridge/MatterbridgeSettings.vue'
import MentionsSettings from './MentionsSettings.vue'
import NotificationsSettings from './NotificationsSettings.vue'
import RecordingConsentSettings from './RecordingConsentSettings.vue'
import SipSettings from './SipSettings.vue'
import { CALL, CONFIG, CONVERSATION, PARTICIPANT } from '../../constants.ts'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { useActorStore } from '../../stores/actor.ts'
const matterbridgeEnabled = loadState('spreed', 'enable_matterbridge')
const supportsArchive = hasTalkFeature('local', 'archived-conversations-v2')
export default {
name: 'ConversationSettingsDialog',
components: {
BanSettings,
BasicInfo,
BotsSettings,
BreakoutRoomsSettings,
ConversationPermissionsSettings,
DangerZone,
ExpirationSettings,
LinkShareSettings,
ListableSettings,
LiveTranscriptionSettings,
LobbySettings,
LockingSettings,
MatterbridgeSettings,
MentionsSettings,
NcAppSettingsDialog,
NcAppSettingsSection,
NcCheckboxRadioSwitch,
NotificationsSettings,
RecordingConsentSettings,
SipSettings,
},
setup() {
const token = ref('')
const meetingHeader = t('spreed', 'Meeting') // TRANSLATORS: Section header for meeting-related settings; also a static name fallback for instant meeting conversation
return {
matterbridgeEnabled,
supportsArchive,
token,
meetingHeader,
actorStore: useActorStore(),
}
},
computed: {
canUserEnableSIP() {
return this.conversation.canEnableSIP
},
isNoteToSelf() {
return this.conversation.type === CONVERSATION.TYPE.NOTE_TO_SELF
},
isOneToOne() {
return this.conversation.type === CONVERSATION.TYPE.ONE_TO_ONE || this.isOneToOneFormer
},
isOneToOneFormer() {
return this.conversation.type === CONVERSATION.TYPE.ONE_TO_ONE_FORMER
},
isGuest() {
return this.actorStore.isActorGuest
},
showSettings() {
return this.token !== ''
},
supportBanV1() {
return hasTalkFeature(this.token, 'ban-v1')
},
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
isArchived() {
return this.conversation.isArchived
},
participantType() {
return this.conversation.participantType
},
selfIsOwnerOrModerator() {
return (this.participantType === PARTICIPANT.TYPE.OWNER || this.participantType === PARTICIPANT.TYPE.MODERATOR)
},
canFullModerate() {
return this.selfIsOwnerOrModerator
&& (this.conversation.type === CONVERSATION.TYPE.GROUP
|| this.conversation.type === CONVERSATION.TYPE.PUBLIC)
},
canDeleteConversation() {
return this.conversation.canDeleteConversation
},
canLeaveConversation() {
return this.conversation.canLeaveConversation
},
isBreakoutRoom() {
return this.conversation.objectType === CONVERSATION.OBJECT_TYPE.BREAKOUT_ROOM
},
supportBotsV1() {
return hasTalkFeature(this.token, 'bots-v1')
},
isLiveTranscriptionSupported() {
return getTalkConfig(this.token, 'call', 'live-transcription') || false
},
canConfigureLiveTranscription() {
return this.isLiveTranscriptionSupported
&& this.selfIsOwnerOrModerator
},
canConfigureBreakoutRooms() {
return this.canFullModerate
&& (getTalkConfig(this.token, 'call', 'breakout-rooms') || false)
&& this.conversation.type === CONVERSATION.TYPE.GROUP
},
recordingConsentAvailable() {
return (getTalkConfig(this.token, 'call', 'recording') || false)
&& hasTalkFeature(this.token, 'recording-consent')
&& getTalkConfig(this.token, 'call', 'recording-consent') !== CONFIG.RECORDING_CONSENT.OFF
},
recordingConsentRequired() {
return this.conversation.recordingConsent === CALL.RECORDING_CONSENT.ENABLED
},
hasMessageExpirationFeature() {
return hasTalkFeature(this.token, 'message-expiration')
},
},
beforeMount() {
subscribe('show-conversation-settings', this.handleShowSettings)
subscribe('hide-conversation-settings', this.handleHideSettings)
},
beforeUnmount() {
unsubscribe('show-conversation-settings', this.handleShowSettings)
unsubscribe('hide-conversation-settings', this.handleHideSettings)
},
methods: {
t,
/**
* Opens ConversationSettingsDialog
*
* @param payload event payload
* @param payload.token conversation token
*/
handleShowSettings(payload) {
this.token = payload.token
},
handleHideSettings() {
this.token = ''
},
async toggleArchiveConversation() {
await this.$store.dispatch('toggleArchive', this.conversation)
},
},
}
</script>
<style lang="scss" scoped>
:deep(.app-settings-section__hint) {
color: var(--color-text-maxcontrast);
padding: 8px 0;
}
:deep(.app-settings-section__subtitle),
.app-settings-section__subtitle {
font-weight: bold;
font-size: var(--default-font-size);
margin: calc(var(--default-grid-baseline) * 4) 0 var(--default-grid-baseline) 0;
}
:deep(.app-settings-subsection:not(:first-child)) {
margin-top: 25px;
}
</style>
@@ -0,0 +1,247 @@
<!--
- SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>
<NcNoteCard type="warning" :text="t('spreed', 'Be careful, these actions cannot be undone.')" />
<div class="danger-zone">
<div v-if="canLeaveConversation" class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Leave conversation') }}
</h4>
<p class="app-settings-section__hint">
{{ t('spreed', 'Once a conversation is left, to rejoin a closed conversation, an invite is needed. An open conversation can be rejoined at any time.') }}
</p>
<NcButton variant="warning" @click="leaveConversation">
{{ t('spreed', 'Leave conversation') }}
</NcButton>
</div>
<div v-if="canDeleteConversation" class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Delete conversation') }}
</h4>
<p class="app-settings-section__hint">
{{ t('spreed', 'Permanently delete this conversation.') }}
</p>
<NcButton
variant="error"
@click="deleteConversation">
{{ t('spreed', 'Delete conversation') }}
</NcButton>
</div>
<div v-if="canDeleteConversation" class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Delete chat messages') }}
</h4>
<p class="app-settings-section__hint">
{{ t('spreed', 'Permanently delete all the messages in this conversation.') }}
</p>
<NcButton
variant="error"
@click="clearChatHistory">
{{ t('spreed', 'Delete chat messages') }}
</NcButton>
</div>
</div>
</div>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import ConfirmDialog from '../UIShared/ConfirmDialog.vue'
import { useGetToken } from '../../composables/useGetToken.ts'
import { hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { useTokenStore } from '../../stores/token.ts'
const supportsArchive = hasTalkFeature('local', 'archived-conversations-v2')
export default {
name: 'DangerZone',
components: {
NcButton,
NcNoteCard,
},
props: {
conversation: {
type: Object,
required: true,
},
canLeaveConversation: {
type: Boolean,
required: true,
},
canDeleteConversation: {
type: Boolean,
required: true,
},
},
setup() {
return {
token: useGetToken(),
tokenStore: useTokenStore(),
}
},
methods: {
t,
hideConversationSettings() {
emit('hide-conversation-settings')
},
/**
* Archives the current conversation.
*/
async toggleArchiveConversation() {
await this.$store.dispatch('toggleArchive', this.conversation)
this.hideConversationSettings()
},
/**
* Deletes the current user from the conversation.
*/
async leaveConversation() {
const customMessages = [
t('spreed', 'Do you really want to leave "{displayName}"?', {
displayName: this.conversation.displayName,
}, { escape: false, sanitize: false }),
]
const buttons = [
{ label: t('spreed', 'No'), variant: 'tertiary', callback: () => undefined },
{ label: t('spreed', 'Yes'), variant: 'warning', callback: () => true },
]
if (supportsArchive && !this.conversation.isArchived) {
// Offer archiving option as an alternative to leaving the conversation
customMessages.push(t('spreed', 'You can archive this conversation instead.'))
buttons.splice(1, 0, {
label: t('spreed', 'Archive conversation'),
variant: 'secondary',
callback: () => {
this.toggleArchiveConversation()
return undefined
},
})
}
const confirmLeaveConversation = await spawnDialog(ConfirmDialog, {
container: '.danger-zone',
name: t('spreed', 'Leave conversation'),
customMessages,
buttons,
})
if (!confirmLeaveConversation) {
return
}
if (this.token === this.conversation.token) {
this.$router.push({ name: 'root' })
}
try {
await this.$store.dispatch('removeCurrentUserFromConversation', { token: this.conversation.token })
this.hideConversationSettings()
} catch (error) {
if (error?.response?.status === 400) {
showError(t('spreed', 'You need to promote a new moderator before you can leave the conversation'))
} else {
console.error(`error while removing yourself from conversation ${error}`)
}
}
},
/**
* Deletes the conversation.
*/
async deleteConversation() {
const confirmDeleteConversation = await spawnDialog(ConfirmDialog, {
container: '.danger-zone',
name: t('spreed', 'Delete conversation'),
message: t('spreed', 'Do you really want to delete "{displayName}"?', {
displayName: this.conversation.displayName,
}, { escape: false, sanitize: false }),
buttons: [
{ label: t('spreed', 'No'), variant: 'tertiary', callback: () => undefined },
{ label: t('spreed', 'Yes'), variant: 'error', callback: () => true },
],
})
if (!confirmDeleteConversation) {
return
}
if (this.token === this.conversation.token) {
this.$router.push({ name: 'root' })
}
try {
await this.$store.dispatch('deleteConversationFromServer', { token: this.conversation.token })
// Close the settings
this.hideConversationSettings()
} catch (error) {
console.debug(`error while deleting conversation ${error}`)
showError(t('spreed', 'Error while deleting conversation'))
}
},
/**
* Clears the chat history
*/
async clearChatHistory() {
const confirmDeleteChatMessages = await spawnDialog(ConfirmDialog, {
container: '.danger-zone',
name: t('spreed', 'Delete all chat messages'),
message: t('spreed', 'Do you really want to delete all messages in "{displayName}"?', {
displayName: this.conversation.displayName,
}, { escape: false, sanitize: false }),
buttons: [
{ label: t('spreed', 'No'), variant: 'tertiary', callback: () => undefined },
{ label: t('spreed', 'Yes'), variant: 'error', callback: () => true },
],
})
if (!confirmDeleteChatMessages) {
return
}
try {
await this.$store.dispatch('clearConversationHistory', { token: this.conversation.token })
// Close the settings
this.hideConversationSettings()
} catch (error) {
console.debug(`error while clearing chat history ${error}`)
showError(t('spreed', 'Error while clearing chat history'))
}
},
},
}
</script>
<style lang="scss" scoped>
h4 {
font-weight: bold;
}
.danger-zone {
&__dialog {
:deep(.modal-container) {
padding-block: 4px 8px;
padding-inline: 12px 8px;
}
}
}
</style>
@@ -0,0 +1,123 @@
<!--
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Message expiration') }}
</h4>
<div class="app-settings-section__hint">
{{ t('spreed', 'Chat messages can be expired after a certain time. Note: Files shared in chat will not be deleted for the owner, but will no longer be shared in the conversation.') }}
</div>
<template v-if="canModerate">
<NcSelect
id="moderation_settings_message_expiration"
v-model="selectedOption"
:inputLabel="t('spreed', 'Set message expiration')"
:options="expirationOptions"
label="label"
:clearable="false" />
</template>
<template v-else>
<h5 class="app-settings-section__subtitle">
{{ t('spreed', 'Current message expiration') }}
</h5>
<p>{{ selectedOption.label }}</p>
</template>
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { n, t } from '@nextcloud/l10n'
import NcSelect from '@nextcloud/vue/components/NcSelect'
export default {
name: 'ExpirationSettings',
components: {
NcSelect,
},
props: {
token: {
type: String,
default: null,
},
canModerate: {
type: Boolean,
default: false,
},
},
data() {
return {
defaultExpirationOptions: [
{ id: 3600, label: n('spreed', '%n hour', '%n hours', 1) },
{ id: 28800, label: n('spreed', '%n hour', '%n hours', 8) },
{ id: 86400, label: n('spreed', '%n day', '%n days', 1) },
{ id: 604800, label: n('spreed', '%n week', '%n weeks', 1) },
{ id: 2419200, label: n('spreed', '%n week', '%n weeks', 4) },
{ id: 0, label: t('spreed', 'Off') },
],
}
},
computed: {
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
expirationOptions() {
const expirationOptions = [...this.defaultExpirationOptions]
if (!expirationOptions.some((option) => option.id === this.conversation.messageExpiration)) {
expirationOptions.push({ id: this.conversation.messageExpiration, label: t('spreed', 'Custom expiration time') })
}
return expirationOptions
},
selectedOption: {
get() {
return this.expirationOptions.find((option) => {
return option.id === this.conversation.messageExpiration
}) ?? this.expirationOptions.at(-1)
},
set(value) {
this.changeExpiration(value)
},
},
},
methods: {
t,
n,
async changeExpiration(expiration) {
try {
await this.$store.dispatch('setMessageExpiration', {
token: this.token,
seconds: expiration.id,
})
if (expiration.id === 0) {
showSuccess(t('spreed', 'Message expiration disabled'))
} else {
showSuccess(t('spreed', 'Message expiration set: {duration}', {
duration: expiration.label,
}))
}
} catch (error) {
showError(t('spreed', 'Error when trying to set message expiration'))
console.error(error)
}
},
},
}
</script>
@@ -0,0 +1,299 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Guest access') }}
</h4>
<template v-if="canModerate">
<p v-if="hasBreakoutRooms" class="app-settings-section__hint">
{{ t('spreed', 'Breakout rooms are not allowed in public conversations.') }}
</p>
<NcCheckboxRadioSwitch
:modelValue="isSharedPublicly"
:disabled="hasBreakoutRooms || isSaving"
type="switch"
aria-describedby="link_share_settings_hint"
@update:modelValue="toggleGuests">
{{ t('spreed', 'Allow guests to join this conversation via link') }}
</NcCheckboxRadioSwitch>
<template v-if="isSharedPublicly">
<NcCheckboxRadioSwitch
v-if="!forcePasswordProtection"
:modelValue="isPasswordProtectionChecked"
:disabled="isSaving"
type="switch"
aria-describedby="link_share_settings_password_hint"
@update:modelValue="togglePassword">
{{ t('spreed', 'Password protection') }}
</NcCheckboxRadioSwitch>
<template v-else>
<p v-if="isPasswordProtectionChecked" class="app-settings-section__hint">
{{ t('spreed', 'This conversation is password-protected. Guests need password to join') }}
</p>
<NcNoteCard
v-else-if="!isSaving"
type="warning">
{{ t('spreed', 'Password protection is needed for public conversations') }}
<NcButton class="warning__button" variant="primary" @click="enforcePassword">
{{ t('spreed', 'Set a password') }}
</NcButton>
</NcNoteCard>
</template>
<form v-if="isPasswordProtectionChecked" class="password-form" @submit.prevent="handleSetNewPassword">
<NcPasswordField
ref="passwordField"
v-model="password"
autocomplete="new-password"
checkPasswordStrength
:disabled="isSaving"
class="password-form__input-field"
labelVisible
:label="conversation.hasPassword ? t('spreed', 'Change password') : t('spreed', 'Enter new password')"
@valid="isValid = true"
@invalid="isValid = false" />
<NcButton
:disabled="isSaving || !isValid || !password.length"
variant="primary"
type="submit"
class="password-form__button">
<template #icon>
<IconContentSaveOutline />
</template>
{{ t('spreed', 'Save password') }}
</NcButton>
<NcButton
v-if="password"
variant="tertiary"
:aria-label="t('spreed', 'Copy password')"
:title="t('spreed', 'Copy password')"
class="password-form__button"
@click="copyPassword">
<template #icon>
<IconContentCopy :size="20" />
</template>
</NcButton>
</form>
</template>
</template>
<p v-else-if="isSharedPublicly">
{{ t('spreed', 'Guests are allowed to join this conversation via link') }}
</p>
<p v-else>
{{ t('spreed', 'Guests are not allowed to join this conversation') }}
</p>
<div class="app-settings-subsection__buttons">
<NcButton
ref="copyLinkButton"
@click="handleCopyLink">
<template #icon>
<IconClipboardTextOutline />
</template>
{{ t('spreed', 'Copy link') }}
</NcButton>
<NcButton
v-if="isSharedPublicly && canModerate"
:disabled="isSendingInvitations"
@click="handleResendInvitations">
<template #icon>
<NcLoadingIcon v-if="isSendingInvitations" />
<IconEmailOutline v-else />
</template>
{{ t('spreed', 'Resend invitations') }}
</NcButton>
</div>
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import IconClipboardTextOutline from 'vue-material-design-icons/ClipboardTextOutline.vue'
import IconContentCopy from 'vue-material-design-icons/ContentCopy.vue'
import IconContentSaveOutline from 'vue-material-design-icons/ContentSaveOutline.vue'
import IconEmailOutline from 'vue-material-design-icons/EmailOutline.vue'
import { CONVERSATION } from '../../constants.ts'
import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import generatePassword from '../../utils/generatePassword.ts'
import { copyConversationLinkToClipboard } from '../../utils/handleUrl.ts'
export default {
name: 'LinkShareSettings',
components: {
NcButton,
NcCheckboxRadioSwitch,
NcPasswordField,
NcNoteCard,
NcLoadingIcon,
// Icons
IconClipboardTextOutline,
IconContentCopy,
IconContentSaveOutline,
IconEmailOutline,
},
props: {
token: {
type: String,
default: null,
},
canModerate: {
type: Boolean,
default: true,
},
},
data() {
return {
// The conversation's password
password: '',
isSaving: false,
isSendingInvitations: false,
isValid: true,
}
},
computed: {
isSharedPublicly() {
return this.conversation.type === CONVERSATION.TYPE.PUBLIC
},
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
hasBreakoutRooms() {
return this.conversation.breakoutRoomMode !== CONVERSATION.BREAKOUT_ROOM_MODE.NOT_CONFIGURED
},
isPasswordProtectionChecked() {
return this.conversation.hasPassword || this.password.length > 0
},
forcePasswordProtection() {
return this.supportForcePasswordProtection && getTalkConfig(this.token, 'conversations', 'force-passwords')
},
supportForcePasswordProtection() {
return hasTalkFeature(this.token, 'conversation-creation-password')
},
},
methods: {
t,
async setConversationPassword(newPassword) {
this.isSaving = true
await this.$store.dispatch('setConversationPassword', {
token: this.token,
newPassword,
})
this.isSaving = false
},
async toggleGuests() {
const allowGuests = this.conversation.type !== CONVERSATION.TYPE.PUBLIC
this.isSaving = true
if (this.forcePasswordProtection && allowGuests) {
await this.togglePassword(allowGuests)
await this.$store.dispatch('toggleGuests', { token: this.token, allowGuests, password: this.password })
} else {
if (!allowGuests) {
await this.togglePassword(false)
}
await this.$store.dispatch('toggleGuests', { token: this.token, allowGuests })
}
this.isSaving = false
},
async togglePassword(checked) {
if (checked) {
// Generate a random password
this.password = await generatePassword()
} else {
// disable the password protection for the current conversation
if (this.conversation.hasPassword) {
await this.setConversationPassword('')
}
this.password = ''
this.isValid = true
}
},
async handleSetNewPassword() {
if (this.isValid) {
await this.setConversationPassword(this.password)
this.password = ''
}
},
handleCopyLink() {
copyConversationLinkToClipboard(this.token)
},
async handleResendInvitations() {
this.isSendingInvitations = true
await this.$store.dispatch('resendInvitations', { token: this.token })
this.isSendingInvitations = false
},
async copyPassword() {
try {
await navigator.clipboard.writeText(this.password)
showSuccess(t('spreed', 'Password copied to clipboard'))
} catch (error) {
showError(t('spreed', 'Password could not be copied'))
}
},
async enforcePassword() {
// Turn on password protection and set a password
await this.togglePassword(true)
await this.$store.dispatch('toggleGuests', { token: this.token, allowGuests: true, password: this.password })
},
},
}
</script>
<style lang="scss" scoped>
.password-form {
display: flex;
gap: 8px;
align-items: flex-start;
:deep(.input-field) {
width: 200px;
}
&__button {
margin-top: 6px;
}
}
.warning__button {
margin-top: var(--default-grid-baseline);
}
.app-settings-subsection__buttons {
display: flex;
gap: 8px;
margin-top: 25px;
& > button {
flex-basis: 50%;
}
}
</style>
@@ -0,0 +1,174 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div v-if="canModerate">
<NcCheckboxRadioSwitch
:modelValue="listable !== LISTABLE.NONE"
:disabled="isListableLoading"
type="switch"
@update:modelValue="toggleListableUsers">
{{ t('spreed', 'Open conversation to registered users, showing it in search results') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-if="listable !== LISTABLE.NONE && isGuestsAccountsEnabled"
class="additional-top-margin"
:modelValue="listable === LISTABLE.ALL"
:disabled="isListableLoading"
type="switch"
@update:modelValue="toggleListableGuests">
{{ t('spreed', 'Also open to users created with the Guests app') }}
</NcCheckboxRadioSwitch>
</div>
<div v-else>
<h5 class="app-settings-section__subtitle">
{{ t('spreed', 'Open conversation') }}
</h5>
<p>{{ summaryLabel }}</p>
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { CONVERSATION } from '../../constants.ts'
export default {
name: 'ListableSettings',
components: {
NcCheckboxRadioSwitch,
},
props: {
token: {
type: String,
default: null,
},
canModerate: {
type: Boolean,
default: true,
},
modelValue: {
type: Number,
default: null,
},
},
emits: ['update:modelValue'],
data() {
return {
listable: null,
isListableLoading: false,
lastNotification: null,
isGuestsAccountsEnabled: loadState('spreed', 'guests_accounts_enabled'),
LISTABLE: CONVERSATION.LISTABLE,
}
},
computed: {
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
summaryLabel() {
switch (this.listable) {
case CONVERSATION.LISTABLE.ALL:
return t('spreed', 'This conversation is open to both registered users and users created with the Guests app')
case CONVERSATION.LISTABLE.USERS:
return t('spreed', 'This conversation is open to registered users')
case CONVERSATION.LISTABLE.NONE:
default:
return t('spreed', 'This conversation is limited to the current participants')
}
},
},
watch: {
modelValue(value) {
this.listable = value
},
conversation: {
immediate: true,
handler() {
this.listable = this.conversation.listable
},
},
},
mounted() {
if (this.token) {
this.listable = this.modelValue || this.conversation.listable
} else {
this.listable = this.modelValue
}
},
beforeUnmount() {
if (this.lastNotification) {
this.lastNotification.hideToast()
this.lastNotification = null
}
},
methods: {
t,
async toggleListableUsers(checked) {
await this.saveListable(checked ? this.LISTABLE.USERS : this.LISTABLE.NONE)
},
async toggleListableGuests(checked) {
await this.saveListable(checked ? this.LISTABLE.ALL : this.LISTABLE.USERS)
},
async saveListable(listable) {
this.$emit('update:modelValue', listable)
if (!this.token) {
this.listable = listable
return
}
this.isListableLoading = true
try {
await this.$store.dispatch('setListable', {
token: this.token,
listable,
})
if (this.lastNotification) {
this.lastNotification.hideToast()
this.lastNotification = null
}
if (listable === CONVERSATION.LISTABLE.NONE) {
this.lastNotification = showSuccess(t('spreed', 'You limited the conversation to the current participants'))
} else if (listable === CONVERSATION.LISTABLE.USERS) {
this.lastNotification = showSuccess(t('spreed', 'You opened the conversation to registered users'))
} else if (listable === CONVERSATION.LISTABLE.ALL) {
this.lastNotification = showSuccess(t('spreed', 'You opened the conversation to both registered users and users created with the Guests app'))
}
this.listable = listable
} catch (e) {
console.error('Error occurred when opening or limiting the conversation', e)
showError(t('spreed', 'Error occurred when opening or limiting the conversation'))
this.listable = this.conversation.listable
}
this.isListableLoading = false
},
},
}
</script>
<style lang="scss" scoped>
.additional-top-margin {
margin-top: 10px;
}
</style>
@@ -0,0 +1,135 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { computed, ref } from 'vue'
import { useStore } from 'vuex'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import { useLiveTranscriptionStore } from '../../stores/liveTranscription.ts'
interface LanguageOption {
id: string
label: string
}
const { token } = defineProps<{
token: string
}>()
const store = useStore()
const liveTranscriptionStore = useLiveTranscriptionStore()
const loadLanguagesFailed = ref(false)
const languageBeingChanged = ref(false)
const conversation = computed(() => {
return store.getters.conversation(token) || store.getters.dummyConversation
})
const inputLabel = computed(() => {
return t('spreed', 'Set language spoken in calls')
})
const placeholder = computed(() => {
if (loadLanguagesFailed.value) {
return t('spreed', 'Languages could not be loaded')
}
if (!languageOptions.value.length) {
return t('spreed', 'Loading languages …')
}
if (conversation.value.liveTranscriptionLanguageId && !selectedOption.value) {
return t('spreed', 'Invalid language')
}
if (!selectedOption.value) {
return t('spreed', 'Default language (English)')
}
return null
})
const languageOptions = computed(() => {
const liveTranscriptionLanguages = liveTranscriptionStore.getLiveTranscriptionLanguages()
if (!liveTranscriptionLanguages) {
return []
}
const languageOptions = Object.keys(liveTranscriptionLanguages).map((key) => {
return {
id: key,
label: liveTranscriptionLanguages[key].name,
}
})
return languageOptions
})
const selectedOption = computed({
get() {
return languageOptions.value.find((option) => {
return option.id === conversation.value.liveTranscriptionLanguageId
}) ?? null
},
set(value: LanguageOption) {
changeLanguage(value)
},
})
liveTranscriptionStore.loadLiveTranscriptionLanguages().catch(() => {
loadLanguagesFailed.value = true
showError(t('spreed', 'Error when trying to load the available live transcription languages'))
})
/**
* Set the live transcription language from the given option
*
* @param language the option with the language to set
*/
async function changeLanguage(language: LanguageOption) {
languageBeingChanged.value = true
try {
await store.dispatch('setLiveTranscriptionLanguage', {
token,
languageId: language ? language.id : '',
})
if (!language) {
showSuccess(t('spreed', 'Default live transcription language set'))
} else {
showSuccess(t('spreed', 'Live transcription language set: {languageName}', {
languageName: language.label,
}))
}
} catch (error) {
showError(t('spreed', 'Error when trying to set live transcription language'))
}
languageBeingChanged.value = false
}
</script>
<template>
<div class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Language') }}
</h4>
<NcSelect
id="live_transcription_settings_language_id"
v-model="selectedOption"
:inputLabel="inputLabel"
:placeholder="placeholder"
:options="languageOptions"
:disabled="!languageOptions.length || loadLanguagesFailed || languageBeingChanged"
:loading="(!languageOptions.length && !loadLanguagesFailed) || languageBeingChanged" />
</div>
</template>
@@ -0,0 +1,265 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div>
<div class="app-settings-subsection">
<NcNoteCard
v-if="hasCall && !hasLobbyEnabled"
type="warning"
:text="t('spreed', 'Enabling the lobby will remove non-moderators from the ongoing call.')" />
<NcCheckboxRadioSwitch
:modelValue="hasLobbyEnabled"
type="switch"
:disabled="isLobbyStateLoading"
@update:modelValue="toggleLobby">
{{ t('spreed', 'Enable lobby, restricting the conversation to moderators') }}
</NcCheckboxRadioSwitch>
</div>
<div v-if="hasLobbyEnabled" class="app-settings-subsection">
<form
:disabled="lobbyTimerFieldDisabled"
@submit.prevent="saveLobbyTimer">
<span class="icon-calendar-dark" />
<div>
<label for="moderation_settings_lobby_timer_field">{{ t('spreed', 'Meeting start time') }}</label>
</div>
<NcDateTimePicker
id="moderation_settings_lobby_timer_field"
v-model="lobbyTimer"
aria-describedby="moderation_settings_lobby_timer_hint"
:defaultValue="defaultLobbyTimer"
:placeholder="t('spreed', 'Start time (optional)')"
:disabled="lobbyTimerFieldDisabled"
type="datetime"
valueType="timestamp"
format="yyyy-MM-dd HH:mm"
:minuteStep="5"
:inputClass="['mx-input', { focusable: !lobbyTimerFieldDisabled }]"
v-bind="dateTimePickerAttrs"
confirm
clearable />
<div class="lobby_timer--timezone">
{{ getTimeZone }}
</div>
<div v-if="showRelativeTime" class="lobby_timer--relative">
{{ getRelativeTime }}
</div>
</form>
</div>
<div v-if="supportImportEmails" class="import-email-participants">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Import email participants') }}
</h4>
<div class="app-settings-section__hint">
{{ t('spreed', 'You can import a list of email participants from a CSV file.') }}
</div>
<NcButton @click="isImportEmailsDialogOpen = true">
<template #icon>
<NcIconSvgWrapper :svg="IconFileUpload" :size="20" />
</template>
{{ t('spreed', 'Import email participants') }}
</NcButton>
<ImportEmailsDialog
v-if="isImportEmailsDialogOpen"
:token="token"
container=".import-email-participants"
@close="isImportEmailsDialogOpen = false" />
<template v-if="canCreatePollDrafts">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Poll drafts') }}
</h4>
<NcButton @click="openPollDraftHandler">
<template #icon>
<IconPoll :size="20" />
</template>
{{ t('spreed', 'Browse poll drafts') }}
</NcButton>
</template>
</div>
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDateTimePicker from '@nextcloud/vue/components/NcDateTimePicker'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import IconPoll from 'vue-material-design-icons/Poll.vue'
import ImportEmailsDialog from '../ImportEmailsDialog.vue'
import IconFileUpload from '../../../img/material-icons/file-upload.svg?raw'
import { WEBINAR } from '../../constants.ts'
import { hasTalkFeature } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts'
import { convertToUnix, futureRelativeTime, ONE_DAY_IN_MS } from '../../utils/formattedTime.ts'
export default {
name: 'LobbySettings',
components: {
NcIconSvgWrapper,
IconPoll,
ImportEmailsDialog,
NcButton,
NcCheckboxRadioSwitch,
NcDateTimePicker,
NcNoteCard,
},
props: {
token: {
type: String,
default: null,
},
},
setup() {
return {
IconFileUpload,
}
},
data() {
return {
isLobbyStateLoading: false,
isLobbyTimerLoading: false,
isImportEmailsDialogOpen: false,
}
},
computed: {
hasCall() {
return this.conversation.hasCall
},
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
hasLobbyEnabled() {
return this.conversation.lobbyState === WEBINAR.LOBBY.NON_MODERATORS
},
lobbyTimerFieldDisabled() {
return this.isLobbyStateLoading || this.isLobbyTimerLoading
},
supportImportEmails() {
return hasTalkFeature(this.token, 'email-csv-import')
},
defaultLobbyTimer() {
let date = new Date()
// strip minutes and seconds
date = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), 0, 0, 0)
// add one hour to reach the next hour
return new Date(date.getTime() + 3600000)
},
lobbyTimer: {
get() {
// A timestamp of 0 means that there is no lobby, but it would be
// interpreted as the Unix epoch by the DateTimePicker.
if (this.conversation.lobbyTimer === 0) {
return undefined
}
// PHP timestamp is second-based; JavaScript timestamp is
// millisecond based.
return this.conversation.lobbyTimer * 1000
},
set(value) {
this.saveLobbyTimer(value)
},
},
dateTimePickerAttrs() {
return {
firstDayOfWeek: window.firstDay + 1, // Provided by server
lang: {
days: window.dayNamesShort, // Provided by server
months: window.monthNamesShort, // Provided by server
},
}
},
showRelativeTime() {
return this.lobbyTimer
&& this.lobbyTimer > Date.now()
&& (this.lobbyTimer - Date.now()) < ONE_DAY_IN_MS // less than 24 hours
},
getTimeZone() {
if (!this.lobbyTimer) {
return ''
}
const date = new Date(this.lobbyTimer)
return t('spreed', 'Start time: {date}', { date: date.toString() })
},
getRelativeTime() {
return futureRelativeTime(this.lobbyTimer)
},
canCreatePollDrafts() {
return hasTalkFeature(this.token, 'talk-polls-drafts')
},
},
methods: {
t,
async toggleLobby() {
this.isLobbyStateLoading = true
await this.$store.dispatch('toggleLobby', {
token: this.token,
enableLobby: this.conversation.lobbyState !== WEBINAR.LOBBY.NON_MODERATORS,
})
this.isLobbyStateLoading = false
},
async saveLobbyTimer(timestamp) {
this.isLobbyTimerLoading = true
try {
await this.$store.dispatch('setLobbyTimer', {
token: this.token,
timestamp: timestamp ? convertToUnix(timestamp) : 0,
})
showSuccess(t('spreed', 'Start time has been updated'))
} catch (e) {
console.error('Error occurred while updating start time', e)
showError(t('spreed', 'Error occurred while updating start time'))
}
this.isLobbyTimerLoading = false
},
openPollDraftHandler() {
EventBus.emit('poll-drafts-open', { token: this.token, selector: '#settings-section_meeting' })
},
},
}
</script>
<style lang="scss" scoped>
.lobby_timer {
&--relative {
color: var(--color-text-maxcontrast);
}
&--timezone {
padding-top: 4px;
}
}
:deep(.mx-input) {
margin: 0;
}
</style>
@@ -0,0 +1,99 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Lock conversation') }}
</h4>
<NcNoteCard
v-if="hasCall"
type="warning"
:text="t('spreed', 'This will also terminate the ongoing call.')" />
<div>
<NcCheckboxRadioSwitch
:modelValue="isReadOnly"
type="switch"
aria-describedby="moderation_settings_lock_conversation_hint"
:disabled="isReadOnlyStateLoading"
@update:modelValue="toggleReadOnly">
{{ t('spreed', 'Lock the conversation to prevent anyone to post messages or start calls') }}
</NcCheckboxRadioSwitch>
</div>
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import { CONVERSATION } from '../../constants.ts'
export default {
name: 'LockingSettings',
components: {
NcCheckboxRadioSwitch,
NcNoteCard,
},
props: {
token: {
type: String,
default: null,
},
},
data() {
return {
isReadOnlyStateLoading: false,
}
},
computed: {
hasCall() {
return this.conversation.hasCall
},
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
isReadOnly() {
return this.conversation.readOnly === CONVERSATION.STATE.READ_ONLY
},
},
methods: {
t,
async toggleReadOnly() {
const newReadOnly = this.isReadOnly ? CONVERSATION.STATE.READ_WRITE : CONVERSATION.STATE.READ_ONLY
this.isReadOnlyStateLoading = true
try {
await this.$store.dispatch('setReadOnlyState', {
token: this.token,
readOnly: newReadOnly,
})
if (newReadOnly) {
showSuccess(t('spreed', 'You locked the conversation'))
} else {
showSuccess(t('spreed', 'You unlocked the conversation'))
}
} catch (e) {
if (newReadOnly) {
console.error('Error occurred when locking the conversation', e)
showError(t('spreed', 'Error occurred when locking the conversation'))
} else {
console.error('Error updating read-only state', e)
showError(t('spreed', 'Error occurred when unlocking the conversation'))
}
}
this.isReadOnlyStateLoading = false
},
},
}
</script>
@@ -0,0 +1,241 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<li class="part" :class="{ readonly: !editing }">
<div class="part__header">
<img class="part__icon" :src="type.iconUrl" :alt="type.name">
<h4 class="part__heading">
{{ type.name }}
</h4>
<NcActions
class="actions"
:container="container"
:inline="editable ? 1 : 0"
placement="bottom">
<NcActionButton v-if="editable" closeAfterClick @click="$emit('editClicked')">
<template #icon>
<IconCheck v-if="editing" :size="20" />
<IconPencilOutline v-else :size="20" />
</template>
{{ editing ? t('spreed', 'Save') : t('spreed', 'Edit') }}
</NcActionButton>
<NcActionLink :href="type.infoTarget" target="_blank" closeAfterClick>
<template #icon>
<IconInformationOutline :size="20" />
</template>
{{ t('spreed', 'More information') }}
</NcActionLink>
<NcActionButton v-if="editable" closeAfterClick @click="$emit('deletePart')">
<template #icon>
<IconTrashCanOutline :size="20" />
</template>
{{ t('spreed', 'Delete') }}
</NcActionButton>
</NcActions>
</div>
<div
v-for="(field, key) in displayedFields"
:key="key"
class="field">
<!-- TODO: do not mutate prop `part` directly -->
<!-- eslint-disable -->
<div v-if="field.type === 'checkbox'" class="checkbox-container">
<input
:id="key + '-' + num"
:ref="key"
v-model="part[key]"
:type="field.type"
:class="classesOf(key)"
:disabled="!editing">
<label :for="key + '-' + num">
{{ field.labelText }}
</label>
</div>
<div v-else>
<label :for="key + '-' + num" class="hidden-visually">
{{ field.placeholder }}
</label>
<input
:id="key + '-' + num"
:ref="key"
v-model="part[key]"
:type="field.type"
:class="classesOf(key)"
:placeholder="field.placeholder"
:readonly="readonly || !editing"
@focus="readonly = false">
</div>
<!-- eslint-enable -->
</div>
</li>
</template>
<script>
import { t } from '@nextcloud/l10n'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionLink from '@nextcloud/vue/components/NcActionLink'
import NcActions from '@nextcloud/vue/components/NcActions'
import IconCheck from 'vue-material-design-icons/Check.vue'
import IconInformationOutline from 'vue-material-design-icons/InformationOutline.vue'
import IconPencilOutline from 'vue-material-design-icons/PencilOutline.vue'
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
export default {
name: 'BridgePart',
components: {
IconCheck,
IconTrashCanOutline,
IconInformationOutline,
IconPencilOutline,
NcActionButton,
NcActionLink,
NcActions,
},
props: {
num: {
type: Number,
required: true,
},
part: {
type: Object,
required: true,
},
type: {
type: Object,
required: true,
},
container: {
type: String,
required: true,
},
editing: {
type: Boolean,
default: false,
},
editable: {
type: Boolean,
default: true,
},
},
emits: ['deletePart', 'editClicked'],
data() {
return {
readonly: true,
}
},
computed: {
displayedFields() {
if (this.editing) {
return this.type.fields
} else {
const fields = {}
if (this.type.fields[this.type.mainField]) {
fields[this.type.mainField] = this.type.fields[this.type.mainField]
}
return fields
}
},
},
watch: {
editing() {
this.focusMainField()
},
},
mounted() {
this.focusMainField()
},
methods: {
t,
classesOf(name) {
const classes = {
icon: true,
}
classes[this.type.fields[name].icon] = true
return classes
},
// focus on main field when entering edition mode and when created
focusMainField() {
if (this.editing && this.$refs[this.type.mainField] && this.$refs[this.type.mainField].length > 0) {
this.$refs[this.type.mainField][0].focus()
this.$refs[this.type.mainField][0].select()
}
},
},
}
</script>
<style lang="scss" scoped>
.part {
&__header {
display: flex;
align-items: center;
gap: calc(2 * var(--default-grid-baseline));
//width: 100%;
}
&__heading {
flex-grow: 1;
margin: 0;
}
&__icon {
flex-grow: 0;
width: var(--clickable-area-small);
height: var(--clickable-area-small);
filter: var(--background-invert-if-dark);
}
}
input {
background-size: 16px;
background-position: 14px;
padding-inline-start: var(--default-clickable-area);
width: 100%;
text-overflow: ellipsis;
&[type=checkbox] {
width: unset;
margin-inline-start: 15px;
margin-inline-end: 10px;
}
}
.readonly input {
border: 0;
}
.checkbox-container {
display: flex;
height: 40px;
> label {
flex-grow: 1;
line-height: 40px;
}
&:hover {
opacity: 1;
background-color: var(--color-background-hover);
border-radius: var(--border-radius-large);
}
}
.field {
margin: 4px 0;
}
</style>
@@ -0,0 +1,359 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="matterbridge-settings">
<div v-if="loading" class="loading" />
<div v-show="!loading">
<div id="matterbridge-header">
<p>
{{ t('spreed', 'You can bridge channels from various instant messaging systems with Matterbridge.') }}
<a href="https://github.com/42wim/matterbridge/wiki" target="_blank" rel="noopener">
<span class="icon icon-external" />
{{ t('spreed', 'More info on Matterbridge') }}
</a>
</p>
</div>
<div class="basic-settings">
<div
v-show="!enabled"
class="add-part-wrapper">
<IconPlus class="icon" :size="20" />
<NcSelect
label="displayName"
:aria-label-combobox="t('spreed', 'Messaging systems')"
:placeholder="newPartPlaceholder"
:options="options"
@update:modelValue="clickAddPart">
<template #option="option">
<img
class="icon-multiselect-service"
:src="option.iconUrl"
alt="">
{{ option.displayName }}
</template>
</NcSelect>
</div>
<div
v-show="parts.length > 0"
class="enable-switch-line">
<NcCheckboxRadioSwitch
:modelValue="enabled"
type="switch"
@update:modelValue="onEnabled">
{{ t('spreed', 'Enable bridge') }}
({{ processStateText }})
</NcCheckboxRadioSwitch>
<NcButton
v-if="enabled"
variant="tertiary"
:title="t('spreed', 'Show Matterbridge log')"
:aria-label="t('spreed', 'Show Matterbridge log')"
@click="showLogContent">
<template #icon>
<IconMessageOutline :size="20" />
</template>
</NcButton>
<NcDialog
v-model:open="logModal"
:name="t('spreed', 'Log content')"
size="normal"
container=".matterbridge-settings"
closeOnClickOutside>
<NcTextArea
:modelValue="processLog"
class="log-content"
:label="t('spreed', 'Log content')"
:rows="29"
readonly
resize="vertical" />
</NcDialog>
</div>
</div>
<ul>
<BridgePart
v-for="(part, i) in parts"
:key="part.type + i"
:num="i + 1"
:part="part"
:type="matterbridgeTypes[part.type]"
:editing="part.editing"
:editable="!enabled"
container=".matterbridge-settings"
@editClicked="onEditClicked(i)"
@deletePart="onDelete(i)" />
</ul>
</div>
</div>
</template>
<script>
import { showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import IconMessageOutline from 'vue-material-design-icons/MessageOutline.vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import BridgePart from './BridgePart.vue'
import { useGetToken } from '../../../composables/useGetToken.ts'
import {
editBridge,
getBridge,
getBridgeProcessState,
} from '../../../services/matterbridgeService.js'
import { matterbridgeTypes } from './matterbridgeTypes.ts'
export default {
name: 'MatterbridgeSettings',
components: {
BridgePart,
NcButton,
NcCheckboxRadioSwitch,
NcDialog,
NcSelect,
NcTextArea,
// Icons
IconMessageOutline,
IconPlus,
},
setup() {
return {
matterbridgeTypes,
token: useGetToken(),
}
},
data() {
return {
enabled: false,
parts: [],
loading: false,
processRunning: null,
processLog: '',
logModal: false,
stateLoop: null,
newPartPlaceholder: t('spreed', 'Add new bridged channel to current conversation'),
}
},
computed: {
options() {
return Object.entries(this.matterbridgeTypes).map(([type, value]) => ({
type,
displayName: value.name,
iconUrl: value.iconUrl,
}))
},
processStateText() {
if (this.processRunning === null) {
return t('spreed', 'unknown state')
}
if (this.processRunning) {
return t('spreed', 'running')
} else {
return this.enabled
? t('spreed', 'not running, check Matterbridge log')
: t('spreed', 'not running')
}
},
},
watch: {
token: {
immediate: true,
handler(token) {
this.getBridge(token)
this.relaunchStateLoop(token)
},
},
},
methods: {
t,
relaunchStateLoop(token) {
// start loop to periodically get bridge state
clearInterval(this.stateLoop)
this.stateLoop = setInterval(() => this.getBridgeProcessState(token), 60000)
},
clickAddPart(event) {
const typeKey = event.type
const type = this.matterbridgeTypes[typeKey]
const newPart = {
type: typeKey,
editing: true,
}
for (const fieldKey in type.fields) {
newPart[fieldKey] = ''
}
this.parts.unshift(newPart)
},
onDelete(i) {
this.parts.splice(i, 1)
this.save()
},
onEditClicked(i) {
this.parts[i].editing = !this.parts[i].editing
if (!this.parts[i].editing) {
this.save()
}
},
onEnabled(checked) {
this.enabled = checked
this.save()
},
save() {
if (this.parts.length === 0) {
this.enabled = false
}
this.editBridge(this.token, this.enabled, this.parts)
},
async getBridge(token) {
this.loading = true
try {
const result = await getBridge(token)
const bridge = result.data.ocs.data
this.enabled = bridge.enabled
this.parts = bridge.parts
this.processLog = bridge.log
this.processRunning = bridge.running
} catch (exception) {
console.error(exception)
}
this.loading = false
},
async editBridge() {
this.loading = true
this.parts.forEach((part) => {
part.editing = false
})
try {
const result = await editBridge(this.token, this.enabled, this.parts)
this.processLog = result.data.ocs.data.log
this.processRunning = result.data.ocs.data.running
showSuccess(t('spreed', 'Bridge saved'))
} catch (exception) {
console.error(exception)
}
this.loading = false
},
async getBridgeProcessState(token) {
try {
const result = await getBridgeProcessState(token)
this.processLog = result.data.ocs.data.log
this.processRunning = result.data.ocs.data.running
} catch (exception) {
console.error(exception)
}
},
showLogContent() {
this.getBridgeProcessState(this.token)
this.logModal = true
},
},
}
</script>
<style lang="scss" scoped>
.icon-multiselect-service {
width: 16px !important;
height: 16px !important;
margin-inline-end: 10px;
filter: var(--background-invert-if-dark);
}
:deep(.modal-container) {
height: 700px;
}
.matterbridge-settings {
.loading {
margin-top: 30px;
}
h3 {
font-weight: bold;
padding: 0;
height: var(--default-clickable-area);
display: flex;
p {
margin-top: auto;
margin-bottom: auto;
}
.icon {
display: inline-block;
width: 40px;
}
&:hover {
background-color: var(--color-background-hover);
}
}
#matterbridge-header {
padding: 0 0 10px 0;
p {
color: var(--color-text-maxcontrast);
a:hover,
a:focus {
border-bottom: 2px solid var(--color-text-maxcontrast);
}
a .icon {
display: inline-block;
margin-bottom: -3px;
}
}
}
.basic-settings {
margin-bottom: calc(4 * var(--default-grid-baseline));
.icon {
display: inline-flex;
justify-content: center;
align-items: center;
width: var(--default-clickable-area);
height: var(--default-clickable-area);
}
.add-part-wrapper {
margin-top: 5px;
display: flex;
align-items: center;
}
.enable-switch-line {
display: flex;
height: var(--default-clickable-area);
margin-top: 5px;
}
}
ul {
display: flex;
flex-direction: column;
gap: calc(4 * var(--default-grid-baseline));
}
}
.log-content :deep(.textarea__input) {
height: unset;
}
</style>
@@ -0,0 +1,386 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { t } from '@nextcloud/l10n'
import { imagePath } from '@nextcloud/router'
type InputField = {
type: 'url' | 'text' | 'password'
placeholder: string
icon: string
} | {
type: 'checkbox'
labelText: string
}
type MatterbridgeType = {
name: string
iconUrl: string
infoTarget: string
fields: Record<string, InputField>
mainField: string
}
export const matterbridgeTypes: Record<string, MatterbridgeType> = {
nctalk: {
name: 'Nextcloud Talk',
iconUrl: imagePath('spreed', 'app-dark.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Section-Nextcloud-Talk-%28basic%29',
fields: {
server: {
type: 'url',
placeholder: t('spreed', 'Nextcloud URL'),
icon: 'icon-link',
},
login: {
type: 'text',
placeholder: t('spreed', 'Nextcloud user'),
icon: 'icon-user',
},
password: {
type: 'password',
placeholder: t('spreed', 'User password'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Talk conversation'),
icon: 'icon-group',
},
skiptls: {
type: 'checkbox',
labelText: t('spreed', 'Skip TLS verification'),
},
},
mainField: 'server',
},
matrix: {
name: 'Matrix',
iconUrl: imagePath('spreed', 'bridge-services/matrix.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Settings#matrix',
fields: {
server: {
type: 'url',
placeholder: t('spreed', 'Matrix server URL'),
icon: 'icon-link',
},
login: {
type: 'text',
placeholder: t('spreed', 'User'),
icon: 'icon-user',
},
password: {
type: 'password',
placeholder: t('spreed', 'User password'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Matrix channel'),
icon: 'icon-group',
},
},
mainField: 'server',
},
mattermost: {
name: 'Mattermost',
iconUrl: imagePath('spreed', 'bridge-services/mattermost.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Settings#mattermost',
fields: {
server: {
type: 'url',
placeholder: t('spreed', 'Mattermost server URL'),
icon: 'icon-link',
},
login: {
type: 'text',
placeholder: t('spreed', 'Mattermost user'),
icon: 'icon-user',
},
password: {
type: 'password',
placeholder: t('spreed', 'User password'),
icon: 'icon-category-auth',
},
team: {
type: 'text',
placeholder: t('spreed', 'Team name'),
icon: 'icon-group',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Channel name'),
icon: 'icon-group',
},
},
mainField: 'server',
},
rocketchat: {
name: 'Rocket.Chat',
iconUrl: imagePath('spreed', 'bridge-services/rocketchat.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Settings#rocketchat',
fields: {
server: {
type: 'url',
placeholder: t('spreed', 'Rocket.Chat server URL'),
icon: 'icon-link',
},
login: {
type: 'text',
placeholder: t('spreed', 'User name or email address'),
icon: 'icon-user',
},
password: {
type: 'password',
placeholder: t('spreed', 'Password'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Rocket.Chat channel'),
icon: 'icon-group',
},
skiptls: {
type: 'checkbox',
labelText: t('spreed', 'Skip TLS verification'),
},
},
mainField: 'server',
},
zulip: {
name: 'Zulip',
iconUrl: imagePath('spreed', 'bridge-services/zulip.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Settings#zulip',
fields: {
server: {
type: 'url',
placeholder: t('spreed', 'Zulip server URL'),
icon: 'icon-link',
},
login: {
type: 'text',
placeholder: t('spreed', 'Bot user name'),
icon: 'icon-user',
},
token: {
type: 'password',
placeholder: t('spreed', 'Bot API key'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Zulip channel'),
icon: 'icon-group',
},
},
mainField: 'server',
},
slack: {
name: 'Slack',
iconUrl: imagePath('spreed', 'bridge-services/slack.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Slack-bot-setup',
fields: {
token: {
type: 'password',
placeholder: t('spreed', 'API token'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Slack channel'),
icon: 'icon-group',
},
},
mainField: 'channel',
},
discord: {
name: 'Discord',
iconUrl: imagePath('spreed', 'bridge-services/discord.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Discord-bot-setup',
fields: {
token: {
type: 'password',
placeholder: t('spreed', 'API token'),
icon: 'icon-category-auth',
},
server: {
type: 'text',
placeholder: t('spreed', 'Server ID or name'),
icon: 'icon-group',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Channel ID (prefixed with "ID:") or name'),
icon: 'icon-group',
},
},
mainField: 'server',
},
telegram: {
name: 'Telegram',
iconUrl: imagePath('spreed', 'bridge-services/telegram.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Settings#telegram',
fields: {
token: {
type: 'password',
placeholder: t('spreed', 'API token'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Channel'),
icon: 'icon-group',
},
},
mainField: 'chatid',
},
steam: {
name: 'Steam',
iconUrl: imagePath('spreed', 'bridge-services/steam.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Settings#steam',
fields: {
login: {
type: 'text',
placeholder: t('spreed', 'Login'),
icon: 'icon-user',
},
password: {
type: 'password',
placeholder: t('spreed', 'Password'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Chat ID'),
icon: 'icon-group',
},
},
mainField: 'chatid',
},
irc: {
name: 'IRC',
iconUrl: imagePath('spreed', 'bridge-services/irc.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Settings#irc',
fields: {
server: {
type: 'url',
placeholder: t('spreed', 'IRC server URL (e.g. chat.freenode.net:6667)'),
icon: 'icon-link',
},
nick: {
type: 'text',
placeholder: t('spreed', 'Nickname'),
icon: 'icon-user',
},
password: {
type: 'password',
placeholder: t('spreed', 'Connection password'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'IRC channel'),
icon: 'icon-group',
},
channelpassword: {
type: 'password',
placeholder: t('spreed', 'Channel password'),
icon: 'icon-category-auth',
},
nickservnick: {
type: 'text',
placeholder: t('spreed', 'NickServ nickname'),
icon: 'icon-user',
},
nickservpassword: {
type: 'password',
placeholder: t('spreed', 'NickServ password'),
icon: 'icon-category-auth',
},
usetls: {
type: 'checkbox',
labelText: t('spreed', 'Use TLS'),
},
usesasl: {
type: 'checkbox',
labelText: t('spreed', 'Use SASL'),
},
skiptls: {
type: 'checkbox',
labelText: t('spreed', 'Skip TLS verification'),
},
},
mainField: 'channel',
},
msteams: {
name: 'Microsoft Teams',
iconUrl: imagePath('spreed', 'bridge-services/msteams.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/MS-Teams-setup',
fields: {
tenantid: {
type: 'text',
placeholder: t('spreed', 'Tenant ID'),
icon: 'icon-user',
},
clientid: {
type: 'password',
placeholder: t('spreed', 'Client ID'),
icon: 'icon-user',
},
teamid: {
type: 'text',
placeholder: t('spreed', 'Team ID'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Thread ID'),
icon: 'icon-group',
},
},
mainField: 'threadid',
},
xmpp: {
name: 'XMPP/Jabber',
iconUrl: imagePath('spreed', 'bridge-services/xmpp.svg'),
infoTarget: 'https://github.com/42wim/matterbridge/wiki/Settings#xmpp',
fields: {
server: {
type: 'url',
placeholder: t('spreed', 'XMPP/Jabber server URL'),
icon: 'icon-link',
},
muc: {
type: 'url',
placeholder: t('spreed', 'MUC server URL'),
icon: 'icon-link',
},
jid: {
type: 'text',
placeholder: t('spreed', 'Jabber ID'),
icon: 'icon-user',
},
nick: {
type: 'text',
placeholder: t('spreed', 'Nickname'),
icon: 'icon-user',
},
password: {
type: 'password',
placeholder: t('spreed', 'Password'),
icon: 'icon-category-auth',
},
channel: {
type: 'text',
placeholder: t('spreed', 'Channel'),
icon: 'icon-group',
},
skiptls: {
type: 'checkbox',
labelText: t('spreed', 'Skip TLS verification'),
},
},
mainField: 'channel',
},
}
@@ -0,0 +1,132 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div v-if="canModerate">
<NcCheckboxRadioSwitch
:modelValue="mentionPermissions === MENTION_PERMISSIONS.EVERYONE"
:disabled="isMentionPermissionsLoading"
type="switch"
@update:modelValue="toggleMentionPermissions">
{{ t('spreed', 'Allow participants to mention @all') }}
</NcCheckboxRadioSwitch>
</div>
<div v-else>
<h5 class="app-settings-section__subtitle">
{{ t('spreed', 'Mention permissions') }}
</h5>
<p>{{ summaryLabel }}</p>
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { CONVERSATION } from '../../constants.ts'
export default {
name: 'MentionsSettings',
components: {
NcCheckboxRadioSwitch,
},
props: {
token: {
type: String,
default: null,
},
canModerate: {
type: Boolean,
default: true,
},
},
setup() {
const { MENTION_PERMISSIONS } = CONVERSATION
return {
MENTION_PERMISSIONS,
}
},
data() {
return {
mentionPermissions: null,
isMentionPermissionsLoading: false,
lastNotification: null,
}
},
computed: {
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
summaryLabel() {
switch (this.mentionPermissions) {
case CONVERSATION.MENTION_PERMISSIONS.MODERATORS:
return t('spreed', 'Only moderators are allowed to mention @all')
case CONVERSATION.MENTION_PERMISSIONS.EVERYONE:
default:
return t('spreed', 'All participants are allowed to mention @all')
}
},
},
watch: {
conversation: {
immediate: true,
handler() {
this.mentionPermissions = this.conversation.mentionPermissions
},
},
},
beforeUnmount() {
if (this.lastNotification) {
this.lastNotification.hideToast()
this.lastNotification = null
}
},
methods: {
t,
async toggleMentionPermissions(checked) {
const mentionPermissions = checked ? this.MENTION_PERMISSIONS.EVERYONE : this.MENTION_PERMISSIONS.MODERATORS
if (!this.token) {
this.mentionPermissions = mentionPermissions
return
}
this.isMentionPermissionsLoading = true
try {
await this.$store.dispatch('setMentionPermissions', {
token: this.token,
mentionPermissions,
})
if (this.lastNotification) {
this.lastNotification.hideToast()
this.lastNotification = null
}
if (mentionPermissions === CONVERSATION.MENTION_PERMISSIONS.EVERYONE) {
this.lastNotification = showSuccess(t('spreed', 'Participants are now allowed to mention @all.'))
} else if (mentionPermissions === CONVERSATION.MENTION_PERMISSIONS.MODERATORS) {
this.lastNotification = showSuccess(t('spreed', 'Mentioning @all has been limited to moderators.'))
}
this.mentionPermissions = mentionPermissions
} catch (e) {
console.error('Error occurred when opening or limiting the conversation', e)
showError(t('spreed', 'Error occurred when opening or limiting the conversation'))
this.mentionPermissions = this.conversation.mentionPermissions
}
this.isMentionPermissionsLoading = false
},
},
}
</script>
@@ -0,0 +1,177 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { Conversation } from '../../types/index.ts'
import { t } from '@nextcloud/l10n'
import { computed, reactive } from 'vue'
import { useStore } from 'vuex'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import IconBellOffOutline from 'vue-material-design-icons/BellOffOutline.vue'
import IconBellOutline from 'vue-material-design-icons/BellOutline.vue'
import IconBellRingOutline from 'vue-material-design-icons/BellRingOutline.vue'
import { PARTICIPANT } from '../../constants.ts'
import { hasTalkFeature } from '../../services/CapabilitiesManager.ts'
const props = defineProps<{
conversation: Conversation
}>()
const supportImportantConversations = hasTalkFeature('local', 'important-conversations')
const supportSensitiveConversations = hasTalkFeature('local', 'sensitive-conversations')
const notificationLevels = [
{
value: PARTICIPANT.NOTIFY.ALWAYS,
icon: IconBellRingOutline,
label: t('spreed', 'All messages'),
},
{
value: PARTICIPANT.NOTIFY.MENTION,
icon: IconBellOutline,
label: t('spreed', '@-mentions only'),
},
{
value: PARTICIPANT.NOTIFY.NEVER,
icon: IconBellOffOutline,
label: t('spreed', 'Off'),
},
]
const store = useStore()
const showCallNotificationSettings = computed(() => {
return !props.conversation.remoteServer || hasTalkFeature(props.conversation.token, 'federation-v2')
})
const loading = reactive({
level: false,
calls: false,
important: false,
sensitive: false,
})
const notificationLevel = computed(() => props.conversation.notificationLevel.toString())
/**
* Set the notification level for the conversation
* FIXME: should be a computed with type "number", but it doesn't work at Vue 2 TS
*
* @param value The notification level to set.
*/
async function setNotificationLevel(value: string) {
loading.level = true
await store.dispatch('setNotificationLevel', {
token: props.conversation.token,
notificationLevel: +value,
})
loading.level = false
}
const notifyCalls = computed({
get: () => props.conversation.notificationCalls === PARTICIPANT.NOTIFY_CALLS.ON,
set: async (value) => {
loading.calls = true
await store.dispatch('setNotificationCalls', {
token: props.conversation.token,
notificationCalls: value ? PARTICIPANT.NOTIFY_CALLS.ON : PARTICIPANT.NOTIFY_CALLS.OFF,
})
loading.calls = false
},
})
const isImportant = computed({
get: () => props.conversation.isImportant,
set: async (value) => {
loading.important = true
await store.dispatch('toggleImportant', {
token: props.conversation.token,
isImportant: value,
})
loading.important = false
},
})
const isSensitive = computed({
get: () => props.conversation.isSensitive,
set: async (value) => {
loading.sensitive = true
await store.dispatch('toggleSensitive', {
token: props.conversation.token,
isSensitive: value,
})
loading.sensitive = false
},
})
</script>
<template>
<div class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Notifications') }}
</h4>
<NcCheckboxRadioSwitch
v-for="level in notificationLevels"
:key="level.value"
:modelValue="notificationLevel"
:value="level.value.toString()"
:disabled="loading.level"
name="notification_level"
type="radio"
@update:modelValue="setNotificationLevel">
<span class="radio-button">
<component :is="level.icon" :size="20" />
{{ level.label }}
</span>
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-if="showCallNotificationSettings"
id="notification_calls"
v-model="notifyCalls"
:disabled="loading.calls"
type="switch">
{{ t('spreed', 'Notify about calls in this conversation') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-if="supportImportantConversations"
id="important"
v-model="isImportant"
:disabled="loading.important"
aria-describedby="important-hint"
type="switch">
{{ t('spreed', 'Important conversation') }}
</NcCheckboxRadioSwitch>
<p id="important-hint" class="app-settings-section__hint">
{{ t('spreed', '"Do not disturb" user status is ignored for important conversations') }}
</p>
<NcCheckboxRadioSwitch
v-if="supportSensitiveConversations"
id="sensitive"
v-model="isSensitive"
:disabled="loading.sensitive"
aria-describedby="sensitive-hint"
type="switch">
{{ t('spreed', 'Sensitive conversation') }}
</NcCheckboxRadioSwitch>
<p id="sensitive-hint" class="app-settings-section__hint">
{{ t('spreed', 'Message preview will be disabled in conversation list and notifications') }}
</p>
</div>
</template>
<style lang="scss" scoped>
.radio-button {
display: flex;
align-items: center;
gap: calc(2 * var(--default-grid-baseline));
}
</style>
@@ -0,0 +1,110 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Recording Consent') }}
</h4>
<div v-if="disabled && !loading" class="app-settings-section__hint">
{{ t('spreed', 'Recording consent cannot be changed once a call or breakout session has started.') }}
</div>
<NcCheckboxRadioSwitch
v-if="canModerate && !isGlobalConsent"
v-model="recordingConsentSelected"
type="switch"
:disabled="disabled"
@update:modelValue="setRecordingConsent">
{{ t('spreed', 'Require recording consent before joining call in this conversation') }}
</NcCheckboxRadioSwitch>
<p v-else-if="isGlobalConsent">
{{ t('spreed', 'Recording consent is required for all calls') }}
</p>
<p v-else>
{{ summaryLabel }}
</p>
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { CALL, CONFIG, CONVERSATION } from '../../constants.ts'
import { getTalkConfig } from '../../services/CapabilitiesManager.ts'
export default {
name: 'RecordingConsentSettings',
components: {
NcCheckboxRadioSwitch,
},
props: {
token: {
type: String,
default: null,
},
canModerate: {
type: Boolean,
default: true,
},
},
data() {
return {
loading: false,
recordingConsentSelected: !!CALL.RECORDING_CONSENT.DISABLED,
}
},
computed: {
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
isGlobalConsent() {
return getTalkConfig(this.token, 'call', 'recording-consent') === CONFIG.RECORDING_CONSENT.REQUIRED
},
isBreakoutRoomStarted() {
return this.conversation.breakoutRoomStatus === CONVERSATION.BREAKOUT_ROOM_STATUS.STARTED
},
disabled() {
return this.loading || this.conversation.hasCall || this.isBreakoutRoomStarted
},
summaryLabel() {
return this.conversation.recordingConsent === CALL.RECORDING_CONSENT.ENABLED
? t('spreed', 'Recording consent is required for calls in this conversation')
: t('spreed', 'Recording consent is not required for calls in this conversation')
},
},
mounted() {
this.recordingConsentSelected = !!this.conversation.recordingConsent
},
methods: {
t,
async setRecordingConsent(value) {
this.loading = true
try {
await this.$store.dispatch('setRecordingConsent', {
token: this.token,
state: value ? CALL.RECORDING_CONSENT.ENABLED : CALL.RECORDING_CONSENT.DISABLED,
})
showSuccess(t('spreed', 'Recording consent requirement was updated'))
} catch (error) {
showError(t('spreed', 'Error occurred while updating recording consent'))
console.error(error)
}
this.loading = false
},
},
}
</script>
@@ -0,0 +1,109 @@
<!--
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<div class="app-settings-subsection">
<h4 class="app-settings-section__subtitle">
{{ t('spreed', 'Phone and SIP dial-in') }}
</h4>
<div>
<NcCheckboxRadioSwitch
:modelValue="hasSIPEnabled"
type="switch"
aria-describedby="sip_settings_hint"
:disabled="isSipLoading"
@update:modelValue="toggleSetting('enable')">
{{ t('spreed', 'Enable phone and SIP dial-in') }}
</NcCheckboxRadioSwitch>
</div>
<div v-if="hasSIPEnabled">
<NcCheckboxRadioSwitch
:modelValue="noPinRequired"
type="switch"
:disabled="isSipLoading || !hasSIPEnabled"
@update:modelValue="toggleSetting('nopin')">
{{ t('spreed', 'Allow to dial-in without a PIN') }}
</NcCheckboxRadioSwitch>
</div>
</div>
</template>
<script>
import { showError, showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { useGetToken } from '../../composables/useGetToken.ts'
import { WEBINAR } from '../../constants.ts'
export default {
name: 'SipSettings',
components: {
NcCheckboxRadioSwitch,
},
setup() {
return {
token: useGetToken(),
}
},
data() {
return {
isSipLoading: false,
}
},
computed: {
conversation() {
return this.$store.getters.conversation(this.token) || this.$store.getters.dummyConversation
},
hasSIPEnabled() {
return this.conversation.sipEnabled !== WEBINAR.SIP.DISABLED
},
noPinRequired() {
return this.conversation.sipEnabled === WEBINAR.SIP.ENABLED_NO_PIN
},
},
methods: {
t,
async toggleSetting(setting) {
let state = WEBINAR.SIP.DISABLED
if (setting === 'enable') {
state = this.conversation.sipEnabled === WEBINAR.SIP.DISABLED ? WEBINAR.SIP.ENABLED : WEBINAR.SIP.DISABLED
} else if (setting === 'nopin') {
state = this.conversation.sipEnabled === WEBINAR.SIP.ENABLED ? WEBINAR.SIP.ENABLED_NO_PIN : WEBINAR.SIP.ENABLED
}
try {
await this.$store.dispatch('setSIPEnabled', {
token: this.token,
state,
})
if (this.conversation.sipEnabled === WEBINAR.SIP.ENABLED_NO_PIN) {
showSuccess(t('spreed', 'SIP dial-in is now possible without PIN requirement'))
} else if (this.conversation.sipEnabled === WEBINAR.SIP.ENABLED) {
showSuccess(t('spreed', 'SIP dial-in is now enabled'))
} else {
showSuccess(t('spreed', 'SIP dial-in is now disabled'))
}
} catch (e) {
// TODO check "precondition failed"
if (!this.conversation.sipEnabled) {
console.error('Error occurred when enabling SIP dial-in', e)
showError(t('spreed', 'Error occurred when enabling SIP dial-in'))
} else {
console.error('Error occurred when disabling SIP dial-in', e)
showError(t('spreed', 'Error occurred when disabling SIP dial-in'))
}
}
},
},
}
</script>
@@ -0,0 +1,120 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script lang="ts" setup>
import { useIsMobile, useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
const { wide = false, title = '', subtitle = '', description = '' } = defineProps<{
wide?: boolean
title?: string
subtitle?: string
description?: string
}>()
const isSmallMobile = useIsSmallMobile()
const isMobile = useIsMobile()
</script>
<template>
<div
class="dashboard-section"
:class="{
'dashboard-section--wide': wide && !isSmallMobile,
'dashboard-section--list': $slots.list,
}">
<div
v-if="!isSmallMobile"
class="dashboard-section__bar"
:class="{
'dashboard-section__bar--narrow': $slots.list || isMobile,
gradient: !$slots.image || isMobile,
'image-container': $slots.image,
}">
<slot v-if="!($slots.list || isMobile)" name="image" />
</div>
<div class="dashboard-section__content">
<h3 class="dashboard-section__title">
{{ title }}
</h3>
<span v-if="subtitle" class="dashboard-section__subtitle">{{ subtitle }}</span>
<span v-if="description" class="dashboard-section__description">{{ description }}</span>
<slot name="list" />
<div v-if="$slots.action" class="dashboard-section__action">
<slot name="action" />
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.dashboard-section {
display: flex;
border-radius: var(--border-radius-large);
overflow: hidden;
border: 2px solid var(--color-border);
height: 100%;
&--wide {
flex-direction: row;
.dashboard-section__content {
justify-content: center;
}
}
&__content {
position: relative;
display: flex;
flex-direction: column;
flex: auto;
min-height: 0;
padding: 0 calc(var(--default-grid-baseline) * 3) calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 5);
}
&__bar {
flex: 0 0 200px;
&.gradient {
background: linear-gradient(78deg, var(--color-primary) 60%, var(--color-main-background) 120%);
}
&--narrow {
flex: 0 0 10px;
}
// Style for slotted images
:deep(img) {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
&.image-container {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
}
&__title {
font-size: 1.25rem;
font-weight: bold;
overflow-wrap: break-word;
}
&__subtitle {
font-weight: bold;
}
&__action {
padding-block: calc(var(--default-grid-baseline) * 2);
}
}
h3 {
margin-block: calc(var(--default-grid-baseline) * 2);
}
</style>
+394
View File
@@ -0,0 +1,394 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script lang="ts" setup>
import type { Conversation, DashboardEventRoom } from '../../types/index.ts'
import { getCanonicalLocale, getLanguage, n, t } from '@nextcloud/l10n'
import { imagePath } from '@nextcloud/router'
import { usernameToColor } from '@nextcloud/vue/functions/usernameToColor'
import { useNow } from '@vueuse/core'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcChip from '@nextcloud/vue/components/NcChip'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import IconCalendarBlankOutline from 'vue-material-design-icons/CalendarBlankOutline.vue'
import IconVideo from 'vue-material-design-icons/Video.vue' // Filled for better indication
import IconVideoOutline from 'vue-material-design-icons/VideoOutline.vue'
import ConversationIcon from '../ConversationIcon.vue'
import IconTalk from '../../../img/app-dark.svg?raw'
import { useIsInCall } from '../../composables/useIsInCall.js'
import { CONVERSATION } from '../../constants.ts'
import { localCapabilities } from '../../services/CapabilitiesManager.ts'
import { formattedTime, ONE_DAY_IN_MS } from '../../utils/formattedTime.ts'
type ConversationFromEvent = Pick<Conversation, 'token' | 'type' | 'name' | 'displayName' | 'avatarVersion' | 'callStartTime' | 'hasCall'>
const props = defineProps<{
eventRoom: DashboardEventRoom
}>()
const isCalendarEnabled = localCapabilities.calendar?.webui ?? false
const store = useStore()
const router = useRouter()
const isInCall = useIsInCall()
const conversation = computed<ConversationFromEvent>(() => {
return store.getters.conversation(props.eventRoom.roomToken) ?? {
token: props.eventRoom.roomToken,
type: props.eventRoom.roomType,
name: props.eventRoom.roomName,
displayName: props.eventRoom.roomDisplayName,
avatarVersion: props.eventRoom.roomAvatarVersion,
callStartTime: props.eventRoom.roomActiveSince ?? 0,
hasCall: props.eventRoom.roomActiveSince !== null,
}
})
const hasCall = computed(() => {
return (conversation.value.hasCall || props.eventRoom.roomActiveSince !== null)
&& props.eventRoom.start * 1000 >= (Date.now() - 600_000) // 10 minutes buffer
})
const elapsedTime = computed(() => {
if (!hasCall.value || !(props.eventRoom.roomActiveSince ?? conversation.value.callStartTime)) {
return ''
}
return formattedTime(+useNow({ interval: 1_000 }).value - (props.eventRoom.roomActiveSince ?? conversation.value.callStartTime) * 1000)
})
const isToday = computed(() => {
return new Date(props.eventRoom.start * 1000).toDateString() === new Date().toDateString()
})
const eventDateLabel = computed(() => {
if (hasCall.value) {
return t('spreed', 'Ongoing')
}
const startDate = new Date(props.eventRoom.start * 1000)
const endDate = new Date(props.eventRoom.end * 1000)
const isTomorrow = startDate.toDateString() === new Date(Date.now() + ONE_DAY_IN_MS).toDateString()
let time
if (startDate.toDateString() === endDate.toDateString()) {
if (isToday.value || isTomorrow) {
// show the time only
const timeRange = Intl.DateTimeFormat(getCanonicalLocale(), {
hour: 'numeric',
minute: 'numeric',
}).formatRange(startDate, endDate)
const relativeFormatter = new Intl.RelativeTimeFormat(getLanguage(), { numeric: 'auto' })
// TRANSLATORS: e.g. "Tomorrow 10:00 - 11:00"
time = t('spreed', '{dayPrefix} {dateTime}', {
dayPrefix: isToday.value ? relativeFormatter.format(0, 'day') : relativeFormatter.format(1, 'day'),
dateTime: timeRange,
})
} else {
time = Intl.DateTimeFormat(getCanonicalLocale(), {
weekday: 'long',
hour: 'numeric',
minute: 'numeric',
}).formatRange(startDate, endDate)
}
} else {
// show the month and the year as well
time = Intl.DateTimeFormat(getCanonicalLocale(), {
month: 'long',
year: 'numeric',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
}).formatRange(startDate, endDate)
}
return time
})
const totalAttachments = computed(() => Object.values(props.eventRoom.eventAttachments))
const invitesLabel = computed(() => {
const acceptedInvites = props.eventRoom.accepted ? n('spreed', '%n person accepted', '%n people accepted', props.eventRoom.accepted) : ''
const declinedInvites = props.eventRoom.declined ? n('spreed', '%n person declined', '%n people declined', props.eventRoom.declined) : ''
// FIXME should be a translated string ??
return [acceptedInvites, declinedInvites].filter(Boolean).join(', ')
})
const attachmentInfo = computed(() => {
if (!totalAttachments.value.length) {
return null
}
const file = totalAttachments.value[0]
return {
icon: OC.MimeType.getIconUrl(file.fmttype) || imagePath('core', 'filetypes/file'),
label: file.filename.replace(/^\//, ''),
extraLabel: totalAttachments.value.length > 1
? n('spreed', 'and %n other attachment', 'and %n other attachments', totalAttachments.value.length - 1)
: '',
url: file.previewLink ?? undefined,
}
})
const roomLabel = computed(() => {
return props.eventRoom.roomType === CONVERSATION.TYPE.ONE_TO_ONE
? t('spreed', 'With {displayName}', { displayName: props.eventRoom.roomDisplayName }, { escape: false, sanitize: false })
: t('spreed', 'In {conversation}', { conversation: props.eventRoom.roomDisplayName }, { escape: false, sanitize: false })
})
/**
* Redirects to the conversation page and opens media settings
*
* @param data object
* @param data.call - if true, opens the media settings
*/
function handleJoin({ call }: { call: boolean }) {
router.push({
name: 'conversation',
params: { token: props.eventRoom.roomToken },
hash: call ? '#direct-call' : undefined,
})
}
</script>
<template>
<div
class="event-card"
:class="{
'event-card--highlighted': isToday,
'event-card--in-call': hasCall,
}">
<h4 class="title">
<span
v-for="calendar in props.eventRoom.calendars"
:key="calendar.principalUri"
class="calendar-badge"
:style="{ backgroundColor: calendar.calendarColor ?? usernameToColor(calendar.principalUri).color }" />
<span class="title_text">
{{ props.eventRoom.eventName }}
</span>
</h4>
<p class="event-card__date secondary_text">
<span>{{ eventDateLabel }}</span>
<template v-if="hasCall">
<IconVideo :size="20" fillColor="var(--color-border-error)" />
<span>{{ elapsedTime }}</span>
</template>
</p>
<span class="event-card__room secondary_text">
<NcChip
variant="tertiary"
:text="roomLabel"
noClose>
<template #icon>
<ConversationIcon
:item="conversation"
hideUserStatus
:size="20" />
</template>
</NcChip>
</span>
<span class="event-card__description">{{ props.eventRoom.eventDescription }}</span>
<template v-if="attachmentInfo">
<a
class="event-card__attachment"
role="link"
:href="attachmentInfo.url"
:title="t('spreed', 'View attachment')"
target="_blank">
<img
class="file-preview__image"
:alt="attachmentInfo.label"
:src="attachmentInfo.icon">
<span> {{ attachmentInfo.label }} </span>
</a>
<span v-if="attachmentInfo.extraLabel" class="secondary_text">
{{ attachmentInfo.extraLabel }}
</span>
</template>
<span class="event-card__invitation-info">
<span v-if="invitesLabel && !hasCall" class="secondary_text">
{{ invitesLabel }}
</span>
<NcButton
v-if="(hasCall && !isInCall)"
variant="primary"
@click="handleJoin({ call: true })">
<template #icon>
<IconVideoOutline :size="20" />
</template>
{{ t('spreed', 'Join') }}
</NcButton>
</span>
<span class="event-card__invitation-info hovered">
<NcButton
variant="tertiary"
@click="handleJoin({ call: false })">
<template #icon>
<NcIconSvgWrapper :svg="IconTalk" :size="20" />
</template>
{{ t('spreed', 'View conversation') }}
</NcButton>
<NcButton
v-if="isCalendarEnabled"
variant="tertiary"
:href="props.eventRoom.eventLink"
target="_blank"
:title="t('spreed', 'View event on Calendar')"
:aria-label="t('spreed', 'View event on Calendar')">
<template #icon>
<IconCalendarBlankOutline :size="20" />
</template>
</NcButton>
</span>
</div>
</template>
<style scoped lang="scss">
.event-card {
position: relative;
height: 250px;
display: flex;
flex-direction: column;
flex: 0 0 100%;
max-width: 300px;
border: 2px solid var(--color-border);
padding: calc(var(--default-grid-baseline) * 2);
border-radius: var(--border-radius-large);
background-color: var(--color-main-background);
&--highlighted {
background-color: var(--color-primary-light);
&:not(.event-card--in-call) {
border-color: var(--color-primary-element-light-hover) !important;
}
}
&--in-call {
border-color: var(--color-primary) !important;
}
&:not(.event-card--in-call):hover > .event-card__invitation-info.hovered {
display: flex;
background-color: inherit;
}
&__date {
display: flex;
gap: 2px;
& > span::first-letter {
text-transform: capitalize;
}
& > * {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
&__description {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
margin-block: calc(var(--default-grid-baseline) * 2);
}
&__room {
display: flex;
align-items: center;
gap: var(--default-grid-baseline);
}
&__attachment {
display: flex;
align-items: center;
gap: var(--default-grid-baseline);
margin-block-start: var(--default-grid-baseline);
padding: var(--default-grid-baseline) calc(var(--default-grid-baseline) / 2);
& > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
font-size: var(--font-size-small);
}
&:hover {
background-color: var(--color-background-hover);
border-radius: var(--border-radius-large);
}
}
&__invitation-info {
position: absolute;
bottom: 0;
inset-inline-start: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
border-radius: var(--border-radius-large);
padding: calc(var(--default-grid-baseline) * 2);
&.hovered {
display: none;
}
}
}
.title {
display: flex;
align-items: center;
padding-inline-start: 6px; // revert negative margin
gap: var(--default-grid-baseline);
font-size: inherit;
margin: 0;
&_text {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.secondary_text {
color: var(--color-text-maxcontrast);
font-size: var(--font-size-small);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.calendar-badge {
display: inline-flex;
width: var(--default-font-size);
height: var(--default-font-size);
border-radius: 50%;
margin-inline-start: -6px; // negative margin to overlap
position: relative;
z-index: 1;
box-shadow: 0 0 0 1px var(--color-main-background);
flex-shrink: 0;
}
:deep(.nc-chip) {
background-color: unset;
overflow: hidden;
}
</style>
+579
View File
@@ -0,0 +1,579 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script lang="ts" setup>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { isRTL, t } from '@nextcloud/l10n'
import { generateUrl, imagePath } from '@nextcloud/router'
import { useIsMobile, useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcInputField from '@nextcloud/vue/components/NcInputField'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import IconCalendarBlankOutline from 'vue-material-design-icons/CalendarBlankOutline.vue'
import IconList from 'vue-material-design-icons/FormatListBulleted.vue'
import IconMicrophoneOutline from 'vue-material-design-icons/MicrophoneOutline.vue'
import IconPhoneOutline from 'vue-material-design-icons/PhoneOutline.vue'
import IconPlus from 'vue-material-design-icons/Plus.vue'
import IconVideoOutline from 'vue-material-design-icons/VideoOutline.vue'
import ConversationsListVirtual from '../LeftSidebar/ConversationsList/ConversationsListVirtual.vue'
import SearchMessageItem from '../RightSidebar/SearchMessages/SearchMessageItem.vue'
import LoadingPlaceholder from '../UIShared/LoadingPlaceholder.vue'
import DashboardSection from './DashboardSection.vue'
import EventCard from './EventCard.vue'
import { CONVERSATION } from '../../constants.ts'
import { getTalkConfig, hasTalkFeature, localCapabilities } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts'
import { useActorStore } from '../../stores/actor.ts'
import { useDashboardStore } from '../../stores/dashboard.ts'
import { hasUnreadMentions } from '../../utils/conversation.ts'
import { copyConversationLinkToClipboard } from '../../utils/handleUrl.ts'
const supportsUpcomingReminders = hasTalkFeature('local', 'upcoming-reminders')
const canModerateSipDialOut = hasTalkFeature('local', 'sip-support-dialout')
&& getTalkConfig('local', 'call', 'sip-enabled')
&& getTalkConfig('local', 'call', 'sip-dialout-enabled')
&& getTalkConfig('local', 'call', 'can-enable-sip')
const isCallEnabled = getTalkConfig('local', 'call', 'enabled')
const canStartConversations = getTalkConfig('local', 'conversations', 'can-create')
const isCalendarEnabled = localCapabilities.calendar?.webui ?? false
const isDirectionRTL = isRTL()
const isMobile = useIsMobile()
const isSmallMobile = useIsSmallMobile()
const store = useStore()
const router = useRouter()
const dashboardStore = useDashboardStore()
const actorStore = useActorStore()
const forwardScrollable = ref(false)
const backwardScrollable = ref(false)
const eventCardsWrapper = ref<HTMLDivElement | null>(null)
const eventRooms = computed(() => dashboardStore.eventRooms || [])
const upcomingReminders = computed(() => dashboardStore.upcomingReminders || [])
const eventsInitialised = computed(() => dashboardStore.eventRoomsInitialised)
const remindersInitialised = computed(() => dashboardStore.upcomingRemindersInitialised)
const conversationName = ref('')
let actualizeDataInterval: ReturnType<typeof setInterval> | null = null
// Data fetching handlers
/**
* Fetches all necessary data for the dashboard.
*/
async function actualizeData() {
await Promise.all([
dashboardStore.fetchDashboardEventRooms(),
dashboardStore.fetchUpcomingReminders(),
])
}
/**
* Initializes the data fetching interval and fetches initial data.
*/
function initActualizeData() {
if (actualizeDataInterval) {
clearInterval(actualizeDataInterval)
}
actualizeData()
actualizeDataInterval = setInterval(actualizeData, 300_000)
}
initActualizeData()
EventBus.on('refresh-talk-dashboard', initActualizeData)
onBeforeUnmount(() => {
if (actualizeDataInterval) {
clearInterval(actualizeDataInterval)
}
if (eventCardsWrapper?.value) {
resizeObserver.disconnect()
}
EventBus.off('refresh-talk-dashboard', initActualizeData)
})
watch(eventCardsWrapper, (newValue) => {
if (newValue) {
resizeObserver.observe(newValue)
}
})
/**
* Updates the scrollable flags based on the current scroll position.
*/
async function updateScrollableFlags() {
await nextTick()
if (eventCardsWrapper.value) {
const { scrollLeft, scrollWidth, clientWidth } = eventCardsWrapper.value
backwardScrollable.value = isDirectionRTL ? scrollLeft < 0 : scrollLeft > 0
forwardScrollable.value = (isDirectionRTL ? -1 : 1) * scrollLeft + clientWidth < scrollWidth - 10 // 10px tolerance
}
}
// Use ResizeObserver to detect size changes
const resizeObserver = new ResizeObserver(() => {
updateScrollableFlags()
})
const conversationsInitialised = computed(() => store.getters.conversationsInitialised)
const filteredConversations = computed(() => store.getters.conversationsList.filter(hasUnreadMentions))
/**
* Creates a new group conversation and navigates to the conversation page.
*/
async function startMeeting() {
try {
const conversation = await store.dispatch('createGroupConversation', {
// TRANSLATORS: Section header for meeting-related settings; also a static name fallback for instant meeting conversation
roomName: conversationName.value || t('spreed', 'Meeting'),
roomType: CONVERSATION.TYPE.PUBLIC,
objectType: CONVERSATION.OBJECT_TYPE.INSTANT_MEETING,
objectId: Math.floor(Date.now() / 1000).toString(),
})
await copyConversationLinkToClipboard(conversation.token)
await router.push({
name: 'conversation',
params: { token: conversation.token },
hash: '#direct-call',
})
} catch (error) {
console.error('Error creating conversation:', error)
showError(t('spreed', 'Error while creating the conversation'))
}
}
/**
* Scrolls the event cards wrapper in the specified direction.
*
* @param payload
* @param payload.direction - The direction to scroll ('backward' or 'forward').
*/
function scrollEventCards({ direction }: { direction: 'backward' | 'forward' }) {
const scrollDirection = (direction === 'backward' ? -1 : 1) * (isDirectionRTL ? -1 : 1)
if (eventCardsWrapper.value) {
const ITEM_WIDTH = 300 + 8 // 300px width + 8px gap
let scrollAmount = 0
const visibleItems = Math.floor(eventCardsWrapper.value.clientWidth / ITEM_WIDTH)
if (visibleItems === 0) {
scrollAmount = eventCardsWrapper.value.clientWidth * scrollDirection
} else {
scrollAmount = visibleItems * ITEM_WIDTH * scrollDirection
// Arrow buttons are 34px wide
if (!backwardScrollable.value && scrollDirection === 1) {
scrollAmount -= 34
} else if (!forwardScrollable.value && scrollDirection === -1) {
scrollAmount += 34
}
}
eventCardsWrapper.value.scrollBy({
left: scrollAmount,
behavior: 'smooth',
})
}
}
</script>
<template>
<div
class="talk-dashboard-wrapper"
:class="{
'talk-dashboard-wrapper--mobile': isMobile,
'talk-dashboard-wrapper--small-mobile': isSmallMobile,
}">
<div class="talk-dashboard__menu">
<h2 class="talk-dashboard__header">
{{ t('spreed', 'Hello, {displayName}', { displayName: actorStore.displayName }, { escape: false }) }}
</h2>
<div class="talk-dashboard__actions">
<NcPopover
v-if="canStartConversations"
popupRole="dialog">
<template #trigger>
<NcButton
v-if="isCallEnabled"
variant="primary">
<template #icon>
<IconVideoOutline />
</template>
{{ t('spreed', 'Start meeting now') }}
</NcButton>
</template>
<div
role="dialog"
aria-labelledby="instant_meeting_dialog"
class="instant-meeting__dialog"
aria-modal="true">
<strong>{{ t('spreed', 'Give your meeting a title') }}</strong>
<NcInputField
id="room-name"
v-model="conversationName"
:placeholder="t('spreed', 'Meeting')" />
<NcButton
variant="primary"
@click="startMeeting">
{{ t('spreed', 'Create and copy link') }}
</NcButton>
</div>
</NcPopover>
<NcButton
v-if="canStartConversations"
@click="EventBus.emit('new-conversation-dialog:show')">
<template #icon>
<IconPlus :size="20" />
</template>
{{ t('spreed', 'Create a new conversation') }}
</NcButton>
<NcButton @click="EventBus.emit('open-conversations-list:show')">
<template #icon>
<IconList :size="20" />
</template>
{{ t('spreed', 'Join open conversations') }}
</NcButton>
<NcButton
v-if="isCallEnabled && canModerateSipDialOut"
@click="EventBus.emit('call-phone-dialog:show')">
<template #icon>
<IconPhoneOutline :size="20" />
</template>
{{ t('spreed', 'Call a phone number') }}
</NcButton>
<NcButton
variant="secondary"
@click="emit('talk:media-settings:show', 'device-check')">
<template #icon>
<IconMicrophoneOutline :size="20" />
</template>
{{ t('spreed', 'Check devices') }}
</NcButton>
</div>
</div>
<div class="talk-dashboard__items">
<div class="event-section">
<template v-if="eventsInitialised && eventRooms.length > 0">
<h3 class="title">
{{ t('spreed', 'Upcoming meetings') }}
</h3>
<div
class="talk-dashboard__event-cards-wrapper"
:class="{ 'forward-scrollable': forwardScrollable, 'backward-scrollable': backwardScrollable }">
<div
ref="eventCardsWrapper"
class="talk-dashboard__event-cards"
@scroll.passive="updateScrollableFlags">
<EventCard
v-for="eventRoom in eventRooms"
:key="eventRoom.eventLink"
:eventRoom="eventRoom"
class="talk-dashboard__event-card" />
</div>
<div class="talk-dashboard__event-cards__scroll-indicator">
<NcButton
v-show="backwardScrollable"
class="button-slide backward"
variant="tertiary"
:title="t('spreed', 'Scroll backward')"
:aria-label="t('spreed', 'Scroll backward')"
@click="scrollEventCards({ direction: 'backward' })">
<template #icon>
<IconArrowLeft class="bidirectional-icon" />
</template>
</NcButton>
<NcButton
v-show="forwardScrollable"
class="button-slide forward"
variant="tertiary"
:title="t('spreed', 'Scroll forward')"
:aria-label="t('spreed', 'Scroll forward')"
@click="scrollEventCards({ direction: 'forward' })">
<template #icon>
<IconArrowRight class="bidirectional-icon" />
</template>
</NcButton>
</div>
</div>
</template>
<LoadingPlaceholder
v-else-if="!eventsInitialised"
type="event-cards" />
<DashboardSection
v-else
class="event-section--empty"
wide
:title="t('spreed', 'Schedule meetings')"
:subtitle="t('spreed', 'You don\'t have any upcoming meetings')"
:description="t('spreed', 'Schedule a meeting from your calendar. A Talk conversation needs to be set as location to show up here')">
<template #image>
<img :src="imagePath('spreed', 'dashboard/meetings.png')">
</template>
<template #action>
<NcButton
v-if="isCalendarEnabled"
variant="secondary"
:href="generateUrl('apps/calendar')"
target="_blank">
<template #icon>
<IconCalendarBlankOutline :size="20" />
</template>
{{ t('spreed', 'Open calendar') }}
</NcButton>
</template>
</DashboardSection>
</div>
<div class="talk-dashboard__chats">
<div class="talk-dashboard__unread-mentions">
<DashboardSection
v-if="filteredConversations.length > 0 || !conversationsInitialised"
:title="t('spreed', 'Unread mentions')">
<template #list>
<ConversationsListVirtual
class="talk-dashboard__conversations-list"
:conversations="filteredConversations"
:loading="!conversationsInitialised" />
</template>
</DashboardSection>
<DashboardSection
v-else
:title="t('spreed', 'Unread mentions')"
:description="t('spreed', 'Messages where you were mentioned will show up here. You can mention people by typing @ followed by their name')">
<template #image>
<img :src="imagePath('spreed', 'dashboard/mentions.png')">
</template>
</DashboardSection>
</div>
<div
v-if="supportsUpcomingReminders"
class="talk-dashboard__upcoming-reminders">
<DashboardSection
v-if="upcomingReminders.length > 0 || !remindersInitialised"
:title="t('spreed', 'Upcoming reminders')">
<template #list>
<ul v-if="remindersInitialised" class="upcoming-reminders-list">
<SearchMessageItem
v-for="reminder in upcomingReminders"
:key="reminder.messageId"
:messageId="reminder.messageId"
:title="reminder.actorDisplayName"
:subline="reminder.message"
:messageParameters="reminder.messageParameters"
:token="reminder.roomToken"
:to="{
name: 'conversation',
params: { token: reminder.roomToken },
hash: `#message_${reminder.messageId}`,
}"
:actorId="reminder.actorId"
:actorType="reminder.actorType"
:timestamp="reminder.reminderTimestamp"
isReminder />
</ul>
<LoadingPlaceholder
v-else
class="upcoming-reminders__loading-placeholder"
type="conversations" />
</template>
</DashboardSection>
<DashboardSection
v-else
:title="t('spreed', 'Message reminders')"
:description="t('spreed', 'Set a reminder on a message to be notified')">
<template #image>
<img :src="imagePath('spreed', 'dashboard/reminders.png')">
</template>
</DashboardSection>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '../../assets/variables' as *;
.talk-dashboard-wrapper {
padding: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 3);
width: min(100%, calc(100vw - 300px - var(--body-container-margin) * 2)); // 300px for the left sidebar and body container margins
margin: 0 auto;
display: flex;
flex-direction: column;
height: 100%;
max-height: 800px;
max-width: 1200px;
&--mobile {
width: 100%;
}
&--small-mobile {
width: 100%;
height: auto;
.talk-dashboard__chats {
grid-template-columns: 1fr;
gap: calc(var(--default-grid-baseline) * 5);
}
}
}
.talk-dashboard__menu {
margin-bottom: calc(var(--default-grid-baseline) * 4);
}
.talk-dashboard__header {
font-size: 21px; // NcDialog header font size
font-weight: bold;
margin: 0 auto calc(var(--default-grid-baseline) * 2);
padding-inline-start: calc(var(--default-clickable-area) + var(--default-grid-baseline)); // navigation button
}
.talk-dashboard__actions {
display: flex;
gap: calc(var(--default-grid-baseline) * 3);
padding-block: var(--default-grid-baseline);
flex-wrap: wrap;
flex-direction: row;
:deep(.button-vue),
:deep(.v-popper--theme-dropdown) {
height: var(--header-menu-item-height);
border-radius: var(--border-radius-large);
}
:deep(.button-vue) {
padding-inline: calc(var(--default-grid-baseline) * 2) calc(var(--default-grid-baseline) * 4);
}
}
.event-section {
margin-block-end: calc(var(--default-grid-baseline) * 6);
&--empty {
height: 225px;
}
}
.talk-dashboard__event-cards {
display: flex;
flex-wrap: nowrap;
gap: calc(var(--default-grid-baseline) * 2);
margin-block: var(--default-grid-baseline);
overflow-x: auto;
scrollbar-width: none;
border-radius: var(--border-radius-large);
}
.talk-dashboard__event-cards-wrapper {
position: relative;
margin-bottom: calc(var(--default-grid-baseline) * 2);
&::before,
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: var(--default-clickable-area);
pointer-events: none;
z-index: 2;
}
.button-slide {
position: absolute !important;
display: flex;
top: 0;
padding: 0;
height: 100%;
margin: 0 !important;
z-index: 3;
justify-content: left;
background: var(--color-main-background);
border-radius: var(--border-radius-large);
&.backward {
inset-inline-start: 0;
}
&.forward {
inset-inline-end: 0;
}
}
}
.talk-dashboard__calendar-button {
position: absolute !important;
bottom: calc(var(--default-grid-baseline) * 2);
inset-inline-start: calc(var(--default-grid-baseline) * 2);
}
.talk-dashboard__items {
display: flex;
flex-direction: column;
justify-content: space-around;
min-width: 0;
flex-grow: 3;
}
.talk-dashboard__chats {
display: grid;
gap: calc(var(--default-grid-baseline) * 8);
grid-template-columns: 1fr 1fr;
flex-grow: 1;
&> div {
max-height: 320px;
}
}
.upcoming-reminders {
&-list {
overflow-y: auto;
}
&__loading-placeholder {
overflow: hidden;
}
}
.talk-dashboard__conversations-list {
flex-grow: 1;
margin-block: var(--default-grid-baseline);
line-height: 20px;
}
.title {
font-size: 1.25rem;
font-weight: bold;
margin-block: 0 calc(var(--default-grid-baseline) * 2);
}
.instant-meeting__dialog {
padding: calc(var(--default-grid-baseline) * 2);
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline) ;
align-items: center;
}
// Override NcButton styles for narrow screen size
@media screen and (max-width: $breakpoint-mobile-small) {
.talk-dashboard__actions {
:deep(.button-vue),
& > div {
width: 100%;
}
:deep(.button-vue) {
padding-inline-end: calc(var(--default-grid-baseline) * 2);
}
}
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcEmptyContent class="empty-view" :name="name" :description="description">
<template #icon>
<slot name="icon" />
</template>
</NcEmptyContent>
</template>
<script>
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
export default {
name: 'EmptyView',
components: {
NcEmptyContent,
},
props: {
name: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
},
}
</script>
<style lang="scss" scoped>
.empty-view {
height: 100%;
padding: calc(var(--default-grid-baseline) * 4);
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { Conversation } from '../types/index.ts'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { provide, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import IconAccountMultiplePlusOutline from 'vue-material-design-icons/AccountMultiplePlusOutline.vue'
import NewConversationContactsPage from './NewConversationDialog/NewConversationContactsPage.vue'
import { ATTENDEE, CONVERSATION } from '../constants.ts'
const props = defineProps<{
token: string
container?: string
}>()
const store = useStore()
const router = useRouter()
const selectedParticipants = ref(getArrayWithSecondAttendee(props.token))
provide('selectedParticipants', selectedParticipants)
const lockedParticipants = ref(getArrayWithSecondAttendee(props.token))
provide('lockedParticipants', lockedParticipants)
// Add a visual bulk selection state for SelectableParticipant component
provide('bulkParticipantsSelection', true)
watch(() => props.token, (newValue) => {
selectedParticipants.value = getArrayWithSecondAttendee(newValue)
lockedParticipants.value = getArrayWithSecondAttendee(newValue)
})
/**
* Returns second attendee of 1-1 conversation as SelectableParticipant-compatible object
*
* @param token - conversation token
*/
function getArrayWithSecondAttendee(token: string) {
const conversation = store.getters.conversation(token) as Conversation | undefined
if (!conversation || conversation.type !== CONVERSATION.TYPE.ONE_TO_ONE) {
return []
}
return [{ id: conversation.name, source: ATTENDEE.ACTOR_TYPE.USERS, label: conversation.displayName }]
}
/**
* Add current participants and selected ones to the new conversation
*/
async function extendOneToOneConversation() {
try {
const newConversation = await store.dispatch('extendOneToOneConversation', {
token: props.token,
newParticipants: selectedParticipants.value,
})
if (newConversation) {
await router.push({ name: 'conversation', params: { token: newConversation.token } })
}
} catch (error) {
console.error('Error creating new conversation: ', error)
showError(t('spreed', 'Error while creating the conversation'))
}
}
</script>
<template>
<NcPopover
:container="container"
popupRole="dialog">
<template #trigger>
<NcButton
variant="tertiary"
:title="t('spreed', 'Start a group conversation')"
:aria-label="t('spreed', 'Start a group conversation')">
<template #icon>
<IconAccountMultiplePlusOutline :size="20" />
</template>
</NcButton>
</template>
<template #default>
<div class="start-group__content">
<h5 class="start-group__header">
{{ t('spreed', 'Start a group conversation') }}
</h5>
<NewConversationContactsPage
v-model:selectedParticipants="selectedParticipants"
class="start-group__contacts"
:token="token"
onlyUsers />
<NcButton
class="start-group__action"
variant="primary"
:disabled="!selectedParticipants.length"
@click="extendOneToOneConversation">
{{ t('spreed', 'Create conversation') }}
</NcButton>
</div>
</template>
</NcPopover>
</template>
<style lang="scss" scoped>
.start-group {
&__content {
display: flex;
flex-direction: column;
gap: calc(2 * var(--default-grid-baseline));
width: 350px;
padding: calc(2 * var(--default-grid-baseline));
}
&__header {
margin-block: 0 var(--default-grid-baseline);
text-align: center;
}
&__contacts {
display: flex;
flex-direction: column;
max-height: 50vh;
}
&__action {
justify-self: flex-end;
align-self: flex-end;
}
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcModal
noClose
:labelId="dialogHeaderId"
size="small">
<div class="modal__content">
<div class="conversation-information">
<ConversationIcon :item="conversation" hideUserStatus />
<h2 :id="dialogHeaderId" class="nc-dialog-alike-header">
{{ conversationDisplayName }}
</h2>
</div>
<p class="description">
{{ conversationDescription }}
</p>
<label for="textField">{{ t('spreed', 'Enter your name') }}</label>
<NcTextField
id="textField"
v-model="guestUserName"
:placeholder="t('spreed', 'Guest')"
class="username-form__input"
:showTrailingButton="false"
labelOutside
@keydown.enter="handleChooseUserName" />
<NcButton
class="submit-button"
variant="primary"
:disabled="invalidGuestUsername"
@click="handleChooseUserName">
{{ t('spreed', 'Submit name and join') }}
<template #icon>
<Check :size="20" />
</template>
</NcButton>
<div class="separator" />
<div class="login-info">
<span> {{ t('spreed', 'Do you already have an account?') }}</span>
<NcButton
variant="secondary"
:href="getLoginUrl()">
{{ t('spreed', 'Log in') }}
</NcButton>
</div>
</div>
</NcModal>
</template>
<script>
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { ref, useId } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcModal from '@nextcloud/vue/components/NcModal'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import Check from 'vue-material-design-icons/CheckBold.vue'
import ConversationIcon from './ConversationIcon.vue'
import { useGuestNameStore } from '../stores/guestName.ts'
export default {
name: 'GuestWelcomeWindow',
components: {
NcModal,
NcTextField,
ConversationIcon,
NcButton,
Check,
},
props: {
token: {
type: String,
required: true,
},
},
setup() {
const guestNameStore = useGuestNameStore()
const guestUserName = ref('')
const dialogHeaderId = `guest-welcome-header-${useId()}`
return {
guestNameStore,
guestUserName,
dialogHeaderId,
}
},
computed: {
conversation() {
return this.$store.getters.conversation(this.token)
},
conversationDisplayName() {
return this.conversation?.displayName
},
conversationDescription() {
return this.conversation?.description
},
invalidGuestUsername() {
return this.guestUserName.trim() === ''
},
},
methods: {
t,
handleChooseUserName() {
this.guestNameStore.submitGuestUsername(this.token, this.guestUserName)
},
getLoginUrl() {
const currentUrl = window.location.pathname
const loginBaseUrl = generateUrl('/login')
const redirectUrl = encodeURIComponent(currentUrl)
return `${loginBaseUrl}?redirect_url=${redirectUrl}`
},
},
}
</script>
<style lang="scss" scoped>
.modal__content {
padding: calc(var(--default-grid-baseline) * 3);
}
.conversation-information {
margin-top: 5px;
display: flex;
flex-direction: column;
align-items: center;
}
.description {
margin-bottom: 12px;
max-height: 8lh;
overflow-x: hidden;
overflow-y: auto;
text-overflow: ellipsis;
}
.username-form__input {
margin-bottom: 20px;
}
.submit-button {
margin: 0 auto;
}
.login-info {
display: flex;
align-items: center;
gap: calc(var(--default-grid-baseline) * 2);
padding-top: calc(var(--default-grid-baseline) * 2);
}
.separator {
margin: calc(var(--default-grid-baseline) * 3) 0 var(--default-grid-baseline);
border-top: 1px solid var(--color-border-dark);
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { ApiErrorResponse } from '../types/index.ts'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { n, t } from '@nextcloud/l10n'
import { computed, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import IconFileUpload from '../../img/material-icons/file-upload.svg?raw'
import { importEmails } from '../services/participantsService.js'
const props = defineProps<{
token: string
container?: string
}>()
const emit = defineEmits<{
(event: 'close'): void
}>()
const loading = ref(false)
const listImport = ref<HTMLInputElement | null>(null)
const importedFile = ref<File | null>(null)
const uploadResult = ref<{ error?: boolean, invalid?: number, message?: string, duplicates?: number, invites?: number } | null>(null)
const uploadResultCaption = computed(() => {
return uploadResult.value?.error
? { class: 'import-list__caption--error', label: t('spreed', 'Error while verifying uploaded file') }
: { class: 'import-list__caption--success', label: t('spreed', 'Uploaded file is verified') }
})
const importListDescription = t('spreed', 'Content format is comma-separated values (CSV):<br/>- Header line is required and must match <samp>"name","email"</samp> or just <samp>"email"</samp><br/>- One entry per line (e.g. <samp>"John Doe","john@example.tld"</samp>)', undefined, undefined, {
escape: true,
sanitize: true,
})
/**
* Call native input[type='file'] to import a file
*/
function triggerImport() {
if (!listImport.value) {
return
}
listImport.value.value = ''
listImport.value.click()
}
/**
* Validate imported file and insert data into form fields
*
* @param event import event
*/
function importList(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) {
return
}
importedFile.value = file
testList(importedFile.value)
}
/**
* Verify imported file and show results
*
* @param file file to upload
*/
async function testList(file: File) {
loading.value = true
uploadResult.value = null
try {
const response = await importEmails(props.token, file, true)
uploadResult.value = response.data.ocs.data
} catch (error) {
uploadResult.value = (error as ApiErrorResponse).response?.data?.ocs?.data ?? null
} finally {
loading.value = false
}
}
/**
* Verify imported file and add participants
*
* @param file file to upload
*/
async function submitList(file: File | null) {
if (!file) {
return
}
try {
await importEmails(props.token, file, false)
showSuccess(t('spreed', 'Participants added successfully'))
emit('close')
} catch (e) {
showError(t('spreed', 'Error while adding participants'))
console.error(e)
}
}
</script>
<template>
<NcDialog
class="import-list"
:name="t('spreed', 'Import email participants')"
size="normal"
closeOnClickOutside
:container="container"
@update:open="emit('close')">
<!--native file picker, hidden -->
<input
id="list-upload"
ref="listImport"
type="file"
class="hidden-visually"
@change="importList">
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="import-list__hint" v-html="importListDescription" />
<div class="import-list__wrapper">
<NcTextField
class="import-list__input"
:modelValue="importedFile?.name ?? ''"
:placeholder="t('spreed', 'Import a file')"
disabled />
<NcButton class="import-list__button" @click="triggerImport">
<template #icon>
<NcLoadingIcon v-if="loading" :size="20" />
<NcIconSvgWrapper v-else :svg="IconFileUpload" :size="20" />
</template>
{{ t('spreed', 'Browse') }}
</NcButton>
</div>
<div class="import-list__form">
<template v-if="loading">
<p class="import-list__caption">
{{ t('spreed', 'Verifying uploaded file …') }}
</p>
<p class="import-list__description">
{{ t('spreed', 'This might take a moment') }}
</p>
</template>
<template v-else-if="uploadResult">
<p
class="import-list__caption"
:class="[uploadResultCaption.class]">
{{ uploadResultCaption.label }}
</p>
<p v-if="uploadResult?.invalid" class="import-list__description">
{{ n('spreed', '%n invalid email', '%n invalid emails', uploadResult.invalid) }}
</p>
<p v-if="uploadResult?.message" class="import-list__description">
{{ uploadResult.message }}
</p>
<p v-if="uploadResult?.duplicates" class="import-list__description">
{{ n('spreed', '%n email is already imported or a duplicate', '%n emails are already imported or duplicates', uploadResult.duplicates) }}
</p>
<p v-if="uploadResult?.invites" class="import-list__description import-list__description--separated">
{{ n('spreed', '%n invitation can be sent', '%n invitations can be sent', uploadResult.invites) }}
</p>
</template>
</div>
<template #actions>
<NcButton
variant="primary"
:disabled="!uploadResult"
@click="submitList(importedFile)">
{{ t('spreed', 'Send invitations') }}
</NcButton>
</template>
</NcDialog>
</template>
<style lang="scss" scoped>
.import-list {
&__wrapper {
display: flex;
gap: var(--default-grid-baseline);
margin-bottom: calc(var(--default-grid-baseline) * 3);
}
&__input {
opacity: 1 !important;
:deep(input) {
opacity: 1 !important;
color: var(--color-main-text) !important;
border-color: var(--color-border-maxcontrast) !important;
}
}
&__button {
flex-shrink: 0;
}
&__form {
display: flex;
flex-direction: column;
gap: var(--default-grid-baseline);
}
&__caption {
font-weight: bold;
&--error {
color: var(--color-text-error);
}
&--success {
color: var(--color-success-text);
}
}
&__description {
white-space: nowrap;
overflow: hidden;
&--separated {
border-top: 1px solid var(--color-border-dark);
}
}
&__hint {
color: var(--color-text-maxcontrast);
padding: calc(var(--default-grid-baseline) * 2);
}
}
</style>
@@ -0,0 +1,229 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcDialog
:open="modal"
:name="t('spreed', 'Call a phone number')"
class="call-phone"
size="normal"
closeOnClickOutside
@update:open="closeModal">
<template v-if="!loading">
<div class="call-phone__form">
<NcTextField
ref="textField"
v-model="searchText"
class="call-phone__form-input"
:label="t('spreed', 'Search participants or phone numbers')"
labelVisible
@keydown.enter="createConversation(participantPhoneItem)" />
<DialpadPanel
v-model:value="searchText"
container=".call-phone__form"
@submit="createConversation(participantPhoneItem)" />
</div>
<SelectPhoneNumber
v-model:participantPhoneItem="participantPhoneItem"
:name="t('spreed', 'Call a phone number')"
:value="searchText"
@select="createConversation" />
</template>
<NcEmptyContent v-else class="call-phone__loading">
<template #icon>
<LoadingComponent />
</template>
<template #description>
<p>{{ t('spreed', 'Creating the conversation …') }}</p>
</template>
</NcEmptyContent>
</NcDialog>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import LoadingComponent from '../../LoadingComponent.vue'
import SelectPhoneNumber from '../../SelectPhoneNumber.vue'
import DialpadPanel from '../../UIShared/DialpadPanel.vue'
import { CONVERSATION, PARTICIPANT } from '../../../constants.ts'
import { callSIPDialOut } from '../../../services/callsService.ts'
import { hasTalkFeature } from '../../../services/CapabilitiesManager.ts'
import { createLegacyConversation } from '../../../services/conversationsService.ts'
import { EventBus } from '../../../services/EventBus.ts'
import { addParticipant } from '../../../services/participantsService.js'
import { useActorStore } from '../../../stores/actor.ts'
export default {
name: 'CallPhoneDialog',
components: {
DialpadPanel,
LoadingComponent,
NcDialog,
NcEmptyContent,
NcTextField,
SelectPhoneNumber,
},
expose: ['showModal'],
setup() {
return {
actorStore: useActorStore(),
}
},
data() {
return {
modal: false,
loading: false,
searchText: '',
participantPhoneItem: {},
}
},
watch: {
modal(value) {
if (!value) {
return
}
this.$nextTick(() => {
this.focusInput()
})
},
},
methods: {
t,
showModal() {
this.modal = true
},
/**
* Reinitialise the component to it's initial state. This is necessary
* because once the component is mounted its data would persist even if
* the modal closes
*/
closeModal() {
this.modal = false
this.loading = false
this.searchText = ''
this.participantPhoneItem = {}
},
focusInput() {
this.$refs.textField.focus()
},
async createConversation() {
let conversation
try {
this.loading = true
const response = await createLegacyConversation({
roomType: CONVERSATION.TYPE.GROUP,
roomName: this.participantPhoneItem.phoneNumber,
objectType: hasTalkFeature('local', 'sip-direct-dialin') ? CONVERSATION.OBJECT_TYPE.PHONE_TEMPORARY : CONVERSATION.OBJECT_TYPE.PHONE_LEGACY,
})
conversation = response.data.ocs.data
await this.$store.dispatch('addConversation', conversation)
await addParticipant(conversation.token, this.participantPhoneItem.id, this.participantPhoneItem.source)
this.$router.push({ name: 'conversation', params: { token: conversation.token } })
} catch (exception) {
console.debug(exception)
showError(t('spreed', 'An error occurred while calling a phone number'))
if (conversation) {
this.$store.dispatch('deleteConversationFromServer', { token: conversation.token })
}
this.closeModal()
return
}
EventBus.once('joined-conversation', ({ token }) => {
if (conversation.token !== token) {
return
}
this.startPhoneCall(conversation.token, this.participantPhoneItem.phoneNumber)
this.closeModal()
})
},
async startPhoneCall(token, phoneNumber) {
let flags = PARTICIPANT.CALL_FLAG.IN_CALL
flags |= PARTICIPANT.CALL_FLAG.WITH_AUDIO
try {
const response = await this.$store.dispatch('fetchParticipants', { token })
// Close navigation
emit('toggle-navigation', { open: false })
console.info('Joining call')
await this.$store.dispatch('joinCall', {
token,
participantIdentifier: this.actorStore.participantIdentifier,
flags,
silent: false,
recordingConsent: true,
})
// request above could be cancelled, if there is parallel request, and return null
// in that case participants list will be fetched anyway and keeped in the store
const participantsList = response?.data.ocs.data || this.$store.getters.participantsList(token)
const attendeeId = participantsList.find((participant) => participant.phoneNumber === phoneNumber)?.attendeeId
await callSIPDialOut(token, attendeeId)
} catch (error) {
if (error?.response?.data?.ocs?.data?.message) {
showError(t('spreed', 'Phone number could not be called: {error}', {
error: error?.response?.data?.ocs?.data?.message,
}))
} else {
console.error(error)
showError(t('spreed', 'Phone number could not be called'))
}
}
},
},
}
</script>
<style lang="scss" scoped>
.call-phone {
:deep(.modal-wrapper) {
.modal-container {
height: 60%;
}
.dialog__content {
padding-bottom: calc(var(--default-grid-baseline) * 3);
}
}
&__form {
display: flex;
align-items: flex-end;
gap: var(--default-grid-baseline);
}
&__loading {
margin: 0 !important;
padding: 0 !important;
height: 100%;
}
}
</style>
@@ -0,0 +1,491 @@
/*
* SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { showError, showSuccess } from '@nextcloud/dialogs'
import { flushPromises, mount } from '@vue/test-utils'
import { cloneDeep } from 'lodash'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { createStore } from 'vuex'
import NcListItem from '@nextcloud/vue/components/NcListItem'
import IconFileOutline from 'vue-material-design-icons/FileOutline.vue'
import ConversationIcon from '../../ConversationIcon.vue'
import ConversationItem from './ConversationItem.vue'
import router from '../../../__mocks__/router.js'
import { ATTENDEE, CONVERSATION, PARTICIPANT } from '../../../constants.ts'
import { leaveConversation } from '../../../services/participantsService.js'
import storeConfig from '../../../store/storeConfig.js'
import { findNcActionButton } from '../../../test-helpers.js'
vi.mock('../../../services/participantsService', () => ({
leaveConversation: vi.fn(),
}))
vi.mock('@nextcloud/vue/functions/dialog', () => ({
spawnDialog: vi.fn().mockResolvedValue(true),
}))
const ComponentStub = {
template: '<div><slot /></div>',
}
describe('ConversationItem.vue', () => {
const TOKEN = 'XXTOKENXX'
let store
let testStoreConfig
let item
let messagesMock
/**
* Shared function to mount component
*/
function mountConversation(isSearchResult = false) {
return mount(ConversationItem, {
global: {
plugins: [router, store],
stubs: {
NcModal: ComponentStub,
NcPopover: ComponentStub,
},
},
props: {
isSearchResult,
item,
},
})
}
beforeEach(() => {
testStoreConfig = cloneDeep(storeConfig)
messagesMock = vi.fn().mockReturnValue({})
testStoreConfig.modules.messagesStore.getters.messages = () => messagesMock
store = createStore(testStoreConfig)
// common defaults
item = {
token: TOKEN,
actorId: 'actor-id-1',
actorType: ATTENDEE.ACTOR_TYPE.USERS,
participants: [
],
participantType: PARTICIPANT.TYPE.USER,
unreadMessages: 0,
unreadMention: false,
objectType: '',
type: CONVERSATION.TYPE.GROUP,
displayName: 'conversation one',
isFavorite: false,
isArchived: false,
isSensitive: false,
lastMessage: {
actorId: 'user-id-alice',
actorDisplayName: 'Alice Wonderland',
actorType: ATTENDEE.ACTOR_TYPE.USERS,
message: 'hello',
messageParameters: {},
systemMessage: '',
timestamp: 100,
},
canLeaveConversation: true,
canDeleteConversation: true,
}
})
afterEach(() => {
vi.clearAllMocks()
})
test('renders conversation entry', () => {
const wrapper = mountConversation(false)
const el = wrapper.findComponent(NcListItem)
expect(el.exists()).toBe(true)
expect(el.props('name')).toBe('conversation one')
const icon = el.findComponent(ConversationIcon)
expect(icon.props('item')).toStrictEqual(item)
expect(icon.props('hideFavorite')).toStrictEqual(false)
expect(icon.props('hideCall')).toStrictEqual(false)
})
describe('displayed subname', () => {
/**
* @param {object} item Conversation data
* @param {string} expectedText Expected subname of the conversation item
* @param {boolean} isSearchResult Whether or not the item is a search result (has no menu)
*/
async function testConversationLabel(item, expectedText, isSearchResult = false) {
const wrapper = mountConversation(isSearchResult)
await flushPromises()
const el = wrapper.find('.conversation__subname')
expect(el.exists()).toBeTruthy()
expect(el.text()).toMatch(expectedText)
return wrapper
}
test('display joining conversation message when not joined yet', async () => {
item.actorId = null
await testConversationLabel(item, 'Joining conversation …')
})
test('displays nothing when there is no last chat message', async () => {
delete item.lastMessage
await testConversationLabel(item, 'No messages')
})
describe('author name', () => {
// items are padded from each other visually
test('displays last chat message with shortened author name', async () => {
await testConversationLabel(item, 'Alice:hello')
})
test('displays last chat message with author name if no space in name', async () => {
item.lastMessage.actorDisplayName = 'Bob'
await testConversationLabel(item, 'Bob:hello')
})
test('displays own last chat message with "You" as author', async () => {
item.lastMessage.actorId = 'actor-id-1'
await testConversationLabel(item, 'You:hello')
})
test('displays last system message without author', async () => {
item.lastMessage.message = 'Alice has joined the call'
item.lastMessage.systemMessage = 'call_joined'
await testConversationLabel(item, 'Alice has joined the call')
})
test('displays last message without author in one to one conversations', async () => {
item.type = CONVERSATION.TYPE.ONE_TO_ONE
await testConversationLabel(item, 'hello')
})
test('displays own last message with "You" author in one to one conversations', async () => {
item.type = CONVERSATION.TYPE.ONE_TO_ONE
item.lastMessage.actorId = 'actor-id-1'
await testConversationLabel(item, 'You:hello')
})
test('displays last guest message with default author when none set', async () => {
item.type = CONVERSATION.TYPE.PUBLIC
item.lastMessage.actorDisplayName = ''
item.lastMessage.actorType = ATTENDEE.ACTOR_TYPE.GUESTS
await testConversationLabel(item, 'Guest:hello')
})
test('displays description for search results', async () => {
// search results have no actor id
item.actorId = null
item.description = 'This is a description'
await testConversationLabel(item, 'This is a description', true)
})
})
test('replaces placeholders in rich object of last message', async () => {
item.lastMessage.message = '{file}'
item.lastMessage.messageParameters = {
file: {
name: 'filename.jpg',
},
}
const wrapper = await testConversationLabel(item, 'Alice:filename.jpg')
expect(wrapper.findComponent(IconFileOutline).exists()).toBeTruthy()
})
test('hides subname for sensitive conversations', () => {
item.isSensitive = true
const wrapper = mountConversation(false)
const el = wrapper.find('.conversation__subname')
expect(el.exists()).toBe(false)
})
})
describe('unread messages counter', () => {
/**
* @param {object} item Conversation data
* @param {string} expectedCounterText The expected unread counter
* @param {boolean} expectedOutlined The expected outlined counter
* @param {boolean} expectedHighlighted Whether or not the unread counter is highlighted with primary color
*/
function testCounter(item, expectedCounterText, expectedOutlined, expectedHighlighted) {
const wrapper = mountConversation(false)
const el = wrapper.findComponent(NcListItem)
expect(el.exists()).toBe(true)
expect(el.props('counterNumber')).toBe(expectedCounterText)
if (expectedOutlined) {
expect(el.props('counterType')).toBe('outlined')
}
if (expectedHighlighted) {
expect(el.props('counterType')).toBe('highlighted')
}
}
test('renders unread messages counter', () => {
item.unreadMessages = 5
item.unreadMention = false
item.unreadMentionDirect = false
testCounter(item, 5, false, false)
})
test('renders unread mentions highlighted for non one-to-one conversations', () => {
item.unreadMessages = 5
item.unreadMention = true
item.unreadMentionDirect = true
testCounter(item, 5, false, true)
})
test('renders group mentions outlined for non one-to-one conversations', () => {
item.unreadMessages = 5
item.unreadMention = true
item.unreadMentionDirect = false
testCounter(item, 5, true, false)
})
test('renders unread mentions always highlighted for one-to-one conversations', () => {
item.unreadMessages = 5
item.unreadMention = false
item.unreadMentionDirect = false
item.type = CONVERSATION.TYPE.ONE_TO_ONE
testCounter(item, 5, false, true)
})
test('does not render counter when no unread messages', () => {
const wrapper = mountConversation(false)
const el = wrapper.findComponent(NcListItem)
expect(el.exists()).toBe(true)
expect(el.vm.$slots.counter).not.toBeDefined()
})
})
describe('actions and routing', () => {
test('change route on click event', async () => {
await router.isReady()
const wrapper = mountConversation(false)
const el = wrapper.findComponent(NcListItem)
expect(el.exists()).toBe(true)
await el.find('a').trigger('click')
await flushPromises()
expect(wrapper.vm.$route.name).toBe('conversation')
expect(wrapper.vm.$route.params).toStrictEqual({ token: TOKEN })
})
/**
* @param {string} actionName The name of the action to shallow
*/
async function shallowMountAndGetAction(actionName) {
store = createStore(testStoreConfig)
const wrapper = mountConversation(false)
const el = wrapper.findComponent(NcListItem)
expect(el.exists()).toBe(true)
await el.find('a').trigger('focus')
return findNcActionButton(el, actionName)
}
/**
* @param {string} actionName The name of the action to click
*/
async function mountAndOpenDialog(actionName) {
const wrapper = mountConversation(false)
const el = wrapper.findComponent(NcListItem)
await el.find('a').trigger('focus')
const action = findNcActionButton(el, actionName)
expect(action.exists()).toBeTruthy()
await action.find('button').trigger('click')
// wait for dialog promise to be resolved
await flushPromises()
}
describe('leaving conversation', () => {
let actionHandler
beforeEach(() => {
leaveConversation.mockResolvedValue()
actionHandler = vi.fn().mockResolvedValueOnce()
testStoreConfig.modules.participantsStore.actions.removeCurrentUserFromConversation = actionHandler
testStoreConfig.modules.conversationsStore.actions.toggleArchive = actionHandler
store = createStore(testStoreConfig)
})
test('leaves conversation when confirmed', async () => {
// Act
await mountAndOpenDialog('Leave conversation')
// Assert
expect(actionHandler).toHaveBeenCalledWith(expect.anything(), { token: TOKEN })
})
test('hides "leave conversation" action when not allowed', async () => {
item.canLeaveConversation = false
const action = await shallowMountAndGetAction('Leave conversation')
expect(action.exists()).toBe(false)
})
test('errors with notification when a new moderator is required before leaving', async () => {
// Arrange
actionHandler = vi.fn().mockRejectedValueOnce({ response: { status: 400 } })
testStoreConfig.modules.participantsStore.actions.removeCurrentUserFromConversation = actionHandler
store = createStore(testStoreConfig)
// Act
await mountAndOpenDialog('Leave conversation')
// Assert
expect(actionHandler).toHaveBeenCalledWith(expect.anything(), { token: TOKEN })
expect(showError).toHaveBeenCalledWith(expect.stringContaining('promote'))
})
})
describe('deleting conversation', () => {
let actionHandler
beforeEach(() => {
vi.spyOn(router, 'push')
actionHandler = vi.fn().mockResolvedValueOnce()
testStoreConfig.modules.conversationsStore.actions.deleteConversationFromServer = actionHandler
store = createStore(testStoreConfig)
})
test('deletes conversation when confirmed', async () => {
// Act
await mountAndOpenDialog('Delete conversation')
// Assert
expect(actionHandler).toHaveBeenCalledWith(expect.anything(), { token: TOKEN })
expect(router.push).not.toHaveBeenCalled()
})
test('hides "delete conversation" action when not allowed', async () => {
item.canDeleteConversation = false
const action = await shallowMountAndGetAction('Delete conversation')
expect(action.exists()).toBe(false)
})
})
test('copies link conversation', async () => {
store = createStore(testStoreConfig)
const copyTextMock = vi.fn().mockResolvedValueOnce()
const wrapper = mountConversation(false)
Object.assign(navigator, {
clipboard: {
writeText: copyTextMock,
},
})
const el = wrapper.findComponent(NcListItem)
expect(el.exists()).toBe(true)
await el.find('a').trigger('focus')
const action = findNcActionButton(el, 'Copy link')
expect(action.exists()).toBe(true)
await action.find('button').trigger('click')
await action.vm.$nextTick()
expect(copyTextMock).toHaveBeenCalledWith('http://localhost/nc-webroot/call/XXTOKENXX')
expect(showSuccess).toHaveBeenCalled()
})
test('sets favorite', async () => {
const toggleFavoriteAction = vi.fn().mockResolvedValueOnce()
testStoreConfig.modules.conversationsStore.actions.toggleFavorite = toggleFavoriteAction
store = createStore(testStoreConfig)
const wrapper = mountConversation(false)
const el = wrapper.findComponent(NcListItem)
expect(el.exists()).toBe(true)
await el.find('a').trigger('focus')
const action = findNcActionButton(el, 'Add to favorites')
expect(action.exists()).toBe(true)
expect(findNcActionButton(el, 'Remove from favorites').exists()).toBe(false)
await action.find('button').trigger('click')
expect(toggleFavoriteAction).toHaveBeenCalledWith(expect.anything(), item)
})
test('unsets favorite', async () => {
const toggleFavoriteAction = vi.fn().mockResolvedValueOnce()
testStoreConfig.modules.conversationsStore.actions.toggleFavorite = toggleFavoriteAction
item.isFavorite = true
store = createStore(testStoreConfig)
const wrapper = mountConversation(false)
const el = wrapper.findComponent(NcListItem)
expect(el.exists()).toBe(true)
await el.find('a').trigger('focus')
const action = findNcActionButton(el, 'Remove from favorites')
expect(action.exists()).toBe(true)
expect(findNcActionButton(el, 'Add to favorites').exists()).toBe(false)
await action.find('button').trigger('click')
expect(toggleFavoriteAction).toHaveBeenCalledWith(expect.anything(), item)
})
test('marks conversation as unread', async () => {
const markConversationUnreadAction = vi.fn().mockResolvedValueOnce()
testStoreConfig.modules.conversationsStore.actions.markConversationUnread = markConversationUnreadAction
const action = await shallowMountAndGetAction('Mark as unread')
expect(action.exists()).toBe(true)
await action.find('button').trigger('click')
expect(markConversationUnreadAction).toHaveBeenCalledWith(expect.anything(), { token: item.token })
})
test('marks conversation as read', async () => {
const clearLastReadMessageAction = vi.fn().mockResolvedValueOnce()
testStoreConfig.modules.conversationsStore.actions.clearLastReadMessage = clearLastReadMessageAction
item.unreadMessages = 1
const action = await shallowMountAndGetAction('Mark as read')
expect(action.exists()).toBe(true)
await action.find('button').trigger('click')
expect(clearLastReadMessageAction).toHaveBeenCalledWith(expect.anything(), { token: item.token })
})
test('does not show all actions for search result (open conversations)', async () => {
store = createStore(testStoreConfig)
const wrapper = mountConversation(true)
const el = wrapper.findComponent(NcListItem)
expect(el.exists()).toBe(true)
await el.find('a').trigger('focus')
// Join conversation and Copy link actions are intended
expect(findNcActionButton(el, 'Join conversation').exists()).toBe(true)
expect(findNcActionButton(el, 'Copy link').exists()).toBe(true)
// But not default conversation actions
expect(findNcActionButton(el, 'Add to favorites').exists()).toBe(false)
expect(findNcActionButton(el, 'Remove from favorites').exists()).toBe(false)
})
})
})
@@ -0,0 +1,677 @@
<!--
- SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcListItem
ref="listItem"
:name="item.displayName"
:title="item.displayName"
:data-nav-id="`conversation_${item.token}`"
class="conversation"
:class="{
'conversation--active': isActive,
'conversation--compact': compact,
'conversation--compact__read': compact && !item.unreadMessages,
}"
:actions-aria-label="t('spreed', 'Conversation actions')"
:to="to"
:bold="!!item.unreadMessages"
:counterNumber="item.unreadMessages"
:counterType="counterType"
forceMenu
:compact="compact"
@click="onClick"
@update:menuOpen="handleActionsMenuOpen">
<template #icon>
<ConversationIcon
:key="item.token"
:item="item"
:hideFavorite="compact"
:hideCall="compact"
:hideUserStatus="item.type !== CONVERSATION.TYPE.ONE_TO_ONE && compact"
:showUserOnlineStatus="compact"
:size="compact ? AVATAR.SIZE.COMPACT : AVATAR.SIZE.DEFAULT" />
</template>
<template #name>
<template v-if="compact && iconType">
<component :is="iconType.component" :size="15" :fillColor="iconType.color" />
<span class="hidden-visually">{{ iconType.text }}</span>
</template>
<span class="text"> {{ item.displayName }} </span>
</template>
<template v-if="!compact && !item.isSensitive" #subname>
<span class="conversation__subname" :title="conversationInformation.title">
<span
v-if="conversationInformation.actor"
class="conversation__subname-actor">
{{ conversationInformation.actor }}
</span>
<component
:is="conversationInformation.icon"
v-if="conversationInformation.icon"
class="conversation__subname-icon"
:size="16" />
<span class="conversation__subname-message">
{{ conversationInformation.message }}
</span>
</span>
</template>
<template v-if="!isSearchResult" #actions>
<template v-if="submenu === null">
<NcActionButton
v-if="canFavorite"
key="toggle-favorite"
closeAfterClick
@click="toggleFavoriteConversation">
<template #icon>
<IconStar :size="20" :fillColor="!item.isFavorite ? '#FFCC00' : undefined" />
</template>
{{ labelFavorite }}
</NcActionButton>
<NcActionButton key="copy-link" @click.stop="handleCopyLink">
<template #icon>
<IconContentCopy :size="20" />
</template>
{{ t('spreed', 'Copy link') }}
</NcActionButton>
<NcActionButton key="toggle-read" closeAfterClick @click="toggleReadConversation">
<template #icon>
<IconEyeOutline v-if="item.unreadMessages" :size="20" />
<IconEyeOffOutline v-else :size="20" />
</template>
{{ labelRead }}
</NcActionButton>
<NcActionButton
key="show-notifications"
isMenu
@click="submenu = 'notifications'">
<template #icon>
<IconBellOutline :size="20" />
</template>
{{ t('spreed', 'Notifications') }}
</NcActionButton>
<NcActionButton key="show-settings" closeAfterClick @click="showConversationSettings">
<template #icon>
<IconCogOutline :size="20" />
</template>
{{ t('spreed', 'Conversation settings') }}
</NcActionButton>
<NcActionButton
v-if="supportsArchive"
key="toggle-archive"
closeAfterClick
@click="toggleArchiveConversation">
<template #icon>
<IconArchiveOutline v-if="!item.isArchived" :size="20" />
<IconArchiveOffOutline v-else :size="20" />
</template>
{{ labelArchive }}
</NcActionButton>
<NcActionButton
v-if="item.canLeaveConversation"
key="leave-conversation"
closeAfterClick
@click="leaveConversation">
<template #icon>
<IconExitToApp :size="20" />
</template>
{{ t('spreed', 'Leave conversation') }}
</NcActionButton>
<NcActionButton
v-if="item.canDeleteConversation"
key="delete-conversation"
closeAfterClick
class="critical"
@click="deleteConversation">
<template #icon>
<IconTrashCanOutline :size="20" />
</template>
{{ t('spreed', 'Delete conversation') }}
</NcActionButton>
</template>
<template v-else-if="submenu === 'notifications'">
<NcActionButton
key="action-back"
:aria-label="t('spreed', 'Back')"
@click.stop="submenu = null">
<template #icon>
<IconArrowLeft class="bidirectional-icon" :size="20" />
</template>
{{ t('spreed', 'Back') }}
</NcActionButton>
<NcActionSeparator />
<NcActionButton
v-for="level in notificationLevels"
:key="level.value"
:modelValue="notificationLevel"
:value="level.value.toString()"
type="radio"
@click="setNotificationLevel(level.value)">
<template #icon>
<component :is="level.icon" :size="20" />
</template>
{{ level.label }}
</NcActionButton>
<template v-if="showCallNotificationSettings">
<NcActionSeparator />
<NcActionButton
key="notification-calls"
type="checkbox"
:modelValue="notificationCalls"
@click="setNotificationCalls(!notificationCalls)">
<template #icon>
<IconPhoneRingOutline :size="20" />
</template>
{{ t('spreed', 'Notify about calls') }}
</NcActionButton>
</template>
<template v-if="supportImportantConversations || supportSensitiveConversations">
<NcActionSeparator />
<NcActionButton
v-if="supportImportantConversations"
key="toggle-important"
type="checkbox"
:description="labelImportantHint"
:modelValue="item.isImportant"
@click="toggleImportant(!item.isImportant)">
<template #icon>
<IconMessageAlertOutline :size="20" />
</template>
{{ t('spreed', 'Important conversation') }}
</NcActionButton>
<NcActionButton
v-if="supportSensitiveConversations"
key="toggle-sensitive"
type="checkbox"
:description="t('spreed', 'Hide message text')"
:modelValue="item.isSensitive"
@click="toggleSensitive(!item.isSensitive)">
<template #icon>
<IconShieldLockOutline :size="20" />
</template>
{{ t('spreed', 'Sensitive conversation') }}
</NcActionButton>
</template>
</template>
</template>
<template v-else-if="item.token" #actions>
<NcActionButton key="join-conversation" closeAfterClick @click="onActionClick">
<template #icon>
<IconArrowRight class="bidirectional-icon" :size="20" />
</template>
{{ t('spreed', 'Join conversation') }}
</NcActionButton>
<NcActionButton key="copy-link" @click.stop="handleCopyLink">
<template #icon>
<IconContentCopy :size="20" />
</template>
{{ t('spreed', 'Copy link') }}
</NcActionButton>
</template>
</NcListItem>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { emit } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { useIsDarkTheme } from '@nextcloud/vue/composables/useIsDarkTheme'
import { spawnDialog } from '@nextcloud/vue/functions/dialog'
import { ref, toRefs } from 'vue'
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcListItem from '@nextcloud/vue/components/NcListItem'
import IconArchiveOffOutline from 'vue-material-design-icons/ArchiveOffOutline.vue'
import IconArchiveOutline from 'vue-material-design-icons/ArchiveOutline.vue'
import IconArrowLeft from 'vue-material-design-icons/ArrowLeft.vue'
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import IconBellOffOutline from 'vue-material-design-icons/BellOffOutline.vue'
import IconBellOutline from 'vue-material-design-icons/BellOutline.vue'
import IconBellRingOutline from 'vue-material-design-icons/BellRingOutline.vue'
import IconCogOutline from 'vue-material-design-icons/CogOutline.vue'
import IconContentCopy from 'vue-material-design-icons/ContentCopy.vue'
import IconExitToApp from 'vue-material-design-icons/ExitToApp.vue'
import IconEyeOffOutline from 'vue-material-design-icons/EyeOffOutline.vue'
import IconEyeOutline from 'vue-material-design-icons/EyeOutline.vue'
import IconMessageAlertOutline from 'vue-material-design-icons/MessageAlertOutline.vue'
import IconPhoneRingOutline from 'vue-material-design-icons/PhoneRingOutline.vue'
import IconShieldLockOutline from 'vue-material-design-icons/ShieldLockOutline.vue'
import IconStar from 'vue-material-design-icons/Star.vue' // Filled for better indication
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
import IconVideo from 'vue-material-design-icons/Video.vue' // Filled for better indication
import ConfirmDialog from '../../UIShared/ConfirmDialog.vue'
import ConversationIcon from './../../ConversationIcon.vue'
import { useConversationInfo } from '../../../composables/useConversationInfo.ts'
import { AVATAR, CONVERSATION, PARTICIPANT } from '../../../constants.ts'
import { hasTalkFeature } from '../../../services/CapabilitiesManager.ts'
import { copyConversationLinkToClipboard } from '../../../utils/handleUrl.ts'
const supportsArchive = hasTalkFeature('local', 'archived-conversations-v2')
const supportImportantConversations = hasTalkFeature('local', 'important-conversations')
const supportSensitiveConversations = hasTalkFeature('local', 'sensitive-conversations')
const notificationLevels = [
{ value: PARTICIPANT.NOTIFY.ALWAYS, label: t('spreed', 'All messages'), icon: IconBellRingOutline },
{ value: PARTICIPANT.NOTIFY.MENTION, label: t('spreed', '@-mentions only'), icon: IconBellOutline },
{ value: PARTICIPANT.NOTIFY.NEVER, label: t('spreed', 'Off'), icon: IconBellOffOutline },
]
export default {
name: 'ConversationItem',
components: {
ConversationIcon,
IconArchiveOutline,
IconArchiveOffOutline,
IconArrowLeft,
IconArrowRight,
IconBellOutline,
IconCogOutline,
IconContentCopy,
IconTrashCanOutline,
IconExitToApp,
IconEyeOutline,
IconEyeOffOutline,
IconMessageAlertOutline,
IconPhoneRingOutline,
IconShieldLockOutline,
IconStar,
IconVideo,
NcActionButton,
NcActionSeparator,
NcButton,
NcDialog,
NcListItem,
},
props: {
isSearchResult: {
type: Boolean,
default: false,
},
item: {
type: Object,
default() {
return {
token: '',
participants: [],
participantType: 0,
unreadMessages: 0,
unreadMention: false,
objectType: '',
type: 0,
displayName: '',
isFavorite: false,
notificationLevel: PARTICIPANT.NOTIFY.DEFAULT,
notificationCalls: PARTICIPANT.NOTIFY_CALLS.ON,
canDeleteConversation: false,
canLeaveConversation: false,
hasCall: false,
isImportant: false,
isSensitive: false,
}
},
},
compact: {
type: Boolean,
default: false,
},
},
emits: ['click'],
setup(props) {
const isDarkTheme = useIsDarkTheme()
const submenu = ref(null)
const { item, isSearchResult } = toRefs(props)
const { counterType, conversationInformation } = useConversationInfo({ item, isSearchResult })
return {
AVATAR,
supportsArchive,
supportImportantConversations,
supportSensitiveConversations,
submenu,
isDarkTheme,
counterType,
conversationInformation,
notificationLevels,
CONVERSATION,
}
},
computed: {
canFavorite() {
return this.item.participantType !== PARTICIPANT.TYPE.USER_SELF_JOINED
},
labelRead() {
return this.item.unreadMessages ? t('spreed', 'Mark as read') : t('spreed', 'Mark as unread')
},
labelFavorite() {
return this.item.isFavorite ? t('spreed', 'Remove from favorites') : t('spreed', 'Add to favorites')
},
labelArchive() {
return this.item.isArchived
? t('spreed', 'Unarchive conversation')
: t('spreed', 'Archive conversation')
},
labelImportantHint() {
return t('spreed', 'Ignore "Do not disturb"')
},
to() {
return this.item?.token
? { name: 'conversation', params: { token: this.item.token } }
: null
},
isActive() {
return this.$route?.params?.token === this.item.token
},
notificationLevel() {
return this.item.notificationLevel.toString()
},
notificationCalls() {
return this.item.notificationCalls === PARTICIPANT.NOTIFY_CALLS.ON
},
showCallNotificationSettings() {
return !this.item.remoteServer || hasTalkFeature(this.item.token, 'federation-v2')
},
iconType() {
if (this.item.hasCall) {
return {
component: IconVideo,
color: '#E9322D',
text: t('spreed', 'Call in progress'),
}
} else if (this.item.isFavorite) {
return {
component: IconStar,
color: this.isDarkTheme ? '#FFCC00' : 'currentColor',
text: t('spreed', 'Favorite'),
}
}
return null
},
},
methods: {
t,
handleCopyLink() {
copyConversationLinkToClipboard(this.item.token)
},
toggleReadConversation() {
if (this.item.unreadMessages) {
this.$store.dispatch('clearLastReadMessage', { token: this.item.token })
} else {
this.$store.dispatch('markConversationUnread', { token: this.item.token })
}
},
showConversationSettings() {
emit('show-conversation-settings', { token: this.item.token })
},
/**
* Deletes the conversation.
*/
async deleteConversation() {
const confirmDeleteConversation = await spawnDialog(ConfirmDialog, {
name: t('spreed', 'Delete conversation'),
message: t('spreed', 'Do you really want to delete "{displayName}"?', {
displayName: this.item.displayName,
}, { escape: false, sanitize: false }),
buttons: [
{ label: t('spreed', 'No'), variant: 'tertiary', callback: () => undefined },
{ label: t('spreed', 'Yes'), variant: 'error', callback: () => true },
],
})
if (!confirmDeleteConversation) {
return
}
try {
if (this.isActive) {
await this.$router.push({ name: 'root' })
.catch((failure) => !isNavigationFailure(failure, NavigationFailureType.duplicated) && Promise.reject(failure))
}
await this.$store.dispatch('deleteConversationFromServer', { token: this.item.token })
} catch (error) {
console.error(`Error while deleting conversation ${error}`)
showError(t('spreed', 'Error while deleting conversation'))
}
},
/**
* Deletes the current user from the conversation.
*/
async leaveConversation() {
const customMessages = [
t('spreed', 'Do you really want to leave "{displayName}"?', {
displayName: this.item.displayName,
}, { escape: false, sanitize: false }),
]
const buttons = [
{ label: t('spreed', 'No'), variant: 'tertiary', callback: () => undefined },
{ label: t('spreed', 'Yes'), variant: 'warning', callback: () => true },
]
if (supportsArchive && !this.item.isArchived) {
// Offer archiving option as an alternative to leaving the conversation
customMessages.push(t('spreed', 'You can archive this conversation instead.'))
buttons.splice(1, 0, {
label: t('spreed', 'Archive conversation'),
variant: 'secondary',
callback: () => {
this.toggleArchiveConversation()
return undefined
},
})
}
const confirmLeaveConversation = await spawnDialog(ConfirmDialog, {
name: t('spreed', 'Leave conversation'),
customMessages,
buttons,
})
if (!confirmLeaveConversation) {
return
}
try {
if (this.isActive) {
await this.$router.push({ name: 'root' })
.catch((failure) => !isNavigationFailure(failure, NavigationFailureType.duplicated) && Promise.reject(failure))
}
await this.$store.dispatch('removeCurrentUserFromConversation', { token: this.item.token })
} catch (error) {
if (error?.response?.status === 400) {
showError(t('spreed', 'You need to promote a new moderator before you can leave the conversation.'))
} else {
console.error(`Error while removing yourself from conversation ${error}`)
}
}
},
async toggleFavoriteConversation() {
this.$store.dispatch('toggleFavorite', this.item)
},
async toggleArchiveConversation() {
this.$store.dispatch('toggleArchive', this.item)
},
/**
* Set the notification level for the conversation
*
* @param {number} level The notification level to set.
*/
async setNotificationLevel(level) {
await this.$store.dispatch('setNotificationLevel', {
token: this.item.token,
notificationLevel: level,
})
},
/**
* Set the call notification level for the conversation
*
* @param {boolean} value Whether or not call notifications are enabled
*/
async setNotificationCalls(value) {
await this.$store.dispatch('setNotificationCalls', {
token: this.item.token,
notificationCalls: value ? PARTICIPANT.NOTIFY_CALLS.ON : PARTICIPANT.NOTIFY_CALLS.OFF,
})
},
/**
* Toggle the important flag for the conversation
*
* @param {boolean} isImportant The important flag to set.
*/
async toggleImportant(isImportant) {
await this.$store.dispatch('toggleImportant', { token: this.item.token, isImportant })
},
/**
* Toggle the sensitive flag for the conversation
*
* @param {boolean} isSensitive The sensitive flag to set.
*/
async toggleSensitive(isSensitive) {
await this.$store.dispatch('toggleSensitive', { token: this.item.token, isSensitive })
},
onClick() {
// add as temporary item that will refresh after the joining process is complete
if (this.isSearchResult) {
this.$store.dispatch('addConversation', this.item)
}
this.$emit('click')
},
onActionClick() {
this.onClick()
// NcActionButton is not a RouterLink, so we should route user manually
this.$router.push(this.to)
.catch((err) => console.debug(`Error while pushing the new conversation's route: ${err}`))
},
handleActionsMenuOpen(open) {
if (!open) {
this.submenu = null
}
},
},
}
</script>
<style lang="scss" scoped>
.critical > :deep(.action-button) {
color: var(--color-text-error);
}
.conversation {
// Overwrite ConversationIcon styles to blend a type icon with NcListItem
& :deep(.list-item:hover .conversation-icon__type) {
background-color: var(--color-background-hover);
border-color: var(--color-background-hover);
}
&--active {
&:deep(.list-item .conversation-icon__type) {
color: var(--color-primary-element-text);
background-color: var(--color-primary-element);
border-color: var(--color-primary-element);
}
&:deep(.list-item:hover .conversation-icon__type) {
color: var(--color-primary-element-text);
background-color: var(--color-primary-element-hover);
border-color: var(--color-primary-element-hover);
}
}
&--compact {
padding-block: 2px !important; // Overwrite list-item 4px padding
&:deep(.list-item-content__name) {
display: flex;
gap: calc(var(--default-grid-baseline) / 2);
}
&__read {
&:deep(.list-item-content__name) {
font-weight: 400;
}
}
}
&__subname {
display: flex;
gap: var(--default-grid-baseline);
&-actor {
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-icon {
flex-shrink: 0;
}
&-message {
flex: 1 1 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.dialog) {
padding-block: 0 8px;
padding-inline: 12px 8px;
}
</style>
@@ -0,0 +1,126 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcListItem
:name="item.displayName"
:title="item.displayName"
:active="item.token === selectedRoom?.token"
:bold="exposeMessagesRef && !!item.unreadMessages"
:counterNumber="exposeMessagesRef ? item.unreadMessages : 0"
:counterType="counterType"
@click="onClick">
<template #icon>
<ConversationIcon
:key="item.token"
:item="item"
:hideFavorite="!item?.attendeeId"
:hideCall="!item?.attendeeId" />
</template>
<template v-if="conversationInformation.message" #subname>
<span class="conversation__subname" :title="conversationInformation.title">
<span
v-if="conversationInformation.actor"
class="conversation__subname-actor">
{{ conversationInformation.actor }}
</span>
<component
:is="conversationInformation.icon"
v-if="conversationInformation.icon"
class="conversation__subname-icon"
:size="16" />
<span class="conversation__subname-message">
{{ conversationInformation.message }}
</span>
</span>
</template>
</NcListItem>
</template>
<script>
import { inject, ref, toRefs } from 'vue'
import NcListItem from '@nextcloud/vue/components/NcListItem'
import ConversationIcon from './../../ConversationIcon.vue'
import { useConversationInfo } from '../../../composables/useConversationInfo.ts'
export default {
name: 'ConversationSearchResult',
components: {
ConversationIcon,
NcListItem,
},
props: {
item: {
type: Object,
default() {
return {
token: '',
participants: [],
participantType: 0,
unreadMessages: 0,
unreadMention: false,
objectType: '',
type: 0,
displayName: '',
isFavorite: false,
notificationLevel: 0,
}
},
},
},
emits: ['click'],
setup(props) {
const { item } = toRefs(props)
const selectedRoom = inject('selectedRoom', null)
const exposeDescriptionRef = inject('exposeDescription', ref(false))
const exposeMessagesRef = inject('exposeMessages', ref(false))
const { counterType, conversationInformation } = useConversationInfo({
item,
exposeDescriptionRef,
exposeMessagesRef,
})
return {
selectedRoom,
counterType,
conversationInformation,
exposeMessagesRef,
}
},
methods: {
onClick() {
this.$emit('click', this.item)
},
},
}
</script>
<style lang="scss" scoped>
.conversation__subname {
display: flex;
gap: var(--default-grid-baseline);
&-actor {
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-icon {
flex-shrink: 0;
}
&-message {
flex: 1 1 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>
@@ -0,0 +1,157 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { Conversation } from '../../../types/index.ts'
import { useVirtualList } from '@vueuse/core'
import { computed, toRef } from 'vue'
import LoadingPlaceholder from '../../UIShared/LoadingPlaceholder.vue'
import ConversationItem from './ConversationItem.vue'
import { AVATAR } from '../../../constants.ts'
const props = defineProps<{
conversations: Conversation[]
loading?: boolean
compact?: boolean
}>()
/**
* Consider:
* avatar size (and two lines of text) or compact mode (28px)
* list-item padding
* list-item__wrapper padding
*/
const itemHeight = computed(() => props.compact ? 28 + 2 * 2 : AVATAR.SIZE.DEFAULT + 2 * 4 + 2 * 2)
const { list, containerProps, wrapperProps } = useVirtualList<Conversation>(toRef(() => props.conversations), {
itemHeight: () => itemHeight.value,
overscan: 10,
})
/**
* Get an index of the first fully visible conversation in viewport
* Math.ceil to include partially of (absolute number of items above viewport) + 1 (next item is in viewport) - 1 (index starts from 0)
*/
function getFirstItemInViewportIndex(): number {
return Math.ceil(containerProps.ref.value!.scrollTop / itemHeight.value)
}
/**
* Get an index of the last fully visible conversation in viewport
* Math.floor to include only fully visible of (absolute number of items below and in viewport) - 1 (index starts from 0)
*/
function getLastItemInViewportIndex(): number {
return Math.floor((containerProps.ref.value!.scrollTop + containerProps.ref.value!.clientHeight) / itemHeight.value) - 1
}
/**
* Scroll to conversation by index
*
* @param index - index of conversation to scroll to
*/
function scrollToItem(index: number) {
const firstItemIndex = getFirstItemInViewportIndex()
const lastItemIndex = getLastItemInViewportIndex()
const viewportHeight = containerProps.ref.value!.clientHeight
/**
* Scroll to a position with smooth scroll imitation
*
* @param to - target position (in px)
*/
const doScroll = (to: number) => {
const ITEMS_TO_BORDER_AFTER_SCROLL = 1
const padding = ITEMS_TO_BORDER_AFTER_SCROLL * itemHeight.value
const from = containerProps.ref.value!.scrollTop
const direction = from < to ? 1 : -1
// If we are far from the target - instantly scroll to a close position
if (Math.abs(from - to) > viewportHeight) {
containerProps.ref.value!.scrollTo({
top: to - direction * viewportHeight,
behavior: 'instant',
})
}
// Scroll to the target with smooth scroll
containerProps.ref.value!.scrollTo({
top: to + padding * direction,
behavior: 'smooth',
})
}
if (index < firstItemIndex) { // Item is above
doScroll(index * itemHeight.value)
} else if (index > lastItemIndex) { // Item is below
// Position of item + item's height and move to bottom
doScroll((index + 1) * itemHeight.value - viewportHeight)
}
}
/**
* Scroll to conversation by token
*
* @param token - token of conversation to scroll to
*/
function scrollToConversation(token: string) {
const index = props.conversations.findIndex((conversation) => conversation.token === token)
if (index !== -1) {
scrollToItem(index)
}
}
defineExpose({
getFirstItemInViewportIndex,
getLastItemInViewportIndex,
scrollToItem,
scrollToConversation,
})
</script>
<template>
<li
:ref="containerProps.ref"
:style="containerProps.style"
@scroll="containerProps.onScroll">
<LoadingPlaceholder v-if="loading" type="conversations" />
<ul
v-else
:style="wrapperProps.style">
<ConversationItem
v-for="item in list"
:key="item.data.id"
:item="item.data"
:compact />
</ul>
</li>
</template>
<style lang="scss" scoped>
// Overwrite NcListItem styles
// TOREMOVE: get rid of it or find better approach
:deep(.list-item) {
outline-offset: -2px;
}
/* Overwrite NcListItem styles for compact view */
:deep(.list-item--compact) {
padding-block: 0 !important;
}
:deep(.list-item--compact:not(:has(.list-item-content__subname))) {
--list-item-height: calc(var(--clickable-area-small, 24px) + 4px) !important;
}
:deep(.list-item--compact .button-vue--size-normal) {
--button-size: var(--clickable-area-small, 24px);
--button-radius: var(--border-radius);
}
:deep(.list-item--compact .list-item-content__actions) {
height: var(--clickable-area-small, 24px);
}
</style>
@@ -0,0 +1,57 @@
<!--
- SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { Conversation } from '../../../types/index.ts'
import { useVirtualList } from '@vueuse/core'
import { toRef } from 'vue'
import LoadingPlaceholder from '../../UIShared/LoadingPlaceholder.vue'
import ConversationSearchResult from './ConversationSearchResult.vue'
import { AVATAR } from '../../../constants.ts'
const props = defineProps<{
conversations: Conversation[]
loading?: boolean
}>()
const emit = defineEmits<{
(event: 'select', item: Conversation): void
}>()
const itemHeight = AVATAR.SIZE.DEFAULT + 2 * 4 + 2 * 2
const { list, containerProps, wrapperProps } = useVirtualList<Conversation>(toRef(() => props.conversations), {
itemHeight,
overscan: 10,
})
/**
* Pass selected conversation to parent component
*
* @param item - selected conversation
*/
function handleClick(item: Conversation) {
emit('select', item)
}
</script>
<template>
<li
:ref="containerProps.ref"
:style="containerProps.style"
@scroll="containerProps.onScroll">
<LoadingPlaceholder v-if="loading" type="conversations" />
<ul
v-else
:style="wrapperProps.style">
<ConversationSearchResult
v-for="item in list"
:key="item.data.id"
:item="item.data"
@click="handleClick" />
</ul>
</li>
</template>

Some files were not shown because too many files have changed in this diff Show More