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
+303
View File
@@ -0,0 +1,303 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Dashboard;
use OCA\Talk\ResponseDefinitions;
/**
* @psalm-import-type TalkDashboardEvent from ResponseDefinitions
* @psalm-import-type TalkDashboardEventCalendar from ResponseDefinitions
* @psalm-import-type TalkDashboardEventAttachment from ResponseDefinitions
*/
class Event implements \JsonSerializable {
/** @var non-empty-list<TalkDashboardEventCalendar> */
protected array $calendars = [];
protected string $eventName = '';
protected string $eventLink = '';
protected int $start = 0;
protected int $end = 0;
protected string $roomToken = '';
protected string $roomAvatarVersion = '';
protected string $roomName = '';
protected string $roomDisplayName = '';
protected int $roomType = 0;
protected ?string $eventDescription = null;
/** @var array<string, TalkDashboardEventAttachment> */
protected array $eventAttachments = [];
protected ?int $roomActiveSince = null;
protected ?int $accepted = null;
protected ?int $tentative = null;
protected ?int $declined = null;
protected ?int $invited = null;
public function __construct() {
}
/**
* @return non-empty-list<TalkDashboardEventCalendar>
*/
public function getCalendars(): array {
return $this->calendars;
}
public function getEventName(): string {
return $this->eventName;
}
public function setEventName(string $eventName): void {
$this->eventName = $eventName;
}
public function getEventDescription(): ?string {
return $this->eventDescription;
}
public function setEventDescription(?string $eventDescription): void {
$this->eventDescription = $eventDescription;
}
/**
* @return array<string, TalkDashboardEventAttachment>
*/
public function getEventAttachments(): array {
return $this->eventAttachments;
}
public function setEventLink(string $eventLink): void {
$this->eventLink = $eventLink;
}
public function getStart(): int {
return $this->start;
}
public function setStart(int $start): void {
$this->start = $start;
}
public function getEnd(): int {
return $this->end;
}
public function setEnd(int $end): void {
$this->end = $end;
}
public function setRoomToken(string $roomToken): void {
$this->roomToken = $roomToken;
}
public function setRoomAvatarVersion(string $roomAvatarVersion): void {
$this->roomAvatarVersion = $roomAvatarVersion;
}
public function setRoomName(string $roomName): void {
$this->roomName = $roomName;
}
public function setRoomDisplayName(string $roomDisplayName): void {
$this->roomDisplayName = $roomDisplayName;
}
public function setRoomType(int $roomType): void {
$this->roomType = $roomType;
}
public function setRoomActiveSince(?int $roomActiveSince): void {
$this->roomActiveSince = $roomActiveSince;
}
public function generateAttendance(array $attendees): void {
foreach ($attendees as $attendee) {
if (!isset($attendee[1]['PARTSTAT'])) {
continue;
}
switch ($attendee[1]['PARTSTAT']->getValue()) {
case 'ACCEPTED':
(int)$this->accepted++;
break;
case 'TENTATIVE':
(int)$this->tentative++;
break;
case 'DECLINED':
(int)$this->declined++;
break;
case 'NEEDS-ACTION':
(int)$this->invited++;
break;
default:
break;
}
}
}
public function isAttendee(array $attendees, string $email): bool {
foreach ($attendees as $attendee) {
if (!isset($attendee[1]['PARTSTAT'])) {
continue;
}
// Calendar emails start with 'mailto:'
if (substr($attendee[0], 7) === $email) {
return true;
}
}
return false;
}
public function isOrganizer(array $organizer, string $email): bool {
// Calendar emails start with 'mailto:'
return substr($organizer[0], 7) === $email;
}
/**
* Takes the room token, start and end time and attendees to build an identifier
* If the identifier already exists, another event is happening at the same time
* in the same room
*
* We only return duplicates if the attendees are different
*
* @return string
*/
public function generateEventIdentifier(): string {
return $this->roomToken . '#' . $this->start . '#' . $this->end . '#' . (int)$this->accepted . '#' . (int)$this->tentative . '#' . (int)$this->declined;
}
/**
* @param string $calendarName
* @param array $attachments
* @return void
*/
public function handleCalendarAttachments(string $calendarName, array $attachments): void {
foreach ($attachments as $attachment) {
$params = $attachment[1];
if (!isset($params['X-NC-FILE-ID'])) {
continue;
}
$this->eventAttachments[$attachment[0]] = [
'calendars' => [$calendarName],
'fmttype' => $params['FMTTYPE']?->getValue() ?? '',
'filename' => $params['FILENAME']?->getValue() ?? '',
'fileid' => $params['X-NC-FILE-ID']->getValue(),
'preview' => $params['X-NC-HAS-PREVIEW']?->getValue() ?? false,
'previewLink' => $params['X-NC-HAS-PREVIEW']?->getValue() ? $attachment[0] : null,
];
}
}
/**
* @param string $principalUri
* @param string $calendarName
* @param string|null $calendarColor
* @return void
*/
public function addCalendar(string $principalUri, string $calendarName, ?string $calendarColor): void {
$this->calendars[] = [
'principalUri' => $principalUri,
'calendarName' => $calendarName,
'calendarColor' => $calendarColor,
];
}
public function mergeAttachments(self $event): void {
$attachments = $event->getEventAttachments();
if (empty($attachments) === true) {
return;
}
if (empty($this->eventAttachments) === true) {
$this->eventAttachments = $attachments;
return;
}
foreach ($attachments as $filename => $attachment) {
if (isset($this->eventAttachments[$filename])) {
$this->eventAttachments[$filename]['calendars']
= array_merge($this->eventAttachments[$filename]['calendars'], $attachment['calendars']);
} else {
$this->eventAttachments[$filename] = $attachment;
}
}
}
public function getEventLink(): string {
return $this->eventLink;
}
public function getRoomToken(): string {
return $this->roomToken;
}
public function getRoomAvatarVersion(): string {
return $this->roomAvatarVersion;
}
public function getRoomName(): string {
return $this->roomName;
}
public function getRoomDisplayName(): string {
return $this->roomDisplayName;
}
public function getRoomType(): int {
return $this->roomType;
}
public function getRoomActiveSince(): ?int {
return $this->roomActiveSince;
}
public function getAccepted(): ?int {
return $this->accepted;
}
public function getTentative(): ?int {
return $this->tentative;
}
public function getDeclined(): ?int {
return $this->declined;
}
public function getInvited(): ?int {
return $this->invited;
}
/**
* @return TalkDashboardEvent
*/
#[\Override]
public function jsonSerialize(): array {
return [
'calendars' => $this->getCalendars(),
'eventName' => $this->getEventName(),
'eventLink' => $this->getEventLink(),
'start' => $this->getStart(),
'end' => $this->getEnd(),
'roomToken' => $this->getRoomToken(),
'roomAvatarVersion' => $this->getRoomAvatarVersion(),
'roomName' => $this->getRoomName(),
'roomDisplayName' => $this->getRoomDisplayName(),
'roomType' => $this->getRoomType(),
'eventDescription' => $this->getEventDescription(),
'eventAttachments' => $this->getEventAttachments(),
'roomActiveSince' => $this->getRoomActiveSince(),
'accepted' => $this->getAccepted(),
'tentative' => $this->getTentative(),
'declined' => $this->getDeclined(),
'invited' => $this->getInvited(),
];
}
}
+345
View File
@@ -0,0 +1,345 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Talk\Dashboard;
use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Chat\MessageParser;
use OCA\Talk\Config;
use OCA\Talk\Events\BeforeRoomsFetchEvent;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\BreakoutRoom;
use OCA\Talk\Model\Message;
use OCA\Talk\Participant;
use OCA\Talk\Room;
use OCA\Talk\Service\AvatarService;
use OCA\Talk\Service\ParticipantService;
use OCA\Talk\Service\ProxyCacheMessageService;
use OCA\Talk\Webinary;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Dashboard\IAPIWidget;
use OCP\Dashboard\IButtonWidget;
use OCP\Dashboard\IConditionalWidget;
use OCP\Dashboard\IIconWidget;
use OCP\Dashboard\IOptionWidget;
use OCP\Dashboard\IReloadableWidget;
use OCP\Dashboard\Model\WidgetButton;
use OCP\Dashboard\Model\WidgetItem;
use OCP\Dashboard\Model\WidgetItems;
use OCP\Dashboard\Model\WidgetOptions;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserSession;
use OCP\Util;
class TalkWidget implements IAPIWidget, IIconWidget, IButtonWidget, IOptionWidget, IConditionalWidget, IReloadableWidget {
public function __construct(
protected IUserSession $userSession,
protected Config $talkConfig,
protected IURLGenerator $url,
protected IL10N $l10n,
protected Manager $manager,
protected AvatarService $avatarService,
protected ParticipantService $participantService,
protected MessageParser $messageParser,
protected ChatManager $chatManager,
protected ProxyCacheMessageService $pcmService,
protected IEventDispatcher $dispatcher,
protected ITimeFactory $timeFactory,
) {
}
/**
* @inheritDoc
*/
#[\Override]
public function getId(): string {
return 'spreed';
}
/**
* @inheritDoc
*/
#[\Override]
public function getTitle(): string {
return $this->l10n->t('Talk mentions');
}
/**
* @inheritDoc
*/
#[\Override]
public function getOrder(): int {
return 10;
}
/**
* @inheritDoc
*/
#[\Override]
public function getIconClass(): string {
return 'dashboard-talk-icon';
}
/**
* @inheritDoc
*/
#[\Override]
public function isEnabled(): bool {
$user = $this->userSession->getUser();
return !($user instanceof IUser && $this->talkConfig->isDisabledForUser($user));
}
#[\Override]
public function getWidgetOptions(): WidgetOptions {
return new WidgetOptions(true);
}
/**
* @return list<WidgetButton>
*/
#[\Override]
public function getWidgetButtons(string $userId): array {
$buttons = [];
$buttons[] = new WidgetButton(
WidgetButton::TYPE_MORE,
$this->url->linkToRouteAbsolute('spreed.Page.index'),
$this->l10n->t('More conversations')
);
return $buttons;
}
/**
* @inheritDoc
*/
#[\Override]
public function getIconUrl(): string {
return $this->url->getAbsoluteURL($this->url->imagePath('spreed', 'app-dark.svg'));
}
/**
* @inheritDoc
*/
#[\Override]
public function getUrl(): ?string {
return $this->url->linkToRouteAbsolute('spreed.Page.index');
}
/**
* @inheritDoc
*/
#[\Override]
public function load(): void {
Util::addStyle('spreed', 'talk-icons');
}
#[\Override]
public function getItems(string $userId, ?string $since = null, int $limit = 7): array {
$event = new BeforeRoomsFetchEvent($userId);
$this->dispatcher->dispatchTyped($event);
$rooms = $this->manager->getRoomsForUser($userId, [], true);
$rooms = array_filter($rooms, function (Room $room) use ($userId) {
if ($room->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
return false;
}
$participant = $this->participantService->getParticipant($room, $userId);
$attendee = $participant->getAttendee();
if ($room->getLobbyState() !== Webinary::LOBBY_NONE
&& !($participant->getPermissions() & Attendee::PERMISSIONS_LOBBY_IGNORE)) {
return false;
}
if (!$attendee->isArchived() && $room->getCallFlag() !== Participant::FLAG_DISCONNECTED) {
return true;
}
if (($room->isFederatedConversation() && $attendee->getLastMentionMessage())
|| (!$room->isFederatedConversation() && $attendee->getLastMentionMessage() > $attendee->getLastReadMessage())) {
return true;
}
return ($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER)
&& $room->getLastMessageId() > $attendee->getLastReadMessage()
&& $this->chatManager->getUnreadCount($room, $attendee->getLastReadMessage()) > 0;
});
uasort($rooms, [$this, 'sortRooms']);
$rooms = array_slice($rooms, 0, $limit);
$result = [];
foreach ($rooms as $room) {
$result[] = $this->prepareRoom($room, $userId);
}
return $result;
}
/**
* @inheritDoc
*/
#[\Override]
public function getItemsV2(string $userId, ?string $since = null, int $limit = 7): WidgetItems {
$event = new BeforeRoomsFetchEvent($userId);
$this->dispatcher->dispatchTyped($event);
$allRooms = $this->manager->getRoomsForUser($userId, [], true);
$rooms = [];
$mentions = [];
foreach ($allRooms as $room) {
if ($room->getObjectType() === BreakoutRoom::PARENT_OBJECT_TYPE) {
continue;
}
$participant = $this->participantService->getParticipant($room, $userId);
$attendee = $participant->getAttendee();
if ($room->getLobbyState() !== Webinary::LOBBY_NONE
&& !($participant->getPermissions() & Attendee::PERMISSIONS_LOBBY_IGNORE)) {
continue;
}
if (!$attendee->isArchived()) {
$rooms[] = $room;
if ($room->getCallFlag() !== Participant::FLAG_DISCONNECTED) {
// Call in progress
$mentions[] = $room;
continue;
}
}
if (($room->isFederatedConversation() && $attendee->getLastMentionMessage())
|| (!$room->isFederatedConversation() && $attendee->getLastMentionMessage() > $attendee->getLastReadMessage())) {
// Really mentioned
$mentions[] = $room;
continue;
}
if (($room->getType() === Room::TYPE_ONE_TO_ONE || $room->getType() === Room::TYPE_ONE_TO_ONE_FORMER)
&& $room->getLastMessageId() > $attendee->getLastReadMessage()) {
// If there are "unread" messages in one-to-one or former one-to-one
// we check if they are actual messages or system messages not
// considered by the read-marker
if ($this->chatManager->getUnreadCount($room, $attendee->getLastReadMessage()) > 0) {
// Unread message in one-to-one are considered "mentions"
$mentions[] = $room;
}
}
}
$roomsToReturn = $rooms;
if (!empty($mentions)) {
$roomsToReturn = $mentions;
}
uasort($roomsToReturn, [$this, 'sortRooms']);
$roomsToReturn = array_slice($roomsToReturn, 0, $limit);
$result = [];
foreach ($roomsToReturn as $room) {
$result[] = $this->prepareRoom($room, $userId);
}
return new WidgetItems(
$result,
empty($result) ? $this->l10n->t('Say hi to your friends and colleagues!') : '',
empty($mentions) ? $this->l10n->t('No unread mentions') : '',
);
}
protected function prepareRoom(Room $room, string $userId): WidgetItem {
$participant = $this->participantService->getParticipant($room, $userId);
$attendee = $participant->getAttendee();
$subtitle = '';
if ($attendee->isSensitive()) {
// Don't leak sensitive last messages on dashboard
} elseif ($room->getLastMessageId() && $room->isFederatedConversation()) {
try {
$cachedMessage = $this->pcmService->findByRemote(
$room->getRemoteServer(),
$room->getRemoteToken(),
$room->getLastMessageId(),
);
$message = $this->messageParser->createMessageFromProxyCache($room, $participant, $cachedMessage, $this->l10n);
$subtitle = $this->getSubtitleFromMessage($message);
} catch (DoesNotExistException) {
// Fallback to empty subtitle
}
} elseif ($room->getLastMessageId() && $room->getLastMessage() && !$room->isFederatedConversation()) {
$message = $this->messageParser->createMessage($room, $participant, $room->getLastMessage(), $this->l10n);
$this->messageParser->parseMessage($message, true);
$subtitle = $this->getSubtitleFromMessage($message);
}
if ($room->getCallFlag() !== Participant::FLAG_DISCONNECTED) {
$subtitle = $this->l10n->t('Call in progress');
} elseif (($room->isFederatedConversation() && $attendee->getLastMentionMessage())
|| (!$room->isFederatedConversation() && $attendee->getLastMentionMessage() > $attendee->getLastReadMessage())) {
$subtitle = $this->l10n->t('You were mentioned');
}
return new WidgetItem(
$room->getDisplayName($userId),
$subtitle,
$this->url->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()]),
$this->avatarService->getAvatarUrl($room)
);
}
protected function getSubtitleFromMessage(Message $message): string {
$expireDate = $message->getExpirationDateTime();
if ($expireDate instanceof \DateTimeInterface
&& $expireDate <= $this->timeFactory->getDateTime()) {
return '';
}
if (!$message->getVisibility()) {
return '';
}
$placeholders = $replacements = [];
foreach ($message->getMessageParameters() as $placeholder => $parameter) {
$placeholders[] = '{' . $placeholder . '}';
if ($parameter['type'] === 'user' || $parameter['type'] === 'guest') {
$replacements[] = '@' . $parameter['name'];
} else {
$replacements[] = $parameter['name'];
}
}
return str_replace($placeholders, $replacements, $message->getMessage());
}
protected function sortRooms(Room $roomA, Room $roomB): int {
if ($roomA->getCallFlag() !== $roomB->getCallFlag()) {
return $roomA->getCallFlag() !== Participant::FLAG_DISCONNECTED ? -1 : 1;
}
return $roomA->getLastActivity() >= $roomB->getLastActivity() ? -1 : 1;
}
/**
* @inheritDoc
*/
#[\Override]
public function getReloadInterval(): int {
return 30;
}
}