]> Untitled Git - lemmy-ui.git/blob - src/shared/components/post.tsx
Adding simple scroll position restore. Fixes #18 (#162)
[lemmy-ui.git] / src / shared / components / post.tsx
1 import { Component, linkEvent } from 'inferno';
2 import { HtmlTags } from './html-tags';
3 import { Subscription } from 'rxjs';
4 import {
5   UserOperation,
6   PostView,
7   GetPostResponse,
8   PostResponse,
9   MarkCommentAsRead,
10   CommentResponse,
11   CommunityResponse,
12   BanFromCommunityResponse,
13   BanUserResponse,
14   AddModToCommunityResponse,
15   AddAdminResponse,
16   SearchType,
17   SortType,
18   Search,
19   GetPost,
20   SearchResponse,
21   GetSiteResponse,
22   GetCommunityResponse,
23   ListCategoriesResponse,
24   Category,
25 } from 'lemmy-js-client';
26 import {
27   CommentSortType,
28   CommentViewType,
29   InitialFetchRequest,
30   CommentNode as CommentNodeI,
31 } from '../interfaces';
32 import { WebSocketService, UserService } from '../services';
33 import {
34   wsJsonToRes,
35   toast,
36   editCommentRes,
37   saveCommentRes,
38   createCommentLikeRes,
39   createPostLikeRes,
40   commentsToFlatNodes,
41   setupTippy,
42   setIsoData,
43   getIdFromProps,
44   getCommentIdFromProps,
45   wsSubscribe,
46   isBrowser,
47   previewLines,
48   isImage,
49   wsUserOp,
50   wsClient,
51   authField,
52   setOptionalAuth,
53   saveScrollPosition,
54   restoreScrollPosition,
55 } from '../utils';
56 import { PostListing } from './post-listing';
57 import { Sidebar } from './sidebar';
58 import { CommentForm } from './comment-form';
59 import { CommentNodes } from './comment-nodes';
60 import autosize from 'autosize';
61 import { i18n } from '../i18next';
62
63 interface PostState {
64   postRes: GetPostResponse;
65   postId: number;
66   commentId?: number;
67   commentSort: CommentSortType;
68   commentViewType: CommentViewType;
69   scrolled?: boolean;
70   loading: boolean;
71   crossPosts: PostView[];
72   siteRes: GetSiteResponse;
73   categories: Category[];
74 }
75
76 export class Post extends Component<any, PostState> {
77   private subscription: Subscription;
78   private isoData = setIsoData(this.context);
79   private emptyState: PostState = {
80     postRes: null,
81     postId: getIdFromProps(this.props),
82     commentId: getCommentIdFromProps(this.props),
83     commentSort: CommentSortType.Hot,
84     commentViewType: CommentViewType.Tree,
85     scrolled: false,
86     loading: true,
87     crossPosts: [],
88     siteRes: this.isoData.site_res,
89     categories: [],
90   };
91
92   constructor(props: any, context: any) {
93     super(props, context);
94
95     this.state = this.emptyState;
96
97     this.parseMessage = this.parseMessage.bind(this);
98     this.subscription = wsSubscribe(this.parseMessage);
99
100     // Only fetch the data if coming from another route
101     if (this.isoData.path == this.context.router.route.match.url) {
102       this.state.postRes = this.isoData.routeData[0];
103       this.state.categories = this.isoData.routeData[1].categories;
104       this.state.loading = false;
105
106       if (isBrowser()) {
107         this.fetchCrossPosts();
108         if (this.state.commentId) {
109           this.scrollCommentIntoView();
110         }
111       }
112     } else {
113       this.fetchPost();
114       WebSocketService.Instance.send(wsClient.listCategories());
115     }
116   }
117
118   fetchPost() {
119     let form: GetPost = {
120       id: this.state.postId,
121       auth: authField(false),
122     };
123     WebSocketService.Instance.send(wsClient.getPost(form));
124   }
125
126   fetchCrossPosts() {
127     if (this.state.postRes.post_view.post.url) {
128       let form: Search = {
129         q: this.state.postRes.post_view.post.url,
130         type_: SearchType.Url,
131         sort: SortType.TopAll,
132         page: 1,
133         limit: 6,
134         auth: authField(false),
135       };
136       WebSocketService.Instance.send(wsClient.search(form));
137     }
138   }
139
140   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
141     let pathSplit = req.path.split('/');
142     let promises: Promise<any>[] = [];
143
144     let id = Number(pathSplit[2]);
145
146     let postForm: GetPost = {
147       id,
148     };
149     setOptionalAuth(postForm, req.auth);
150
151     promises.push(req.client.getPost(postForm));
152     promises.push(req.client.listCategories());
153
154     return promises;
155   }
156
157   componentWillUnmount() {
158     this.subscription.unsubscribe();
159     window.isoData.path = undefined;
160     saveScrollPosition(this.context);
161   }
162
163   componentDidMount() {
164     WebSocketService.Instance.send(
165       wsClient.postJoin({ post_id: this.state.postId })
166     );
167     autosize(document.querySelectorAll('textarea'));
168   }
169
170   componentDidUpdate(_lastProps: any, lastState: PostState, _snapshot: any) {
171     if (
172       this.state.commentId &&
173       !this.state.scrolled &&
174       lastState.postRes &&
175       lastState.postRes.comments.length > 0
176     ) {
177       this.scrollCommentIntoView();
178     }
179
180     // Necessary if you are on a post and you click another post (same route)
181     if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
182       // TODO Couldnt get a refresh working. This does for now.
183       location.reload();
184
185       // let currentId = this.props.match.params.id;
186       // WebSocketService.Instance.getPost(currentId);
187       // this.context.refresh();
188       // this.context.router.history.push(_lastProps.location.pathname);
189     }
190   }
191
192   scrollCommentIntoView() {
193     var elmnt = document.getElementById(`comment-${this.state.commentId}`);
194     elmnt.scrollIntoView();
195     elmnt.classList.add('mark');
196     this.state.scrolled = true;
197     this.markScrolledAsRead(this.state.commentId);
198   }
199
200   // TODO this needs some re-work
201   markScrolledAsRead(commentId: number) {
202     let found = this.state.postRes.comments.find(
203       c => c.comment.id == commentId
204     );
205     let parent = this.state.postRes.comments.find(
206       c => found.comment.parent_id == c.comment.id
207     );
208     let parent_user_id = parent
209       ? parent.creator.id
210       : this.state.postRes.post_view.creator.id;
211
212     if (
213       UserService.Instance.user &&
214       UserService.Instance.user.id == parent_user_id
215     ) {
216       let form: MarkCommentAsRead = {
217         comment_id: found.comment.id,
218         read: true,
219         auth: authField(),
220       };
221       WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
222       UserService.Instance.unreadCountSub.next(
223         UserService.Instance.unreadCountSub.value - 1
224       );
225     }
226   }
227
228   get documentTitle(): string {
229     return `${this.state.postRes.post_view.post.name} - ${this.state.siteRes.site_view.site.name}`;
230   }
231
232   get imageTag(): string {
233     let post = this.state.postRes.post_view.post;
234     return (
235       post.thumbnail_url ||
236       (post.url ? (isImage(post.url) ? post.url : undefined) : undefined)
237     );
238   }
239
240   get descriptionTag(): string {
241     let body = this.state.postRes.post_view.post.body;
242     return body ? previewLines(body) : undefined;
243   }
244
245   render() {
246     let pv = this.state.postRes?.post_view;
247     return (
248       <div class="container">
249         {this.state.loading ? (
250           <h5>
251             <svg class="icon icon-spinner spin">
252               <use xlinkHref="#icon-spinner"></use>
253             </svg>
254           </h5>
255         ) : (
256           <div class="row">
257             <div class="col-12 col-md-8 mb-3">
258               <HtmlTags
259                 title={this.documentTitle}
260                 path={this.context.router.route.match.url}
261                 image={this.imageTag}
262                 description={this.descriptionTag}
263               />
264               <PostListing
265                 post_view={pv}
266                 duplicates={this.state.crossPosts}
267                 showBody
268                 showCommunity
269                 moderators={this.state.postRes.moderators}
270                 admins={this.state.siteRes.admins}
271                 enableDownvotes={
272                   this.state.siteRes.site_view.site.enable_downvotes
273                 }
274                 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
275               />
276               <div className="mb-2" />
277               <CommentForm
278                 postId={this.state.postId}
279                 disabled={pv.post.locked}
280               />
281               {this.state.postRes.comments.length > 0 && this.sortRadios()}
282               {this.state.commentViewType == CommentViewType.Tree &&
283                 this.commentsTree()}
284               {this.state.commentViewType == CommentViewType.Chat &&
285                 this.commentsFlat()}
286             </div>
287             <div class="col-12 col-sm-12 col-md-4">{this.sidebar()}</div>
288           </div>
289         )}
290       </div>
291     );
292   }
293
294   sortRadios() {
295     return (
296       <>
297         <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
298           <label
299             className={`btn btn-outline-secondary pointer ${
300               this.state.commentSort === CommentSortType.Hot && 'active'
301             }`}
302           >
303             {i18n.t('hot')}
304             <input
305               type="radio"
306               value={CommentSortType.Hot}
307               checked={this.state.commentSort === CommentSortType.Hot}
308               onChange={linkEvent(this, this.handleCommentSortChange)}
309             />
310           </label>
311           <label
312             className={`btn btn-outline-secondary pointer ${
313               this.state.commentSort === CommentSortType.Top && 'active'
314             }`}
315           >
316             {i18n.t('top')}
317             <input
318               type="radio"
319               value={CommentSortType.Top}
320               checked={this.state.commentSort === CommentSortType.Top}
321               onChange={linkEvent(this, this.handleCommentSortChange)}
322             />
323           </label>
324           <label
325             className={`btn btn-outline-secondary pointer ${
326               this.state.commentSort === CommentSortType.New && 'active'
327             }`}
328           >
329             {i18n.t('new')}
330             <input
331               type="radio"
332               value={CommentSortType.New}
333               checked={this.state.commentSort === CommentSortType.New}
334               onChange={linkEvent(this, this.handleCommentSortChange)}
335             />
336           </label>
337           <label
338             className={`btn btn-outline-secondary pointer ${
339               this.state.commentSort === CommentSortType.Old && 'active'
340             }`}
341           >
342             {i18n.t('old')}
343             <input
344               type="radio"
345               value={CommentSortType.Old}
346               checked={this.state.commentSort === CommentSortType.Old}
347               onChange={linkEvent(this, this.handleCommentSortChange)}
348             />
349           </label>
350         </div>
351         <div class="btn-group btn-group-toggle flex-wrap mb-2">
352           <label
353             className={`btn btn-outline-secondary pointer ${
354               this.state.commentViewType === CommentViewType.Chat && 'active'
355             }`}
356           >
357             {i18n.t('chat')}
358             <input
359               type="radio"
360               value={CommentViewType.Chat}
361               checked={this.state.commentViewType === CommentViewType.Chat}
362               onChange={linkEvent(this, this.handleCommentViewTypeChange)}
363             />
364           </label>
365         </div>
366       </>
367     );
368   }
369
370   commentsFlat() {
371     return (
372       <div>
373         <CommentNodes
374           nodes={commentsToFlatNodes(this.state.postRes.comments)}
375           noIndent
376           locked={this.state.postRes.post_view.post.locked}
377           moderators={this.state.postRes.moderators}
378           admins={this.state.siteRes.admins}
379           postCreatorId={this.state.postRes.post_view.creator.id}
380           showContext
381           enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
382           sort={this.state.commentSort}
383         />
384       </div>
385     );
386   }
387
388   sidebar() {
389     return (
390       <div class="mb-3">
391         <Sidebar
392           community_view={this.state.postRes.community_view}
393           moderators={this.state.postRes.moderators}
394           admins={this.state.siteRes.admins}
395           online={this.state.postRes.online}
396           enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
397           showIcon
398           categories={this.state.categories}
399         />
400       </div>
401     );
402   }
403
404   handleCommentSortChange(i: Post, event: any) {
405     i.state.commentSort = Number(event.target.value);
406     i.state.commentViewType = CommentViewType.Tree;
407     i.setState(i.state);
408   }
409
410   handleCommentViewTypeChange(i: Post, event: any) {
411     i.state.commentViewType = Number(event.target.value);
412     i.state.commentSort = CommentSortType.New;
413     i.setState(i.state);
414   }
415
416   buildCommentsTree(): CommentNodeI[] {
417     let map = new Map<number, CommentNodeI>();
418     for (let comment_view of this.state.postRes.comments) {
419       let node: CommentNodeI = {
420         comment_view: comment_view,
421         children: [],
422       };
423       map.set(comment_view.comment.id, { ...node });
424     }
425     let tree: CommentNodeI[] = [];
426     for (let comment_view of this.state.postRes.comments) {
427       let child = map.get(comment_view.comment.id);
428       if (comment_view.comment.parent_id) {
429         let parent_ = map.get(comment_view.comment.parent_id);
430         parent_.children.push(child);
431       } else {
432         tree.push(child);
433       }
434
435       this.setDepth(child);
436     }
437
438     return tree;
439   }
440
441   setDepth(node: CommentNodeI, i: number = 0): void {
442     for (let child of node.children) {
443       child.depth = i;
444       this.setDepth(child, i + 1);
445     }
446   }
447
448   commentsTree() {
449     let nodes = this.buildCommentsTree();
450     return (
451       <div>
452         <CommentNodes
453           nodes={nodes}
454           locked={this.state.postRes.post_view.post.locked}
455           moderators={this.state.postRes.moderators}
456           admins={this.state.siteRes.admins}
457           postCreatorId={this.state.postRes.post_view.creator.id}
458           sort={this.state.commentSort}
459           enableDownvotes={this.state.siteRes.site_view.site.enable_downvotes}
460         />
461       </div>
462     );
463   }
464
465   parseMessage(msg: any) {
466     let op = wsUserOp(msg);
467     console.log(msg);
468     if (msg.error) {
469       toast(i18n.t(msg.error), 'danger');
470       return;
471     } else if (msg.reconnect) {
472       let postId = Number(this.props.match.params.id);
473       WebSocketService.Instance.send(wsClient.postJoin({ post_id: postId }));
474       WebSocketService.Instance.send(
475         wsClient.getPost({
476           id: postId,
477           auth: authField(false),
478         })
479       );
480     } else if (op == UserOperation.GetPost) {
481       let data = wsJsonToRes<GetPostResponse>(msg).data;
482       this.state.postRes = data;
483       this.state.loading = false;
484
485       // Get cross-posts
486       this.fetchCrossPosts();
487       this.setState(this.state);
488       setupTippy();
489       restoreScrollPosition(this.context);
490     } else if (op == UserOperation.CreateComment) {
491       let data = wsJsonToRes<CommentResponse>(msg).data;
492
493       // Necessary since it might be a user reply, which has the recipients, to avoid double
494       if (data.recipient_ids.length == 0) {
495         this.state.postRes.comments.unshift(data.comment_view);
496         this.state.postRes.post_view.counts.comments++;
497         this.setState(this.state);
498         setupTippy();
499       }
500     } else if (
501       op == UserOperation.EditComment ||
502       op == UserOperation.DeleteComment ||
503       op == UserOperation.RemoveComment
504     ) {
505       let data = wsJsonToRes<CommentResponse>(msg).data;
506       editCommentRes(data.comment_view, this.state.postRes.comments);
507       this.setState(this.state);
508     } else if (op == UserOperation.SaveComment) {
509       let data = wsJsonToRes<CommentResponse>(msg).data;
510       saveCommentRes(data.comment_view, this.state.postRes.comments);
511       this.setState(this.state);
512       setupTippy();
513     } else if (op == UserOperation.CreateCommentLike) {
514       let data = wsJsonToRes<CommentResponse>(msg).data;
515       createCommentLikeRes(data.comment_view, this.state.postRes.comments);
516       this.setState(this.state);
517     } else if (op == UserOperation.CreatePostLike) {
518       let data = wsJsonToRes<PostResponse>(msg).data;
519       createPostLikeRes(data.post_view, this.state.postRes.post_view);
520       this.setState(this.state);
521     } else if (
522       op == UserOperation.EditPost ||
523       op == UserOperation.DeletePost ||
524       op == UserOperation.RemovePost ||
525       op == UserOperation.LockPost ||
526       op == UserOperation.StickyPost ||
527       op == UserOperation.SavePost
528     ) {
529       let data = wsJsonToRes<PostResponse>(msg).data;
530       this.state.postRes.post_view = data.post_view;
531       this.setState(this.state);
532       setupTippy();
533     } else if (
534       op == UserOperation.EditCommunity ||
535       op == UserOperation.DeleteCommunity ||
536       op == UserOperation.RemoveCommunity ||
537       op == UserOperation.FollowCommunity
538     ) {
539       let data = wsJsonToRes<CommunityResponse>(msg).data;
540       this.state.postRes.community_view = data.community_view;
541       this.state.postRes.post_view.community = data.community_view.community;
542       this.setState(this.state);
543       this.setState(this.state);
544     } else if (op == UserOperation.BanFromCommunity) {
545       let data = wsJsonToRes<BanFromCommunityResponse>(msg).data;
546       this.state.postRes.comments
547         .filter(c => c.creator.id == data.user_view.user.id)
548         .forEach(c => (c.creator_banned_from_community = data.banned));
549       if (this.state.postRes.post_view.creator.id == data.user_view.user.id) {
550         this.state.postRes.post_view.creator_banned_from_community =
551           data.banned;
552       }
553       this.setState(this.state);
554     } else if (op == UserOperation.AddModToCommunity) {
555       let data = wsJsonToRes<AddModToCommunityResponse>(msg).data;
556       this.state.postRes.moderators = data.moderators;
557       this.setState(this.state);
558     } else if (op == UserOperation.BanUser) {
559       let data = wsJsonToRes<BanUserResponse>(msg).data;
560       this.state.postRes.comments
561         .filter(c => c.creator.id == data.user_view.user.id)
562         .forEach(c => (c.creator.banned = data.banned));
563       if (this.state.postRes.post_view.creator.id == data.user_view.user.id) {
564         this.state.postRes.post_view.creator.banned = data.banned;
565       }
566       this.setState(this.state);
567     } else if (op == UserOperation.AddAdmin) {
568       let data = wsJsonToRes<AddAdminResponse>(msg).data;
569       this.state.siteRes.admins = data.admins;
570       this.setState(this.state);
571     } else if (op == UserOperation.Search) {
572       let data = wsJsonToRes<SearchResponse>(msg).data;
573       this.state.crossPosts = data.posts.filter(
574         p => p.post.id != Number(this.props.match.params.id)
575       );
576       this.setState(this.state);
577     } else if (op == UserOperation.TransferSite) {
578       let data = wsJsonToRes<GetSiteResponse>(msg).data;
579       this.state.siteRes = data;
580       this.setState(this.state);
581     } else if (op == UserOperation.TransferCommunity) {
582       let data = wsJsonToRes<GetCommunityResponse>(msg).data;
583       this.state.postRes.community_view = data.community_view;
584       this.state.postRes.post_view.community = data.community_view.community;
585       this.state.postRes.moderators = data.moderators;
586       this.setState(this.state);
587     } else if (op == UserOperation.ListCategories) {
588       let data = wsJsonToRes<ListCategoriesResponse>(msg).data;
589       this.state.categories = data.categories;
590       this.setState(this.state);
591     }
592   }
593 }