From 913020f9b8627440ab99a9051ef01ebc681c9851 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Mon, 24 Aug 2026 21:07:37 +0200 Subject: [PATCH 1/3] feat(reference): load Talk widget bundle on RenderReferenceEvent Register a listener that calls Util::addScript() so other apps' pages (Text, Collectives, comments, etc.) load Talk's reference widget bundle whenever they render Smart Picker / link-preview references, not only when Talk itself renders the page. Assisted-by: Claude Sonnet 5:claude-sonnet-4.5 --- lib/AppInfo/Application.php | 3 ++ .../RenderReferenceEventListener.php | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 lib/Collaboration/Reference/RenderReferenceEventListener.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 44d09641cff..a43df3d7d87 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -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; @@ -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; @@ -266,6 +268,7 @@ public function register(IRegistrationContext $context): void { $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); diff --git a/lib/Collaboration/Reference/RenderReferenceEventListener.php b/lib/Collaboration/Reference/RenderReferenceEventListener.php new file mode 100644 index 00000000000..cd6626ef02c --- /dev/null +++ b/lib/Collaboration/Reference/RenderReferenceEventListener.php @@ -0,0 +1,34 @@ + + */ +class RenderReferenceEventListener implements IEventListener { + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof RenderReferenceEvent)) { + return; + } + + Util::addScript(Application::APP_ID, 'talk-reference'); + } +} From f7e3c36feaed491a9aafac0ff02305be7af78532 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Mon, 24 Aug 2026 21:07:46 +0200 Subject: [PATCH 2/3] feat(reference): render a richer widget for Talk conversation links Register a 'call' Smart Picker reference widget that replaces the generic open-graph card with a Talk-shaped one: avatar, conversation type, display name and (when available) a non-expired last message. Live conversation data is fetched client-side through the existing, already-authorized room endpoint; the cached reference payload produced by TalkReferenceProvider is left untouched so no volatile data ends up in the reference cache. hasInteractiveView is explicitly false: this widget is read-only, with no composer, joining, or message history in this change. Assisted-by: Claude Sonnet 5:claude-sonnet-4.5 --- rspack.config.js | 1 + .../ReferenceWidgets/CallReferenceWidget.vue | 157 ++++++++++++++++++ src/reference.ts | 43 +++++ src/services/conversationsService.ts | 5 +- src/types/index.ts | 11 ++ 5 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/components/ReferenceWidgets/CallReferenceWidget.vue create mode 100644 src/reference.ts diff --git a/rspack.config.js b/rspack.config.js index a109438d217..5cb34b9a20c 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -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'), }, diff --git a/src/components/ReferenceWidgets/CallReferenceWidget.vue b/src/components/ReferenceWidgets/CallReferenceWidget.vue new file mode 100644 index 00000000000..782f03eec59 --- /dev/null +++ b/src/components/ReferenceWidgets/CallReferenceWidget.vue @@ -0,0 +1,157 @@ + + + + + + + diff --git a/src/reference.ts b/src/reference.ts new file mode 100644 index 00000000000..a6ba540fbd3 --- /dev/null +++ b/src/reference.ts @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { App } from 'vue' +import type { TalkReferenceRichObject } from './types/index.ts' + +import { getCSPNonce } from '@nextcloud/auth' +import { generateFilePath } from '@nextcloud/router' +import { registerWidget } from '@nextcloud/vue/functions/reference' +import { createApp, defineAsyncComponent } from 'vue' + +// CSP config for webpack dynamic chunk loading +__webpack_nonce__ = getCSPNonce() + +// Correct the root of the app for chunk loading +// OC.linkTo matches the apps folders +// OC.generateUrl ensure the index.php (or not) +// We do not want the index.php since we're loading files +__webpack_public_path__ = generateFilePath('spreed', '', 'js/') + +const CallReferenceWidget = defineAsyncComponent(() => import('./components/ReferenceWidgets/CallReferenceWidget.vue')) + +// Track mounted widget instances per host element so the destroy callback can unmount them +const widgetApps = new WeakMap() + +registerWidget('call', (el, { richObject, accessible, openGraphObject }) => { + const app = createApp(CallReferenceWidget, { + richObject: richObject as unknown as TalkReferenceRichObject, + accessible, + fallbackAvatarUrl: openGraphObject?.thumb ?? null, + }) + widgetApps.set(el, app) + app.mount(el) +}, (el) => { + widgetApps.get(el)?.unmount() + widgetApps.delete(el) +}, { + hasInteractiveView: false, + fullWidth: false, + isResizable: false, +}) diff --git a/src/services/conversationsService.ts b/src/services/conversationsService.ts index da5e688493b..d3ffb0bf773 100644 --- a/src/services/conversationsService.ts +++ b/src/services/conversationsService.ts @@ -78,9 +78,10 @@ async function fetchConversations(params: getAllConversationsParams, options?: A * Fetches a conversation from the server. * * @param token The token of the conversation to be fetched. + * @param [options] Axios request options */ -async function fetchConversation(token: string): getSingleConversationResponse { - return axios.get(generateOcsUrl('apps/spreed/api/v4/room/{token}', { token })) +async function fetchConversation(token: string, options?: AxiosRequestConfig): getSingleConversationResponse { + return axios.get(generateOcsUrl('apps/spreed/api/v4/room/{token}', { token }), options) } /** diff --git a/src/types/index.ts b/src/types/index.ts index f01cf08d455..3ca0250dd55 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -387,6 +387,17 @@ export type importEmailsResponse = ApiResponse & { 'mention-id'?: string } + +// Collaboration reference (Smart Picker / link preview) payload for a Talk conversation link, +// as produced by TalkReferenceProvider::fetchReference(). Not a RichObjectParameter: no 'type' key. +export type TalkReferenceRichObject = { + id: string + name: string + link: string + 'call-type': string + 'message-id'?: string +} + export type File = RichObject<'size' | 'path' | 'link' | 'mimetype' | 'preview-available'> & { etag: string permissions: string From 31c07f89ea1c0b70efe7bd27bd67760518e45e35 Mon Sep 17 00:00:00 2001 From: Jos Poortvliet Date: Mon, 24 Aug 2026 21:07:51 +0200 Subject: [PATCH 3/3] test(reference): cover the reference widget and its script listener Assisted-by: Claude Sonnet 5:claude-sonnet-4.5 --- .../CallReferenceWidget.spec.ts | 167 ++++++++++++++++++ .../RenderReferenceEventListenerTest.php | 39 ++++ 2 files changed, 206 insertions(+) create mode 100644 src/components/ReferenceWidgets/CallReferenceWidget.spec.ts create mode 100644 tests/php/Collaboration/Reference/RenderReferenceEventListenerTest.php diff --git a/src/components/ReferenceWidgets/CallReferenceWidget.spec.ts b/src/components/ReferenceWidgets/CallReferenceWidget.spec.ts new file mode 100644 index 00000000000..6c80d3d85e2 --- /dev/null +++ b/src/components/ReferenceWidgets/CallReferenceWidget.spec.ts @@ -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' + +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: { + 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: { + 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: { + 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: { + actorDisplayName: 'Alice', + actorType: 'users', + message: 'hello', + messageParameters: {}, + messageType: 'comment', + systemMessage: '', + expirationTimestamp: 0, + }, + }, + }, + }, + }) + + const wrapper = mountWidget() + await flushPromises() + + expect(wrapper.text()).toContain('Alice: hello') + }) +}) diff --git a/tests/php/Collaboration/Reference/RenderReferenceEventListenerTest.php b/tests/php/Collaboration/Reference/RenderReferenceEventListenerTest.php new file mode 100644 index 00000000000..3a9a7bfbb45 --- /dev/null +++ b/tests/php/Collaboration/Reference/RenderReferenceEventListenerTest.php @@ -0,0 +1,39 @@ +listener = new RenderReferenceEventListener(); + } + + public function testHandleIgnoresUnrelatedEvents(): void { + $scriptsBefore = Util::getScripts(); + + $this->listener->handle($this->createMock(Event::class)); + + self::assertSame($scriptsBefore, Util::getScripts()); + } + + public function testHandleLoadsTheReferenceScriptOnRenderReferenceEvent(): void { + $this->listener->handle(new RenderReferenceEvent()); + + self::assertContains('spreed/js/talk-reference', Util::getScripts()); + } +}