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
+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>