CommentCard.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  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="profileUrl(status)" 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="profileUrl(reply)" 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="profileUrl(reply)" 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="statusUrl(reply)"></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="profileUrl(s)" :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="statusUrl(s)"></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: {
  188. 'status': {
  189. type: Object
  190. },
  191. 'profile': {
  192. type: Object
  193. },
  194. 'backToStatus': {
  195. type: Boolean,
  196. default: false
  197. }
  198. },
  199. components: {
  200. "context-menu": ContextMenu
  201. },
  202. data() {
  203. return {
  204. ids: [],
  205. config: window.App.config,
  206. tributeSettings: {
  207. collection: [
  208. {
  209. trigger: '@',
  210. menuShowMinLength: 2,
  211. values: (function (text, cb) {
  212. let url = '/api/compose/v0/search/mention';
  213. axios.get(url, { params: { q: text }})
  214. .then(res => {
  215. cb(res.data);
  216. })
  217. .catch(err => {
  218. console.log(err);
  219. })
  220. })
  221. },
  222. {
  223. trigger: '#',
  224. menuShowMinLength: 2,
  225. values: (function (text, cb) {
  226. let url = '/api/compose/v0/search/hashtag';
  227. axios.get(url, { params: { q: text }})
  228. .then(res => {
  229. cb(res.data);
  230. })
  231. .catch(err => {
  232. console.log(err);
  233. })
  234. })
  235. }
  236. ]
  237. },
  238. replies: [],
  239. replyId: null,
  240. replyText: '',
  241. replyNsfw: false,
  242. replySending: false,
  243. pagination: {},
  244. ctxMenuStatus: false,
  245. emoji: window.App.util.emoji
  246. }
  247. },
  248. beforeMount() {
  249. this.fetchComments();
  250. },
  251. methods: {
  252. commentNavigateBack(id) {
  253. if(this.backToStatus) {
  254. window.location.href = this.statusUrl(this.status);
  255. return;
  256. }
  257. $('nav').show();
  258. $('footer').show();
  259. $('.mobile-footer-spacer').attr('style', 'display:block');
  260. $('.mobile-footer').attr('style', 'display:block');
  261. this.$emit('current-layout', 'feed');
  262. let path = '/';
  263. window.history.pushState({}, '', path);
  264. },
  265. trimCaption(caption, len = 60) {
  266. return _.truncate(caption, {
  267. length: len
  268. });
  269. },
  270. replyFocus(e, index, prependUsername = false) {
  271. if($('body').hasClass('loggedIn') == false) {
  272. this.redirect('/login?next=' + encodeURIComponent(window.location.pathname));
  273. return;
  274. }
  275. if(this.status.comments_disabled) {
  276. return;
  277. }
  278. this.replyToIndex = index;
  279. this.replyingToId = e.id;
  280. this.replyingToUsername = e.account.username;
  281. this.reply_to_profile_id = e.account.id;
  282. let username = e.account.local ? '@' + e.account.username + ' '
  283. : '@' + e.account.acct + ' ';
  284. if(prependUsername == true) {
  285. this.replyText = username;
  286. }
  287. this.$refs.replyModal.show();
  288. setTimeout(function() {
  289. $('.replyModalTextarea').focus();
  290. }, 500);
  291. },
  292. commentSubmit(status, $event) {
  293. this.replySending = true;
  294. let id = status.id;
  295. let comment = this.replyText;
  296. let limit = this.config.uploader.max_caption_length;
  297. if(comment.length > limit) {
  298. this.replySending = false;
  299. swal('Comment Too Long', 'Please make sure your comment is '+limit+' characters or less.', 'error');
  300. return;
  301. }
  302. axios.post('/i/comment', {
  303. item: id,
  304. comment: comment,
  305. sensitive: this.replyNsfw
  306. }).then(res => {
  307. this.replyText = '';
  308. this.replies.push(res.data.entity);
  309. this.$refs.replyModal.hide();
  310. });
  311. this.replySending = false;
  312. },
  313. timeAgo(ts) {
  314. return App.util.format.timeAgo(ts);
  315. },
  316. fetchComments() {
  317. console.log('Fetching comments...');
  318. let url = '/api/v2/comments/'+this.status.account.id+'/status/'+this.status.id;
  319. axios.get(url)
  320. .then(res => {
  321. this.replies = res.data.data;
  322. this.pagination = res.data.meta.pagination;
  323. }).catch(error => {
  324. if(!error.response) {
  325. $('.postCommentsLoader .lds-ring')
  326. .attr('style','width:100%')
  327. .addClass('pt-4 font-weight-bold text-muted')
  328. .text('An error occurred, cannot fetch comments. Please try again later.');
  329. } else {
  330. switch(error.response.status) {
  331. case 401:
  332. $('.postCommentsLoader .lds-ring')
  333. .attr('style','width:100%')
  334. .addClass('pt-4 font-weight-bold text-muted')
  335. .text('Please login to view.');
  336. break;
  337. default:
  338. $('.postCommentsLoader .lds-ring')
  339. .attr('style','width:100%')
  340. .addClass('pt-4 font-weight-bold text-muted')
  341. .text('An error occurred, cannot fetch comments. Please try again later.');
  342. break;
  343. }
  344. }
  345. });
  346. },
  347. loadMoreComments() {
  348. if(this.pagination.total_pages == 1 || this.pagination.current_page == this.pagination.total_pages) {
  349. $('.load-more-link').addClass('d-none');
  350. return;
  351. }
  352. $('.load-more-link').addClass('d-none');
  353. $('.postCommentsLoader').removeClass('d-none');
  354. let next = this.pagination.links.next;
  355. axios.get(next)
  356. .then(response => {
  357. let self = this;
  358. let res = response.data.data;
  359. $('.postCommentsLoader').addClass('d-none');
  360. for(let i=0; i < res.length; i++) {
  361. this.replies.unshift(res[i]);
  362. }
  363. this.pagination = response.data.meta.pagination;
  364. $('.load-more-link').removeClass('d-none');
  365. });
  366. },
  367. likeReply(status, $event) {
  368. if($('body').hasClass('loggedIn') == false) {
  369. swal('Login', 'Please login to perform this action.', 'info');
  370. return;
  371. }
  372. axios.post('/i/like', {
  373. item: status.id
  374. }).then(res => {
  375. status.favourites_count = res.data.count;
  376. if(status.favourited == true) {
  377. status.favourited = false;
  378. } else {
  379. status.favourited = true;
  380. }
  381. }).catch(err => {
  382. swal('Error', 'Something went wrong, please try again later.', 'error');
  383. });
  384. },
  385. ctxMenu(status) {
  386. this.ctxMenuStatus = status;
  387. this.$refs.cMenu.open();
  388. },
  389. statusUrl(status) {
  390. if(status.local == true) {
  391. return status.url;
  392. }
  393. return '/i/web/post/_/' + status.account.id + '/' + status.id;
  394. },
  395. profileUrl(status) {
  396. if(status.local == true) {
  397. return status.account.url;
  398. }
  399. return '/i/web/profile/_/' + status.account.id;
  400. },
  401. }
  402. }
  403. </script>
  404. <style type="text/css" scoped>
  405. .emoji-reactions .nav-item {
  406. font-size: 1.2rem;
  407. padding: 9px;
  408. cursor: pointer;
  409. }
  410. .emoji-reactions::-webkit-scrollbar {
  411. width: 0px;
  412. height: 0px;
  413. background: transparent;
  414. }
  415. </style>