]> Untitled Git - lemmy-ui.git/blob - src/shared/components/person/profile.tsx
Fix profile paging. Fixes #416 (#417)
[lemmy-ui.git] / src / shared / components / person / profile.tsx
1 import { Component, linkEvent } from "inferno";
2 import { Link } from "inferno-router";
3 import {
4   AddAdminResponse,
5   BanPersonResponse,
6   BlockPersonResponse,
7   CommentResponse,
8   GetPersonDetails,
9   GetPersonDetailsResponse,
10   GetSiteResponse,
11   PostResponse,
12   SortType,
13   UserOperation,
14 } from "lemmy-js-client";
15 import moment from "moment";
16 import { Subscription } from "rxjs";
17 import { i18n } from "../../i18next";
18 import { InitialFetchRequest, PersonDetailsView } from "../../interfaces";
19 import { UserService, WebSocketService } from "../../services";
20 import {
21   authField,
22   createCommentLikeRes,
23   createPostLikeFindRes,
24   editCommentRes,
25   editPostFindRes,
26   fetchLimit,
27   getUsernameFromProps,
28   mdToHtml,
29   previewLines,
30   restoreScrollPosition,
31   routeSortTypeToEnum,
32   saveCommentRes,
33   saveScrollPosition,
34   setIsoData,
35   setOptionalAuth,
36   setupTippy,
37   toast,
38   updatePersonBlock,
39   wsClient,
40   wsJsonToRes,
41   wsSubscribe,
42   wsUserOp,
43 } from "../../utils";
44 import { BannerIconHeader } from "../common/banner-icon-header";
45 import { HtmlTags } from "../common/html-tags";
46 import { Icon, Spinner } from "../common/icon";
47 import { MomentTime } from "../common/moment-time";
48 import { SortSelect } from "../common/sort-select";
49 import { CommunityLink } from "../community/community-link";
50 import { PersonDetails } from "./person-details";
51 import { PersonListing } from "./person-listing";
52
53 interface ProfileState {
54   personRes: GetPersonDetailsResponse;
55   userName: string;
56   view: PersonDetailsView;
57   sort: SortType;
58   page: number;
59   loading: boolean;
60   siteRes: GetSiteResponse;
61 }
62
63 interface ProfileProps {
64   view: PersonDetailsView;
65   sort: SortType;
66   page: number;
67   person_id: number | null;
68   username: string;
69 }
70
71 interface UrlParams {
72   view?: string;
73   sort?: SortType;
74   page?: number;
75 }
76
77 export class Profile extends Component<any, ProfileState> {
78   private isoData = setIsoData(this.context);
79   private subscription: Subscription;
80   private emptyState: ProfileState = {
81     personRes: undefined,
82     userName: getUsernameFromProps(this.props),
83     loading: true,
84     view: Profile.getViewFromProps(this.props.match.view),
85     sort: Profile.getSortTypeFromProps(this.props.match.sort),
86     page: Profile.getPageFromProps(this.props.match.page),
87     siteRes: this.isoData.site_res,
88   };
89
90   constructor(props: any, context: any) {
91     super(props, context);
92
93     this.state = this.emptyState;
94     this.handleSortChange = this.handleSortChange.bind(this);
95     this.handlePageChange = this.handlePageChange.bind(this);
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.personRes = this.isoData.routeData[0];
103       this.state.loading = false;
104     } else {
105       this.fetchUserData();
106     }
107
108     setupTippy();
109   }
110
111   fetchUserData() {
112     let form: GetPersonDetails = {
113       username: this.state.userName,
114       sort: this.state.sort,
115       saved_only: this.state.view === PersonDetailsView.Saved,
116       page: this.state.page,
117       limit: fetchLimit,
118       auth: authField(false),
119     };
120     WebSocketService.Instance.send(wsClient.getPersonDetails(form));
121   }
122
123   get isCurrentUser() {
124     return (
125       UserService.Instance.myUserInfo?.local_user_view.person.id ==
126       this.state.personRes.person_view.person.id
127     );
128   }
129
130   static getViewFromProps(view: string): PersonDetailsView {
131     return view ? PersonDetailsView[view] : PersonDetailsView.Overview;
132   }
133
134   static getSortTypeFromProps(sort: string): SortType {
135     return sort ? routeSortTypeToEnum(sort) : SortType.New;
136   }
137
138   static getPageFromProps(page: number): number {
139     return page ? Number(page) : 1;
140   }
141
142   static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
143     let pathSplit = req.path.split("/");
144     let promises: Promise<any>[] = [];
145
146     // It can be /u/me, or /username/1
147     let idOrName = pathSplit[2];
148     let person_id: number;
149     let username: string;
150     if (isNaN(Number(idOrName))) {
151       username = idOrName;
152     } else {
153       person_id = Number(idOrName);
154     }
155
156     let view = this.getViewFromProps(pathSplit[4]);
157     let sort = this.getSortTypeFromProps(pathSplit[6]);
158     let page = this.getPageFromProps(Number(pathSplit[8]));
159
160     let form: GetPersonDetails = {
161       sort,
162       saved_only: view === PersonDetailsView.Saved,
163       page,
164       limit: fetchLimit,
165     };
166     setOptionalAuth(form, req.auth);
167     this.setIdOrName(form, person_id, username);
168     promises.push(req.client.getPersonDetails(form));
169     return promises;
170   }
171
172   static setIdOrName(obj: any, id: number, name_: string) {
173     if (id) {
174       obj.person_id = id;
175     } else {
176       obj.username = name_;
177     }
178   }
179
180   componentWillUnmount() {
181     this.subscription.unsubscribe();
182     saveScrollPosition(this.context);
183   }
184
185   static getDerivedStateFromProps(props: any): ProfileProps {
186     return {
187       view: this.getViewFromProps(props.match.params.view),
188       sort: this.getSortTypeFromProps(props.match.params.sort),
189       page: this.getPageFromProps(props.match.params.page),
190       person_id: Number(props.match.params.id) || null,
191       username: props.match.params.username,
192     };
193   }
194
195   componentDidUpdate(lastProps: any) {
196     // Necessary if you are on a post and you click another post (same route)
197     if (
198       lastProps.location.pathname.split("/")[2] !==
199       lastProps.history.location.pathname.split("/")[2]
200     ) {
201       // Couldnt get a refresh working. This does for now.
202       location.reload();
203     }
204   }
205
206   get documentTitle(): string {
207     return `@${this.state.personRes.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`;
208   }
209
210   get bioTag(): string {
211     return this.state.personRes.person_view.person.bio
212       ? previewLines(this.state.personRes.person_view.person.bio)
213       : undefined;
214   }
215
216   render() {
217     return (
218       <div class="container">
219         {this.state.loading ? (
220           <h5>
221             <Spinner large />
222           </h5>
223         ) : (
224           <div class="row">
225             <div class="col-12 col-md-8">
226               <>
227                 <HtmlTags
228                   title={this.documentTitle}
229                   path={this.context.router.route.match.url}
230                   description={this.bioTag}
231                   image={this.state.personRes.person_view.person.avatar}
232                 />
233                 {this.userInfo()}
234                 <hr />
235               </>
236               {!this.state.loading && this.selects()}
237               <PersonDetails
238                 personRes={this.state.personRes}
239                 admins={this.state.siteRes.admins}
240                 sort={this.state.sort}
241                 page={this.state.page}
242                 limit={fetchLimit}
243                 enableDownvotes={
244                   this.state.siteRes.site_view.site.enable_downvotes
245                 }
246                 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
247                 view={this.state.view}
248                 onPageChange={this.handlePageChange}
249               />
250             </div>
251
252             {!this.state.loading && (
253               <div class="col-12 col-md-4">
254                 {this.moderates()}
255                 {this.isCurrentUser && this.follows()}
256               </div>
257             )}
258           </div>
259         )}
260       </div>
261     );
262   }
263
264   viewRadios() {
265     return (
266       <div class="btn-group btn-group-toggle flex-wrap mb-2">
267         <label
268           className={`btn btn-outline-secondary pointer
269             ${this.state.view == PersonDetailsView.Overview && "active"}
270           `}
271         >
272           <input
273             type="radio"
274             value={PersonDetailsView.Overview}
275             checked={this.state.view === PersonDetailsView.Overview}
276             onChange={linkEvent(this, this.handleViewChange)}
277           />
278           {i18n.t("overview")}
279         </label>
280         <label
281           className={`btn btn-outline-secondary pointer
282             ${this.state.view == PersonDetailsView.Comments && "active"}
283           `}
284         >
285           <input
286             type="radio"
287             value={PersonDetailsView.Comments}
288             checked={this.state.view == PersonDetailsView.Comments}
289             onChange={linkEvent(this, this.handleViewChange)}
290           />
291           {i18n.t("comments")}
292         </label>
293         <label
294           className={`btn btn-outline-secondary pointer
295             ${this.state.view == PersonDetailsView.Posts && "active"}
296           `}
297         >
298           <input
299             type="radio"
300             value={PersonDetailsView.Posts}
301             checked={this.state.view == PersonDetailsView.Posts}
302             onChange={linkEvent(this, this.handleViewChange)}
303           />
304           {i18n.t("posts")}
305         </label>
306         <label
307           className={`btn btn-outline-secondary pointer
308             ${this.state.view == PersonDetailsView.Saved && "active"}
309           `}
310         >
311           <input
312             type="radio"
313             value={PersonDetailsView.Saved}
314             checked={this.state.view == PersonDetailsView.Saved}
315             onChange={linkEvent(this, this.handleViewChange)}
316           />
317           {i18n.t("saved")}
318         </label>
319       </div>
320     );
321   }
322
323   selects() {
324     return (
325       <div className="mb-2">
326         <span class="mr-3">{this.viewRadios()}</span>
327         <SortSelect
328           sort={this.state.sort}
329           onChange={this.handleSortChange}
330           hideHot
331           hideMostComments
332         />
333         <a
334           href={`/feeds/u/${this.state.userName}.xml?sort=${this.state.sort}`}
335           rel="noopener"
336           title="RSS"
337         >
338           <Icon icon="rss" classes="text-muted small mx-2" />
339         </a>
340       </div>
341     );
342   }
343
344   userInfo() {
345     let pv = this.state.personRes?.person_view;
346
347     return (
348       <div>
349         <BannerIconHeader banner={pv.person.banner} icon={pv.person.avatar} />
350         <div class="mb-3">
351           <div class="">
352             <div class="mb-0 d-flex flex-wrap">
353               <div>
354                 {pv.person.display_name && (
355                   <h5 class="mb-0">{pv.person.display_name}</h5>
356                 )}
357                 <ul class="list-inline mb-2">
358                   <li className="list-inline-item">
359                     <PersonListing
360                       person={pv.person}
361                       realLink
362                       useApubName
363                       muted
364                       hideAvatar
365                     />
366                   </li>
367                   {pv.person.banned && (
368                     <li className="list-inline-item badge badge-danger">
369                       {i18n.t("banned")}
370                     </li>
371                   )}
372                 </ul>
373               </div>
374               <div className="flex-grow-1 unselectable pointer mx-2"></div>
375               {!this.isCurrentUser && (
376                 <>
377                   <a
378                     className={`d-flex align-self-start btn btn-secondary mr-2 ${
379                       !pv.person.matrix_user_id && "invisible"
380                     }`}
381                     rel="noopener"
382                     href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
383                   >
384                     {i18n.t("send_secure_message")}
385                   </a>
386                   <Link
387                     className={"d-flex align-self-start btn btn-secondary"}
388                     to={`/create_private_message/recipient/${pv.person.id}`}
389                   >
390                     {i18n.t("send_message")}
391                   </Link>
392                 </>
393               )}
394             </div>
395             {pv.person.bio && (
396               <div className="d-flex align-items-center mb-2">
397                 <div
398                   className="md-div"
399                   dangerouslySetInnerHTML={mdToHtml(pv.person.bio)}
400                 />
401               </div>
402             )}
403             <div>
404               <ul class="list-inline mb-2">
405                 <li className="list-inline-item badge badge-light">
406                   {i18n.t("number_of_posts", { count: pv.counts.post_count })}
407                 </li>
408                 <li className="list-inline-item badge badge-light">
409                   {i18n.t("number_of_comments", {
410                     count: pv.counts.comment_count,
411                   })}
412                 </li>
413               </ul>
414             </div>
415             <div class="text-muted">
416               {i18n.t("joined")}{" "}
417               <MomentTime data={pv.person} showAgo ignoreUpdated />
418             </div>
419             <div className="d-flex align-items-center text-muted mb-2">
420               <Icon icon="cake" />
421               <span className="ml-2">
422                 {i18n.t("cake_day_title")}{" "}
423                 {moment.utc(pv.person.published).local().format("MMM DD, YYYY")}
424               </span>
425             </div>
426           </div>
427         </div>
428       </div>
429     );
430   }
431
432   moderates() {
433     return (
434       <div>
435         {this.state.personRes.moderates.length > 0 && (
436           <div class="card border-secondary mb-3">
437             <div class="card-body">
438               <h5>{i18n.t("moderates")}</h5>
439               <ul class="list-unstyled mb-0">
440                 {this.state.personRes.moderates.map(cmv => (
441                   <li>
442                     <CommunityLink community={cmv.community} />
443                   </li>
444                 ))}
445               </ul>
446             </div>
447           </div>
448         )}
449       </div>
450     );
451   }
452
453   follows() {
454     let follows = UserService.Instance.myUserInfo.follows;
455     return (
456       <div>
457         {follows.length > 0 && (
458           <div class="card border-secondary mb-3">
459             <div class="card-body">
460               <h5>{i18n.t("subscribed")}</h5>
461               <ul class="list-unstyled mb-0">
462                 {follows.map(cfv => (
463                   <li>
464                     <CommunityLink community={cfv.community} />
465                   </li>
466                 ))}
467               </ul>
468             </div>
469           </div>
470         )}
471       </div>
472     );
473   }
474
475   updateUrl(paramUpdates: UrlParams) {
476     const page = paramUpdates.page || this.state.page;
477     const viewStr = paramUpdates.view || PersonDetailsView[this.state.view];
478     const sortStr = paramUpdates.sort || this.state.sort;
479
480     let typeView = `/u/${this.state.userName}`;
481
482     this.props.history.push(
483       `${typeView}/view/${viewStr}/sort/${sortStr}/page/${page}`
484     );
485     this.state.loading = true;
486     this.setState(this.state);
487     this.fetchUserData();
488   }
489
490   handlePageChange(page: number) {
491     this.updateUrl({ page });
492   }
493
494   handleSortChange(val: SortType) {
495     this.updateUrl({ sort: val, page: 1 });
496   }
497
498   handleViewChange(i: Profile, event: any) {
499     i.updateUrl({
500       view: PersonDetailsView[Number(event.target.value)],
501       page: 1,
502     });
503   }
504
505   parseMessage(msg: any) {
506     let op = wsUserOp(msg);
507     console.log(msg);
508     if (msg.error) {
509       toast(i18n.t(msg.error), "danger");
510       if (msg.error == "couldnt_find_that_username_or_email") {
511         this.context.router.history.push("/");
512       }
513       return;
514     } else if (msg.reconnect) {
515       this.fetchUserData();
516     } else if (op == UserOperation.GetPersonDetails) {
517       // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
518       // and set the parent state if it is not set or differs
519       // TODO this might need to get abstracted
520       let data = wsJsonToRes<GetPersonDetailsResponse>(msg).data;
521       this.state.personRes = data;
522       console.log(data);
523       this.state.loading = false;
524       this.setState(this.state);
525       restoreScrollPosition(this.context);
526     } else if (op == UserOperation.AddAdmin) {
527       let data = wsJsonToRes<AddAdminResponse>(msg).data;
528       this.state.siteRes.admins = data.admins;
529       this.setState(this.state);
530     } else if (op == UserOperation.CreateCommentLike) {
531       let data = wsJsonToRes<CommentResponse>(msg).data;
532       createCommentLikeRes(data.comment_view, this.state.personRes.comments);
533       this.setState(this.state);
534     } else if (
535       op == UserOperation.EditComment ||
536       op == UserOperation.DeleteComment ||
537       op == UserOperation.RemoveComment
538     ) {
539       let data = wsJsonToRes<CommentResponse>(msg).data;
540       editCommentRes(data.comment_view, this.state.personRes.comments);
541       this.setState(this.state);
542     } else if (op == UserOperation.CreateComment) {
543       let data = wsJsonToRes<CommentResponse>(msg).data;
544       if (
545         UserService.Instance.myUserInfo &&
546         data.comment_view.creator.id ==
547           UserService.Instance.myUserInfo.local_user_view.person.id
548       ) {
549         toast(i18n.t("reply_sent"));
550       }
551     } else if (op == UserOperation.SaveComment) {
552       let data = wsJsonToRes<CommentResponse>(msg).data;
553       saveCommentRes(data.comment_view, this.state.personRes.comments);
554       this.setState(this.state);
555     } else if (
556       op == UserOperation.EditPost ||
557       op == UserOperation.DeletePost ||
558       op == UserOperation.RemovePost ||
559       op == UserOperation.LockPost ||
560       op == UserOperation.StickyPost ||
561       op == UserOperation.SavePost
562     ) {
563       let data = wsJsonToRes<PostResponse>(msg).data;
564       editPostFindRes(data.post_view, this.state.personRes.posts);
565       this.setState(this.state);
566     } else if (op == UserOperation.CreatePostLike) {
567       let data = wsJsonToRes<PostResponse>(msg).data;
568       createPostLikeFindRes(data.post_view, this.state.personRes.posts);
569       this.setState(this.state);
570     } else if (op == UserOperation.BanPerson) {
571       let data = wsJsonToRes<BanPersonResponse>(msg).data;
572       this.state.personRes.comments
573         .filter(c => c.creator.id == data.person_view.person.id)
574         .forEach(c => (c.creator.banned = data.banned));
575       this.state.personRes.posts
576         .filter(c => c.creator.id == data.person_view.person.id)
577         .forEach(c => (c.creator.banned = data.banned));
578       this.setState(this.state);
579     } else if (op == UserOperation.BlockPerson) {
580       let data = wsJsonToRes<BlockPersonResponse>(msg).data;
581       updatePersonBlock(data);
582     }
583   }
584 }