Direct.vue 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  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. export default {
  105. components: {
  106. "drawer": Drawer,
  107. "sidebar": Sidebar,
  108. "intersect": Intersect,
  109. "dm-placeholder": Placeholder
  110. },
  111. data() {
  112. return {
  113. isLoaded: false,
  114. profile: undefined,
  115. canLoadMore: true,
  116. threadsLoaded: false,
  117. composeLoading: false,
  118. threads: [],
  119. tabIndex: 0,
  120. tabs: [
  121. 'inbox',
  122. 'sent',
  123. 'requests'
  124. ],
  125. page: 1,
  126. ids: [],
  127. isIntersecting: false
  128. }
  129. },
  130. mounted() {
  131. this.profile = window._sharedData.user;
  132. this.isLoaded = true;
  133. this.fetchThreads();
  134. },
  135. methods: {
  136. fetchThreads() {
  137. axios.get('/api/v1/conversations', {
  138. params: {
  139. scope: this.tabs[this.tabIndex]
  140. }
  141. })
  142. .then(res => {
  143. let data = res.data.filter(m => {
  144. return m && m.hasOwnProperty('last_status') && m.last_status;
  145. })
  146. let ids = data.map(dm => dm.accounts[0].id);
  147. this.ids = ids;
  148. this.threads = data;
  149. this.threadsLoaded = true;
  150. this.page++;
  151. });
  152. },
  153. timeago(ts) {
  154. return App.util.format.timeAgo(ts);
  155. },
  156. enterIntersect() {
  157. if(this.isIntersecting) {
  158. return;
  159. }
  160. this.isIntersecting = true;
  161. axios.get('/api/v1/conversations', {
  162. params: {
  163. scope: this.tabs[this.tabIndex],
  164. page: this.page
  165. }
  166. })
  167. .then(res => {
  168. let data = res.data.filter(m => {
  169. return m && m.hasOwnProperty('last_status') && m.last_status;
  170. })
  171. data.forEach(dm => {
  172. if(this.ids.indexOf(dm.accounts[0].id) == -1) {
  173. this.ids.push(dm.accounts[0].id);
  174. this.threads.push(dm);
  175. }
  176. })
  177. // this.threads.push(...res.data);
  178. if(!res.data.length || res.data.length < 5) {
  179. this.canLoadMore = false;
  180. this.isIntersecting = false;
  181. return;
  182. }
  183. this.page++;
  184. this.isIntersecting = false;
  185. });
  186. },
  187. toggleTab(index) {
  188. event.currentTarget.blur();
  189. this.threadsLoaded = false;
  190. this.page = 1;
  191. this.tabIndex = index;
  192. this.fetchThreads();
  193. },
  194. threadSummary(status, len = 50) {
  195. if(status.pf_type == 'photo') {
  196. let sender = this.profile.id == status.account.id;
  197. 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>';
  198. icon += sender ? 'Sent a photo' : 'Received a photo';
  199. return icon + '</span></div>';
  200. }
  201. if(status.pf_type == 'video') {
  202. let sender = this.profile.id == status.account.id;
  203. 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>';
  204. icon += sender ? 'Sent a video' : 'Received a video';
  205. return icon + '</span></div>';
  206. }
  207. let res = '';
  208. if(this.profile.id == status.account.id) {
  209. res += '<i class="far fa-reply-all fa-flip-both"></i> ';
  210. }
  211. let content = status.content;
  212. let text = content.replace(/(<([^>]+)>)/gi, "");
  213. if(text.length > len) {
  214. return res + text.slice(0, len) + '...';
  215. }
  216. return res + text;
  217. },
  218. openCompose() {
  219. this.$refs.compose.show();
  220. },
  221. composeSearch(input) {
  222. if (input.length < 1) { return []; };
  223. let self = this;
  224. let results = [];
  225. return axios.post('/api/direct/lookup', {
  226. q: input
  227. }).then(res => {
  228. return res.data;
  229. });
  230. },
  231. getTagResultValue(result) {
  232. // return '@' + result.name;
  233. return result.local ? '@' + result.name : result.name;
  234. },
  235. onTagSubmitLocation(result) {
  236. //this.$refs.autocomplete.value = '';
  237. this.composeLoading = true;
  238. window.location.href = '/i/web/direct/thread/' + result.id;
  239. return;
  240. },
  241. closeCompose() {
  242. this.$refs.compose.hide();
  243. }
  244. }
  245. }
  246. </script>
  247. <style lang="scss" scoped>
  248. .dms-page-component {
  249. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  250. .dm {
  251. &-thread-summary {
  252. margin-bottom: 0;
  253. font-size: 12px;
  254. line-height: 12px;
  255. }
  256. &-display-name {
  257. font-size: 16px;
  258. }
  259. }
  260. }
  261. </style>