Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ebdb3ee
feat(pinned): Add basic API layout of pinned messages
nickvergessen Oct 30, 2025
bfd33aa
feat(pinned): Add response fields for last pinned and last dismissed ids
nickvergessen Oct 30, 2025
3206065
fix(pinned): Return pinned messages when requesting the overview
nickvergessen Oct 30, 2025
07a8931
fix(pinned): Implement actual pinning, hiding and unpinning API methods
nickvergessen Oct 30, 2025
47c9525
test(pinned): Add integration tests for pinned messages
nickvergessen Oct 30, 2025
30ef63d
feat(pinned): Add capability
nickvergessen Oct 30, 2025
40e2a46
fix(pinned): Reset the hide-pinned-id when the same message is repinned
nickvergessen Oct 30, 2025
00c3755
fix(pinned): Reset the last pinned id on unpinning
nickvergessen Oct 30, 2025
bf0f5f0
fix(pinned): Allow "Pin until"
nickvergessen Oct 31, 2025
3e32fbb
feat(pinned): Expose pin actor and pinned until for messages
nickvergessen Oct 31, 2025
8851d2b
fix(pinned): Handle message expiration
nickvergessen Nov 3, 2025
4449271
chore(assets): Recompile assets
nickvergessen Oct 30, 2025
c331bdb
fix(pinned): Remove federation flag for now
nickvergessen Nov 3, 2025
e856d8e
fix(tests): Fix field name in tests
nickvergessen Nov 3, 2025
b483d2a
fix(pinned): Align exposed key with documented and tested one
nickvergessen Nov 3, 2025
1918a39
test(pinned): Make test non-flaky
nickvergessen Nov 3, 2025
a341078
fix(pinned): At the timestamp when a message was pinned
nickvergessen Nov 4, 2025
50a4eda
chore(assets): Recompile assets
nickvergessen Nov 4, 2025
f9ef8f7
fix(pinned): Fix sort order of pinned messages
nickvergessen Nov 4, 2025
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
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* πŸŒ‰ **Sync with other chat solutions** With [Matterbridge](https://github.com/42wim/matterbridge/) being integrated in Talk, you can easily sync a lot of other chat solutions to Nextcloud Talk and vice-versa.
]]></description>

<version>23.0.0-dev.1</version>
<version>23.0.0-dev.2</version>
<licence>agpl</licence>

<author>Anna Larch</author>
Expand Down
3 changes: 3 additions & 0 deletions docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,6 @@
## 22
* `threads` - Whether the chat supports threads
* `config => call => live-transcription` - Whether live transcription is supported in calls

## 23
* `pinned-messages` - Whether messages can be pinned
64 changes: 64 additions & 0 deletions lib/BackgroundJob/UnpinMessage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Talk\BackgroundJob;

use OCA\Talk\Chat\ChatManager;
use OCA\Talk\Exceptions\RoomNotFoundException;
use OCA\Talk\Manager;
use OCA\Talk\Model\Attachment;
use OCA\Talk\Service\AttachmentService;
use OCA\Talk\Service\RoomService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
use OCP\Comments\NotFoundException;

class UnpinMessage extends QueuedJob {
public function __construct(
ITimeFactory $time,
protected Manager $manager,
protected ChatManager $chatManager,
protected AttachmentService $attachmentService,
protected RoomService $roomService,
) {
parent::__construct($time);
}

/**
* @inheritDoc
*/
#[\Override]
protected function run($argument): void {
$roomId = (int)$argument['roomId'];

try {
$room = $this->manager->getRoomById($roomId);
} catch (RoomNotFoundException) {
return;
}

$messageId = (int)$argument['messageId'];
try {
$comment = $this->chatManager->getComment($room, (string)$messageId);
} catch (NotFoundException) {
// Message most likely expired, reset the last_pinned_id if matching
if ($room->getLastPinnedId() === $messageId) {
$newLastPinned = 0;
$attachments = $this->attachmentService->getAttachmentsByType($room, Attachment::TYPE_PINNED, 0, 1);
if (isset($attachments[0])) {
$newLastPinned = $attachments[0]->getMessageId();
}
$this->roomService->setLastPinnedId($room, $newLastPinned);
}
Comment on lines +50 to +58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, that only works for messages that have a unpin set, i.e. have a unpin job. I would have expected it to be in https://github.com/nextcloud/spreed/blob/main/lib/BackgroundJob/ExpireChatMessages.php, but I see that it's a db operation there. So we might end up in a situation were it's set on room, but still expired?

Also, just to mention it, this code part is identical to the chatmanager.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that only works for messages that have a unpin set, i.e. have a unpin job.

Which will be the case going forward. At the moment of pinning I will set pinUntil when the message expires at some point

Also, just to mention it, this code part is identical to the chatmanager.

Yeah just renamed variables…

return;
}

$this->chatManager->unpinMessage($room, $comment, null);
}
}
1 change: 1 addition & 0 deletions lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ class Capabilities implements IPublicCapability {
'upcoming-reminders',
'sensitive-conversations',
'threads',
'pinned-messages',
];

public const CONDITIONAL_FEATURES = [
Expand Down
127 changes: 127 additions & 0 deletions lib/Chat/ChatManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use DateInterval;
use OC\Memcache\ArrayCache;
use OC\Memcache\NullCache;
use OCA\Talk\BackgroundJob\UnpinMessage;
use OCA\Talk\CachePrefix;
use OCA\Talk\Events\BeforeChatMessageSentEvent;
use OCA\Talk\Events\BeforeSystemMessageSentEvent;
Expand All @@ -19,6 +20,7 @@
use OCA\Talk\Exceptions\InvalidRoomException;
use OCA\Talk\Exceptions\MessagingNotAllowedException;
use OCA\Talk\Exceptions\ParticipantNotFoundException;
use OCA\Talk\Model\Attachment;
use OCA\Talk\Model\Attendee;
use OCA\Talk\Model\Message;
use OCA\Talk\Model\Poll;
Expand All @@ -34,6 +36,7 @@
use OCA\Talk\Share\RoomShareProvider;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\Collaboration\Reference\IReferenceManager;
use OCP\Comments\IComment;
use OCP\Comments\MessageTooLongException;
Expand Down Expand Up @@ -122,6 +125,7 @@ public function __construct(
protected IReferenceManager $referenceManager,
protected ILimiter $rateLimiter,
protected IRequest $request,
protected IJobList $jobList,
protected IL10N $l,
protected LoggerInterface $logger,
) {
Expand Down Expand Up @@ -751,6 +755,129 @@ public function editMessage(Room $chat, IComment $comment, Participant $particip
);
}

public function pinMessage(Room $chat, IComment $comment, Participant $participant, int $pinUntil): ?IComment {
$metaData = $comment->getMetaData() ?? [];

if (!empty($metaData[Message::METADATA_PINNED_MESSAGE_ID])) {
// Message is already pinned
return null;
}

$message = $this->addSystemMessage(
$chat,
$participant,
$participant->getAttendee()->getActorType(),
$participant->getAttendee()->getActorId(),
json_encode(['message' => 'message_pinned', 'parameters' => ['message' => $comment->getId()]]),
$this->timeFactory->getDateTime(),
false,
null,
$comment,
);

if ($pinUntil === 0) {
// Pinned without expiration, pin until message expiration, if applicable
$pinUntil = $message->getExpireDate()?->getTimestamp() ?? 0;
} elseif ($message->getExpireDate()) {
// When the message expires, expire the pin latest at that time
$pinUntil = min($pinUntil, $message->getExpireDate()->getTimestamp());
}

$metaData[Message::METADATA_PINNED_AT] = $this->timeFactory->getTime();
$metaData[Message::METADATA_PINNED_MESSAGE_ID] = (int)$message->getId();
$metaData[Message::METADATA_PINNED_BY_TYPE] = $participant->getAttendee()->getActorType();
$metaData[Message::METADATA_PINNED_BY_ID] = $participant->getAttendee()->getActorId();
if ($pinUntil) {
$metaData[Message::METADATA_PINNED_UNTIL] = $pinUntil;
}
$comment->setMetaData($metaData);
$this->commentsManager->save($comment);

$this->participantService->resetHiddenPinnedId($chat, (int)$comment->getId());

$this->roomService->setLastPinnedId($chat, (int)$comment->getId());

$this->attachmentService->createAttachmentEntryGeneric(
$chat,
$comment,
Attachment::TYPE_PINNED,
);

if ($pinUntil) {
$this->jobList->scheduleAfter(
UnpinMessage::class,
$pinUntil,
[
'roomId' => $chat->getId(),
'messageId' => (int)$comment->getId(),
],
);
}

return $message;
}

public function unpinMessage(Room $chat, IComment $comment, ?Participant $participant): ?IComment {
$metaData = $comment->getMetaData() ?? [];

if (empty($metaData[Message::METADATA_PINNED_MESSAGE_ID])) {
// Message is not pinned
return null;
}

$pinnedId = (int)$comment->getId();
unset(
$metaData[Message::METADATA_PINNED_AT],
$metaData[Message::METADATA_PINNED_MESSAGE_ID],
$metaData[Message::METADATA_PINNED_BY_TYPE],
$metaData[Message::METADATA_PINNED_BY_ID],
$metaData[Message::METADATA_PINNED_UNTIL],
);
$comment->setMetaData($metaData);
$this->commentsManager->save($comment);

$this->attachmentService->deleteAttachmentByMessageId($pinnedId);

$this->jobList->remove(
UnpinMessage::class,
[
'roomId' => $chat->getId(),
'messageId' => $pinnedId,
],
);

if ($chat->getLastPinnedId() === $pinnedId) {
$newLastPinned = 0;
$attachments = $this->attachmentService->getAttachmentsByType($chat, Attachment::TYPE_PINNED, 0, 1);
if (isset($attachments[0])) {
$newLastPinned = $attachments[0]->getMessageId();
}
$this->roomService->setLastPinnedId($chat, $newLastPinned);
}

if ($participant instanceof Participant) {
$actorType = $participant->getAttendee()->getActorType();
$actorId = $participant->getAttendee()->getActorId();
} else {
$actorType = Attendee::ACTOR_GUESTS;
$actorId = Attendee::ACTOR_ID_SYSTEM;
}

return $this->addSystemMessage(
$chat,
$participant,
$actorType,
$actorId,
json_encode(['message' => 'message_unpinned', 'parameters' => ['message' => $comment->getId()]]),
$this->timeFactory->getDateTime(),
false,
null,
$comment,
true,
true,
);
}

protected static function compareMention(array $mention1, array $mention2): int {
if ($mention1['type'] === $mention2['type']) {
return $mention1['id'] <=> $mention2['id'];
Expand Down
29 changes: 28 additions & 1 deletion lib/Chat/MessageParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ public function __construct(
}

public function createMessage(Room $room, ?Participant $participant, IComment $comment, IL10N $l): Message {
return new Message($room, $participant, $comment, $l);
$message = new Message($room, $participant, $comment, $l);
$metaData = $this->addPinnedActorDisplayNameInfo($message, $comment->getMetaData() ?? []);
$message->setMetaData($metaData);
return $message;
}

public function createMessageFromProxyCache(Room $room, ?Participant $participant, ProxyCacheMessage $proxy, IL10N $l): Message {
Expand All @@ -63,6 +66,15 @@ public function createMessageFromProxyCache(Room $room, ?Participant $participan
$proxy->getParsedMessageParameters()
);

try {
$metaData = json_decode($proxy->getMetaData(), true, flags: JSON_THROW_ON_ERROR);
if (is_array($metaData)) {
$metaData = $this->addPinnedActorDisplayNameInfo($message, $metaData);
$message->setMetaData($metaData);
}
} catch (\JsonException) {
}

return $message;
}

Expand Down Expand Up @@ -121,6 +133,21 @@ protected function setLastEditInfo(Message $message): void {
}
}

protected function addPinnedActorDisplayNameInfo(Message $message, array $metaData): array {
if (isset($metaData[Message::METADATA_PINNED_BY_TYPE], $metaData[Message::METADATA_PINNED_BY_ID])) {
[$actorType, $actorId, $displayName] = $this->getActorInformation(
$message,
$metaData[Message::METADATA_PINNED_BY_TYPE],
$metaData[Message::METADATA_PINNED_BY_ID],
);

$metaData[Message::METADATA_PINNED_BY_TYPE] = $actorType;
$metaData[Message::METADATA_PINNED_BY_ID] = $actorId;
$metaData[Message::METADATA_PINNED_BY_NAME] = $displayName;
}
return $metaData;
}

protected function getActorInformation(Message $message, string $actorType, string $actorId, string $displayName = ''): array {
if ($actorType === Attendee::ACTOR_USERS) {
$tempDisplayName = $this->userManager->getDisplayName($actorId);
Expand Down
15 changes: 15 additions & 0 deletions lib/Chat/Parser/SystemMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,21 @@ protected function parseMessage(Message $chatMessage, $allowInaccurate): void {
if ($currentUserIsActor) {
$parsedMessage = $this->l->t('You edited a message');
}
} elseif ($message === 'message_pinned') {
$parsedMessage = $this->l->t('{actor} pinned a message');
if ($currentUserIsActor) {
$parsedMessage = $this->l->t('You pinned a message');
}
} elseif ($message === 'message_unpinned') {
$systemIsActor = $parsedParameters['actor']['type'] === Attendee::ACTOR_GUESTS
&& $parsedParameters['actor']['id'] === Attendee::ACTOR_ID_SYSTEM;

$parsedMessage = $this->l->t('{actor} unpinned a message');
if ($systemIsActor) {
$parsedMessage = $this->l->t('Message was automatically unpinned');
} elseif ($currentUserIsActor) {
$parsedMessage = $this->l->t('You unpinned a message');
}
} elseif ($message === 'reaction_revoked') {
$parsedMessage = $this->l->t('{actor} deleted a reaction');
if ($currentUserIsActor) {
Expand Down
Loading
Loading