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 @@ -263,6 +265,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);
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
1 change: 1 addition & 0 deletions src/components/ConversationIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export default {
type: Number,
default: AVATAR.SIZE.DEFAULT,
},

},

setup() {
Expand Down
224 changes: 224 additions & 0 deletions src/components/ReferenceWidgets/CallReferenceWidget.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* 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 CallReferenceWidget from './CallReferenceWidget.vue'
import { CONVERSATION, MESSAGE } from '../../constants.ts'
import { fetchConversation } from '../../services/conversationsService.ts'

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,
description: 'A useful room description',
unreadMessages: 2,
hasCall: true,
lastMessage: {
actorDisplayName: 'Alice',
actorId: 'alice',
actorType: 'users',
message: '',
messageParameters: {},
messageType: 'comment',
systemMessage: '',
expirationTimestamp: 0,
timestamp: 1710000000,
},
},
},
},
} as never)

const wrapper = mountWidget()
await flushPromises()

expect(fetchConversation).toHaveBeenCalledWith('XXTOKENXX')
expect(wrapper.text()).toContain('Live conversation name')
expect(wrapper.text()).toContain('Group conversation')
expect(wrapper.text()).toContain('A useful room description')
expect(wrapper.text()).toContain('2 unread messages')
expect(wrapper.text()).toContain('Call in progress')
const conversationIcon = wrapper.findComponent(ConversationIcon)
expect(conversationIcon.exists()).toBe(true)
expect(wrapper.find('.talk-reference-call__avatar-frame').exists()).toBe(true)
})

test('renders a message reference using the provider metadata', async () => {
vi.mocked(fetchConversation).mockResolvedValueOnce({
data: {
ocs: {
data: {
token: 'XXTOKENXX',
displayName: 'Live conversation name',
type: CONVERSATION.TYPE.GROUP,
lastMessage: {
actorDisplayName: 'Latest actor',
actorId: 'latest-actor',
actorType: 'users',
message: 'latest message',
messageParameters: {},
messageType: 'comment',
systemMessage: '',
expirationTimestamp: 0,
},
},
},
},
} as never)

const wrapper = mountWidget({
richObject: { ...richObject, 'message-id': '42' },
referenceTitle: 'Alice in Project room',
referenceDescription: 'A message from Alice',
})
await flushPromises()

expect(wrapper.text()).toContain('Alice in Project room')
expect(wrapper.text()).toContain('A message from Alice')
expect(wrapper.text()).toContain('Message')
expect(wrapper.text()).not.toContain('Latest actor: latest message')
})

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('.talk-reference-call__avatar-frame').exists()).toBe(true)
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,
},
},
},
},
} as never)

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,
},
},
},
},
} as never)

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,
},
},
},
},
} as never)

const wrapper = mountWidget()
await flushPromises()

expect(wrapper.text()).toContain('Alice: hello')
})
})
Loading
Loading