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