Comments.vue 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. <!--
  2. - @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
  3. -
  4. - @author John Molakvoæ <skjnldsv@protonmail.com>
  5. - @author Richard Steinmetz <richard@steinmetz.cloud>
  6. -
  7. - @license GNU AGPL version 3 or any later version
  8. -
  9. - This program is free software: you can redistribute it and/or modify
  10. - it under the terms of the GNU Affero General Public License as
  11. - published by the Free Software Foundation, either version 3 of the
  12. - License, or (at your option) any later version.
  13. -
  14. - This program is distributed in the hope that it will be useful,
  15. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. - GNU Affero General Public License for more details.
  18. -
  19. - You should have received a copy of the GNU Affero General Public License
  20. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  21. -
  22. -->
  23. <template>
  24. <div v-observe-visibility="onVisibilityChange"
  25. class="comments"
  26. :class="{ 'icon-loading': isFirstLoading }">
  27. <!-- Editor -->
  28. <Comment v-bind="editorData"
  29. :auto-complete="autoComplete"
  30. :user-data="userData"
  31. :editor="true"
  32. :ressource-id="ressourceId"
  33. class="comments__writer"
  34. @new="onNewComment" />
  35. <template v-if="!isFirstLoading">
  36. <NcEmptyContent v-if="!hasComments && done"
  37. class="comments__empty"
  38. :name="t('comments', 'No comments yet, start the conversation!')">
  39. <template #icon>
  40. <MessageReplyTextIcon />
  41. </template>
  42. </NcEmptyContent>
  43. <ul v-else>
  44. <!-- Comments -->
  45. <Comment v-for="comment in comments"
  46. :key="comment.props.id"
  47. tag="li"
  48. v-bind="comment.props"
  49. :auto-complete="autoComplete"
  50. :message.sync="comment.props.message"
  51. :ressource-id="ressourceId"
  52. :user-data="genMentionsData(comment.props.mentions)"
  53. class="comments__list"
  54. @delete="onDelete" />
  55. </ul>
  56. <!-- Loading more message -->
  57. <div v-if="loading && !isFirstLoading" class="comments__info icon-loading" />
  58. <div v-else-if="hasComments && done" class="comments__info">
  59. {{ t('comments', 'No more messages') }}
  60. </div>
  61. <!-- Error message -->
  62. <template v-else-if="error">
  63. <NcEmptyContent class="comments__error" :name="error">
  64. <template #icon>
  65. <AlertCircleOutlineIcon />
  66. </template>
  67. </NcEmptyContent>
  68. <NcButton class="comments__retry" @click="getComments">
  69. <template #icon>
  70. <RefreshIcon />
  71. </template>
  72. {{ t('comments', 'Retry') }}
  73. </NcButton>
  74. </template>
  75. </template>
  76. </div>
  77. </template>
  78. <script>
  79. import { generateOcsUrl } from '@nextcloud/router'
  80. import { getCurrentUser } from '@nextcloud/auth'
  81. import { loadState } from '@nextcloud/initial-state'
  82. import { showError } from '@nextcloud/dialogs'
  83. import axios from '@nextcloud/axios'
  84. import VTooltip from 'v-tooltip'
  85. import Vue from 'vue'
  86. import VueObserveVisibility from 'vue-observe-visibility'
  87. import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
  88. import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
  89. import RefreshIcon from 'vue-material-design-icons/Refresh.vue'
  90. import MessageReplyTextIcon from 'vue-material-design-icons/MessageReplyText.vue'
  91. import AlertCircleOutlineIcon from 'vue-material-design-icons/AlertCircleOutline.vue'
  92. import Comment from '../components/Comment.vue'
  93. import { getComments, DEFAULT_LIMIT } from '../services/GetComments.ts'
  94. import cancelableRequest from '../utils/cancelableRequest.js'
  95. import { markCommentsAsRead } from '../services/ReadComments.ts'
  96. Vue.use(VTooltip)
  97. Vue.use(VueObserveVisibility)
  98. export default {
  99. name: 'Comments',
  100. components: {
  101. // Avatar,
  102. Comment,
  103. NcEmptyContent,
  104. NcButton,
  105. RefreshIcon,
  106. MessageReplyTextIcon,
  107. AlertCircleOutlineIcon,
  108. },
  109. data() {
  110. return {
  111. error: '',
  112. loading: false,
  113. done: false,
  114. ressourceId: null,
  115. offset: 0,
  116. comments: [],
  117. cancelRequest: () => {},
  118. editorData: {
  119. actorDisplayName: getCurrentUser().displayName,
  120. actorId: getCurrentUser().uid,
  121. key: 'editor',
  122. },
  123. Comment,
  124. userData: {},
  125. }
  126. },
  127. computed: {
  128. hasComments() {
  129. return this.comments.length > 0
  130. },
  131. isFirstLoading() {
  132. return this.loading && this.offset === 0
  133. },
  134. },
  135. methods: {
  136. async onVisibilityChange(isVisible) {
  137. if (isVisible) {
  138. try {
  139. await markCommentsAsRead(this.commentsType, this.ressourceId, new Date())
  140. } catch (e) {
  141. showError(e.message || t('comments', 'Failed to mark comments as read'))
  142. }
  143. }
  144. },
  145. /**
  146. * Update current ressourceId and fetch new data
  147. *
  148. * @param {number} ressourceId the current ressourceId (fileId...)
  149. */
  150. async update(ressourceId) {
  151. this.ressourceId = ressourceId
  152. this.resetState()
  153. this.getComments()
  154. },
  155. /**
  156. * Ran when the bottom of the tab is reached
  157. */
  158. onScrollBottomReached() {
  159. /**
  160. * Do not fetch more if we:
  161. * - are showing an error
  162. * - already fetched everything
  163. * - are currently loading
  164. */
  165. if (this.error || this.done || this.loading) {
  166. return
  167. }
  168. this.getComments()
  169. },
  170. /**
  171. * Make sure we have all mentions as Array of objects
  172. *
  173. * @param {any[]} mentions the mentions list
  174. * @return {Record<string, object>}
  175. */
  176. genMentionsData(mentions) {
  177. Object.values(mentions)
  178. .flat()
  179. .forEach(mention => {
  180. this.userData[mention.mentionId] = {
  181. // TODO: support groups
  182. icon: 'icon-user',
  183. id: mention.mentionId,
  184. label: mention.mentionDisplayName,
  185. source: 'users',
  186. primary: getCurrentUser().uid === mention.mentionId,
  187. }
  188. })
  189. return this.userData
  190. },
  191. /**
  192. * Get the existing shares infos
  193. */
  194. async getComments() {
  195. // Cancel any ongoing request
  196. this.cancelRequest('cancel')
  197. try {
  198. this.loading = true
  199. this.error = ''
  200. // Init cancellable request
  201. const { request, abort } = cancelableRequest(getComments)
  202. this.cancelRequest = abort
  203. // Fetch comments
  204. const { data: comments } = await request({
  205. commentsType: this.commentsType,
  206. ressourceId: this.ressourceId,
  207. }, { offset: this.offset }) || { data: [] }
  208. this.logger.debug(`Processed ${comments.length} comments`, { comments })
  209. // We received less than the requested amount,
  210. // we're done fetching comments
  211. if (comments.length < DEFAULT_LIMIT) {
  212. this.done = true
  213. }
  214. // Insert results
  215. this.comments.push(...comments)
  216. // Increase offset for next fetch
  217. this.offset += DEFAULT_LIMIT
  218. } catch (error) {
  219. if (error.message === 'cancel') {
  220. return
  221. }
  222. this.error = t('comments', 'Unable to load the comments list')
  223. console.error('Error loading the comments list', error)
  224. } finally {
  225. this.loading = false
  226. }
  227. },
  228. /**
  229. * Autocomplete @mentions
  230. *
  231. * @param {string} search the query
  232. * @param {Function} callback the callback to process the results with
  233. */
  234. async autoComplete(search, callback) {
  235. const results = await axios.get(generateOcsUrl('core/autocomplete/get'), {
  236. params: {
  237. search,
  238. itemType: 'files',
  239. itemId: this.ressourceId,
  240. sorter: 'commenters|share-recipients',
  241. limit: loadState('comments', 'maxAutoCompleteResults'),
  242. },
  243. })
  244. // Save user data so it can be used by the editor to replace mentions
  245. results.data.ocs.data.forEach(user => { this.userData[user.id] = user })
  246. return callback(Object.values(this.userData))
  247. },
  248. /**
  249. * Add newly created comment to the list
  250. *
  251. * @param {object} comment the new comment
  252. */
  253. onNewComment(comment) {
  254. this.comments.unshift(comment)
  255. },
  256. /**
  257. * Remove deleted comment from the list
  258. *
  259. * @param {number} id the deleted comment
  260. */
  261. onDelete(id) {
  262. const index = this.comments.findIndex(comment => comment.props.id === id)
  263. if (index > -1) {
  264. this.comments.splice(index, 1)
  265. } else {
  266. console.error('Could not find the deleted comment in the list', id)
  267. }
  268. },
  269. /**
  270. * Reset the current view to its default state
  271. */
  272. resetState() {
  273. this.error = ''
  274. this.loading = false
  275. this.done = false
  276. this.offset = 0
  277. this.comments = []
  278. },
  279. },
  280. }
  281. </script>
  282. <style lang="scss" scoped>
  283. .comments {
  284. // Do not add emptycontent top margin
  285. &__empty,
  286. &__error {
  287. margin-top: 0 !important;
  288. }
  289. &__retry {
  290. margin: 0 auto;
  291. }
  292. &__info {
  293. height: 60px;
  294. color: var(--color-text-maxcontrast);
  295. text-align: center;
  296. line-height: 60px;
  297. }
  298. }
  299. </style>