1 import { Component, linkEvent } from "inferno";
2 import { Link } from "inferno-router";
11 GetPersonDetailsResponse,
16 } from "lemmy-js-client";
17 import moment from "moment";
18 import { Subscription } from "rxjs";
19 import { i18n } from "../../i18next";
20 import { InitialFetchRequest, PersonDetailsView } from "../../interfaces";
21 import { UserService, WebSocketService } from "../../services";
25 capitalizeFirstLetter,
27 createPostLikeFindRes,
39 restoreScrollPosition,
53 import { BannerIconHeader } from "../common/banner-icon-header";
54 import { HtmlTags } from "../common/html-tags";
55 import { Icon, Spinner } from "../common/icon";
56 import { MomentTime } from "../common/moment-time";
57 import { SortSelect } from "../common/sort-select";
58 import { CommunityLink } from "../community/community-link";
59 import { PersonDetails } from "./person-details";
60 import { PersonListing } from "./person-listing";
62 interface ProfileState {
63 personRes: GetPersonDetailsResponse;
65 view: PersonDetailsView;
69 personBlocked: boolean;
70 siteRes: GetSiteResponse;
71 showBanDialog: boolean;
73 banExpireDays: number;
77 interface ProfileProps {
78 view: PersonDetailsView;
81 person_id: number | null;
91 export class Profile extends Component<any, ProfileState> {
92 private isoData = setIsoData(this.context);
93 private subscription: Subscription;
94 private emptyState: ProfileState = {
96 userName: getUsernameFromProps(this.props),
98 view: Profile.getViewFromProps(this.props.match.view),
99 sort: Profile.getSortTypeFromProps(this.props.match.sort),
100 page: Profile.getPageFromProps(this.props.match.page),
101 personBlocked: false,
102 siteRes: this.isoData.site_res,
103 showBanDialog: false,
109 constructor(props: any, context: any) {
110 super(props, context);
112 this.state = this.emptyState;
113 this.handleSortChange = this.handleSortChange.bind(this);
114 this.handlePageChange = this.handlePageChange.bind(this);
116 this.parseMessage = this.parseMessage.bind(this);
117 this.subscription = wsSubscribe(this.parseMessage);
119 // Only fetch the data if coming from another route
120 if (this.isoData.path == this.context.router.route.match.url) {
121 this.state.personRes = this.isoData.routeData[0];
122 this.state.loading = false;
124 this.fetchUserData();
127 this.setPersonBlock();
131 let form: GetPersonDetails = {
132 username: this.state.userName,
133 sort: this.state.sort,
134 saved_only: this.state.view === PersonDetailsView.Saved,
135 page: this.state.page,
137 auth: authField(false),
139 WebSocketService.Instance.send(wsClient.getPersonDetails(form));
142 get isCurrentUser() {
144 UserService.Instance.myUserInfo?.local_user_view.person.id ==
145 this.state.personRes?.person_view.person.id
150 this.state.personBlocked = UserService.Instance.myUserInfo?.person_blocks
151 .map(a => a.target.id)
152 .includes(this.state.personRes?.person_view.person.id);
155 static getViewFromProps(view: string): PersonDetailsView {
156 return view ? PersonDetailsView[view] : PersonDetailsView.Overview;
159 static getSortTypeFromProps(sort: string): SortType {
160 return sort ? routeSortTypeToEnum(sort) : SortType.New;
163 static getPageFromProps(page: number): number {
164 return page ? Number(page) : 1;
167 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
168 let pathSplit = req.path.split("/");
169 let promises: Promise<any>[] = [];
171 let username = pathSplit[2];
172 let view = this.getViewFromProps(pathSplit[4]);
173 let sort = this.getSortTypeFromProps(pathSplit[6]);
174 let page = this.getPageFromProps(Number(pathSplit[8]));
176 let form: GetPersonDetails = {
178 saved_only: view === PersonDetailsView.Saved,
183 setOptionalAuth(form, req.auth);
184 promises.push(req.client.getPersonDetails(form));
188 componentDidMount() {
192 componentWillUnmount() {
193 this.subscription.unsubscribe();
194 saveScrollPosition(this.context);
197 static getDerivedStateFromProps(props: any): ProfileProps {
199 view: this.getViewFromProps(props.match.params.view),
200 sort: this.getSortTypeFromProps(props.match.params.sort),
201 page: this.getPageFromProps(props.match.params.page),
202 person_id: Number(props.match.params.id) || null,
203 username: props.match.params.username,
207 componentDidUpdate(lastProps: any) {
208 // Necessary if you are on a post and you click another post (same route)
210 lastProps.location.pathname.split("/")[2] !==
211 lastProps.history.location.pathname.split("/")[2]
213 // Couldnt get a refresh working. This does for now.
218 get documentTitle(): string {
219 return `@${this.state.personRes.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`;
222 get bioTag(): string {
223 return this.state.personRes.person_view.person.bio
224 ? previewLines(this.state.personRes.person_view.person.bio)
230 <div class="container">
231 {this.state.loading ? (
237 <div class="col-12 col-md-8">
240 title={this.documentTitle}
241 path={this.context.router.route.match.url}
242 description={this.bioTag}
243 image={this.state.personRes.person_view.person.avatar}
248 {!this.state.loading && this.selects()}
250 personRes={this.state.personRes}
251 admins={this.state.siteRes.admins}
252 sort={this.state.sort}
253 page={this.state.page}
256 this.state.siteRes.site_view.site.enable_downvotes
258 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
259 view={this.state.view}
260 onPageChange={this.handlePageChange}
264 {!this.state.loading && (
265 <div class="col-12 col-md-4">
267 {this.isCurrentUser && this.follows()}
278 <div class="btn-group btn-group-toggle flex-wrap mb-2">
280 className={`btn btn-outline-secondary pointer
281 ${this.state.view == PersonDetailsView.Overview && "active"}
286 value={PersonDetailsView.Overview}
287 checked={this.state.view === PersonDetailsView.Overview}
288 onChange={linkEvent(this, this.handleViewChange)}
293 className={`btn btn-outline-secondary pointer
294 ${this.state.view == PersonDetailsView.Comments && "active"}
299 value={PersonDetailsView.Comments}
300 checked={this.state.view == PersonDetailsView.Comments}
301 onChange={linkEvent(this, this.handleViewChange)}
306 className={`btn btn-outline-secondary pointer
307 ${this.state.view == PersonDetailsView.Posts && "active"}
312 value={PersonDetailsView.Posts}
313 checked={this.state.view == PersonDetailsView.Posts}
314 onChange={linkEvent(this, this.handleViewChange)}
319 className={`btn btn-outline-secondary pointer
320 ${this.state.view == PersonDetailsView.Saved && "active"}
325 value={PersonDetailsView.Saved}
326 checked={this.state.view == PersonDetailsView.Saved}
327 onChange={linkEvent(this, this.handleViewChange)}
336 let profileRss = `/feeds/u/${this.state.userName}.xml?sort=${this.state.sort}`;
339 <div className="mb-2">
340 <span class="mr-3">{this.viewRadios()}</span>
342 sort={this.state.sort}
343 onChange={this.handleSortChange}
347 <a href={profileRss} rel={relTags} title="RSS">
348 <Icon icon="rss" classes="text-muted small mx-2" />
350 <link rel="alternate" type="application/atom+xml" href={profileRss} />
354 handleBlockPerson(personId: number) {
356 let blockUserForm: BlockPerson = {
361 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
364 handleUnblockPerson(recipientId: number) {
365 let blockUserForm: BlockPerson = {
366 person_id: recipientId,
370 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
374 let pv = this.state.personRes?.person_view;
378 <BannerIconHeader banner={pv.person.banner} icon={pv.person.avatar} />
381 <div class="mb-0 d-flex flex-wrap">
383 {pv.person.display_name && (
384 <h5 class="mb-0">{pv.person.display_name}</h5>
386 <ul class="list-inline mb-2">
387 <li className="list-inline-item">
396 {isBanned(pv.person) && (
397 <li className="list-inline-item badge badge-danger">
401 {pv.person.admin && (
402 <li className="list-inline-item badge badge-light">
406 {pv.person.bot_account && (
407 <li className="list-inline-item badge badge-light">
408 {i18n.t("bot_account").toLowerCase()}
414 <div className="flex-grow-1 unselectable pointer mx-2"></div>
415 {!this.isCurrentUser && UserService.Instance.myUserInfo && (
418 className={`d-flex align-self-start btn btn-secondary mr-2 ${
419 !pv.person.matrix_user_id && "invisible"
422 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
424 {i18n.t("send_secure_message")}
427 className={"d-flex align-self-start btn btn-secondary mr-2"}
428 to={`/create_private_message/recipient/${pv.person.id}`}
430 {i18n.t("send_message")}
432 {this.state.personBlocked ? (
435 "d-flex align-self-start btn btn-secondary mr-2"
439 this.handleUnblockPerson
442 {i18n.t("unblock_user")}
447 "d-flex align-self-start btn btn-secondary mr-2"
449 onClick={linkEvent(pv.person.id, this.handleBlockPerson)}
451 {i18n.t("block_user")}
458 !this.personIsAdmin &&
459 !this.state.showBanDialog &&
460 (!isBanned(pv.person) ? (
462 className={"d-flex align-self-start btn btn-secondary mr-2"}
463 onClick={linkEvent(this, this.handleModBanShow)}
464 aria-label={i18n.t("ban")}
466 {capitalizeFirstLetter(i18n.t("ban"))}
470 className={"d-flex align-self-start btn btn-secondary mr-2"}
471 onClick={linkEvent(this, this.handleModBanSubmit)}
472 aria-label={i18n.t("unban")}
474 {capitalizeFirstLetter(i18n.t("unban"))}
479 <div className="d-flex align-items-center mb-2">
482 dangerouslySetInnerHTML={mdToHtml(pv.person.bio)}
487 <ul class="list-inline mb-2">
488 <li className="list-inline-item badge badge-light">
489 {i18n.t("number_of_posts", {
490 count: pv.counts.post_count,
491 formattedCount: numToSI(pv.counts.post_count),
494 <li className="list-inline-item badge badge-light">
495 {i18n.t("number_of_comments", {
496 count: pv.counts.comment_count,
497 formattedCount: numToSI(pv.counts.comment_count),
502 <div class="text-muted">
503 {i18n.t("joined")}{" "}
504 <MomentTime data={pv.person} showAgo ignoreUpdated />
506 <div className="d-flex align-items-center text-muted mb-2">
508 <span className="ml-2">
509 {i18n.t("cake_day_title")}{" "}
510 {moment.utc(pv.person.published).local().format("MMM DD, YYYY")}
520 let pv = this.state.personRes?.person_view;
523 {this.state.showBanDialog && (
524 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
525 <div class="form-group row col-12">
526 <label class="col-form-label" htmlFor="profile-ban-reason">
531 id="profile-ban-reason"
532 class="form-control mr-2"
533 placeholder={i18n.t("reason")}
534 value={this.state.banReason}
535 onInput={linkEvent(this, this.handleModBanReasonChange)}
537 <label class="col-form-label" htmlFor={`mod-ban-expires`}>
542 id={`mod-ban-expires`}
543 class="form-control mr-2"
544 placeholder={i18n.t("number_of_days")}
545 value={this.state.banExpireDays}
546 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
548 <div class="form-group">
549 <div class="form-check">
551 class="form-check-input"
552 id="mod-ban-remove-data"
554 checked={this.state.removeData}
555 onChange={linkEvent(this, this.handleModRemoveDataChange)}
558 class="form-check-label"
559 htmlFor="mod-ban-remove-data"
560 title={i18n.t("remove_content_more")}
562 {i18n.t("remove_content")}
567 {/* TODO hold off on expires until later */}
568 {/* <div class="form-group row"> */}
569 {/* <label class="col-form-label">Expires</label> */}
570 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
572 <div class="form-group row">
575 class="btn btn-secondary mr-2"
576 aria-label={i18n.t("cancel")}
577 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
583 class="btn btn-secondary"
584 aria-label={i18n.t("ban")}
586 {i18n.t("ban")} {pv.person.name}
598 {this.state.personRes.moderates.length > 0 && (
599 <div class="card border-secondary mb-3">
600 <div class="card-body">
601 <h5>{i18n.t("moderates")}</h5>
602 <ul class="list-unstyled mb-0">
603 {this.state.personRes.moderates.map(cmv => (
605 <CommunityLink community={cmv.community} />
617 let follows = UserService.Instance.myUserInfo.follows;
620 {follows.length > 0 && (
621 <div class="card border-secondary mb-3">
622 <div class="card-body">
623 <h5>{i18n.t("subscribed")}</h5>
624 <ul class="list-unstyled mb-0">
625 {follows.map(cfv => (
627 <CommunityLink community={cfv.community} />
638 updateUrl(paramUpdates: UrlParams) {
639 const page = paramUpdates.page || this.state.page;
640 const viewStr = paramUpdates.view || PersonDetailsView[this.state.view];
641 const sortStr = paramUpdates.sort || this.state.sort;
643 let typeView = `/u/${this.state.userName}`;
645 this.props.history.push(
646 `${typeView}/view/${viewStr}/sort/${sortStr}/page/${page}`
648 this.state.loading = true;
649 this.setState(this.state);
650 this.fetchUserData();
653 get canAdmin(): boolean {
655 this.state.siteRes?.admins &&
657 UserService.Instance.myUserInfo,
658 this.state.siteRes.admins.map(a => a.person.id),
659 this.state.personRes?.person_view.person.id
664 get personIsAdmin(): boolean {
666 this.state.siteRes?.admins &&
668 this.state.siteRes.admins.map(a => a.person.id),
669 this.state.personRes?.person_view.person.id
674 handlePageChange(page: number) {
675 this.updateUrl({ page });
678 handleSortChange(val: SortType) {
679 this.updateUrl({ sort: val, page: 1 });
682 handleViewChange(i: Profile, event: any) {
684 view: PersonDetailsView[Number(event.target.value)],
689 handleModBanShow(i: Profile) {
690 i.state.showBanDialog = true;
694 handleModBanReasonChange(i: Profile, event: any) {
695 i.state.banReason = event.target.value;
699 handleModBanExpireDaysChange(i: Profile, event: any) {
700 i.state.banExpireDays = event.target.value;
704 handleModRemoveDataChange(i: Profile, event: any) {
705 i.state.removeData = event.target.checked;
709 handleModBanSubmitCancel(i: Profile, event?: any) {
710 event.preventDefault();
711 i.state.showBanDialog = false;
715 handleModBanSubmit(i: Profile, event?: any) {
716 if (event) event.preventDefault();
718 let pv = i.state.personRes.person_view;
719 // If its an unban, restore all their data
720 let ban = !pv.person.banned;
722 i.state.removeData = false;
724 let form: BanPerson = {
725 person_id: pv.person.id,
727 remove_data: i.state.removeData,
728 reason: i.state.banReason,
729 expires: futureDaysToUnixTime(i.state.banExpireDays),
732 WebSocketService.Instance.send(wsClient.banPerson(form));
734 i.state.showBanDialog = false;
738 parseMessage(msg: any) {
739 let op = wsUserOp(msg);
742 toast(i18n.t(msg.error), "danger");
743 if (msg.error == "couldnt_find_that_username_or_email") {
744 this.context.router.history.push("/");
747 } else if (msg.reconnect) {
748 this.fetchUserData();
749 } else if (op == UserOperation.GetPersonDetails) {
750 // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
751 // and set the parent state if it is not set or differs
752 // TODO this might need to get abstracted
753 let data = wsJsonToRes<GetPersonDetailsResponse>(msg).data;
754 this.state.personRes = data;
756 this.state.loading = false;
757 this.setPersonBlock();
758 this.setState(this.state);
759 restoreScrollPosition(this.context);
760 } else if (op == UserOperation.AddAdmin) {
761 let data = wsJsonToRes<AddAdminResponse>(msg).data;
762 this.state.siteRes.admins = data.admins;
763 this.setState(this.state);
764 } else if (op == UserOperation.CreateCommentLike) {
765 let data = wsJsonToRes<CommentResponse>(msg).data;
766 createCommentLikeRes(data.comment_view, this.state.personRes.comments);
767 this.setState(this.state);
769 op == UserOperation.EditComment ||
770 op == UserOperation.DeleteComment ||
771 op == UserOperation.RemoveComment
773 let data = wsJsonToRes<CommentResponse>(msg).data;
774 editCommentRes(data.comment_view, this.state.personRes.comments);
775 this.setState(this.state);
776 } else if (op == UserOperation.CreateComment) {
777 let data = wsJsonToRes<CommentResponse>(msg).data;
779 UserService.Instance.myUserInfo &&
780 data.comment_view.creator.id ==
781 UserService.Instance.myUserInfo?.local_user_view.person.id
783 toast(i18n.t("reply_sent"));
785 } else if (op == UserOperation.SaveComment) {
786 let data = wsJsonToRes<CommentResponse>(msg).data;
787 saveCommentRes(data.comment_view, this.state.personRes.comments);
788 this.setState(this.state);
790 op == UserOperation.EditPost ||
791 op == UserOperation.DeletePost ||
792 op == UserOperation.RemovePost ||
793 op == UserOperation.LockPost ||
794 op == UserOperation.StickyPost ||
795 op == UserOperation.SavePost
797 let data = wsJsonToRes<PostResponse>(msg).data;
798 editPostFindRes(data.post_view, this.state.personRes.posts);
799 this.setState(this.state);
800 } else if (op == UserOperation.CreatePostLike) {
801 let data = wsJsonToRes<PostResponse>(msg).data;
802 createPostLikeFindRes(data.post_view, this.state.personRes.posts);
803 this.setState(this.state);
804 } else if (op == UserOperation.BanPerson) {
805 let data = wsJsonToRes<BanPersonResponse>(msg).data;
806 this.state.personRes.comments
807 .filter(c => c.creator.id == data.person_view.person.id)
808 .forEach(c => (c.creator.banned = data.banned));
809 this.state.personRes.posts
810 .filter(c => c.creator.id == data.person_view.person.id)
811 .forEach(c => (c.creator.banned = data.banned));
812 let pv = this.state.personRes.person_view;
814 if (pv.person.id == data.person_view.person.id) {
815 pv.person.banned = data.banned;
817 this.setState(this.state);
818 } else if (op == UserOperation.BlockPerson) {
819 let data = wsJsonToRes<BlockPersonResponse>(msg).data;
820 updatePersonBlock(data);
821 this.setPersonBlock();
822 this.setState(this.state);