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