Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use OCA\Talk\Chat\SystemMessage\Listener as SystemMessageListener;
use OCA\Talk\Collaboration\Collaborators\Listener as CollaboratorsListener;
use OCA\Talk\Collaboration\Reference\ReferenceInvalidationListener;
use OCA\Talk\Collaboration\Reference\RenderReferenceEventListener as TalkRenderReferenceEventListener;
use OCA\Talk\Collaboration\Reference\TalkReferenceProvider;
use OCA\Talk\Collaboration\Resources\ConversationProvider;
use OCA\Talk\Collaboration\Resources\Listener as ResourceListener;
Expand Down Expand Up @@ -142,6 +143,7 @@
use OCP\Calendar\Events\CalendarObjectCreatedEvent;
use OCP\Calendar\Events\CalendarObjectUpdatedEvent;
use OCP\Collaboration\AutoComplete\AutoCompleteFilterEvent;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\Collaboration\Resources\IProviderManager;
use OCP\Collaboration\Resources\LoadAdditionalScriptsEvent;
use OCP\Config\BeforePreferenceSetEvent;
Expand Down Expand Up @@ -266,6 +268,7 @@
$context->registerEventListener(LobbyModifiedEvent::class, ReferenceInvalidationListener::class);
$context->registerEventListener(RoomDeletedEvent::class, ReferenceInvalidationListener::class);
$context->registerEventListener(RoomModifiedEvent::class, ReferenceInvalidationListener::class);
$context->registerEventListener(RenderReferenceEvent::class, TalkRenderReferenceEventListener::class);

// Resources listeners
$context->registerEventListener(AttendeesAddedEvent::class, ResourceListener::class);
Expand Down Expand Up @@ -420,7 +423,7 @@
}

public function registerNavigationLink(INavigationManager $navigationManager): void {
$navigationManager->add(static function () {

Check failure on line 426 in lib/AppInfo/Application.php

View workflow job for this annotation

GitHub Actions / static-psalm-analysis

InvalidArgument

lib/AppInfo/Application.php:426:27: InvalidArgument: Argument 1 of OCP\INavigationManager::add expects array{app?: string, classes?: string, color?: string, href: string, icon?: string, id: string, name: string, order: int, type?: 'action'|'guest'|'link'|'quota'|'settings'}|callable():(array{app?: string, classes?: string, color?: string, href: string, icon?: string, id: string, name: string, order: int, type?: 'action'|'guest'|'link'|'quota'|'settings'}|null), but impure-Closure():array{href: non-empty-string, icon: string, id: 'spreed', name: non-empty-string, order: -5, type: 'hidden'|'link'} provided (see https://psalm.dev/004)
$config = Server::get(Config::class);
$userSession = Server::get(IUserSession::class);
$urlGenerator = Server::get(IURLGenerator::class);
Expand Down
34 changes: 34 additions & 0 deletions lib/Collaboration/Reference/RenderReferenceEventListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\Collaboration\Reference;

use OCA\Talk\AppInfo\Application;
use OCP\Collaboration\Reference\RenderReferenceEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;

/**
* Loads the Talk reference widget bundle whenever a page might render
* references (e.g. link previews or Smart Picker widgets), so a Talk
* conversation link can be rendered with a richer, Talk-specific widget
* instead of the generic link preview.
*
* @template-implements IEventListener<Event>
*/
class RenderReferenceEventListener implements IEventListener {
#[\Override]
public function handle(Event $event): void {
if (!($event instanceof RenderReferenceEvent)) {
return;
}

Util::addScript(Application::APP_ID, 'talk-reference');
}
}
1 change: 1 addition & 0 deletions rspack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ module.exports = defineConfig((env) => {
deck: path.join(__dirname, 'src', 'deck.js'),
maps: path.join(__dirname, 'src', 'maps.js'),
search: path.join(__dirname, 'src', 'search.js'),
reference: path.join(__dirname, 'src', 'reference.ts'),
icons: path.join(__dirname, 'src', 'icons.css'),
},

Expand Down
167 changes: 167 additions & 0 deletions src/components/ReferenceWidgets/CallReferenceWidget.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, test, vi } from 'vitest'
import ConversationIcon from '../ConversationIcon.vue'
import { CONVERSATION, MESSAGE } from '../../constants.ts'
import { fetchConversation } from '../../services/conversationsService.ts'
import CallReferenceWidget from './CallReferenceWidget.vue'

Check failure on line 11 in src/components/ReferenceWidgets/CallReferenceWidget.spec.ts

View workflow job for this annotation

GitHub Actions / NPM lint

Expected "./CallReferenceWidget.vue" (internalVue) to come before "../../services/conversationsService.ts" (unknown)

vi.mock('../../services/conversationsService.ts', () => ({
fetchConversation: vi.fn(),
}))

// ConversationIcon reads capabilities/cached conversations from BrowserStorage at import time
vi.mock('../../services/CapabilitiesManager.ts', () => ({
hasTalkFeature: vi.fn(() => false),
getTalkConfig: vi.fn(),
}))

describe('CallReferenceWidget.vue', () => {
const richObject = {
id: 'XXTOKENXX',
name: 'Fallback conversation name',
link: 'https://nextcloud.local/call/XXTOKENXX',
'call-type': 'group',
}

/**
* @param props additional props to merge on top of the defaults
*/
function mountWidget(props = {}) {
return mount(CallReferenceWidget, {
props: {
richObject,
accessible: true,
...props,
},
})
}

test('renders nothing when the reference is not accessible', async () => {
const wrapper = mountWidget({ accessible: false })
await flushPromises()

expect(wrapper.find('a').exists()).toBe(false)
expect(fetchConversation).not.toHaveBeenCalled()
})

test('renders the live conversation once loaded', async () => {
vi.mocked(fetchConversation).mockResolvedValueOnce({
data: {
ocs: {
data: {

Check failure on line 56 in src/components/ReferenceWidgets/CallReferenceWidget.spec.ts

View workflow job for this annotation

GitHub Actions / test

Type '{ token: string; displayName: string; type: 2; }' is missing the following properties from type '{ actorId: string; invitedActorId?: string | undefined; actorType: string; attendeeId: number; attendeePermissions: number; attendeePin: string | null; avatarVersion: string; breakoutRoomMode: number; ... 58 more ...; attributes: number; }': actorId, actorType, attendeeId, attendeePermissions, and 52 more.
token: 'XXTOKENXX',
displayName: 'Live conversation name',
type: CONVERSATION.TYPE.GROUP,
},
},
},
})

const wrapper = mountWidget()
await flushPromises()

expect(fetchConversation).toHaveBeenCalledWith('XXTOKENXX')
expect(wrapper.text()).toContain('Live conversation name')
expect(wrapper.findComponent(ConversationIcon).exists()).toBe(true)
})

test('falls back to the reference metadata when the live fetch fails', async () => {
vi.mocked(fetchConversation).mockRejectedValueOnce(new Error('403'))

const wrapper = mountWidget({ fallbackAvatarUrl: 'https://nextcloud.local/avatar.png' })
await flushPromises()

expect(wrapper.text()).toContain('Fallback conversation name')
expect(wrapper.findComponent(ConversationIcon).exists()).toBe(false)
expect(wrapper.find('img').attributes('src')).toBe('https://nextcloud.local/avatar.png')
})

test('does not show an expired last message', async () => {
vi.mocked(fetchConversation).mockResolvedValueOnce({
data: {
ocs: {
data: {
token: 'XXTOKENXX',
displayName: 'Live conversation name',
type: CONVERSATION.TYPE.GROUP,
lastMessage: {

Check failure on line 92 in src/components/ReferenceWidgets/CallReferenceWidget.spec.ts

View workflow job for this annotation

GitHub Actions / test

Type '{ actorDisplayName: string; actorType: string; message: string; messageParameters: {}; messageType: string; systemMessage: string; expirationTimestamp: number; }' is not assignable to type '{ actorDisplayName: string; actorId: string; actorType: string; expirationTimestamp: number; message: string; messageParameters: { [key: string]: { type: string; id: string; name: string; server?: string | undefined; ... 24 more ...; blurhash?: string | undefined; }; }; messageType: string; systemMessage: string; } ...'.
actorDisplayName: 'Alice',
actorType: 'users',
message: 'hello',
messageParameters: {},
messageType: 'comment',
systemMessage: '',
expirationTimestamp: 1,
},
},
},
},
})

const wrapper = mountWidget()
await flushPromises()

expect(wrapper.text()).not.toContain('hello')
})

test('does not show a deleted last message', async () => {
vi.mocked(fetchConversation).mockResolvedValueOnce({
data: {
ocs: {
data: {
token: 'XXTOKENXX',
displayName: 'Live conversation name',
type: CONVERSATION.TYPE.GROUP,
lastMessage: {

Check failure on line 120 in src/components/ReferenceWidgets/CallReferenceWidget.spec.ts

View workflow job for this annotation

GitHub Actions / test

Type '{ actorDisplayName: string; actorType: string; message: string; messageParameters: {}; messageType: "comment_deleted"; systemMessage: string; expirationTimestamp: number; }' is not assignable to type '{ actorDisplayName: string; actorId: string; actorType: string; expirationTimestamp: number; message: string; messageParameters: { [key: string]: { type: string; id: string; name: string; server?: string | undefined; ... 24 more ...; blurhash?: string | undefined; }; }; messageType: string; systemMessage: string; } ...'.
actorDisplayName: 'Alice',
actorType: 'users',
message: 'hello',
messageParameters: {},
messageType: MESSAGE.TYPE.COMMENT_DELETED,
systemMessage: '',
expirationTimestamp: 0,
},
},
},
},
})

const wrapper = mountWidget()
await flushPromises()

expect(wrapper.text()).not.toContain('hello')
})

test('shows a non-expired last message with its actor', async () => {
vi.mocked(fetchConversation).mockResolvedValueOnce({
data: {
ocs: {
data: {
token: 'XXTOKENXX',
displayName: 'Live conversation name',
type: CONVERSATION.TYPE.GROUP,
lastMessage: {

Check failure on line 148 in src/components/ReferenceWidgets/CallReferenceWidget.spec.ts

View workflow job for this annotation

GitHub Actions / test

Type '{ actorDisplayName: string; actorType: string; message: string; messageParameters: {}; messageType: string; systemMessage: string; expirationTimestamp: number; }' is not assignable to type '{ actorDisplayName: string; actorId: string; actorType: string; expirationTimestamp: number; message: string; messageParameters: { [key: string]: { type: string; id: string; name: string; server?: string | undefined; ... 24 more ...; blurhash?: string | undefined; }; }; messageType: string; systemMessage: string; } ...'.
actorDisplayName: 'Alice',
actorType: 'users',
message: 'hello',
messageParameters: {},
messageType: 'comment',
systemMessage: '',
expirationTimestamp: 0,
},
},
},
},
})

const wrapper = mountWidget()
await flushPromises()

expect(wrapper.text()).toContain('Alice: hello')
})
})
157 changes: 157 additions & 0 deletions src/components/ReferenceWidgets/CallReferenceWidget.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

<template>
<a
v-if="accessible"
class="talk-reference-call"
:href="richObject.link"
target="_blank"
rel="noopener noreferrer">
<NcLoadingIcon v-if="loading" :size="32" />

<template v-else>
<ConversationIcon
v-if="conversation"
:item="conversation"
:size="AVATAR.SIZE.DEFAULT"
hideUserStatus />
<img
v-else-if="fallbackAvatarUrl"
:src="fallbackAvatarUrl"
:alt="displayName"
class="talk-reference-call__fallback-avatar">

<span class="talk-reference-call__body">
<span class="talk-reference-call__title">{{ displayName }}</span>
<span v-if="lastMessagePreview" class="talk-reference-call__subtitle">{{ lastMessagePreview }}</span>
</span>
</template>
</a>
</template>

<script setup lang="ts">
import type { Conversation, TalkReferenceRichObject } from '../../types/index.ts'

import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import ConversationIcon from '../ConversationIcon.vue'
import { AVATAR, MESSAGE } from '../../constants.ts'
import { fetchConversation } from '../../services/conversationsService.ts'
import { getDisplayNameWithFallback } from '../../utils/getDisplayName.ts'
import { parseToSimpleMessage } from '../../utils/textParse.ts'

const props = defineProps<{
richObject: TalkReferenceRichObject
accessible: boolean
/** Server-rendered fallback avatar (open graph thumbnail), used only until/if the live conversation loads */
fallbackAvatarUrl?: string | null
}>()

const loading = ref(true)
const conversation = ref<Conversation | null>(null)
let cancelled = false

onMounted(async () => {
if (!props.accessible) {
loading.value = false
return
}

try {
const response = await fetchConversation(props.richObject.id)
if (!cancelled) {
conversation.value = response.data.ocs.data
}
} catch (error) {
// Show less rather than showing something wrong: fall back to the reference metadata
console.debug('Could not load live Talk conversation data for reference widget', error)
} finally {
if (!cancelled) {
loading.value = false
}
}
})

onBeforeUnmount(() => {
cancelled = true
})

const displayName = computed(() => conversation.value?.displayName || props.richObject.name)

const fallbackAvatarUrl = computed(() => props.fallbackAvatarUrl ?? null)

/**
* A short "actor: message" preview of the conversation's last message.
* Only shown when the live conversation response demonstrably includes a non-expired,
* non-deleted, non-system message; otherwise omitted entirely.
*/
const lastMessagePreview = computed(() => {
const lastMessage = conversation.value?.lastMessage
if (!lastMessage) {
return ''
}

if (lastMessage.messageType === MESSAGE.TYPE.COMMENT_DELETED || lastMessage.systemMessage) {
return ''
}

if (lastMessage.expirationTimestamp !== 0 && lastMessage.expirationTimestamp * 1000 <= Date.now()) {
return ''
}

const text = parseToSimpleMessage(lastMessage.message, lastMessage.messageParameters)
if (!text) {
return ''
}

const actor = getDisplayNameWithFallback(lastMessage.actorDisplayName, lastMessage.actorType)
return actor ? `${actor}: ${text}` : text
})
</script>

<style lang="scss" scoped>
.talk-reference-call {
display: flex;
align-items: center;
gap: calc(var(--default-grid-baseline) * 2);
padding: calc(var(--default-grid-baseline) * 2);
color: var(--color-main-text);
text-decoration: none;

&:hover,
&:focus {
background-color: var(--color-background-hover);
border-radius: var(--border-radius-large);
}

&__fallback-avatar {
width: v-bind('`${AVATAR.SIZE.DEFAULT}px`');
height: v-bind('`${AVATAR.SIZE.DEFAULT}px`');
border-radius: 50%;
object-fit: cover;
}

&__body {
display: flex;
flex-direction: column;
min-width: 0;
}

&__title {
font-weight: bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

&__subtitle {
color: var(--color-text-maxcontrast);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>
Loading
Loading