diff --git a/appinfo/routes.php b/appinfo/routes.php index 884e86b9f4c..b5804d7117a 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -319,6 +319,19 @@ 'requirements' => ['apiVersion' => 'v1'], ], + /** + * Files + */ + [ + 'name' => 'Files#getRoom', + 'url' => '/api/{apiVersion}/file/{fileId}', + 'verb' => 'GET', + 'requirements' => [ + 'apiVersion' => 'v1', + 'fileId' => '.+' + ], + ], + /** * Guest */ diff --git a/css/files.scss b/css/files.scss new file mode 100644 index 00000000000..2deca5d89b4 --- /dev/null +++ b/css/files.scss @@ -0,0 +1,118 @@ +/** + * Cascade parent element height to the chat view in the sidebar to limit the + * vertical scroll bar only to the list of messages. Otherwise, the vertical + * scroll bar would be shown for the whole sidebar and everything would be + * moved when scrolling to see overflown messages. + * + * The list of messages should stretch to fill the available space at the bottom + * of the right sidebar, so the height is cascaded using flex boxes. + * + * It is horrible, I know (but better than using JavaScript ;-) ). Please + * improve it if you know how :-) + */ +#app-sidebar { + /* Override "display: block" set inline by jQuery. */ + display: flex !important; + flex-direction: column; +} + +#app-sidebar.disappear { + /* Override "display: flex !important" when the sidebar has to be hidden. */ + display: none !important; +} + +#app-sidebar .detailFileInfoContainer { + flex-shrink: 0; +} + +#app-sidebar .tabsContainer { + display: flex; + flex-direction: column; + + flex-grow: 1; +} + +#app-sidebar .tab { + display: flex; + flex-direction: column; + + flex-grow: 1; +} + +#app-sidebar .tabsContainer.with-inner-scroll-bars, +#app-sidebar .tabsContainer.with-inner-scroll-bars .tab { + overflow: hidden; +} + +#app-sidebar .tab.hidden { + display: none; +} + +#app-sidebar #chatView { + display: flex; + flex-direction: column; + overflow: hidden; + + flex-grow: 1; +} + +#app-sidebar #chatView .comments { + overflow-y: auto; + + /* Needed for proper calculation of comment positions in the scrolling + container (as otherwise the comment position is calculated with respect + to the closest ancestor with a relative position) */ + position: relative; +} + +/** + * Place the scroll bar of the message list on the right edge of the sidebar, + * but keeping the padding of the tab view. + * + * The padding must be set on the left too to ensure that the contacts menu + * shown when clicking on an author name does not overflow the tab (as it would + * be hidden). + * + * The bottom padding is removed to extend the chat view to the bottom edge of + * the sidebar. + */ +#app-sidebar .tab-chat { + padding-left: 0; + padding-right: 0; + padding-bottom: 0; +} + +/* Hack needed to overcome the padding of the tab container and move the scroll + * bar of the messages list to the right border of the sidebar. */ +#app-sidebar .tabsContainer.with-inner-scroll-bars .tab { + padding-right: 0px; +} + +#app-sidebar .tabsContainer .tab .app-not-started-placeholder { + /* Make the placeholder take the full tab height until the app is + * started. */ + flex-grow: 1; +} + +#app-sidebar #chatView .comments { + padding-right: 15px; +} + +#app-sidebar #chatView .comments .wrapper-background, +#app-sidebar #chatView .comments .wrapper { + /* Padding is not respected in the comment wrapper due to its absolute + * positioning, so it must be set through its position. */ + right: 15px; +} + +#app-sidebar #chatView .newCommentRow { + /* The details view in the Files app has a bottom padding of 15px, so the + * general bottom margin used for comments should be reduced for the new + * comment form. */ + margin-bottom: 5px; +} + +#app-sidebar #chatView .newCommentForm { + /* Make room to show the "Add" button when chat is shown in the sidebar. */ + margin-right: 44px; +} diff --git a/js/embedded.js b/js/embedded.js new file mode 100644 index 00000000000..a2c79ff4ff8 --- /dev/null +++ b/js/embedded.js @@ -0,0 +1,128 @@ +/* global Marionette, Backbone, _, $ */ + +/** + * + * @copyright Copyright (c) 2018, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @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 . + * + */ + +(function(OC, OCA, Marionette, Backbone, _, $) { + 'use strict'; + + OCA.Talk = OCA.Talk || {}; + + var roomChannel = Backbone.Radio.channel('rooms'); + + OCA.Talk.Embedded = Marionette.Application.extend({ + OWNER: 1, + MODERATOR: 2, + USER: 3, + GUEST: 4, + USERSELFJOINED: 5, + + /* Must stay in sync with values in "lib/Room.php". */ + FLAG_DISCONNECTED: 0, + FLAG_IN_CALL: 1, + FLAG_WITH_AUDIO: 2, + FLAG_WITH_VIDEO: 4, + + /** @property {OCA.SpreedMe.Models.Room} activeRoom */ + activeRoom: null, + + /** @property {String} token */ + token: null, + + /** @property {OCA.Talk.Connection} connection */ + connection: null, + + /** @property {OCA.Talk.Signaling.base} signaling */ + signaling: null, + + /** @property {OCA.SpreedMe.Models.RoomCollection} _rooms */ + _rooms: null, + + _registerPageEvents: function() { + // Initialize button tooltips + $('[data-toggle="tooltip"]').tooltip({trigger: 'hover'}).click(function() { + $(this).tooltip('hide'); + }); + }, + + /** + * @param {string} token + */ + _setRoomActive: function(token) { + if (OC.getCurrentUser().uid) { + this._rooms.forEach(function(room) { + room.set('active', room.get('token') === token); + }); + } + }, + syncAndSetActiveRoom: function(token) { + var self = this; + this.signaling.syncRooms() + .then(function() { + self.stopListening(self.activeRoom, 'change:participantFlags'); + + if (OC.getCurrentUser().uid) { + roomChannel.trigger('active', token); + + self._rooms.forEach(function(room) { + if (room.get('token') === token) { + self.activeRoom = room; + } + }); + } + }); + }, + + setEmptyContentMessage: function() { + }, + restoreEmptyContent: function() { + }, + + initialize: function() { + if (OC.getCurrentUser().uid) { + this._rooms = new OCA.SpreedMe.Models.RoomCollection(); + this.listenTo(roomChannel, 'active', this._setRoomActive); + } + + this._messageCollection = new OCA.SpreedMe.Models.ChatMessageCollection(null, {token: null}); + this._chatView = new OCA.SpreedMe.Views.ChatView({ + collection: this._messageCollection, + id: 'chatView' + }); + + this._messageCollection.listenTo(roomChannel, 'leaveCurrentRoom', function() { + this.stopReceivingMessages(); + }); + }, + onStart: function() { + this.signaling = OCA.Talk.Signaling.createConnection(); + this.connection = new OCA.Talk.Connection(this); + + $(window).unload(function () { + this.connection.leaveCurrentRoom(false); + this.signaling.disconnect(); + }.bind(this)); + + this._registerPageEvents(); + }, + }); + +})(OC, OCA, Marionette, Backbone, _, $); diff --git a/js/filesplugin.js b/js/filesplugin.js new file mode 100644 index 00000000000..9eb35f2b01d --- /dev/null +++ b/js/filesplugin.js @@ -0,0 +1,303 @@ +/** + * + * @copyright Copyright (c) 2018, Daniel Calviño Sánchez (danxuliu@gmail.com) + * + * @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 . + * + */ + +(function(OC, OCA) { + + 'use strict'; + + OCA.Talk = OCA.Talk || {}; + + var roomsChannel = Backbone.Radio.channel('rooms'); + + /** + * Tab view for Talk chat in the details view of the Files app. + * + * This view shows the chat for the Talk room associated with the file. The + * tab is shown only for those files in which the Talk sidebar is supported, + * otherwise it is hidden. + */ + OCA.Talk.TalkChatDetailTabView = OCA.Files.DetailTabView.extend({ + + id: 'talkChatTabView', + + /** + * Higher priority than other tabs. + */ + order: -10, + + initialize: function() { + this.$el.append('
'); + }, + + /** + * Returns a CSS class to force scroll bars in the chat view instead of + * in the whole sidebar. + */ + getTabsContainerExtraClasses: function() { + return 'with-inner-scroll-bars'; + }, + + getLabel: function() { + return t('spreed', 'Chat'); + }, + + getIcon: function() { + return 'icon-talk'; + }, + + /** + * Returns whether the Talk tab can be displayed for the file. + * + * @param OCA.Files.FileInfoModel fileInfo + * @return True if the tab can be displayed, false otherwise. + * @see OCA.Talk.FilesPlugin.isTalkSidebarSupportedForFile + */ + canDisplay: function(fileInfo) { + if (OCA.Talk.FilesPlugin.isTalkSidebarSupportedForFile(fileInfo)) { + return true; + } + + // If the Talk tab can not be displayed then the current room is + // left; this must be done here because "setFileInfo" will not get + // called with the new file if the tab can not be displayed. + if (this._appStarted) { + OCA.Talk.FilesPlugin.leaveCurrentRoom(); + } else { + this.model = null; + } + + return false; + }, + + /** + * Sets the FileInfoModel for the currently selected file. + * + * Rooms are associated to the id of the file, so the chat can not be + * loaded until the file info is set and the token for the room is got. + * + * @param OCA.Files.FileInfoModel fileInfo + */ + setFileInfo: function(fileInfo) { + if (!this._appStarted) { + this.model = fileInfo; + + return; + } + + if (this.model === fileInfo) { + // If the tab was hidden and it is being shown again at this + // point the tab has not been made visible yet, so the + // operations need to be delayed. However, the scroll position + // is saved before the tab is made visible to avoid it being + // reset. + // Note that the system tags may finish to load once the chat + // view was already loaded; in that case the input for tags will + // be shown, "compressing" slightly the chat view and thus + // causing it to "lose" the last visible element (as the scroll + // position is kept so the elements at the bottom are hidden). + // Unfortunately there does not seem to be anything that can be + // done to prevent that. + var lastKnownScrollPosition = OCA.SpreedMe.app._chatView.getLastKnownScrollPosition(); + setTimeout(function() { + OCA.SpreedMe.app._chatView.restoreScrollPosition(lastKnownScrollPosition); + + // Load the pending elements that may have been added while + // the tab was hidden. + OCA.SpreedMe.app._chatView.reloadMessageList(); + + OCA.SpreedMe.app._chatView.focusChatInput(); + }, 0); + + return; + } + + this.model = fileInfo; + + if (!fileInfo || fileInfo.get('id') === undefined) { + // This should never happen, except during the initial setup of + // the Files app (and not even in that case due to having to + // wait for the signaling settings to be fetched before + // registering the tab). + // Nevertheless, disconnect from the previous room just in case. + OCA.Talk.FilesPlugin.leaveCurrentRoom(); + + return; + } + + $.ajax({ + url: OC.linkToOCS('apps/spreed/api/v1', 2) + 'file/' + fileInfo.get('id'), + type: 'GET', + beforeSend: function(request) { + request.setRequestHeader('Accept', 'application/json'); + }, + success: function(ocsResponse) { + OCA.Talk.FilesPlugin.joinRoom(ocsResponse.ocs.data.token); + }, + error: function() { + OC.Notification.showTemporary(t('spreed', 'Error while getting the room ID'), {type: 'error'}); + + OCA.Talk.FilesPlugin.leaveCurrentRoom(); + } + }); + + // If the details view is rendered again after the chat view has + // been appended to this tab the chat view would stop working due to + // the element being removed instead of detached, which would make + // the references to its elements invalid (apparently even if + // rendered again; "delegateEvents()" should probably need to be + // called too in that case). However, the details view would only be + // rendered again if new tabs were added, so in general this should + // be safe. + OCA.SpreedMe.app._chatView.$el.appendTo(this.$el); + OCA.SpreedMe.app._chatView.reloadMessageList(); + OCA.SpreedMe.app._chatView.setTooltipContainer($('#app-sidebar')); + OCA.SpreedMe.app._chatView.focusChatInput(); + }, + + setAppStarted: function() { + this._appStarted = true; + + this.$el.find('.app-not-started-placeholder').remove(); + + // Set again the file info now that the app has started. + if (this.model !== null) { + var fileInfo = this.model; + this.model = null; + this.setFileInfo(fileInfo); + } + }, + + }); + + /** + * @namespace + */ + OCA.Talk.FilesPlugin = { + ignoreLists: [ + 'files_trashbin', + 'files.public' + ], + + attach: function(fileList) { + var self = this; + if (this.ignoreLists.indexOf(fileList.id) >= 0) { + return; + } + + var talkChatDetailTabView = new OCA.Talk.TalkChatDetailTabView(); + + OCA.SpreedMe.app.on('start', function() { + self.setupSignalingEventHandlers(); + + // While the app is being started the view just shows a + // placeholder UI that is replaced by the actual UI once + // started. + talkChatDetailTabView.setAppStarted(); + }.bind(this)); + + fileList.registerTabView(talkChatDetailTabView); + + // Unlike in the regular Talk app when Talk is embedded the + // signaling settings are not initially included in the HTML, so + // they need to be explicitly loaded before starting the app. + OCA.Talk.Signaling.loadSettings().then(function() { + OCA.SpreedMe.app.start(); + }); + }, + + /** + * Returns whether the Talk tab can be displayed for the file. + * + * @return True if the file is shared with the current user or by the + * current user to another user (as a user, group...), false + * otherwise. + */ + isTalkSidebarSupportedForFile: function(fileInfo) { + if (!fileInfo) { + return false; + } + + if (fileInfo.get('type') === 'dir') { + return false; + } + + if (fileInfo.get('shareOwnerId')) { + // Shared with me + // TODO How to check that it is not a remote share? At least for + // local shares "shareTypes" is not defined when shared with me. + return true; + } + + if (!fileInfo.get('shareTypes')) { + return false; + } + + var shareTypes = fileInfo.get('shareTypes').filter(function(shareType) { + return shareType === OC.Share.SHARE_TYPE_USER || + shareType === OC.Share.SHARE_TYPE_GROUP || + shareType === OC.Share.SHARE_TYPE_CIRCLE || + shareType === OC.Share.SHARE_TYPE_ROOM; + }); + + if (shareTypes.length === 0) { + return false; + } + + return true; + }, + + setupSignalingEventHandlers: function() { + OCA.SpreedMe.app.signaling.on('joinRoom', function(joinedRoomToken) { + if (OCA.SpreedMe.app.token !== joinedRoomToken) { + return; + } + + OCA.SpreedMe.app.signaling.syncRooms().then(function() { + OCA.SpreedMe.app._messageCollection.setRoomToken(OCA.SpreedMe.app.activeRoom.get('token')); + OCA.SpreedMe.app._messageCollection.receiveMessages(); + }); + }); + }, + + joinRoom: function(token) { + OCA.SpreedMe.app.activeRoom = new OCA.SpreedMe.Models.Room({token: token}); + OCA.SpreedMe.app.signaling.setRoom(OCA.SpreedMe.app.activeRoom); + + OCA.SpreedMe.app.token = token; + OCA.SpreedMe.app.signaling.joinRoom(token); + }, + + leaveCurrentRoom: function() { + OCA.SpreedMe.app.signaling.leaveCurrentRoom(); + + roomsChannel.trigger('leaveCurrentRoom'); + + OCA.SpreedMe.app.token = null; + OCA.SpreedMe.app.activeRoom = null; + } + + }; + + OCA.SpreedMe.app = new OCA.Talk.Embedded(); + + OC.Plugins.register('OCA.Files.FileList', OCA.Talk.FilesPlugin); + +})(OC, OCA); diff --git a/js/views/chatview.js b/js/views/chatview.js index bc0c14d60af..e4f17879075 100644 --- a/js/views/chatview.js +++ b/js/views/chatview.js @@ -283,19 +283,55 @@ }, /** - * Restores the scroll position of the message list to the last saved - * position. + * Restores the scroll position of the message list. + * + * The scroll position is restored to the given position or, if none is + * given, to the last saved position. If neither a scroll position is + * given nor a scroll position was saved the current scroll position is + * not modified. * * Note that the saved scroll position is valid only if the chat view * was not resized since it was saved; restoring the scroll position * after the chat view was resized may or may not work as expected. + * + * @param {number} scrollPosition the scroll position to restore to, or + * undefined to restore to the last saved position. + */ + restoreScrollPosition: function(scrollPosition) { + if (_.isUndefined(this.$container) || + (_.isUndefined(this._savedScrollPosition) && _.isUndefined(scrollPosition))) { + return; + } + + if (_.isUndefined(scrollPosition)) { + this.$container.scrollTop(this._savedScrollPosition); + + return; + } + + this.$container.scrollTop(scrollPosition); + }, + + /** + * Returns the last known scroll position of the message list. + * + * Note that this value is updated asynchronously, so in some cases it + * will not match the current scroll position of the message list. + * Moreover, it could also be influenced in surprising ways, for + * example, by animations that change the width of the message list. + * + * If possible, save the scroll position explicitly at a known safe + * point to be able to restore to it instead of restoring to the value + * returned by this method. + * + * @return {number} the last known scroll position of the message list. */ - restoreScrollPosition: function() { - if (_.isUndefined(this.$container) || _.isUndefined(this._savedScrollPosition)) { + getLastKnownScrollPosition: function() { + if (_.isUndefined(this._virtualList)) { return; } - this.$container.scrollTop(this._savedScrollPosition); + return this._virtualList.getLastKnownScrollPosition(); }, /** diff --git a/js/views/virtuallist.js b/js/views/virtuallist.js index ec4f1fe2900..e2e1172a947 100644 --- a/js/views/virtuallist.js +++ b/js/views/virtuallist.js @@ -72,6 +72,15 @@ * the position and size of all the elements, so in that case "reload()" * needs to be called. * + * Some operations on the virtual list, like reloading it, updating the + * visible elements or scrolling to certain element, require that the + * container is visible; if called while the container is hidden those + * operations will just be ignored. + * + * Adding new elements is still possible while the virtual list is hidden, + * but note that "reload()" must be explicitly called once the container is + * visible again for the added elements to be loaded. + * * * * Internal description: @@ -174,6 +183,8 @@ var self = this; this._$container.on('scroll', function() { + self._lastKnownScrollPosition = self._$container.scrollTop(); + self.updateVisibleElements(); }); }; @@ -196,6 +207,10 @@ return this._$lastVisibleElement; }, + getLastKnownScrollPosition: function() { + return this._lastKnownScrollPosition; + }, + prependElementStart: function() { this._prependedElementsBuffer = document.createDocumentFragment(); @@ -253,6 +268,12 @@ }, prependElementEnd: function() { + if (this._isContainerHidden()) { + delete this._prependedElementsBuffer; + + return; + } + // If the prepended elements are not immediately before the first // loaded element there is nothing to load now; they will be loaded // as needed with the other pending elements. @@ -282,6 +303,12 @@ }, appendElementEnd: function() { + if (this._isContainerHidden()) { + delete this._prependedElementsBuffer; + + return; + } + // If the appended elements are not immediately after the last // loaded element there is nothing to load now; they will be loaded // as needed with the other pending elements. @@ -325,7 +352,8 @@ * * On the other hand, when only the height has changed no reload is * needed; in that case the visibility of the elements is updated based - * on the new height. + * on the new height. If some elements were added to the list while its + * container was hidden they will be loaded too without a full reload. * * Reloading the list requires to recalculate the position and size of * all the elements. The initial call reloads the last visible element @@ -343,11 +371,20 @@ * elements is a float. */ reload: function() { + if (this._isContainerHidden()) { + return; + } + if (this._lastContainerWidth === this._$container.width()) { // If the width is the same the cache is still valid, so no need // for a full reload. this.updateVisibleElements(); + if (this._$firstLoadedElement !== this._$firstElement || + this._$lastLoadedElement !== this._$lastElement) { + this._queueLoadOfPendingElements(); + } + return; } @@ -466,6 +503,10 @@ this._pendingLoad = setTimeout(function() { delete this._pendingLoad; + if (this._isContainerHidden()) { + return; + } + var numberOfElementsToLoad = 200; numberOfElementsToLoad -= this._loadPreviousPendingElements(numberOfElementsToLoad/2); this._loadNextPendingElements(numberOfElementsToLoad); @@ -808,6 +849,10 @@ * of a pixel would be wrongly shown or hidden. */ updateVisibleElements: function() { + if (this._isContainerHidden()) { + return; + } + if (!this._$firstVisibleElement && !this._$firstLoadedElement) { return; } @@ -909,6 +954,10 @@ this._$wrapper.appendTo(this._$container); }, + _isContainerHidden: function() { + return this._$container.is(":hidden"); + }, + /** * Scroll the list to the given element. * @@ -918,6 +967,10 @@ * @param {jQuery} $element the element of the list to scroll to. */ scrollTo: function($element) { + if (this._isContainerHidden()) { + return; + } + if (!this._isLoaded($element)) { return; } diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 877c9472a7d..991b09c9e21 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -89,6 +89,14 @@ public function register() { /** @var \OCA\Spreed\PublicShareAuth\TemplateLoader $shareAuthTemplateLoader */ $shareAuthTemplateLoader = $this->getContainer()->query(\OCA\Spreed\PublicShareAuth\TemplateLoader::class); $shareAuthTemplateLoader->register(); + + /** @var \OCA\Spreed\Files\Listener $filesListener */ + $filesListener = $this->getContainer()->query(\OCA\Spreed\Files\Listener::class); + $filesListener->register(); + + /** @var \OCA\Spreed\Files\TemplateLoader $filesTemplateLoader */ + $filesTemplateLoader = $this->getContainer()->query(\OCA\Spreed\Files\TemplateLoader::class); + $filesTemplateLoader->register(); } protected function registerNotifier(IServerContainer $server) { diff --git a/lib/BackgroundJob/RemoveEmptyRooms.php b/lib/BackgroundJob/RemoveEmptyRooms.php index 203697488fe..35df6200baa 100644 --- a/lib/BackgroundJob/RemoveEmptyRooms.php +++ b/lib/BackgroundJob/RemoveEmptyRooms.php @@ -64,7 +64,7 @@ public function callback(Room $room) { if ($room->getType() === Room::ONE_TO_ONE_CALL && $room->getNumberOfParticipants(false) <= 1) { $room->deleteRoom(); $this->numDeletedRooms++; - } else if ($room->getNumberOfParticipants(false) === 0) { + } else if ($room->getNumberOfParticipants(false) === 0 && $room->getObjectType() !== 'file') { $room->deleteRoom(); $this->numDeletedRooms++; } diff --git a/lib/Chat/AutoComplete/SearchPlugin.php b/lib/Chat/AutoComplete/SearchPlugin.php index 3b7ec2d12c6..e48489c9b2a 100644 --- a/lib/Chat/AutoComplete/SearchPlugin.php +++ b/lib/Chat/AutoComplete/SearchPlugin.php @@ -22,6 +22,7 @@ namespace OCA\Spreed\Chat\AutoComplete; +use OCA\Spreed\Files\Util; use OCA\Spreed\Room; use OCP\Collaboration\Collaborators\ISearchPlugin; use OCP\Collaboration\Collaborators\ISearchResult; @@ -33,6 +34,8 @@ class SearchPlugin implements ISearchPlugin { /** @var IUserManager */ protected $userManager; + /** @var Util */ + protected $util; /** @var string */ protected $userId; @@ -40,8 +43,9 @@ class SearchPlugin implements ISearchPlugin { /** @var Room */ protected $room; - public function __construct(IUserManager $userManager, $userId) { + public function __construct(IUserManager $userManager, Util $util, $userId) { $this->userManager = $userManager; + $this->util = $util; $this->userId = $userId; } @@ -62,6 +66,13 @@ public function search($search, $limit, $offset, ISearchResult $searchResult) { // FIXME Handle guests $this->searchUsers($search, $this->room->getParticipantUserIds(), $searchResult); + if ($this->room->getObjectType() === 'file') { + $usersWithFileAccess = $this->util->getUsersWithAccessFile($this->room->getObjectId()); + if (!empty($usersWithFileAccess)) { + $this->searchUsers($search, $usersWithFileAccess, $searchResult); + } + } + return false; } diff --git a/lib/Chat/Notifier.php b/lib/Chat/Notifier.php index 99d580f53f8..516291659c5 100644 --- a/lib/Chat/Notifier.php +++ b/lib/Chat/Notifier.php @@ -25,6 +25,7 @@ use OCA\Spreed\Exceptions\ParticipantNotFoundException; use OCA\Spreed\Exceptions\RoomNotFoundException; +use OCA\Spreed\Files\Util; use OCA\Spreed\Manager; use OCA\Spreed\Participant; use OCA\Spreed\Room; @@ -51,12 +52,17 @@ class Notifier { /** @var Manager */ private $manager; + /** @var Util */ + private $util; + public function __construct(INotificationManager $notificationManager, IUserManager $userManager, - Manager $manager) { + Manager $manager, + Util $util) { $this->notificationManager = $notificationManager; $this->userManager = $userManager; $this->manager = $manager; + $this->util = $util; } /** @@ -273,11 +279,17 @@ private function shouldUserBeNotified($userId, IComment $comment) { try { $room = $this->manager->getRoomById($comment->getObjectId()); - $participant = $room->getParticipant($userId); - return $participant->getNotificationLevel() !== Participant::NOTIFY_NEVER; } catch (RoomNotFoundException $e) { return false; + } + + try { + $participant = $room->getParticipant($userId); + return $participant->getNotificationLevel() !== Participant::NOTIFY_NEVER; } catch (ParticipantNotFoundException $e) { + if ($room->getObjectType() === 'file') { + return $this->util->canUserAccessFile($room->getObjectId(), $userId); + } return false; } } diff --git a/lib/Controller/FilesController.php b/lib/Controller/FilesController.php new file mode 100644 index 00000000000..a706b8404a0 --- /dev/null +++ b/lib/Controller/FilesController.php @@ -0,0 +1,109 @@ +. + * + */ + +namespace OCA\Spreed\Controller; + +use OCA\Spreed\Exceptions\RoomNotFoundException; +use OCA\Spreed\Files\Util; +use OCA\Spreed\Manager; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCSController; +use OCP\IL10N; +use OCP\IRequest; + +class FilesController extends OCSController { + + /** @var string */ + private $currentUser; + /** @var Manager */ + private $manager; + /** @var Util */ + private $util; + /** @var IL10N */ + private $l; + + /** + * @param string $appName + * @param IRequest $request + * @param string $userId + * @param Manager $manager + * @param Util $util + * @param IL10N $l10n + */ + public function __construct( + string $appName, + IRequest $request, + string $userId, + Manager $manager, + Util $util, + IL10N $l10n + ) { + parent::__construct($appName, $request); + $this->currentUser = $userId; + $this->manager = $manager; + $this->util = $util; + $this->l = $l10n; + } + + /** + * @NoAdminRequired + * + * Returns the token of the room associated to the given file id. + * + * If there is no room associated to the given file id a new room is + * created; the new room is a public room associated with a "file" object + * with the given file id. Unlike normal rooms in which the owner is the + * user that created the room these are special rooms without owner or any + * other persistent participant. + * + * In any case, to create or even get the token of the room, the file must + * be shared and the user must have direct access to that file; an error + * is returned otherwise. A user has direct access to a file if she has + * access to it through a user, group, circle or room share (but not through + * a link share, for example), or if she is the owner of such a file. + * + * @param string $fileId + * @return DataResponse the status code is "200 OK" if a room is returned, + * or "404 Not found" if the given file id was invalid. + */ + public function getRoom(string $fileId): DataResponse { + $share = $this->util->getAnyDirectShareOfFileAccessibleByUser($fileId, $this->currentUser); + if (!$share) { + throw new OCSNotFoundException($this->l->t('File is not shared, or shared but not with the user')); + } + + try { + $room = $this->manager->getRoomByObject('file', $fileId); + } catch (RoomNotFoundException $e) { + $node = $share->getNode(); + $room = $this->manager->createPublicRoom($node->getName(), 'file', $fileId); + } + + return new DataResponse([ + 'token' => $room->getToken() + ]); + } + +} diff --git a/lib/Controller/PublicShareAuthController.php b/lib/Controller/PublicShareAuthController.php index c0d61394d84..a180d6dbeae 100644 --- a/lib/Controller/PublicShareAuthController.php +++ b/lib/Controller/PublicShareAuthController.php @@ -33,14 +33,14 @@ use OCP\IUser; use OCP\IUserManager; use OCP\Share; -use OCP\Share\IManager as ShareManager; +use OCP\Share\IManager as IShareManager; use OCP\Share\Exceptions\ShareNotFound; class PublicShareAuthController extends OCSController { /** @var IUserManager */ private $userManager; - /** @var ShareManager */ + /** @var IShareManager */ private $shareManager; /** @var Manager */ private $manager; @@ -49,14 +49,14 @@ class PublicShareAuthController extends OCSController { * @param string $appName * @param IRequest $request * @param IUserManager $userManager - * @param ShareManager $shareManager + * @param IShareManager $shareManager * @param Manager $manager */ public function __construct( string $appName, IRequest $request, IUserManager $userManager, - ShareManager $shareManager, + IShareManager $shareManager, Manager $manager ) { parent::__construct($appName, $request); diff --git a/lib/Files/Listener.php b/lib/Files/Listener.php new file mode 100644 index 00000000000..965c662d152 --- /dev/null +++ b/lib/Files/Listener.php @@ -0,0 +1,114 @@ +. + * + */ + +namespace OCA\Spreed\Files; + +use OCA\Spreed\Room; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\GenericEvent; + +/** + * Custom behaviour for rooms for files. + * + * The rooms for files are intended to give the users a way to talk about a + * specific shared file, for example, when collaboratively editing it. The room + * is persistent and can be accessed simultaneously by any user with direct + * access (user, group, circle and room share, but not link share, for example) + * to that file. The room has no owner, and there are no persistent participants + * (it is a public room that users join and leave on each session). + * + * These rooms are associated to a "file" object, and their custom behaviour is + * provided by calling the methods of this class as a response to different room + * events. + */ +class Listener { + + /** @var EventDispatcherInterface */ + protected $dispatcher; + /** @var Util */ + protected $util; + + public function __construct(EventDispatcherInterface $dispatcher, Util $util) { + $this->dispatcher = $dispatcher; + $this->util = $util; + } + + public function register() { + $listener = function(GenericEvent $event) { + /** @var Room $room */ + $room = $event->getSubject(); + $this->preventUsersWithoutDirectAccessToTheFileFromJoining($room, $event->getArgument('userId')); + }; + $this->dispatcher->addListener(Room::class . '::preJoinRoom', $listener); + + $listener = function(GenericEvent $event) { + /** @var Room $room */ + $room = $event->getSubject(); + $this->preventGuestsFromJoining($room); + }; + $this->dispatcher->addListener(Room::class . '::preJoinRoomGuest', $listener); + } + + /** + * Prevents users from joining if they do not have direct access to the + * file. + * + * A user has direct access to a file if she received the file through a + * user, group, circle or room share (but not through a link share, for + * example), or if she is the owner of such a file. + * + * This method should be called before a user joins a room. + * + * @param Room $room + * @param string $userId + * @throws \Exception + */ + public function preventUsersWithoutDirectAccessToTheFileFromJoining(Room $room, string $userId) { + if ($room->getObjectType() !== 'file') { + return; + } + + $share = $this->util->getAnyDirectShareOfFileAccessibleByUser($room->getObjectId(), $userId); + if (!$share) { + throw new \Exception('User does not have direct access to the file'); + } + } + + /** + * Prevents guests from joining the room. + * + * This method should be called before a guest joins a room. + * + * @param Room $room + * @throws \Exception + */ + public function preventGuestsFromJoining(Room $room) { + if ($room->getObjectType() !== 'file') { + return; + } + + throw new \Exception('Guests are not allowed in rooms for files'); + } + +} diff --git a/lib/Files/TemplateLoader.php b/lib/Files/TemplateLoader.php new file mode 100644 index 00000000000..70418d0480d --- /dev/null +++ b/lib/Files/TemplateLoader.php @@ -0,0 +1,82 @@ +. + * + */ + +namespace OCA\Spreed\Files; + +use OCP\Util; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\GenericEvent; + +/** + * Helper class to add the Talk UI to the sidebar of the Files app. + */ +class TemplateLoader { + + /** @var EventDispatcherInterface */ + protected $dispatcher; + + public function __construct(EventDispatcherInterface $dispatcher) { + $this->dispatcher = $dispatcher; + } + + public function register() { + $listener = function() { + $this->loadTalkSidebarForFilesApp(); + }; + $this->dispatcher->addListener('OCA\Files::loadAdditionalScripts', $listener); + } + + /** + * Loads the Talk UI in the sidebar of the Files app. + * + * This method should be called when loading additional scripts for the + * Files app. + */ + public function loadTalkSidebarForFilesApp() { + Util::addStyle('spreed', 'files'); + Util::addStyle('spreed', 'chatview'); + Util::addStyle('spreed', 'autocomplete'); + + Util::addScript('spreed', 'vendor/backbone/backbone-min'); + Util::addScript('spreed', 'vendor/backbone.radio/build/backbone.radio.min'); + Util::addScript('spreed', 'vendor/backbone.marionette/lib/backbone.marionette.min'); + Util::addScript('spreed', 'vendor/jshashes/hashes.min'); + Util::addScript('spreed', 'vendor/Caret.js/dist/jquery.caret.min'); + Util::addScript('spreed', 'vendor/At.js/dist/js/jquery.atwho.min'); + Util::addScript('spreed', 'models/chatmessage'); + Util::addScript('spreed', 'models/chatmessagecollection'); + Util::addScript('spreed', 'models/room'); + Util::addScript('spreed', 'models/roomcollection'); + Util::addScript('spreed', 'views/chatview'); + Util::addScript('spreed', 'views/editabletextlabel'); + Util::addScript('spreed', 'views/richobjectstringparser'); + Util::addScript('spreed', 'views/templates'); + Util::addScript('spreed', 'views/virtuallist'); + Util::addScript('spreed', 'signaling'); + Util::addScript('spreed', 'connection'); + Util::addScript('spreed', 'embedded'); + Util::addScript('spreed', 'filesplugin'); + } + +} diff --git a/lib/Files/Util.php b/lib/Files/Util.php new file mode 100644 index 00000000000..a6fd328558c --- /dev/null +++ b/lib/Files/Util.php @@ -0,0 +1,171 @@ +. + * + */ + +namespace OCA\Spreed\Files; + +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Share\IManager as IShareManager; +use OCP\Share\IShare; + +class Util { + + /** @var IRootFolder */ + private $rootFolder; + /** @var IShareManager */ + private $shareManager; + /** @var array[] */ + private $accessLists = []; + + /** + * @param IRootFolder $rootFolder + * @param IShareManager $shareManager + */ + public function __construct( + IRootFolder $rootFolder, + IShareManager $shareManager + ) { + $this->rootFolder = $rootFolder; + $this->shareManager = $shareManager; + } + + public function getUsersWithAccessFile(string $fileId): array { + if (!isset($this->accessLists[$fileId])) { + $nodes = $this->rootFolder->getById($fileId); + + if (empty($nodes)) { + return []; + } + + $node = array_shift($nodes); + $accessList = $this->shareManager->getAccessList($node); + + $this->accessLists[$fileId] = $accessList['users']; + } + + return $this->accessLists[$fileId]; + } + + public function canUserAccessFile(string $fileId, string $userId): bool { + return \in_array($userId, $this->getUsersWithAccessFile($fileId), true); + } + + /** + * Returns any share of the file that the user has direct access to. + * + * A user has direct access to a share and, thus, to a file, if she received + * the file through a user, group, circle or room share (but not through a + * public link, for example), or if she is the owner of such a share. + * + * Only files are taken into account; folders are ignored. + * + * @param string $fileId + * @param string $userId + * @return IShare|null + */ + public function getAnyDirectShareOfFileAccessibleByUser(string $fileId, string $userId) { + $userFolder = $this->rootFolder->getUserFolder($userId); + $fileById = $userFolder->getById($fileId); + if (empty($fileById)) { + return null; + } + + foreach ($fileById as $node) { + $share = $this->getAnyDirectShareOfNodeAccessibleByUser($node, $userId); + if ($share) { + return $share; + } + } + + return null; + } + + /** + * Returns any share of the node that the user has direct access to. + * + * Only files are taken into account; folders are ignored. + * + * @param Node $node + * @param string $userId + * @return IShare|null + */ + private function getAnyDirectShareOfNodeAccessibleByUser(Node $node, string $userId) { + if ($node->getType() === \OCP\Files\FileInfo::TYPE_FOLDER) { + return null; + } + + $reshares = false; + $limit = 1; + + $shares = $this->shareManager->getSharesBy($userId, \OCP\Share::SHARE_TYPE_USER, $node, $reshares, $limit); + if (\count($shares) > 0) { + return $shares[0]; + } + + $shares = $this->shareManager->getSharesBy($userId, \OCP\Share::SHARE_TYPE_GROUP, $node, $reshares, $limit); + if (\count($shares) > 0) { + return $shares[0]; + } + + $shares = $this->shareManager->getSharesBy($userId, \OCP\Share::SHARE_TYPE_CIRCLE, $node, $reshares, $limit); + if (\count($shares) > 0) { + return $shares[0]; + } + + $shares = $this->shareManager->getSharesBy($userId, \OCP\Share::SHARE_TYPE_ROOM, $node, $reshares, $limit); + if (\count($shares) > 0) { + return $shares[0]; + } + + // If the node is not shared then there is no need for further checks. + // Note that "isShared()" returns false for owned shares, so the check + // can not be moved above. + if (!$node->isShared()) { + return null; + } + + $shares = $this->shareManager->getSharedWith($userId, \OCP\Share::SHARE_TYPE_USER, $node, $limit); + if (\count($shares) > 0) { + return $shares[0]; + } + + $shares = $this->shareManager->getSharedWith($userId, \OCP\Share::SHARE_TYPE_GROUP, $node, $limit); + if (\count($shares) > 0) { + return $shares[0]; + } + + $shares = $this->shareManager->getSharedWith($userId, \OCP\Share::SHARE_TYPE_CIRCLE, $node, $limit); + if (\count($shares) > 0) { + return $shares[0]; + } + + $shares = $this->shareManager->getSharedWith($userId, \OCP\Share::SHARE_TYPE_ROOM, $node, $limit); + if (\count($shares) > 0) { + return $shares[0]; + } + + return null; + } + +} diff --git a/lib/Manager.php b/lib/Manager.php index 3316ee31d45..6d1865b6ac1 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -298,6 +298,30 @@ public function getRoomByToken($token) { return $this->createRoomObject($row); } + /** + * @param string $objectType + * @param string $objectId + * @return Room + * @throws RoomNotFoundException + */ + public function getRoomByObject(string $objectType, string $objectId): Room { + $query = $this->db->getQueryBuilder(); + $query->select('*') + ->from('talk_rooms') + ->where($query->expr()->eq('object_type', $query->createNamedParameter($objectType))) + ->andWhere($query->expr()->eq('object_id', $query->createNamedParameter($objectId))); + + $result = $query->execute(); + $row = $result->fetch(); + $result->closeCursor(); + + if ($row === false) { + throw new RoomNotFoundException(); + } + + return $this->createRoomObject($row); + } + /** * @param string|null $userId * @param string $sessionId diff --git a/lib/Notification/Hooks.php b/lib/Notification/Hooks.php index 8db2a44551c..ebf2d1db532 100644 --- a/lib/Notification/Hooks.php +++ b/lib/Notification/Hooks.php @@ -99,6 +99,10 @@ public function generateCallNotifications(Room $room) { return; } + if ($room->getObjectType() === 'file') { + return; + } + $actor = $this->userSession->getUser(); $actorId = $actor instanceof IUser ? $actor->getUID() :''; diff --git a/tests/php/Chat/AutoComplete/SearchPluginTest.php b/tests/php/Chat/AutoComplete/SearchPluginTest.php index b4eff43da59..7ce7bfd5543 100644 --- a/tests/php/Chat/AutoComplete/SearchPluginTest.php +++ b/tests/php/Chat/AutoComplete/SearchPluginTest.php @@ -22,6 +22,7 @@ namespace OCA\Spreed\Tests\php\Chat; use OCA\Spreed\Chat\AutoComplete\SearchPlugin; +use OCA\Spreed\Files\Util; use OCA\Spreed\Room; use OCP\Collaboration\Collaborators\ISearchResult; use OCP\IUser; @@ -32,6 +33,9 @@ class SearchPluginTest extends \Test\TestCase { /** @var IUserManager|\PHPUnit_Framework_MockObject_MockObject */ protected $userManager; + /** @var Util|\PHPUnit_Framework_MockObject_MockObject */ + protected $util; + /** @var string */ protected $userId; @@ -42,6 +46,7 @@ public function setUp() { parent::setUp(); $this->userManager = $this->createMock(IUserManager::class); + $this->util = $this->createMock(Util::class); $this->userId = 'current'; } @@ -53,6 +58,7 @@ protected function getPlugin(array $methods = []) { if (empty($methods)) { return new SearchPlugin( $this->userManager, + $this->util, $this->userId ); } @@ -60,6 +66,7 @@ protected function getPlugin(array $methods = []) { return $this->getMockBuilder(SearchPlugin::class) ->setConstructorArgs([ $this->userManager, + $this->util, $this->userId, ]) ->setMethods($methods) diff --git a/tests/php/Chat/NotifierTest.php b/tests/php/Chat/NotifierTest.php index bdf32fa6610..3c26e7fa94d 100644 --- a/tests/php/Chat/NotifierTest.php +++ b/tests/php/Chat/NotifierTest.php @@ -25,6 +25,7 @@ use OCA\Spreed\Chat\Notifier; use OCA\Spreed\Exceptions\ParticipantNotFoundException; +use OCA\Spreed\Files\Util; use OCA\Spreed\Manager; use OCA\Spreed\Participant; use OCA\Spreed\Room; @@ -44,6 +45,9 @@ class NotifierTest extends \Test\TestCase { /** @var \OCA\Spreed\Manager|\PHPUnit_Framework_MockObject_MockObject */ protected $manager; + /** @var \OCA\Spreed\Files\Util|\PHPUnit_Framework_MockObject_MockObject */ + protected $util; + /** @var \OCA\Spreed\Chat\Notifier */ protected $notifier; @@ -65,9 +69,12 @@ public function setUp() { $this->manager = $this->createMock(Manager::class); + $this->util = $this->createMock(Util::class); + $this->notifier = new Notifier($this->notificationManager, $this->userManager, - $this->manager); + $this->manager, + $this->util); } private function newComment($id, $actorType, $actorId, $creationDateTime, $message) { diff --git a/tests/php/Notification/NotifierTest.php b/tests/php/Notification/NotifierTest.php index b21602da728..a6aeb1bd5b0 100644 --- a/tests/php/Notification/NotifierTest.php +++ b/tests/php/Notification/NotifierTest.php @@ -35,7 +35,7 @@ use OCP\L10N\IFactory; use OCP\Notification\INotification; use OCP\RichObjectStrings\Definitions; -use OCP\Share\IManager as ShareManager; +use OCP\Share\IManager as IShareManager; use PHPUnit\Framework\MockObject\MockObject; class NotifierTest extends \Test\TestCase { @@ -46,7 +46,7 @@ class NotifierTest extends \Test\TestCase { protected $url; /** @var IUserManager|\PHPUnit_Framework_MockObject_MockObject */ protected $userManager; - /** @var ShareManager|\PHPUnit_Framework_MockObject_MockObject */ + /** @var IShareManager|\PHPUnit_Framework_MockObject_MockObject */ protected $shareManager; /** @var Manager|\PHPUnit_Framework_MockObject_MockObject */ protected $manager; @@ -65,7 +65,7 @@ public function setUp() { $this->lFactory = $this->createMock(IFactory::class); $this->url = $this->createMock(IURLGenerator::class); $this->userManager = $this->createMock(IUserManager::class); - $this->shareManager = $this->createMock(ShareManager::class); + $this->shareManager = $this->createMock(IShareManager::class); $this->manager = $this->createMock(Manager::class); $this->commentsManager = $this->createMock(ICommentsManager::class); $this->messageParser = $this->createMock(MessageParser::class);