CommentCard.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. <template>
  2. <div>
  3. <div class="container p-0 overflow-hidden">
  4. <div class="row">
  5. <div class="col-12 col-md-6 offset-md-3">
  6. <div class="card shadow-none border" style="height:100vh;">
  7. <div class="card-header d-flex justify-content-between align-items-center">
  8. <div
  9. @click="commentNavigateBack(status.id)"
  10. class="cursor-pointer"
  11. >
  12. <i class="fas fa-chevron-left fa-lg px-2"></i>
  13. </div>
  14. <div>
  15. <p class="font-weight-bold mb-0 h5">Comments</p>
  16. </div>
  17. <div>
  18. <i class="fas fa-cog fa-lg text-white"></i>
  19. </div>
  20. </div>
  21. <div class="card-body" style="overflow-y: auto !important">
  22. <div class="media">
  23. <img :src="status.account.avatar" class="rounded-circle border mr-3" width="32px" height="32px">
  24. <div class="media-body">
  25. <p class="d-flex justify-content-between align-items-top mb-0" style="overflow-y: hidden;">
  26. <span class="mr-2" style="font-size: 13px;">
  27. <a class="text-dark font-weight-bold mr-1 text-break" :href="status.account.url" v-bind:title="status.account.username">{{trimCaption(status.account.username,15)}}</a>
  28. <span class="text-break comment-body" style="word-break: break-all;" v-html="status.content"></span>
  29. </span>
  30. </p>
  31. </div>
  32. </div>
  33. <hr>
  34. <div class="postCommentsLoader text-center py-2">
  35. <div class="spinner-border" role="status">
  36. <span class="sr-only">Loading...</span>
  37. </div>
  38. </div>
  39. <div class="postCommentsContainer d-none">
  40. <p v-if="replies.length" class="mb-1 text-center load-more-link my-4">
  41. <a
  42. href="#"
  43. class="text-dark"
  44. title="Load more comments"
  45. @click.prevent="loadMoreComments"
  46. >
  47. <svg class="bi bi-plus-circle" width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg" style="font-size:2em;"> <path fill-rule="evenodd" d="M8 3.5a.5.5 0 01.5.5v4a.5.5 0 01-.5.5H4a.5.5 0 010-1h3.5V4a.5.5 0 01.5-.5z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M7.5 8a.5.5 0 01.5-.5h4a.5.5 0 010 1H8.5V12a.5.5 0 01-1 0V8z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M8 15A7 7 0 108 1a7 7 0 000 14zm0 1A8 8 0 108 0a8 8 0 000 16z" clip-rule="evenodd"/></svg>
  48. </a>
  49. </p>
  50. <div v-if="replies.length" v-for="(reply, index) in replies" class="pb-3 media" :key="'tl' + reply.id + '_' + index">
  51. <img :src="reply.account.avatar" class="rounded-circle border mr-3" width="32px" height="32px">
  52. <div class="media-body">
  53. <div v-if="reply.sensitive == true">
  54. <span class="py-3">
  55. <a class="text-dark font-weight-bold mr-3" style="font-size: 13px;" :href="reply.account.url" v-bind:title="reply.account.username">{{trimCaption(reply.account.username,15)}}</a>
  56. <span class="text-break" style="font-size: 13px;">
  57. <span class="font-italic text-muted">This comment may contain sensitive material</span>
  58. <span class="text-primary cursor-pointer pl-1" @click="reply.sensitive = false;">Show</span>
  59. </span>
  60. </span>
  61. </div>
  62. <div v-else>
  63. <p class="d-flex justify-content-between align-items-top read-more mb-0" style="overflow-y: hidden;">
  64. <span class="mr-3" style="font-size: 13px;">
  65. <a class="text-dark font-weight-bold mr-1 text-break" :href="reply.account.url" v-bind:title="reply.account.username">{{trimCaption(reply.account.username,15)}}</a>
  66. <span class="text-break comment-body" style="word-break: break-all;" v-html="reply.content"></span>
  67. </span>
  68. <span class="text-right" style="min-width: 30px;">
  69. <span v-on:click="likeReply(reply, $event)"><i v-bind:class="[reply.favourited ? 'fas fa-heart fa-sm text-danger':'far fa-heart fa-sm text-lighter']"></i></span>
  70. <span class="pl-2 text-lighter cursor-pointer" @click="ctxMenu(reply)">
  71. <span class="fas fa-ellipsis-v text-lighter"></span>
  72. </span>
  73. </span>
  74. </p>
  75. <p class="mb-0">
  76. <a v-once class="text-muted mr-3 text-decoration-none small" style="width: 20px;" v-text="timeAgo(reply.created_at)" :href="reply.url"></a>
  77. <span v-if="reply.favourites_count" class="text-muted comment-reaction font-weight-bold mr-3 small">{{reply.favourites_count == 1 ? '1 like' : reply.favourites_count + ' likes'}}</span>
  78. <span class="small text-muted comment-reaction font-weight-bold cursor-pointer" v-on:click="replyFocus(reply, index, true)">Reply</span>
  79. </p>
  80. <div v-if="reply.reply_count > 0" class="cursor-pointer pb-2" v-on:click="toggleReplies(reply)">
  81. <span class="show-reply-bar"></span>
  82. <span class="comment-reaction small font-weight-bold">{{reply.thread ? 'Hide' : 'View'}} Replies ({{reply.reply_count}})</span>
  83. </div>
  84. <div v-if="reply.thread == true" class="comment-thread">
  85. <div v-for="(s, sindex) in reply.replies" class="py-1 media" :key="'cr' + s.id + '_' + index">
  86. <img :src="s.account.avatar" class="rounded-circle border mr-3" width="25px" height="25px">
  87. <div class="media-body">
  88. <p class="d-flex justify-content-between align-items-top read-more mb-0" style="overflow-y: hidden;">
  89. <span class="mr-2" style="font-size: 13px;">
  90. <a class="text-dark font-weight-bold mr-1" :href="s.account.url" :title="s.account.username">{{s.account.username}}</a>
  91. <span class="text-break comment-body" style="word-break: break-all;" v-html="s.content"></span>
  92. </span>
  93. <span>
  94. <span v-on:click="likeReply(s, $event)"><i v-bind:class="[s.favourited ? 'fas fa-heart fa-sm text-danger':'far fa-heart fa-sm text-lighter']"></i></span>
  95. </span>
  96. </p>
  97. <p class="mb-0">
  98. <a v-once class="text-muted mr-3 text-decoration-none small" style="width: 20px;" v-text="timeAgo(s.created_at)" :href="s.url"></a>
  99. <span v-if="s.favourites_count" class="text-muted comment-reaction font-weight-bold mr-3">{{s.favourites_count == 1 ? '1 like' : s.favourites_count + ' likes'}}</span>
  100. </p>
  101. </div>
  102. </div>
  103. </div>
  104. </div>
  105. </div>
  106. </div>
  107. <div v-if="!replies.length">
  108. <p class="text-center text-muted font-weight-bold small">No comments yet</p>
  109. </div>
  110. </div>
  111. </div>
  112. <div class="card-footer mb-3">
  113. <div class="align-middle d-flex">
  114. <img
  115. :src="profile.avatar"
  116. width="36"
  117. height="36"
  118. class="rounded-circle border mr-3">
  119. <textarea
  120. class="form-control rounded-pill"
  121. name="comment"
  122. placeholder="Add a comment…"
  123. autocomplete="off"
  124. autocorrect="off"
  125. rows="1"
  126. maxlength="0"
  127. style="resize: none;overflow-y: hidden"
  128. @click="replyFocus(status)">
  129. </textarea>
  130. </div>
  131. </div>
  132. </div>
  133. </div>
  134. </div>
  135. </div>
  136. <context-menu
  137. ref="cMenu"
  138. :status="ctxMenuStatus"
  139. :profile="profile"
  140. />
  141. <b-modal ref="replyModal"
  142. id="ctx-reply-modal"
  143. hide-footer
  144. centered
  145. rounded
  146. :title-html="status.account ? 'Reply to <span class=text-dark>' + status.account.username + '</span>' : ''"
  147. title-tag="p"
  148. title-class="font-weight-bold text-muted"
  149. size="md"
  150. body-class="p-2 rounded">
  151. <div>
  152. <vue-tribute :options="tributeSettings">
  153. <textarea
  154. class="form-control replyModalTextarea"
  155. rows="4"
  156. v-model="replyText">
  157. </textarea>
  158. </vue-tribute>
  159. <div class="border-top border-bottom my-2">
  160. <ul class="nav align-items-center emoji-reactions" style="overflow-x: scroll;flex-wrap: unset;">
  161. <li class="nav-item" v-on:click="emojiReaction(status)" v-for="e in emoji">{{e}}</li>
  162. </ul>
  163. </div>
  164. <div class="d-flex justify-content-between align-items-center">
  165. <div>
  166. <span class="pl-2 small text-muted font-weight-bold text-monospace">
  167. <span :class="[replyText.length > config.uploader.max_caption_length ? 'text-danger':'text-dark']">{{replyText.length > config.uploader.max_caption_length ? config.uploader.max_caption_length - replyText.length : replyText.length}}</span>/{{config.uploader.max_caption_length}}
  168. </span>
  169. </div>
  170. <div class="d-flex align-items-center">
  171. <div class="custom-control custom-switch mr-3">
  172. <input type="checkbox" class="custom-control-input" id="replyModalCWSwitch" v-model="replyNsfw">
  173. <label :class="[replyNsfw ? 'custom-control-label font-weight-bold text-dark':'custom-control-label text-lighter']" for="replyModalCWSwitch">Mark as NSFW</label>
  174. </div>
  175. <button class="btn btn-primary btn-sm py-2 px-4 lead text-uppercase font-weight-bold" v-on:click.prevent="commentSubmit(status, $event)" :disabled="replyText.length == 0">
  176. {{replySending == true ? 'POSTING' : 'POST'}}
  177. </button>
  178. </div>
  179. </div>
  180. </div>
  181. </b-modal>
  182. </div>
  183. </template>
  184. <script type="text/javascript">
  185. import ContextMenu from './ContextMenu.vue';
  186. export default {
  187. props: ['status', 'profile'],
  188. components: {
  189. "context-menu": ContextMenu
  190. },
  191. data() {
  192. return {
  193. ids: [],
  194. config: window.App.config,
  195. tributeSettings: {
  196. collection: [
  197. {
  198. trigger: '@',
  199. menuShowMinLength: 2,
  200. values: (function (text, cb) {
  201. let url = '/api/compose/v0/search/mention';
  202. axios.get(url, { params: { q: text }})
  203. .then(res => {
  204. cb(res.data);
  205. })
  206. .catch(err => {
  207. console.log(err);
  208. })
  209. })
  210. },
  211. {
  212. trigger: '#',
  213. menuShowMinLength: 2,
  214. values: (function (text, cb) {
  215. let url = '/api/compose/v0/search/hashtag';
  216. axios.get(url, { params: { q: text }})
  217. .then(res => {
  218. cb(res.data);
  219. })
  220. .catch(err => {
  221. console.log(err);
  222. })
  223. })
  224. }
  225. ]
  226. },
  227. replies: [],
  228. replyId: null,
  229. replyText: '',
  230. replyNsfw: false,
  231. replySending: false,
  232. pagination: {},
  233. ctxMenuStatus: false,
  234. emoji: window.App.util.emoji
  235. }
  236. },
  237. beforeMount() {
  238. this.fetchComments();
  239. },
  240. methods: {
  241. commentNavigateBack(id) {
  242. $('nav').show();
  243. $('footer').show();
  244. $('.mobile-footer-spacer').attr('style', 'display:block');
  245. $('.mobile-footer').attr('style', 'display:block');
  246. this.$emit('current-layout', 'feed');
  247. let path = '/';
  248. window.history.pushState({}, '', path);
  249. },
  250. trimCaption(caption, len = 60) {
  251. return _.truncate(caption, {
  252. length: len
  253. });
  254. },
  255. replyFocus(e, index, prependUsername = false) {
  256. if($('body').hasClass('loggedIn') == false) {
  257. this.redirect('/login?next=' + encodeURIComponent(window.location.pathname));
  258. return;
  259. }
  260. if(this.status.comments_disabled) {
  261. return;
  262. }
  263. this.replyToIndex = index;
  264. this.replyingToId = e.id;
  265. this.replyingToUsername = e.account.username;
  266. this.reply_to_profile_id = e.account.id;
  267. let username = e.account.local ? '@' + e.account.username + ' '
  268. : '@' + e.account.acct + ' ';
  269. if(prependUsername == true) {
  270. this.replyText = username;
  271. }
  272. this.$refs.replyModal.show();
  273. setTimeout(function() {
  274. $('.replyModalTextarea').focus();
  275. }, 500);
  276. },
  277. commentSubmit(status, $event) {
  278. this.replySending = true;
  279. let id = status.id;
  280. let comment = this.replyText;
  281. let limit = this.config.uploader.max_caption_length;
  282. if(comment.length > limit) {
  283. this.replySending = false;
  284. swal('Comment Too Long', 'Please make sure your comment is '+limit+' characters or less.', 'error');
  285. return;
  286. }
  287. axios.post('/i/comment', {
  288. item: id,
  289. comment: comment,
  290. sensitive: this.replyNsfw
  291. }).then(res => {
  292. this.replyText = '';
  293. this.replies.push(res.data.entity);
  294. this.$refs.replyModal.hide();
  295. });
  296. this.replySending = false;
  297. },
  298. timeAgo(ts) {
  299. return App.util.format.timeAgo(ts);
  300. },
  301. fetchComments() {
  302. console.log('Fetching comments...');
  303. let url = '/api/v2/comments/'+this.status.account.id+'/status/'+this.status.id;
  304. axios.get(url)
  305. .then(res => {
  306. this.replies = res.data.data;
  307. this.pagination = res.data.meta.pagination;
  308. }).catch(error => {
  309. if(!error.response) {
  310. $('.postCommentsLoader .lds-ring')
  311. .attr('style','width:100%')
  312. .addClass('pt-4 font-weight-bold text-muted')
  313. .text('An error occurred, cannot fetch comments. Please try again later.');
  314. } else {
  315. switch(error.response.status) {
  316. case 401:
  317. $('.postCommentsLoader .lds-ring')
  318. .attr('style','width:100%')
  319. .addClass('pt-4 font-weight-bold text-muted')
  320. .text('Please login to view.');
  321. break;
  322. default:
  323. $('.postCommentsLoader .lds-ring')
  324. .attr('style','width:100%')
  325. .addClass('pt-4 font-weight-bold text-muted')
  326. .text('An error occurred, cannot fetch comments. Please try again later.');
  327. break;
  328. }
  329. }
  330. });
  331. },
  332. loadMoreComments() {
  333. if(this.pagination.total_pages == 1 || this.pagination.current_page == this.pagination.total_pages) {
  334. $('.load-more-link').addClass('d-none');
  335. return;
  336. }
  337. $('.load-more-link').addClass('d-none');
  338. $('.postCommentsLoader').removeClass('d-none');
  339. let next = this.pagination.links.next;
  340. axios.get(next)
  341. .then(response => {
  342. let self = this;
  343. let res = response.data.data;
  344. $('.postCommentsLoader').addClass('d-none');
  345. for(let i=0; i < res.length; i++) {
  346. this.replies.unshift(res[i]);
  347. }
  348. this.pagination = response.data.meta.pagination;
  349. $('.load-more-link').removeClass('d-none');
  350. });
  351. },
  352. likeReply(status, $event) {
  353. if($('body').hasClass('loggedIn') == false) {
  354. swal('Login', 'Please login to perform this action.', 'info');
  355. return;
  356. }
  357. axios.post('/i/like', {
  358. item: status.id
  359. }).then(res => {
  360. status.favourites_count = res.data.count;
  361. if(status.favourited == true) {
  362. status.favourited = false;
  363. } else {
  364. status.favourited = true;
  365. }
  366. }).catch(err => {
  367. swal('Error', 'Something went wrong, please try again later.', 'error');
  368. });
  369. },
  370. ctxMenu(status) {
  371. this.ctxMenuStatus = status;
  372. this.$refs.cMenu.open();
  373. }
  374. }
  375. }
  376. </script>
  377. <style type="text/css" scoped>
  378. .emoji-reactions .nav-item {
  379. font-size: 1.2rem;
  380. padding: 9px;
  381. cursor: pointer;
  382. }
  383. .emoji-reactions::-webkit-scrollbar {
  384. width: 0px;
  385. height: 0px;
  386. background: transparent;
  387. }
  388. </style>