Timeline.vue 36 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138
  1. <template>
  2. <div>
  3. <div v-if="currentLayout === 'feed'" class="container">
  4. <div class="row">
  5. <div v-if="morePostsAvailable == true" class="col-12 mt-5 pt-3 mb-3 fixed-top">
  6. <p class="text-center">
  7. <button class="btn btn-dark px-4 rounded-pill font-weight-bold shadow" @click="syncNewPosts">Load New Posts</button>
  8. </p>
  9. </div>
  10. <div class="col-md-8 col-lg-8 px-0 mb-sm-3 timeline order-2 order-md-1">
  11. <div style="margin-top:-2px;">
  12. <story-component v-if="config.features.stories"></story-component>
  13. </div>
  14. <div>
  15. <div v-if="loading" class="text-center" style="padding-top:10px;">
  16. <div class="spinner-border" role="status">
  17. <span class="sr-only">Loading...</span>
  18. </div>
  19. </div>
  20. <div :data-status-id="status.id" v-for="(status, index) in feed" :key="`feed-${index}-${status.id}`">
  21. <div v-if="index == 0 && showTips && !loading" class="my-4 card-tips">
  22. <announcements-card v-on:show-tips="showTips = $event"></announcements-card>
  23. </div>
  24. <div v-if="index == 2 && showSuggestions == true && suggestions.length" class="card status-card rounded-0 shadow-none border">
  25. <div class="card-header d-flex align-items-center justify-content-between bg-white border-0 pb-0">
  26. <h6 class="text-muted font-weight-bold mb-0">Suggestions For You</h6>
  27. <span class="cursor-pointer text-muted" v-on:click="hideSuggestions"><i class="fas fa-times"></i></span>
  28. </div>
  29. <div class="card-body row mx-0">
  30. <div class="col-12 col-md-4 mb-3" v-for="(rec, index) in suggestions">
  31. <div class="card">
  32. <div class="card-body text-center pt-3">
  33. <p class="mb-0">
  34. <a :href="'/'+rec.username">
  35. <img :src="rec.avatar" class="img-fluid rounded-circle cursor-pointer" width="45px" height="45px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
  36. </a>
  37. </p>
  38. <div class="py-3">
  39. <p class="font-weight-bold text-dark cursor-pointer mb-0">
  40. <a :href="'/'+rec.username" class="text-decoration-none text-dark">
  41. {{rec.username}}
  42. </a>
  43. </p>
  44. <p class="small text-muted mb-0">{{rec.message}}</p>
  45. </div>
  46. <p class="mb-0">
  47. <a class="btn btn-primary btn-block font-weight-bold py-0" href="#" @click.prevent="expRecFollow(rec.id, index)">Follow</a>
  48. </p>
  49. </div>
  50. </div>
  51. </div>
  52. </div>
  53. </div>
  54. <div v-if="index == 4 && showHashtagPosts && hashtagPosts.length" class="card status-card rounded-0 shadow-none border border-top-0">
  55. <div class="card-header bg-white border-0 mb-0">
  56. <div class="d-flex align-items-center justify-content-between pt-2">
  57. <div></div>
  58. <div>
  59. <h6 class="text-muted lead font-weight-bold mb-0"><a :href="'/discover/tags/'+hashtagPostsName+'?src=tr'">#{{hashtagPostsName}}</a></h6>
  60. </div>
  61. <div class="cursor-pointer text-muted" v-on:click="showHashtagPosts = false"><i class="fas fa-times"></i></div>
  62. </div>
  63. <p class="small text-muted text-center mb-0">You follow this hashtag. <a href="/site/kb/hashtags">Learn more</a></p>
  64. </div>
  65. <div class="card-body row mx-0">
  66. <div v-for="(tag, index) in hashtagPosts" class="col-4 p-1 hashtag-post-square">
  67. <a class="card info-overlay card-md-border-0" :href="tag.status.url">
  68. <div class="square">
  69. <div v-if="tag.status.sensitive" class="square-content">
  70. <div class="info-overlay-text-label">
  71. <h5 class="text-white m-auto font-weight-bold">
  72. <span>
  73. <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
  74. </span>
  75. </h5>
  76. </div>
  77. <blur-hash-canvas
  78. width="32"
  79. height="32"
  80. :hash="tag.status.media_attachments[0].blurhash"
  81. />
  82. </div>
  83. <div v-else class="square-content">
  84. <blur-hash-image
  85. width="32"
  86. height="32"
  87. :hash="tag.status.media_attachments[0].blurhash"
  88. :src="tag.status.media_attachments[0].preview_url"
  89. onerror="this.onerror=null;this.src='/storage/no-preview.png'"
  90. />
  91. </div>
  92. </div>
  93. </a>
  94. </div>
  95. </div>
  96. </div>
  97. <status-card
  98. :class="{ 'border-top': index === 0 }"
  99. :status="status"
  100. :reaction-bar="reactionBar"
  101. v-on:status-delete="deleteStatus"
  102. v-on:comment-focus="commentFocus"
  103. />
  104. </div>
  105. <div v-if="!loading && feed.length">
  106. <div class="card rounded-0 border-top-0 status-card rounded-0 shadow-none border">
  107. <div class="card-body py-5 my-5">
  108. <infinite-loading @infinite="infiniteTimeline" :distance="800">
  109. <div slot="no-more">
  110. <div v-if="recentFeed">
  111. <p class="text-center"><i class="far fa-check-circle fa-8x text-lighter"></i></p>
  112. <p class="text-center h3 font-weight-light">You're All Caught Up!</p>
  113. <p class="text-center text-muted font-weight-light">You've seen all the new posts from the accounts you follow.</p>
  114. <p class="text-center mb-0">
  115. <a class="btn btn-link font-weight-bold px-4" href="/?a=vop">View Older Posts</a>
  116. </p>
  117. <p class="text-center mb-0">
  118. <a class="btn btn-link font-weight-bold px-4" href="/" @click.prevent="alwaysViewOlderPosts()">Always show older posts on this device</a>
  119. </p>
  120. </div>
  121. <div v-else>
  122. <p class="text-center h3 font-weight-light">You've reached the end of this feed</p>
  123. <p class="text-center mb-0">
  124. <a class="btn btn-link font-weight-bold px-4" href="/discover">Discover new posts and people</a>
  125. </p>
  126. </div>
  127. </div>
  128. <div slot="no-results">
  129. <div v-if="recentFeed">
  130. <p class="text-center"><i class="far fa-check-circle fa-8x text-lighter"></i></p>
  131. <p class="text-center h3 font-weight-light">You're All Caught Up!</p>
  132. <p class="text-center text-muted font-weight-light">You've seen all the new posts from the accounts you follow.</p>
  133. <p class="text-center mb-0">
  134. <a class="btn btn-link font-weight-bold px-4" href="/?a=vop">View Older Posts</a>
  135. </p>
  136. <p class="text-center mb-0">
  137. <a class="btn btn-link font-weight-bold px-4" href="/" @click.prevent="alwaysViewOlderPosts()">Always show older posts on this device</a>
  138. </p>
  139. </div>
  140. <div v-else>
  141. <p class="text-center h3 font-weight-light">You've reached the end of this feed</p>
  142. <p class="text-center mb-0">
  143. <a class="btn btn-link font-weight-bold px-4" href="/discover">Discover new posts and people</a>
  144. </p>
  145. </div>
  146. </div>
  147. </infinite-loading>
  148. </div>
  149. </div>
  150. </div>
  151. <div v-if="!loading && scope == 'home' && feed.length == 0">
  152. <div class="card rounded-0 mt-4 status-card rounded-0 shadow-none border">
  153. <div v-if="profile.following_count != '0'" class="card-body py-5 my-5">
  154. <p class="text-center"><i class="far fa-check-circle fa-8x text-lighter"></i></p>
  155. <p class="text-center h3 font-weight-light">You're All Caught Up!</p>
  156. <p class="text-center text-muted font-weight-light">You've seen all the new posts from the accounts you follow.</p>
  157. <p class="text-center mb-0">
  158. <a class="btn btn-link font-weight-bold px-4" href="/?a=vop">View Older Posts</a>
  159. </p>
  160. <p class="text-center mb-0">
  161. <a class="btn btn-link font-weight-bold px-4" href="/" @click.prevent="alwaysViewOlderPosts()">Always show older posts on this device</a>
  162. </p>
  163. </div>
  164. <div v-else class="card-body py-5 my-5">
  165. <p class="text-center"><i class="far fa-smile fa-8x text-lighter"></i></p>
  166. <p class="text-center h3 font-weight-light">Hello {{profile.username}}</p>
  167. <p class="text-center text-muted font-weight-light">Accounts you follow will appear in this feed.</p>
  168. <p class="text-center mb-0">
  169. <a class="btn btn-link font-weight-bold px-4" href="/discover">Discover new posts and people</a>
  170. </p>
  171. </div>
  172. </div>
  173. </div>
  174. <div v-if="!loading && scope == 'home' && recentFeed && discover_feed.length" class="pt-3">
  175. <p class="h5 font-weight-bold py-3 d-flex justify-content-between align-items-center">
  176. <span>Suggested Posts</span>
  177. <a href="/?a=vop" class="small font-weight-bold">Older Posts</a>
  178. </p>
  179. </div>
  180. <div
  181. v-if="!loading && scope == 'home' && recentFeed && discover_feed.length"
  182. :data-status-id="status.id"
  183. v-for="(status, index) in discover_feed"
  184. :key="`discover_feed-${index}-${status.id}`">
  185. <status-card
  186. :class="{'border-top': index === 0}"
  187. :status="status"
  188. :recommended="true" />
  189. </div>
  190. <div v-if="!loading && emptyFeed && scope !== 'home'">
  191. <div class="card rounded-0 mt-3 status-card rounded-0 shadow-none border">
  192. <div class="card-body py-5 my-5">
  193. <p class="text-center"><i class="fas fa-battery-empty fa-8x text-lighter"></i></p>
  194. <p class="text-center h3 font-weight-light">empty_timeline.jpg</p>
  195. <p class="text-center text-muted font-weight-light">We cannot find any posts for this timeline.</p>
  196. <p class="text-center mb-0">
  197. <a class="btn btn-link font-weight-bold px-4" href="/discover">Discover new posts and people</a>
  198. </p>
  199. </div>
  200. </div>
  201. </div>
  202. </div>
  203. </div>
  204. <div class="col-md-4 col-lg-4 my-4 order-1 order-md-2 d-none d-md-block">
  205. <div>
  206. <div class="mb-4">
  207. <div v-show="!loading" class="">
  208. <div class="pb-2">
  209. <div class="media d-flex align-items-center">
  210. <a :href="!userStory ? profile.url : '/stories/' + profile.acct" class="mr-3">
  211. <!-- <img class="mr-3 rounded-circle box-shadow" :src="profile.avatar || '/storage/avatars/default.png'" alt="avatar" width="64px" height="64px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'"> -->
  212. <div v-if="userStory" class="has-story cursor-pointer shadow-sm" @click="storyRedirect()">
  213. <img class="rounded-circle box-shadow" :src="profile.avatar" width="64px" height="64px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
  214. </div>
  215. <div v-else>
  216. <img class="rounded-circle box-shadow" :src="profile.avatar" width="64px" height="64px" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
  217. </div>
  218. </a>
  219. <div class="media-body d-flex justify-content-between word-break" >
  220. <div>
  221. <p class="mb-0 px-0 font-weight-bold"><a :href="profile.url" class="text-dark">{{profile.username || 'loading...'}}</a></p>
  222. <p class="my-0 text-muted pb-0">{{profile.display_name || 'loading...'}}</p>
  223. </div>
  224. <div class="ml-2">
  225. <a class="text-muted" href="/settings/home">
  226. <i class="fas fa-cog fa-lg"></i>
  227. <span class="sr-only">User Settings</span>
  228. </a>
  229. </div>
  230. </div>
  231. </div>
  232. </div>
  233. <!-- <div class="card-footer bg-transparent border-top mt-2 py-1">
  234. <div class="d-flex justify-content-between text-center">
  235. <span class="cursor-pointer" @click="redirect(profile.url)">
  236. <p class="mb-0 font-weight-bold">{{formatCount(profile.statuses_count)}}</p>
  237. <p class="mb-0 small text-muted">Posts</p>
  238. </span>
  239. <span class="cursor-pointer" @click="redirect(profile.url+'?md=followers')">
  240. <p class="mb-0 font-weight-bold">{{formatCount(profile.followers_count)}}</p>
  241. <p class="mb-0 small text-muted">Followers</p>
  242. </span>
  243. <span class="cursor-pointer" @click="redirect(profile.url+'?md=following')">
  244. <p class="mb-0 font-weight-bold">{{formatCount(profile.following_count)}}</p>
  245. <p class="mb-0 small text-muted">Following</p>
  246. </span>
  247. </div>
  248. </div> -->
  249. </div>
  250. <div class="card-footer bg-transparent border-0 pt-0 pb-1">
  251. <div class="d-flex justify-content-between text-center">
  252. <span class="cursor-pointer" @click="redirect(profile.url)">
  253. <p class="mb-0 font-weight-bold">{{formatCount(profile.statuses_count)}}</p>
  254. <p class="mb-0 small text-muted">Posts</p>
  255. </span>
  256. <span class="cursor-pointer" @click="redirect(profile.url+'?md=followers')">
  257. <p class="mb-0 font-weight-bold">{{formatCount(profile.followers_count)}}</p>
  258. <p class="mb-0 small text-muted">Followers</p>
  259. </span>
  260. <span class="cursor-pointer" @click="redirect(profile.url+'?md=following')">
  261. <p class="mb-0 font-weight-bold">{{formatCount(profile.following_count)}}</p>
  262. <p class="mb-0 small text-muted">Following</p>
  263. </span>
  264. </div>
  265. </div>
  266. </div>
  267. <div v-show="modes.notify == true && !loading" class="mb-4">
  268. <notification-card></notification-card>
  269. </div>
  270. <div v-show="showSuggestions == true && suggestions.length && config.ab && config.ab.rec == true" class="mb-4">
  271. <div class="card shadow-none border">
  272. <div class="card-header bg-white d-flex align-items-center justify-content-between">
  273. <a class="small text-muted cursor-pointer" href="#" @click.prevent="refreshSuggestions" ref="suggestionRefresh"><i class="fas fa-sync-alt"></i></a>
  274. <div class="small text-dark text-uppercase font-weight-bold">Suggestions</div>
  275. <div class="small text-muted cursor-pointer" v-on:click="hideSuggestions"><i class="fas fa-times"></i></div>
  276. </div>
  277. <div class="card-body pt-0">
  278. <div v-for="(rec, index) in suggestions" class="media align-items-center mt-3">
  279. <a :href="'/'+rec.username">
  280. <img :src="rec.avatar" width="32px" height="32px" class="rounded-circle mr-3" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=2'" alt="avatar">
  281. </a>
  282. <div class="media-body">
  283. <p class="mb-0 font-weight-bold small">
  284. <a :href="'/'+rec.username" class="text-decoration-none text-dark">
  285. {{rec.username}}
  286. </a>
  287. </p>
  288. <p class="mb-0 small text-muted">{{rec.message}}</p>
  289. </div>
  290. <a class="font-weight-bold small" href="#" @click.prevent="expRecFollow(rec.id, index)">Follow</a>
  291. </div>
  292. </div>
  293. </div>
  294. </div>
  295. <footer>
  296. <div class="container px-0 pb-5">
  297. <p class="mb-2 small text-justify">
  298. <a href="/site/about" class="text-lighter pr-2">About</a>
  299. <a href="/site/help" class="text-lighter pr-2">Help</a>
  300. <a href="/site/language" class="text-lighter pr-2">Language</a>
  301. <a href="/discover/places" class="text-lighter pr-2">Places</a>
  302. <a href="/site/privacy" class="text-lighter pr-2">Privacy</a>
  303. <a href="/site/terms" class="text-lighter pr-2">Terms</a>
  304. </p>
  305. <p class="mb-0 text-uppercase text-muted small">
  306. <a href="http://pixelfed.org" class="text-lighter" rel="noopener" title="" data-toggle="tooltip">Powered by Pixelfed</a>
  307. </p>
  308. </div>
  309. </footer>
  310. </div>
  311. </div>
  312. </div>
  313. </div>
  314. <comment-card
  315. v-if="replyStatus && replyStatus.hasOwnProperty('id')"
  316. :status="replyStatus"
  317. :profile="profile"
  318. v-on:current-layout="setCurrentLayout"
  319. />
  320. <div class="modal-stack">
  321. <b-modal ref="replyModal"
  322. id="ctx-reply-modal"
  323. hide-footer
  324. centered
  325. rounded
  326. :title-html="replyStatus.account ? 'Reply to <span class=text-dark>' + replyStatus.account.username + '</span>' : ''"
  327. title-tag="p"
  328. title-class="font-weight-bold text-muted"
  329. size="md"
  330. body-class="p-2 rounded">
  331. <div>
  332. <vue-tribute :options="tributeSettings">
  333. <textarea
  334. class="form-control replyModalTextarea"
  335. rows="4"
  336. v-model="replyText">
  337. </textarea>
  338. </vue-tribute>
  339. <div class="border-top border-bottom my-2">
  340. <ul class="nav align-items-center emoji-reactions" style="overflow-x: scroll;flex-wrap: unset;">
  341. <li class="nav-item" v-on:click="emojiReaction(status)" v-for="e in emoji">{{e}}</li>
  342. </ul>
  343. </div>
  344. <div class="d-flex justify-content-between align-items-center">
  345. <div>
  346. <span class="pl-2 small text-muted font-weight-bold text-monospace">
  347. <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}}
  348. </span>
  349. </div>
  350. <div class="d-flex align-items-center">
  351. <div class="custom-control custom-switch mr-3">
  352. <input type="checkbox" class="custom-control-input" id="replyModalCWSwitch" v-model="replyNsfw">
  353. <label :class="[replyNsfw ? 'custom-control-label font-weight-bold text-dark':'custom-control-label text-lighter']" for="replyModalCWSwitch">Mark as NSFW</label>
  354. </div>
  355. <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">
  356. {{replySending == true ? 'POSTING' : 'POST'}}
  357. </button>
  358. </div>
  359. </div>
  360. </div>
  361. </b-modal>
  362. <b-modal ref="ctxStatusModal"
  363. id="ctx-status-modal"
  364. hide-header
  365. hide-footer
  366. centered
  367. rounded
  368. size="xl"
  369. body-class="list-group-flush p-0 m-0 rounded">
  370. <!-- <post-component
  371. v-if="ctxMenuStatus"
  372. :status-template="ctxMenuStatus.pf_type"
  373. :status-id="ctxMenuStatus.id"
  374. :status-username="ctxMenuStatus.account.username"
  375. :status-url="ctxMenuStatus.url"
  376. :status-profile-url="ctxMenuStatus.account.url"
  377. :status-avatar="ctxMenuStatus.account.avatar"
  378. :status-profile-id="ctxMenuStatus.account.id"
  379. profile-layout="metro">
  380. </post-component> -->
  381. </b-modal>
  382. </div>
  383. </div>
  384. </template>
  385. <script type="text/javascript">
  386. import VueTribute from 'vue-tribute'
  387. import StatusCard from './partials/StatusCard.vue';
  388. import CommentCard from './partials/CommentCard.vue';
  389. export default {
  390. props: ['scope', 'layout'],
  391. components: {
  392. VueTribute,
  393. StatusCard,
  394. CommentCard
  395. },
  396. data() {
  397. return {
  398. ids: [],
  399. config: window.App.config,
  400. page: 2,
  401. feed: [],
  402. profile: {},
  403. min_id: 0,
  404. max_id: 0,
  405. suggestions: {},
  406. loading: true,
  407. replies: [],
  408. replyId: null,
  409. modes: {
  410. 'mod': false,
  411. 'dark': false,
  412. 'notify': true,
  413. 'distractionFree': false
  414. },
  415. followers: [],
  416. followerCursor: 1,
  417. followerMore: true,
  418. following: [],
  419. followingCursor: 1,
  420. followingMore: true,
  421. lightboxMedia: false,
  422. showSuggestions: true,
  423. showReadMore: true,
  424. replyStatus: {},
  425. replyText: '',
  426. replyNsfw: false,
  427. emoji: window.App.util.emoji,
  428. showHashtagPosts: false,
  429. hashtagPosts: [],
  430. hashtagPostsName: '',
  431. copiedEmbed: false,
  432. showTips: true,
  433. userStory: false,
  434. replySending: false,
  435. morePostsAvailable: false,
  436. mpCount: 0,
  437. mpData: false,
  438. mpInterval: 15000,
  439. mpEnabled: false,
  440. mpPoller: null,
  441. confirmModalTitle: 'Are you sure?',
  442. confirmModalIdentifer: null,
  443. confirmModalType: false,
  444. currentLayout: 'feed',
  445. pagination: {},
  446. tributeSettings: {
  447. collection: [
  448. {
  449. trigger: '@',
  450. menuShowMinLength: 2,
  451. values: (function (text, cb) {
  452. let url = '/api/compose/v0/search/mention';
  453. axios.get(url, { params: { q: text }})
  454. .then(res => {
  455. cb(res.data);
  456. })
  457. .catch(err => {
  458. console.log(err);
  459. })
  460. })
  461. },
  462. {
  463. trigger: '#',
  464. menuShowMinLength: 2,
  465. values: (function (text, cb) {
  466. let url = '/api/compose/v0/search/hashtag';
  467. axios.get(url, { params: { q: text }})
  468. .then(res => {
  469. cb(res.data);
  470. })
  471. .catch(err => {
  472. console.log(err);
  473. })
  474. })
  475. }
  476. ]
  477. },
  478. discover_min_id: 0,
  479. discover_max_id: 0,
  480. discover_feed: [],
  481. recentFeed: this.scope === 'home' ? true : false,
  482. recentFeedMin: null,
  483. recentFeedMax: null,
  484. reactionBar: true,
  485. emptyFeed: false
  486. }
  487. },
  488. beforeMount() {
  489. let avop = window.localStorage.getItem('pf.feed:avop') === 'always';
  490. let u = new URLSearchParams(window.location.search);
  491. if(u.has('a')) {
  492. switch(u.get('a')) {
  493. case 'recent_feed':
  494. if(this.scope === 'home') {
  495. this.recentFeed = true;
  496. }
  497. break;
  498. case 'vop':
  499. this.recentFeed = false;
  500. break;
  501. }
  502. }
  503. this.recentFeed = avop ? false : this.recentFeed;
  504. this.fetchProfile();
  505. this.fetchTimelineApi();
  506. },
  507. mounted() {
  508. // todo: release after dark mode updates
  509. /* if(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches || $('link[data-stylesheet="dark"]').length != 0) {
  510. this.modes.dark = true;
  511. let el = document.querySelector('link[data-stylesheet="light"]');
  512. el.setAttribute('href', '/css/appdark.css?id=' + Date.now());
  513. el.setAttribute('data-stylesheet', 'dark');
  514. }*/
  515. if(localStorage.getItem('pf_metro_ui.exp.rec') == 'false') {
  516. this.showSuggestions = false;
  517. } else {
  518. this.showSuggestions = true;
  519. }
  520. if(localStorage.getItem('pf_metro_ui.exp.rm') == 'false') {
  521. this.showReadMore = false;
  522. } else {
  523. this.showReadMore = true;
  524. }
  525. if(localStorage.getItem('metro-tips') == 'false') {
  526. this.showTips = false;
  527. }
  528. this.$nextTick(function () {
  529. $('[data-toggle="tooltip"]').tooltip();
  530. let u = new URLSearchParams(window.location.search);
  531. if(u.has('a')) {
  532. switch(u.get('a')) {
  533. case 'co':
  534. $('#composeModal').modal('show');
  535. break;
  536. }
  537. }
  538. });
  539. },
  540. updated() {
  541. if(this.showReadMore == true) {
  542. pixelfed.readmore();
  543. }
  544. },
  545. methods: {
  546. fetchProfile() {
  547. axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
  548. this.profile = res.data;
  549. if(this.profile.is_admin == true) {
  550. this.modes.mod = true;
  551. }
  552. window._sharedData.curUser = res.data;
  553. window.App.util.navatar();
  554. this.hasStory();
  555. // this.expRec();
  556. }).catch(err => {
  557. swal(
  558. 'Oops, something went wrong',
  559. 'Please reload the page.',
  560. 'error'
  561. );
  562. });
  563. },
  564. fetchTimelineApi() {
  565. let apiUrl = false;
  566. switch(this.scope) {
  567. case 'home':
  568. apiUrl = '/api/pixelfed/v1/timelines/home';
  569. break;
  570. case 'local':
  571. apiUrl = '/api/pixelfed/v1/timelines/public';
  572. break;
  573. case 'network':
  574. apiUrl = '/api/pixelfed/v1/timelines/network';
  575. break;
  576. }
  577. axios.get(apiUrl, {
  578. params: {
  579. max_id: this.max_id,
  580. limit: 12,
  581. recent_feed: this.recentFeed
  582. }
  583. }).then(res => {
  584. let data = res.data;
  585. if(!data.length) {
  586. this.loading = false;
  587. this.emptyFeed = true;
  588. return;
  589. }
  590. this.feed.push(...data);
  591. let ids = data.map(status => status.id);
  592. this.ids = ids;
  593. this.min_id = Math.max(...ids).toString();
  594. this.max_id = Math.min(...ids).toString();
  595. this.loading = false;
  596. $('.timeline .pagination').removeClass('d-none');
  597. if(this.hashtagPosts.length == 0) {
  598. this.fetchHashtagPosts();
  599. }
  600. // this.fetchStories();
  601. // this.rtw();
  602. setTimeout(function() {
  603. document.querySelectorAll('.timeline .card-body .comments .comment-body a').forEach(function(i, e) {
  604. i.href = App.util.format.rewriteLinks(i);
  605. });
  606. }, 500);
  607. axios.get('/api/pixelfed/v2/discover/posts/trending', {
  608. params: {
  609. range: 'daily'
  610. }
  611. }).then(res => {
  612. let data = res.data.filter(post => this.ids.indexOf(post.id) === -1);
  613. this.discover_feed = data;
  614. });
  615. }).catch(err => {
  616. swal(
  617. 'Oops, something went wrong',
  618. 'Please reload the page.',
  619. 'error'
  620. );
  621. });
  622. },
  623. infiniteTimeline($state) {
  624. if(this.loading) {
  625. $state.complete();
  626. return;
  627. }
  628. if(this.page > 40) {
  629. this.loading = false;
  630. $state.complete();
  631. }
  632. let apiUrl = false;
  633. switch(this.scope) {
  634. case 'home':
  635. apiUrl = '/api/pixelfed/v1/timelines/home';
  636. break;
  637. case 'local':
  638. apiUrl = '/api/pixelfed/v1/timelines/public';
  639. break;
  640. case 'network':
  641. apiUrl = '/api/pixelfed/v1/timelines/network';
  642. break;
  643. }
  644. axios.get(apiUrl, {
  645. params: {
  646. max_id: this.max_id,
  647. limit: 6,
  648. recent_feed: this.recentFeed
  649. },
  650. }).then(res => {
  651. if (res.data.length && this.loading == false) {
  652. let data = res.data;
  653. let self = this;
  654. let vids = [];
  655. if(self.recentFeed && self.ids.indexOf(data[0].id) != -1) {
  656. this.loading = false;
  657. $state.complete();
  658. return;
  659. }
  660. data.forEach((d, index) => {
  661. if(self.ids.indexOf(d.id) == -1) {
  662. self.feed.push(d);
  663. self.ids.push(d.id);
  664. // vids.push({
  665. // sid: d.id,
  666. // pid: d.account.id
  667. // });
  668. }
  669. });
  670. this.min_id = Math.max(...this.ids).toString();
  671. this.max_id = Math.min(...this.ids).toString();
  672. this.page += 1;
  673. $state.loaded();
  674. this.loading = false;
  675. // axios.post('/api/status/view', {
  676. // '_v': vids,
  677. // });
  678. } else {
  679. $state.complete();
  680. }
  681. }).catch(err => {
  682. this.loading = false;
  683. $state.complete();
  684. });
  685. },
  686. redirect(url) {
  687. window.location.href = url;
  688. return;
  689. },
  690. expRec() {
  691. //return;
  692. if(this.config.ab.rec == false) {
  693. return;
  694. }
  695. axios.get('/api/local/exp/rec')
  696. .then(res => {
  697. this.suggestions = res.data;
  698. })
  699. },
  700. expRecFollow(id, index) {
  701. return;
  702. if(this.config.ab.rec == false) {
  703. return;
  704. }
  705. axios.post('/i/follow', {
  706. item: id
  707. }).then(res => {
  708. this.suggestions.splice(index, 1);
  709. }).catch(err => {
  710. if(err.response.data.message) {
  711. swal('Error', err.response.data.message, 'error');
  712. }
  713. });
  714. },
  715. owner(status) {
  716. return this.profile.id === status.account.id;
  717. },
  718. admin() {
  719. return this.profile.is_admin == true;
  720. },
  721. ownerOrAdmin(status) {
  722. return this.owner(status) || this.admin();
  723. },
  724. hideSuggestions() {
  725. localStorage.setItem('pf_metro_ui.exp.rec', false);
  726. this.showSuggestions = false;
  727. },
  728. emojiReaction(status) {
  729. let em = event.target.innerText;
  730. if(this.replyText.length == 0) {
  731. this.replyText = em + ' ';
  732. $('textarea[name="comment"]').focus();
  733. } else {
  734. this.replyText += em + ' ';
  735. $('textarea[name="comment"]').focus();
  736. }
  737. },
  738. refreshSuggestions() {
  739. return;
  740. let el = event.target.parentNode;
  741. if(el.classList.contains('disabled') == true) {
  742. return;
  743. }
  744. axios.get('/api/local/exp/rec', {
  745. params: {
  746. refresh: true
  747. }
  748. })
  749. .then(res => {
  750. this.suggestions = res.data;
  751. if (el.classList) {
  752. el.classList.add('disabled');
  753. el.classList.add('text-light');
  754. }
  755. else {
  756. el.className += ' ' + 'disabled text-light';
  757. }
  758. setTimeout(function() {
  759. el.setAttribute('href', '#');
  760. if (el.classList) {
  761. el.classList.remove('disabled');
  762. el.classList.remove('text-light');
  763. }
  764. else {
  765. el.className = el.className.replace(new RegExp('(^|\\b)' + className.split(' ').join('|') + '(\\b|$)', 'gi'), 'disabled text-light');
  766. }
  767. }, 10000);
  768. });
  769. },
  770. fetchHashtagPosts() {
  771. axios.get('/api/local/discover/tag/list')
  772. .then(res => {
  773. let tags = res.data;
  774. if(tags.length == 0) {
  775. return;
  776. }
  777. let hashtag = tags[Math.floor(Math.random(), tags.length)];
  778. this.hashtagPostsName = hashtag;
  779. axios.get('/api/v2/discover/tag', {
  780. params: {
  781. hashtag: hashtag
  782. }
  783. }).then(res => {
  784. if(res.data.tags.length > 3) {
  785. this.showHashtagPosts = true;
  786. this.hashtagPosts = res.data.tags.splice(0,9);
  787. }
  788. })
  789. })
  790. },
  791. commentFocus(status, $event) {
  792. if(status.comments_disabled) {
  793. return;
  794. }
  795. // if(this.status && this.status.id == status.id) {
  796. // this.$refs.replyModal.show();
  797. // return;
  798. // }
  799. this.status = status;
  800. this.replies = {};
  801. this.replyStatus = {};
  802. this.replyText = '';
  803. this.replyId = status.id;
  804. this.replyStatus = status;
  805. // this.$refs.replyModal.show();
  806. this.fetchStatusComments(status, '');
  807. $('nav').hide();
  808. $('footer').hide();
  809. $('.mobile-footer-spacer').attr('style', 'display:none !important');
  810. $('.mobile-footer').attr('style', 'display:none !important');
  811. this.currentLayout = 'comments';
  812. window.history.pushState({}, '', this.statusUrl(status));
  813. return;
  814. },
  815. fetchStatusComments(status, card) {
  816. let url = '/api/v2/comments/'+status.account.id+'/status/'+status.id;
  817. axios.get(url)
  818. .then(response => {
  819. let self = this;
  820. this.replies = _.reverse(response.data.data);
  821. this.pagination = response.data.meta.pagination;
  822. if(this.replies.length > 0) {
  823. $('.load-more-link').removeClass('d-none');
  824. }
  825. $('.postCommentsLoader').addClass('d-none');
  826. $('.postCommentsContainer').removeClass('d-none');
  827. // setTimeout(function() {
  828. // document.querySelectorAll('.status-comment .postCommentsContainer .comment-body a').forEach(function(i, e) {
  829. // i.href = App.util.format.rewriteLinks(i);
  830. // });
  831. // }, 500);
  832. }).catch(error => {
  833. if(!error.response) {
  834. $('.postCommentsLoader .lds-ring')
  835. .attr('style','width:100%')
  836. .addClass('pt-4 font-weight-bold text-muted')
  837. .text('An error occurred, cannot fetch comments. Please try again later.');
  838. } else {
  839. switch(error.response.status) {
  840. case 401:
  841. $('.postCommentsLoader .lds-ring')
  842. .attr('style','width:100%')
  843. .addClass('pt-4 font-weight-bold text-muted')
  844. .text('Please login to view.');
  845. break;
  846. default:
  847. $('.postCommentsLoader .lds-ring')
  848. .attr('style','width:100%')
  849. .addClass('pt-4 font-weight-bold text-muted')
  850. .text('An error occurred, cannot fetch comments. Please try again later.');
  851. break;
  852. }
  853. }
  854. });
  855. },
  856. statusUrl(status) {
  857. if(status.local == true) {
  858. return status.url;
  859. }
  860. return '/i/web/post/_/' + status.account.id + '/' + status.id;
  861. },
  862. profileUrl(status) {
  863. if(status.local == true) {
  864. return status.account.url;
  865. }
  866. return '/i/web/profile/_/' + status.account.id;
  867. },
  868. formatCount(count) {
  869. return App.util.format.count(count);
  870. },
  871. hasStory() {
  872. axios.get('/api/stories/v0/exists/'+this.profile.id)
  873. .then(res => {
  874. this.userStory = res.data;
  875. })
  876. },
  877. // real time watcher
  878. rtw() {
  879. this.mpPoller = setInterval(() => {
  880. let apiUrl = false;
  881. this.mpCount++;
  882. if(this.mpCount > 10) {
  883. this.mpInterval = 30000;
  884. }
  885. if(this.mpCount > 50) {
  886. this.mpInterval = (5 * 60 * 1000);
  887. }
  888. switch(this.scope) {
  889. case 'home':
  890. apiUrl = '/api/pixelfed/v1/timelines/home';
  891. break;
  892. case 'local':
  893. apiUrl = '/api/pixelfed/v1/timelines/public';
  894. break;
  895. case 'network':
  896. apiUrl = '/api/pixelfed/v1/timelines/network';
  897. break;
  898. }
  899. axios.get(apiUrl, {
  900. params: {
  901. max_id: 0,
  902. limit: 20,
  903. recent_feed: this.recentFeed
  904. }
  905. }).then(res => {
  906. let self = this;
  907. let tids = this.feed.map(status => status.id);
  908. let data = res.data.filter(d => {
  909. return d.id > self.min_id && tids.indexOf(d.id) == -1;
  910. });
  911. let ids = data.map(status => status.id);
  912. let max = Math.max(...ids).toString();
  913. let newer = max > this.min_id;
  914. if(newer) {
  915. this.morePostsAvailable = true;
  916. this.mpData = data;
  917. }
  918. });
  919. }, this.mpInterval);
  920. },
  921. syncNewPosts() {
  922. let self = this;
  923. let data = this.mpData;
  924. let ids = data.map(s => s.id);
  925. this.min_id = Math.max(...ids).toString();
  926. this.max_id = Math.min(...ids).toString();
  927. this.feed.unshift(...data);
  928. this.morePostsAvailable = false;
  929. this.mpData = null;
  930. },
  931. toggleReplies(reply) {
  932. if(reply.thread) {
  933. reply.thread = false;
  934. } else {
  935. if(reply.replies.length > 0) {
  936. reply.thread = true;
  937. return;
  938. }
  939. let url = '/api/v2/comments/'+reply.account.id+'/status/'+reply.id;
  940. axios.get(url)
  941. .then(response => {
  942. reply.replies = _.reverse(response.data.data);
  943. reply.thread = true;
  944. });
  945. }
  946. },
  947. replyFocus(e, index, prependUsername = false) {
  948. if($('body').hasClass('loggedIn') == false) {
  949. this.redirect('/login?next=' + encodeURIComponent(window.location.pathname));
  950. return;
  951. }
  952. if(this.status.comments_disabled) {
  953. return;
  954. }
  955. this.replyToIndex = index;
  956. this.replyingToId = e.id;
  957. this.replyingToUsername = e.account.username;
  958. this.reply_to_profile_id = e.account.id;
  959. let username = e.account.local ? '@' + e.account.username + ' '
  960. : '@' + e.account.acct + ' ';
  961. if(prependUsername == true) {
  962. this.replyText = username;
  963. }
  964. this.$refs.replyModal.show();
  965. setTimeout(function() {
  966. $('.replyModalTextarea').focus();
  967. }, 500);
  968. },
  969. alwaysViewOlderPosts() {
  970. // Set Feed:Always View Older Posts
  971. window.localStorage.setItem('pf.feed:avop', 'always');
  972. window.location.href = '/';
  973. },
  974. setCurrentLayout(layout) {
  975. this.currentLayout = layout;
  976. },
  977. deleteStatus(status) {
  978. this.feed = this.feed.filter(s => {
  979. return s.id != status;
  980. });
  981. }
  982. },
  983. beforeDestroy () {
  984. clearInterval(this.mpInterval);
  985. }
  986. }
  987. </script>
  988. <style type="text/css" scoped>
  989. .postPresenterContainer {
  990. display: flex;
  991. align-items: center;
  992. background: #fff;
  993. }
  994. .word-break {
  995. word-break: break-all;
  996. }
  997. .small .custom-control-label {
  998. padding-top: 3px;
  999. }
  1000. /*.reply-btn {
  1001. position: absolute;
  1002. bottom: 30px;
  1003. right: 20px;
  1004. width: 60px;
  1005. text-align: center;
  1006. font-size: 13px;
  1007. border-radius: 0 3px 3px 0;
  1008. }*/
  1009. .reply-btn[disabled] {
  1010. opacity: .3;
  1011. color: #3897f0;
  1012. }
  1013. .replyModalTextarea {
  1014. border: none;
  1015. font-size: 18px;
  1016. resize: none;
  1017. white-space: pre-wrap;
  1018. outline: none;
  1019. }
  1020. .has-story {
  1021. width: 64px;
  1022. height: 64px;
  1023. border-radius: 50%;
  1024. padding: 2px;
  1025. background: radial-gradient(ellipse at 70% 70%, #ee583f 8%, #d92d77 42%, #bd3381 58%);
  1026. }
  1027. .has-story img {
  1028. width: 60px;
  1029. height: 60px;
  1030. border-radius: 50%;
  1031. padding: 3px;
  1032. background: #fff;
  1033. }
  1034. .has-story.has-story-sm {
  1035. width: 32px;
  1036. height: 32px;
  1037. border-radius: 50%;
  1038. padding: 2px;
  1039. background: radial-gradient(ellipse at 70% 70%, #ee583f 8%, #d92d77 42%, #bd3381 58%);
  1040. }
  1041. .has-story.has-story-sm img {
  1042. width: 28px;
  1043. height: 28px;
  1044. border-radius: 50%;
  1045. padding: 3px;
  1046. background: #fff;
  1047. }
  1048. #ctx-reply-modal .form-control:focus {
  1049. border: none;
  1050. outline: 0;
  1051. box-shadow: none;
  1052. }
  1053. </style>