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