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