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');
+ }
+}
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.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/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 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ displayName }}
+ {{ lastMessagePreview }}
+
+
+
+
+
+
+
+
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
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());
+ }
+}