diff --git a/appinfo/routes.php b/appinfo/routes.php index 196a4326a79..97fe3c06149 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -120,6 +120,24 @@ 'token' => '^[a-z0-9]{4,30}$', ], ], + [ + 'name' => 'Chat#setReadMarker', + 'url' => '/api/{apiVersion}/chat/{token}/read', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v1', + 'token' => '^[a-z0-9]{4,30}$', + ], + ], + [ + 'name' => 'Chat#saveChatLog', + 'url' => '/api/{apiVersion}/chat/{token}/log', + 'verb' => 'POST', + 'requirements' => [ + 'apiVersion' => 'v1', + 'token' => '^[a-z0-9]{4,30}$', + ], + ], [ 'name' => 'Chat#mentions', 'url' => '/api/{apiVersion}/chat/{token}/mentions', diff --git a/docs/api-v1.md b/docs/api-v1.md index 22dc9a4a2d7..8b29c29c7b6 100644 --- a/docs/api-v1.md +++ b/docs/api-v1.md @@ -30,6 +30,8 @@ - [Chat](#chat) * [Receive chat messages of a room](#receive-chat-messages-of-a-room) * [Sending a new chat message](#sending-a-new-chat-message) + * [Mark chat as read](#mark-chat-as-read) + * [Get mention autocomplete suggestions](#get-mention-autocomplete-suggestions) - [Guests](#guests) * [Set display name](#set-display-name) - [Signaling](#signaling) @@ -81,6 +83,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` ### 5.0 * `invite-by-mail` - Guests can be invited with their email address * `notification-levels` - Users can select when they want to be notified in conversations +* `new-chat-flow` - The read marker for the chat needs to be set manually, see [Better chat flow experience](https://github.com/nextcloud/spreed/issues/1164) ## Room management @@ -145,6 +148,7 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` `notificationLevel` | int | The notification level for the user (one of `Participant::NOTIFY_*` (1-3)) `unreadMessages` | int | Number of unread chat messages in the room (only available with `chat-v2` capability) `unreadMention` | bool | Flag if the user was mentioned since their last visit + `lastReadMessage` | int | ID of the last read message in a room `lastMessage` | message | Last message in a room if available, otherwise empty `objectType` | string | The type of object that the room is associated with; "share:password" if the room is used to request a password for a share, otherwise empty `objectId` | string | Share token if "objectType" is "share:password", otherwise empty @@ -570,6 +574,22 @@ Base endpoint is: `/ocs/v2.php/apps/spreed/api/v1` - Data: The full message array of the new message, as defined in [Receive chat messages of a room](#receive-chat-messages-of-a-room) +### Mark chat as read + +* Method: `POST` +* Endpoint: `/chat/{token}/read` +* Data: + + field | type | Description + ------|------|------------ + `lastReadMessage` | int | The last read message ID + +* Response: + - Header: + + `200 OK` + + `404 Not Found` When the room could not be found for the participant, + or the participant is a guest. + ### Get mention autocomplete suggestions * Method: `GET` diff --git a/js/app.js b/js/app.js index 99d9e9348a9..fad6cc87776 100644 --- a/js/app.js +++ b/js/app.js @@ -529,7 +529,7 @@ }); this._sidebarView.setCallInfoView(callInfoView); - this._messageCollection.setRoomToken(this.activeRoom.get('token')); + this._messageCollection.setRoomToken(this.activeRoom.get('token'), this.activeRoom.get('lastReadMessage')); this._messageCollection.receiveMessages(); }, setPageTitle: function(title){ diff --git a/js/models/chatmessagecollection.js b/js/models/chatmessagecollection.js index 0410139a1a9..c0f06c4ecdb 100644 --- a/js/models/chatmessagecollection.js +++ b/js/models/chatmessagecollection.js @@ -77,11 +77,13 @@ * explicitly called if needed. * * @param {?string} token the token of the room. + * @param {?int} lastReadMessage the last read message in that room */ - setRoomToken: function(token) { + setRoomToken: function(token, lastReadMessage) { this.stopReceivingMessages(); this.token = token; + this.lastReadMessage = lastReadMessage || 0; if (token !== null) { this.signaling = OCA.SpreedMe.app.signaling; @@ -103,7 +105,7 @@ receiveMessages: function() { if (this.signaling) { this.signaling.on("chatMessagesReceived", this._handler); - this.signaling.startReceiveMessages(); + this.signaling.startReceiveMessages(this.lastReadMessage); } }, diff --git a/js/signaling.js b/js/signaling.js index 4d50f3be16c..cac0c7bf5b0 100644 --- a/js/signaling.js +++ b/js/signaling.js @@ -348,10 +348,10 @@ return defer; }; - OCA.Talk.Signaling.Base.prototype.startReceiveMessages = function() { + OCA.Talk.Signaling.Base.prototype.startReceiveMessages = function(lastKnownMessageId) { this._waitTimeUntilRetry = 1; this.receiveMessagesAgain = true; - this.lastKnownMessageId = 0; + this.lastKnownMessageId = lastKnownMessageId; this._receiveChatMessages(); }; diff --git a/lib/Capabilities.php b/lib/Capabilities.php index ae43a802e45..995b807bfb7 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -47,6 +47,7 @@ public function getCapabilities(): array { 'in-call-flags', 'invite-by-mail', 'notification-levels', + 'new-chat-flow', ], ], ]; diff --git a/lib/Chat/ChatManager.php b/lib/Chat/ChatManager.php index 145ac069c8e..96c56ea676a 100644 --- a/lib/Chat/ChatManager.php +++ b/lib/Chat/ChatManager.php @@ -124,7 +124,7 @@ public function sendMessage(Room $chat, $actorType, $actorId, $message, \DateTim $mentionedUsers = $this->notifier->notifyMentionedUsers($chat, $comment); if (!empty($mentionedUsers)) { - $chat->markUsersAsMentioned($mentionedUsers, $creationDateTime); + $chat->markUsersAsMentioned($notifiedUsers, (int) $comment->getId()); } // User was not mentioned, send a normal notification @@ -139,16 +139,21 @@ public function sendMessage(Room $chat, $actorType, $actorId, $message, \DateTim return $comment; } - public function getUnreadMarker(Room $chat, IUser $user): \DateTime { + public function getLastReadMessageFromLegacy(Room $chat, IUser $user): int { $marker = $this->commentsManager->getReadMark('chat', $chat->getId(), $user); if ($marker === null) { - $marker = new \DateTime('2000-01-01'); + return 0; } - return $marker; + + return $this->getLastMessageBeforeDate($chat, $marker); + } + + public function getLastMessageBeforeDate(Room $chat, \DateTime $dateTime): int { + return $this->commentsManager->getLastCommentBeforeDate('chat', (string) $chat->getId(), $dateTime, 'comment'); } - public function getUnreadCount(Room $chat, \DateTime $unreadSince): int { - return $this->commentsManager->getNumberOfCommentsForObject('chat', $chat->getId(), $unreadSince, 'comment'); + public function getUnreadCount(Room $chat, int $lastReadMessage): int { + return $this->commentsManager->getNumberOfCommentsForObjectSinceComment('chat', (string) $chat->getId(), $lastReadMessage, 'comment'); } /** @@ -157,12 +162,12 @@ public function getUnreadCount(Room $chat, \DateTime $unreadSince): int { * @param Room $chat * @param int $offset Last known message id * @param int $limit + * @param string $sort * @return IComment[] the messages found (only the id, actor type and id, - * creation date and message are relevant), or an empty array if the - * timeout expired. + * creation date and message are relevant) */ - public function getHistory(Room $chat, $offset, $limit): array { - return $this->commentsManager->getForObjectSince('chat', (string) $chat->getId(), $offset, 'desc', $limit); + public function getHistory(Room $chat, int $offset, int $limit, string $sort = 'desc'): array { + return $this->commentsManager->getForObjectSince('chat', (string) $chat->getId(), $offset, strtolower($sort), $limit); } /** @@ -191,11 +196,6 @@ public function waitForNewMessages(Room $chat, $offset, $limit, $timeout, $user) $elapsedTime = 0; $comments = $this->commentsManager->getForObjectSince('chat', (string) $chat->getId(), $offset, 'asc', $limit); - - if ($user instanceof IUser) { - $this->commentsManager->setReadMark('chat', (string) $chat->getId(), new \DateTime(), $user); - } - while (empty($comments) && $elapsedTime < $timeout) { sleep(1); $elapsedTime++; diff --git a/lib/Chat/CommentsManager.php b/lib/Chat/CommentsManager.php index bbfe9d2c17f..9b588d28ba1 100644 --- a/lib/Chat/CommentsManager.php +++ b/lib/Chat/CommentsManager.php @@ -73,4 +73,43 @@ public function getLastCommentDateByActor( return $lastComments; } + + public function getNumberOfCommentsForObjectSinceComment(string $objectType, string $objectId, int $lastRead, string $verb = ''): int { + $query = $this->dbConn->getQueryBuilder(); + $query->selectAlias($query->createFunction('COUNT(' . $query->getColumnName('id') . ')'), 'num_messages') + ->from('comments') + ->where($query->expr()->eq('object_type', $query->createNamedParameter($objectType))) + ->andWhere($query->expr()->eq('object_id', $query->createNamedParameter($objectId))) + ->andWhere($query->expr()->gt('id', $query->createNamedParameter($lastRead))); + + if ($verb !== '') { + $query->andWhere($query->expr()->eq('verb', $query->createNamedParameter($verb))); + } + + $result = $query->execute(); + $data = $result->fetch(); + $result->closeCursor(); + + return isset($data['num_messages']) ? (int) $data['num_messages'] : 0; + } + + public function getLastCommentBeforeDate(string $objectType, string $objectId, \DateTime $beforeDate, string $verb = ''): int { + $query = $this->dbConn->getQueryBuilder(); + $query->select('id') + ->from('comments') + ->where($query->expr()->eq('object_type', $query->createNamedParameter($objectType))) + ->andWhere($query->expr()->eq('object_id', $query->createNamedParameter($objectId))) + ->andWhere($query->expr()->lt('creation_timestamp', $query->createNamedParameter($beforeDate, IQueryBuilder::PARAM_DATE))) + ->orderBy('creation_timestamp', 'desc'); + + if ($verb !== '') { + $query->andWhere($query->expr()->eq('verb', $query->createNamedParameter($verb))); + } + + $result = $query->execute(); + $data = $result->fetch(); + $result->closeCursor(); + + return (int) ($data['id'] ?? 0); + } } diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php index 82653d7c638..d4cbbc96f09 100644 --- a/lib/Controller/ChatController.php +++ b/lib/Controller/ChatController.php @@ -40,6 +40,8 @@ use OCP\Collaboration\Collaborators\ISearchResult; use OCP\Comments\IComment; use OCP\Comments\MessageTooLongException; +use OCP\Files\IRootFolder; +use OCP\IConfig; use OCP\IL10N; use OCP\IRequest; use OCP\IUser; @@ -83,6 +85,12 @@ class ChatController extends OCSController { /** @var ISearchResult */ private $searchResult; + /** @var IRootFolder */ + private $rootFolder; + + /** @var IConfig */ + private $config; + /** @var IL10N */ private $l; @@ -99,6 +107,8 @@ class ChatController extends OCSController { * @param IManager $autoCompleteManager * @param SearchPlugin $searchPlugin * @param ISearchResult $searchResult + * @param IRootFolder $rootFolder + * @param IConfig $config * @param IL10N $l */ public function __construct(string $appName, @@ -113,6 +123,8 @@ public function __construct(string $appName, IManager $autoCompleteManager, SearchPlugin $searchPlugin, ISearchResult $searchResult, + IRootFolder $rootFolder, + IConfig $config, IL10N $l) { parent::__construct($appName, $request); @@ -126,6 +138,8 @@ public function __construct(string $appName, $this->autoCompleteManager = $autoCompleteManager; $this->searchPlugin = $searchPlugin; $this->searchResult = $searchResult; + $this->rootFolder = $rootFolder; + $this->config = $config; $this->l = $l; } @@ -178,7 +192,7 @@ private function getRoom($token) { * "404 Not found" if the room or session for a guest user was not * found". */ - public function sendMessage($token, $message, $actorDisplayName = '') { + public function sendMessage($token, $message, $actorDisplayName = ''): DataResponse { $room = $this->getRoom($token); if ($room === null) { return new DataResponse([], Http::STATUS_NOT_FOUND); @@ -276,7 +290,7 @@ public function sendMessage($token, $message, $actorDisplayName = '') { * 'actorDisplayName', 'timestamp' (in seconds and UTC timezone) and * 'message'. */ - public function receiveMessages($token, $lookIntoFuture, $limit = 100, $lastKnownMessageId = 0, $timeout = 30) { + public function receiveMessages($token, $lookIntoFuture, $limit = 100, $lastKnownMessageId = 0, $timeout = 30): DataResponse { $room = $this->getRoom($token); if ($room === null) { return new DataResponse([], Http::STATUS_NOT_FOUND); @@ -342,6 +356,30 @@ public function receiveMessages($token, $lookIntoFuture, $limit = 100, $lastKnow return $response; } + /** + * @NoAdminRequired + * + * @param string $token the room token + * @param int $lastReadMessage + * @return DataResponse + */ + public function setReadMarker($token, $lastReadMessage): DataResponse { + $room = $this->getRoom($token); + if ($room === null) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + try { + $participant = $room->getParticipant($this->userId); + } catch (ParticipantNotFoundException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + $participant->setLastReadMessage($lastReadMessage); + + return new DataResponse(); + } + /** * @PublicPage * @@ -350,7 +388,7 @@ public function receiveMessages($token, $lookIntoFuture, $limit = 100, $lastKnow * @param int $limit * @return DataResponse */ - public function mentions($token, $search, $limit = 20) { + public function mentions($token, $search, $limit = 20): DataResponse { $room = $this->getRoom($token); if ($room === null) { return new DataResponse([], Http::STATUS_NOT_FOUND); @@ -378,8 +416,92 @@ public function mentions($token, $search, $limit = 20) { return new DataResponse($results); } + /** + * @NoAdminRequired + * + * @param string $token the room token + * @param string $since + * @return DataResponse + */ + public function saveChatLog(string $token, string $since): DataResponse { + $room = $this->getRoom($token); + if ($room === null) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + try { + $room->getParticipant($this->userId); + } catch (ParticipantNotFoundException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + $dateTime = \DateTime::createFromFormat(\DateTime::ATOM, $since); + if (!$dateTime instanceof \DateTime) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + $timezone = $this->config->getUserValue($this->userId, 'core', 'timezone', 'UTC'); + $dateTimeZone = new \DateTimeZone($timezone); + $dateTime->setTimezone($dateTimeZone); + + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $file = $userFolder->newFile($room->getName() . '-' . $dateTime->format(\DateTime::ATOM) . '.txt'); + $handle = $file->fopen('w+'); + + $offsetMessageId = $this->chatManager->getLastMessageBeforeDate($room, $dateTime); + $comments = $this->chatManager->getHistory($room, $offsetMessageId, 200, 'asc'); + + $guestNames = []; + $guestNumber = 1; + while (!empty($comments)) { + /** @var IComment $comment */ + $comment = array_shift($comments); + list($message, $messageParameters) = $this->messageParser->parseMessage($room, $comment, $this->l, null); + + if ($comment->getVerb() !== 'comment') { + continue; + } + + $comment->getCreationDateTime()->setTimezone($dateTimeZone); + $line = '[' . $this->l->l('datetime', $comment->getCreationDateTime(), ['width' => 'long|medium']) . ']'; + if ($comment->getActorType() === 'users') { + $author = $this->userManager->get($comment->getActorId()); + if ($author instanceof IUser) { + $line .= ' @' . $author->getDisplayName() . ': '; + } else { + $line .= ' @' . $comment->getActorId() . ': '; + } + } else { + if (!isset($guestNames[$comment->getActorId()])) { + try { + $guestNames[$comment->getActorId()] = $this->guestManager->getNameBySessionHash($comment->getActorId()); + } catch (ParticipantNotFoundException $e) { + $guestNames[$comment->getActorId()] = $this->l->t('Guest') . ' #' . $guestNumber; + $guestNumber++; + } + } + $line .= ' ' . $guestNames[$comment->getActorId()] . ' (' . $this->l->t('Guest') . '): '; + } + + $search = $replace = []; + foreach ($messageParameters as $key => $parameter) { + $search[] = '{' . $key . '}'; + $replace[] = ($parameter['type'] === 'user' ? '@' : '') . $parameter['name']; + } + $search[] = "\n"; + $replace[] = "\n "; + $line .= str_replace($search, $replace, $message); + fwrite($handle, $line . "\n"); + + } + + fclose($handle); + $file->touch(); + + return new DataResponse(); + } - protected function prepareResultArray(array $results) { + protected function prepareResultArray(array $results): array { $output = []; foreach ($results as $type => $subResult) { foreach ($subResult as $result) { diff --git a/lib/Controller/RoomController.php b/lib/Controller/RoomController.php index a786cdcdbae..6013c8e003a 100644 --- a/lib/Controller/RoomController.php +++ b/lib/Controller/RoomController.php @@ -205,6 +205,7 @@ protected function formatRoom(Room $room, Participant $participant = null): arra 'hasPassword' => $room->hasPassword(), 'hasCall' => $room->getActiveSince() instanceof \DateTimeInterface, 'lastActivity' => $lastActivity, + 'lastReadMessage' => 0, 'unreadMessages' => 0, 'unreadMention' => false, 'isFavorite' => $favorite, @@ -232,12 +233,21 @@ protected function formatRoom(Room $room, Participant $participant = null): arra $currentUser = $this->userManager->get($this->userId); if ($currentUser instanceof IUser) { - $unreadSince = $this->chatManager->getUnreadMarker($room, $currentUser); - if ($participant instanceof Participant) { - $lastMention = $participant->getLastMention(); - $roomData['unreadMention'] = $lastMention !== null && $unreadSince < $lastMention; + $lastReadMessage = $participant->getLastReadMessage(); + if ($lastReadMessage === -1) { + /* + * Because the migration from the old comment_read_markers was + * not possible in a programmatic way with a reasonable O(1) or O(n) + * but only with O(userĂ—chat), we do the conversion here. + */ + $lastReadMessage = $this->chatManager->getLastReadMessageFromLegacy($room, $currentUser); + $participant->setLastReadMessage($lastReadMessage); } - $roomData['unreadMessages'] = $this->chatManager->getUnreadCount($room, $unreadSince); + $roomData['unreadMessages'] = $this->chatManager->getUnreadCount($room, $lastReadMessage); + + $lastMention = $participant->getLastMentionMessage(); + $roomData['unreadMention'] = $lastMention !== 0 && $lastReadMessage < $lastMention; + $roomData['lastReadMessage'] = $lastReadMessage; } // Sort by lastPing diff --git a/lib/Manager.php b/lib/Manager.php index 3316ee31d45..e45bfe9b329 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -116,12 +116,8 @@ public function createRoomObject(array $row) { * @return Participant */ public function createParticipantObject(Room $room, array $row) { - $lastMention = null; - if (!empty($row['last_mention'])) { - $lastMention = new \DateTime($row['last_mention']); - } - return new Participant($this->db, $room, (string) $row['user_id'], (int) $row['participant_type'], (int) $row['last_ping'], (string) $row['session_id'], (int) $row['in_call'], (int) $row['notification_level'], (bool) $row['favorite'], $lastMention); + return new Participant($this->db, $room, (string) $row['user_id'], (int) $row['participant_type'], (int) $row['last_ping'], (string) $row['session_id'], (int) $row['in_call'], (int) $row['notification_level'], (bool) $row['favorite'], (int) $row['last_read_message'], (int) $row['last_mention_message']); } /** diff --git a/lib/Migration/Version4099Date20180831082627.php b/lib/Migration/Version4099Date20180831082627.php new file mode 100644 index 00000000000..0f6407c6cff --- /dev/null +++ b/lib/Migration/Version4099Date20180831082627.php @@ -0,0 +1,115 @@ + + * + * @author Joas Schilling + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\Spreed\Migration; + +use Doctrine\DBAL\Schema\SchemaException; +use Doctrine\DBAL\Types\Type; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Migration\SimpleMigrationStep; +use OCP\Migration\IOutput; + +class Version4099Date20180831082627 extends SimpleMigrationStep { + + /** @var IDBConnection */ + protected $connection; + + public function __construct(IDBConnection $connection) { + $this->connection = $connection; + } + + /** + * @param IOutput $output + * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + * @throws SchemaException + * @since 13.0.0 + */ + public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->getTable('talk_participants'); + if (!$table->hasColumn('last_read_message')) { + $table->addColumn('last_read_message', Type::BIGINT, [ + 'default' => 0, + 'notnull' => false, + ]); + $table->addColumn('last_mention_message', Type::BIGINT, [ + 'default' => 0, + 'notnull' => false, + ]); + } + + return $schema; + } + + /** + * @param IOutput $output + * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @since 13.0.0 + */ + public function postSchemaChange(IOutput $output, \Closure $schemaClosure, array $options) { + $query = $this->connection->getQueryBuilder(); + $query->select('m.user_id', 'm.object_id') + ->selectAlias($query->createFunction('MAX(' . $query->getColumnName('c.id') . ')'), 'last_comment') + ->from('comments_read_markers', 'm') + ->leftJoin('m', 'comments', 'c', $query->expr()->andX( + $query->expr()->eq('c.object_id', 'm.object_id'), + $query->expr()->eq('c.object_type', 'm.object_type'), + $query->expr()->eq('c.creation_timestamp', 'm.marker_datetime') + )) + ->where($query->expr()->eq('m.object_type', $query->createNamedParameter('chat'))) + ->groupBy('m.user_id', 'm.object_id'); + + $update = $this->connection->getQueryBuilder(); + $update->update('talk_participants') + ->set('last_read_message', $update->createParameter('message_id')) + ->where($update->expr()->eq('user_id', $update->createParameter('user_id'))) + ->andWhere($update->expr()->eq('room_id', $update->createParameter('room_id'))); + + $result = $query->execute(); + while ($row = $result->fetch()) { + $update->setParameter('message_id', (int) $row['last_comment'], IQueryBuilder::PARAM_INT) + ->setParameter('user_id', $row['user_id']) + ->setParameter('room_id', (int) $row['object_id'], IQueryBuilder::PARAM_INT); + $update->execute(); + } + $result->closeCursor(); + + /** + * The above query only works if the user read in the same exact second + * as the comment was posted (author only), we set the read marker to -1 + * for all users and in case of -1 we calculate the marker on the next request. + */ + $default = $this->connection->getQueryBuilder(); + $default->update('talk_participants') + ->set('last_read_message', $default->createNamedParameter(-1)) + ->where($default->expr()->isNotNull('user_id')) + ->andWhere($default->expr()->eq('last_read_message', $default->createNamedParameter(0))); + $default->execute(); + } +} diff --git a/lib/Migration/Version4099Date20180831082628.php b/lib/Migration/Version4099Date20180831082628.php new file mode 100644 index 00000000000..cdc75cecd63 --- /dev/null +++ b/lib/Migration/Version4099Date20180831082628.php @@ -0,0 +1,95 @@ + + * + * @author Joas Schilling + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\Spreed\Migration; + +use Doctrine\DBAL\Schema\SchemaException; +use OCP\DB\ISchemaWrapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; +use OCP\Migration\SimpleMigrationStep; +use OCP\Migration\IOutput; + +class Version4099Date20180831082628 extends SimpleMigrationStep { + + /** @var IDBConnection */ + protected $connection; + + public function __construct(IDBConnection $connection) { + $this->connection = $connection; + } + + /** + * @param IOutput $output + * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @since 13.0.0 + */ + public function preSchemaChange(IOutput $output, \Closure $schemaClosure, array $options) { + $query = $this->connection->getQueryBuilder(); + $query->select('p.user_id', 'p.room_id') + ->selectAlias($query->createFunction('MAX(' . $query->getColumnName('c.id') . ')'), 'last_mention_message') + ->from('talk_participants', 'p') + ->leftJoin('p', 'comments', 'c', $query->expr()->andX( + $query->expr()->eq('c.object_id', 'p.room_id'), + $query->expr()->eq('c.object_type', $query->createNamedParameter('chat')), + $query->expr()->eq('c.creation_timestamp', 'p.last_mention') + )) + ->where($query->expr()->isNotNull('p.user_id')) + ->groupBy('p.user_id', 'p.room_id'); + + $update = $this->connection->getQueryBuilder(); + $update->update('talk_participants') + ->set('last_mention_message', $update->createParameter('message_id')) + ->where($update->expr()->eq('user_id', $update->createParameter('user_id'))) + ->andWhere($update->expr()->eq('room_id', $update->createParameter('room_id'))); + + $result = $query->execute(); + while ($row = $result->fetch()) { + $update->setParameter('message_id', (int) $row['last_mention_message'], IQueryBuilder::PARAM_INT) + ->setParameter('user_id', $row['user_id']) + ->setParameter('room_id', (int) $row['room_id'], IQueryBuilder::PARAM_INT); + $update->execute(); + } + $result->closeCursor(); + } + + /** + * @param IOutput $output + * @param \Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + * @throws SchemaException + * @since 13.0.0 + */ + public function changeSchema(IOutput $output, \Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + $table = $schema->getTable('talk_participants'); + if ($table->hasColumn('last_mention')) { + $table->dropColumn('last_mention'); + } + + return $schema; + } +} diff --git a/lib/Participant.php b/lib/Participant.php index fef5850ee1b..9d6e9b449fe 100644 --- a/lib/Participant.php +++ b/lib/Participant.php @@ -63,10 +63,12 @@ class Participant { protected $notificationLevel; /** @var bool */ private $isFavorite; - /** @var \DateTime|null */ - private $lastMention; + /** @var int */ + private $lastReadMessage; + /** @var int */ + private $lastMentionMessage; - public function __construct(IDBConnection $db, Room $room, string $user, int $participantType, int $lastPing, string $sessionId, int $inCall, int $notificationLevel, bool $isFavorite, \DateTime $lastMention = null) { + public function __construct(IDBConnection $db, Room $room, string $user, int $participantType, int $lastPing, string $sessionId, int $inCall, int $notificationLevel, bool $isFavorite, int $lastReadMessage, int $lastMentionMessage) { $this->db = $db; $this->room = $room; $this->user = $user; @@ -76,7 +78,8 @@ public function __construct(IDBConnection $db, Room $room, string $user, int $pa $this->inCall = $inCall; $this->notificationLevel = $notificationLevel; $this->isFavorite = $isFavorite; - $this->lastMention = $lastMention; + $this->lastReadMessage = $lastReadMessage; + $this->lastMentionMessage = $lastMentionMessage; } public function getUser(): string { @@ -111,13 +114,6 @@ public function getInCallFlags(): int { return $this->inCall; } - /** - * @return \DateTime|null - */ - public function getLastMention() { - return $this->lastMention; - } - public function isFavorite(): bool { return $this->isFavorite; } @@ -165,4 +161,44 @@ public function setNotificationLevel(int $notificationLevel): bool { $this->notificationLevel = $notificationLevel; return true; } + + public function getLastReadMessage(): int { + return $this->lastReadMessage; + } + + public function setLastReadMessage(int $messageId): bool { + if (!$this->user) { + return false; + } + + $query = $this->db->getQueryBuilder(); + $query->update('talk_participants') + ->set('last_read_message', $query->createNamedParameter($messageId, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('user_id', $query->createNamedParameter($this->user))) + ->andWhere($query->expr()->eq('room_id', $query->createNamedParameter($this->room->getId()))); + $query->execute(); + + $this->lastReadMessage = $messageId; + return true; + } + + public function getLastMentionMessage(): int { + return $this->lastMentionMessage; + } + + public function setLastMentionMessage(int $messageId): bool { + if (!$this->user) { + return false; + } + + $query = $this->db->getQueryBuilder(); + $query->update('talk_participants') + ->set('last_mention_message', $query->createNamedParameter($messageId, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('user_id', $query->createNamedParameter($this->user))) + ->andWhere($query->expr()->eq('room_id', $query->createNamedParameter($this->room->getId()))); + $query->execute(); + + $this->lastMentionMessage = $messageId; + return true; + } } diff --git a/lib/Room.php b/lib/Room.php index 294eb63f479..f9275a34a34 100644 --- a/lib/Room.php +++ b/lib/Room.php @@ -936,10 +936,10 @@ public function getNumberOfParticipants($ignoreGuests = true, $lastPing = 0) { return isset($row['num_participants']) ? (int) $row['num_participants'] : 0; } - public function markUsersAsMentioned(array $userIds, \DateTime $time) { + public function markUsersAsMentioned(array $userIds, int $messageId) { $query = $this->db->getQueryBuilder(); $query->update('talk_participants') - ->set('last_mention', $query->createNamedParameter($time, 'datetime')) + ->set('last_mention_message', $query->createNamedParameter($messageId, IQueryBuilder::PARAM_INT)) ->where($query->expr()->eq('room_id', $query->createNamedParameter($this->getId(), IQueryBuilder::PARAM_INT))) ->andWhere($query->expr()->in('user_id', $query->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY))); $query->execute(); diff --git a/tests/php/CapabilitiesTest.php b/tests/php/CapabilitiesTest.php index dd76f3e535a..3c2cb94df97 100644 --- a/tests/php/CapabilitiesTest.php +++ b/tests/php/CapabilitiesTest.php @@ -51,6 +51,7 @@ public function testGetCapabilities() { 'in-call-flags', 'invite-by-mail', 'notification-levels', + 'new-chat-flow', ], ], ], $capabilities->getCapabilities()); diff --git a/tests/php/Chat/ChatManagerTest.php b/tests/php/Chat/ChatManagerTest.php index 090f20f7fbd..114b7b3e7c2 100644 --- a/tests/php/Chat/ChatManagerTest.php +++ b/tests/php/Chat/ChatManagerTest.php @@ -30,6 +30,7 @@ use OCP\Comments\IComment; use OCP\Comments\ICommentsManager; use OCP\IUser; +use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\EventDispatcher\EventDispatcherInterface; class ChatManagerTest extends \Test\TestCase { @@ -211,6 +212,20 @@ public function testWaitForNewMessagesWithWaiting() { $this->assertEquals($expected, $comments); } + public function testGetUnreadCount() { + /** @var Room|MockObject $chat */ + $chat = $this->createMock(Room::class); + $chat->expects($this->once()) + ->method('getId') + ->willReturn(23); + + $this->commentsManager->expects($this->once()) + ->method('getNumberOfCommentsForObjectSinceComment') + ->with('chat', 23, 42, 'comment'); + + $this->chatManager->getUnreadCount($chat, 42); + } + public function testDeleteMessages() { $chat = $this->createMock(Room::class);