Direct.vue 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. <template>
  2. <div class="dms-page-component">
  3. <div v-if="isLoaded" class="container-fluid mt-3">
  4. <div class="row">
  5. <div class="col-md-3 d-md-block">
  6. <sidebar :user="profile" />
  7. </div>
  8. <div class="col-md-5 offset-md-1 mb-5 order-2 order-md-1">
  9. <h1 class="font-weight-bold mb-4">Direct Messages</h1>
  10. <div v-if="threadsLoaded">
  11. <div v-for="(thread, idx) in threads" class="card shadow-sm mb-1" style="border-radius:15px;">
  12. <div class="card-body p-3">
  13. <div class="media">
  14. <img :src="thread.accounts[0].avatar" width="45" height="45" class="shadow-sm mr-3" style="border-radius: 15px;" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
  15. <div class="media-body">
  16. <!-- <p class="lead mb-n2">{{ thread.accounts[0].display_name }}</p> -->
  17. <div class="d-flex justify-content-between align-items-start mb-1">
  18. <p class="dm-display-name font-weight-bold mb-0">&commat;{{ thread.accounts[0].acct }}</p>
  19. <p class="font-weight-bold small text-muted mb-0">{{ timeago(thread.last_status.created_at) }} ago</p>
  20. </div>
  21. <p class="dm-thread-summary text-muted mr-4" v-html="threadSummary(thread.last_status)"></p>
  22. </div>
  23. <router-link class="btn btn-link stretched-link align-self-center mr-n3" :to="`/i/web/direct/thread/${thread.accounts[0].id}`">
  24. <i class="fal fa-chevron-right fa-lg text-lighter"></i>
  25. </router-link>
  26. </div>
  27. </div>
  28. </div>
  29. <div v-if="!threads || !threads.length" class="row justify-content-center">
  30. <div class="col-12 text-center">
  31. <img src="/img/illustrations/dk-nature-man-monochrome.svg" class="img-fluid" style="opacity: 0.6;">
  32. <p class="lead text-muted font-weight-bold">Your inbox is empty</p>
  33. </div>
  34. </div>
  35. <div v-if="canLoadMore">
  36. <intersect @enter="enterIntersect">
  37. <dm-placeholder />
  38. </intersect>
  39. </div>
  40. </div>
  41. <div v-else>
  42. <dm-placeholder />
  43. </div>
  44. </div>
  45. <div class="col-md-3 d-md-block order-1 order-md-2 mb-4">
  46. <button class="btn btn-dark shadow-sm font-weight-bold btn-block" @click="openCompose"><i class="far fa-envelope mr-1"></i> Compose</button>
  47. <hr>
  48. <div class="d-flex d-md-block">
  49. <button
  50. v-for="(tab, index) in tabs"
  51. class="btn shadow-sm font-weight-bold btn-block text-capitalize mt-0 mt-md-2 mx-1 mx-md-0"
  52. :class="[ index === tabIndex ? 'btn-primary' : 'btn-light' ]"
  53. @click="toggleTab(index)"
  54. >
  55. {{ $t('directMessages.' + tab) }}
  56. </button>
  57. </div>
  58. </div>
  59. </div>
  60. <drawer />
  61. </div>
  62. <div v-else class="d-flex justify-content-center align-items-center" style="height:calc(100vh - 58px);">
  63. <b-spinner />
  64. </div>
  65. <b-modal
  66. ref="compose"
  67. hide-header
  68. hide-footer
  69. centered
  70. rounded
  71. size="md"
  72. >
  73. <div class="card shadow-none mt-4">
  74. <div class="card-body d-flex align-items-center justify-content-between flex-column" style="min-height: 50vh;">
  75. <h3 class="font-weight-bold">New Direct Message</h3>
  76. <div>
  77. <p class="mb-0 font-weight-bold">Select Recipient</p>
  78. <autocomplete
  79. :search="composeSearch"
  80. :disabled="composeLoading"
  81. placeholder="@dansup"
  82. aria-label="Search usernames"
  83. :get-result-value="getTagResultValue"
  84. @submit="onTagSubmitLocation"
  85. ref="autocomplete"
  86. >
  87. </autocomplete>
  88. <p class="small text-muted">Search by username, or webfinger (@dansup@pixelfed.social)</p>
  89. <div style="width:300px;"></div>
  90. </div>
  91. <div>
  92. <button class="btn btn-outline-dark rounded-pill font-weight-bold px-5 py-1" @click="closeCompose">Cancel</button>
  93. </div>
  94. </div>
  95. </div>
  96. </b-modal>
  97. </div>
  98. </template>
  99. <script type="text/javascript">
  100. import Drawer from './partials/drawer.vue';
  101. import Sidebar from './partials/sidebar.vue';
  102. import Placeholder from './partials/placeholders/DirectMessagePlaceholder.vue';
  103. import Intersect from 'vue-intersect'
  104. import Autocomplete from '@trevoreyre/autocomplete-vue'
  105. import '@trevoreyre/autocomplete-vue/dist/style.css';
  106. export default {
  107. components: {
  108. "drawer": Drawer,
  109. "sidebar": Sidebar,
  110. "intersect": Intersect,
  111. "dm-placeholder": Placeholder,
  112. "autocomplete": Autocomplete
  113. },
  114. data() {
  115. return {
  116. isLoaded: false,
  117. profile: undefined,
  118. canLoadMore: true,
  119. threadsLoaded: false,
  120. composeLoading: false,
  121. threads: [],
  122. tabIndex: 0,
  123. tabs: [
  124. 'inbox',
  125. 'sent',
  126. 'requests'
  127. ],
  128. page: 1,
  129. ids: [],
  130. isIntersecting: false
  131. }
  132. },
  133. mounted() {
  134. this.profile = window._sharedData.user;
  135. this.isLoaded = true;
  136. this.fetchThreads();
  137. },
  138. methods: {
  139. fetchThreads() {
  140. axios.get('/api/v1/conversations', {
  141. params: {
  142. scope: this.tabs[this.tabIndex]
  143. }
  144. })
  145. .then(res => {
  146. let data = res.data.filter(m => {
  147. return m && m.hasOwnProperty('last_status') && m.last_status;
  148. })
  149. let ids = data.map(dm => dm.accounts[0].id);
  150. this.ids = ids;
  151. this.threads = data;
  152. this.threadsLoaded = true;
  153. this.page++;
  154. });
  155. },
  156. timeago(ts) {
  157. return App.util.format.timeAgo(ts);
  158. },
  159. enterIntersect() {
  160. if(this.isIntersecting) {
  161. return;
  162. }
  163. this.isIntersecting = true;
  164. axios.get('/api/v1/conversations', {
  165. params: {
  166. scope: this.tabs[this.tabIndex],
  167. page: this.page
  168. }
  169. })
  170. .then(res => {
  171. let data = res.data.filter(m => {
  172. return m && m.hasOwnProperty('last_status') && m.last_status;
  173. })
  174. data.forEach(dm => {
  175. if(this.ids.indexOf(dm.accounts[0].id) == -1) {
  176. this.ids.push(dm.accounts[0].id);
  177. this.threads.push(dm);
  178. }
  179. })
  180. // this.threads.push(...res.data);
  181. if(!res.data.length || res.data.length < 5) {
  182. this.canLoadMore = false;
  183. this.isIntersecting = false;
  184. return;
  185. }
  186. this.page++;
  187. this.isIntersecting = false;
  188. });
  189. },
  190. toggleTab(index) {
  191. event.currentTarget.blur();
  192. this.threadsLoaded = false;
  193. this.page = 1;
  194. this.tabIndex = index;
  195. this.fetchThreads();
  196. },
  197. threadSummary(status, len = 50) {
  198. if(status.pf_type == 'photo') {
  199. let sender = this.profile.id == status.account.id;
  200. let icon = '<div class="' + (sender ? 'text-muted' : 'text-primary') + ' border px-2 py-1 mt-1 rounded" style="font-size:11px;width: fit-content"><i class="far fa-image mr-1"></i> <span>';
  201. icon += sender ? 'Sent a photo' : 'Received a photo';
  202. return icon + '</span></div>';
  203. }
  204. if(status.pf_type == 'video') {
  205. let sender = this.profile.id == status.account.id;
  206. let icon = '<div class="' + (sender ? 'text-muted' : 'text-primary') + ' border px-2 py-1 mt-1 rounded" style="font-size:11px;width: fit-content"><i class="far fa-video mr-1"></i> <span>';
  207. icon += sender ? 'Sent a video' : 'Received a video';
  208. return icon + '</span></div>';
  209. }
  210. let res = '';
  211. if(this.profile.id == status.account.id) {
  212. res += '<i class="far fa-reply-all fa-flip-both"></i> ';
  213. }
  214. let content = status.content;
  215. let text = content.replace(/(<([^>]+)>)/gi, "");
  216. if(text.length > len) {
  217. return res + text.slice(0, len) + '...';
  218. }
  219. return res + text;
  220. },
  221. openCompose() {
  222. this.$refs.compose.show();
  223. },
  224. composeSearch(input) {
  225. if (input.length < 1) { return []; };
  226. let self = this;
  227. let results = [];
  228. return axios.post('/api/direct/lookup', {
  229. q: input
  230. }).then(res => {
  231. return res.data;
  232. });
  233. },
  234. getTagResultValue(result) {
  235. // return '@' + result.name;
  236. return result.local ? '@' + result.name : result.name;
  237. },
  238. onTagSubmitLocation(result) {
  239. //this.$refs.autocomplete.value = '';
  240. this.composeLoading = true;
  241. window.location.href = '/i/web/direct/thread/' + result.id;
  242. return;
  243. },
  244. closeCompose() {
  245. this.$refs.compose.hide();
  246. }
  247. }
  248. }
  249. </script>
  250. <style lang="scss" scoped>
  251. .dms-page-component {
  252. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  253. .dm {
  254. &-thread-summary {
  255. margin-bottom: 0;
  256. font-size: 12px;
  257. line-height: 12px;
  258. }
  259. &-display-name {
  260. font-size: 16px;
  261. }
  262. }
  263. }
  264. </style>