Direct.vue 12 KB

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