Direct.vue 8.8 KB

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