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,
38 restoreScrollPosition,
52 import { BannerIconHeader } from "../common/banner-icon-header";
53 import { HtmlTags } from "../common/html-tags";
54 import { Icon, Spinner } from "../common/icon";
55 import { MomentTime } from "../common/moment-time";
56 import { SortSelect } from "../common/sort-select";
57 import { CommunityLink } from "../community/community-link";
58 import { PersonDetails } from "./person-details";
59 import { PersonListing } from "./person-listing";
61 interface ProfileState {
62 personRes: GetPersonDetailsResponse;
64 view: PersonDetailsView;
68 personBlocked: boolean;
69 siteRes: GetSiteResponse;
70 showBanDialog: boolean;
72 banExpireDays: number;
76 interface ProfileProps {
77 view: PersonDetailsView;
80 person_id: number | null;
90 export class Profile extends Component<any, ProfileState> {
91 private isoData = setIsoData(this.context);
92 private subscription: Subscription;
93 private emptyState: ProfileState = {
95 userName: getUsernameFromProps(this.props),
97 view: Profile.getViewFromProps(this.props.match.view),
98 sort: Profile.getSortTypeFromProps(this.props.match.sort),
99 page: Profile.getPageFromProps(this.props.match.page),
100 personBlocked: false,
101 siteRes: this.isoData.site_res,
102 showBanDialog: false,
108 constructor(props: any, context: any) {
109 super(props, context);
111 this.state = this.emptyState;
112 this.handleSortChange = this.handleSortChange.bind(this);
113 this.handlePageChange = this.handlePageChange.bind(this);
115 this.parseMessage = this.parseMessage.bind(this);
116 this.subscription = wsSubscribe(this.parseMessage);
118 // Only fetch the data if coming from another route
119 if (this.isoData.path == this.context.router.route.match.url) {
120 this.state.personRes = this.isoData.routeData[0];
121 this.state.loading = false;
123 this.fetchUserData();
126 this.setPersonBlock();
130 let form: GetPersonDetails = {
131 username: this.state.userName,
132 sort: this.state.sort,
133 saved_only: this.state.view === PersonDetailsView.Saved,
134 page: this.state.page,
136 auth: authField(false),
138 WebSocketService.Instance.send(wsClient.getPersonDetails(form));
141 get isCurrentUser() {
143 UserService.Instance.myUserInfo?.local_user_view.person.id ==
144 this.state.personRes?.person_view.person.id
149 this.state.personBlocked = UserService.Instance.myUserInfo?.person_blocks
150 .map(a => a.target.id)
151 .includes(this.state.personRes?.person_view.person.id);
154 static getViewFromProps(view: string): PersonDetailsView {
155 return view ? PersonDetailsView[view] : PersonDetailsView.Overview;
158 static getSortTypeFromProps(sort: string): SortType {
159 return sort ? routeSortTypeToEnum(sort) : SortType.New;
162 static getPageFromProps(page: number): number {
163 return page ? Number(page) : 1;
166 static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
167 let pathSplit = req.path.split("/");
168 let promises: Promise<any>[] = [];
170 let username = pathSplit[2];
171 let view = this.getViewFromProps(pathSplit[4]);
172 let sort = this.getSortTypeFromProps(pathSplit[6]);
173 let page = this.getPageFromProps(Number(pathSplit[8]));
175 let form: GetPersonDetails = {
177 saved_only: view === PersonDetailsView.Saved,
182 setOptionalAuth(form, req.auth);
183 promises.push(req.client.getPersonDetails(form));
187 componentDidMount() {
191 componentWillUnmount() {
192 this.subscription.unsubscribe();
193 saveScrollPosition(this.context);
196 static getDerivedStateFromProps(props: any): ProfileProps {
198 view: this.getViewFromProps(props.match.params.view),
199 sort: this.getSortTypeFromProps(props.match.params.sort),
200 page: this.getPageFromProps(props.match.params.page),
201 person_id: Number(props.match.params.id) || null,
202 username: props.match.params.username,
206 componentDidUpdate(lastProps: any) {
207 // Necessary if you are on a post and you click another post (same route)
209 lastProps.location.pathname.split("/")[2] !==
210 lastProps.history.location.pathname.split("/")[2]
212 // Couldnt get a refresh working. This does for now.
217 get documentTitle(): string {
218 return `@${this.state.personRes.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`;
221 get bioTag(): string {
222 return this.state.personRes.person_view.person.bio
223 ? this.state.personRes.person_view.person.bio
229 <div class="container">
230 {this.state.loading ? (
236 <div class="col-12 col-md-8">
239 title={this.documentTitle}
240 path={this.context.router.route.match.url}
241 description={this.bioTag}
242 image={this.state.personRes.person_view.person.avatar}
247 {!this.state.loading && this.selects()}
249 personRes={this.state.personRes}
250 admins={this.state.siteRes.admins}
251 sort={this.state.sort}
252 page={this.state.page}
255 this.state.siteRes.site_view.site.enable_downvotes
257 enableNsfw={this.state.siteRes.site_view.site.enable_nsfw}
258 view={this.state.view}
259 onPageChange={this.handlePageChange}
263 {!this.state.loading && (
264 <div class="col-12 col-md-4">
266 {this.isCurrentUser && this.follows()}
277 <div class="btn-group btn-group-toggle flex-wrap mb-2">
279 className={`btn btn-outline-secondary pointer
280 ${this.state.view == PersonDetailsView.Overview && "active"}
285 value={PersonDetailsView.Overview}
286 checked={this.state.view === PersonDetailsView.Overview}
287 onChange={linkEvent(this, this.handleViewChange)}
292 className={`btn btn-outline-secondary pointer
293 ${this.state.view == PersonDetailsView.Comments && "active"}
298 value={PersonDetailsView.Comments}
299 checked={this.state.view == PersonDetailsView.Comments}
300 onChange={linkEvent(this, this.handleViewChange)}
305 className={`btn btn-outline-secondary pointer
306 ${this.state.view == PersonDetailsView.Posts && "active"}
311 value={PersonDetailsView.Posts}
312 checked={this.state.view == PersonDetailsView.Posts}
313 onChange={linkEvent(this, this.handleViewChange)}
318 className={`btn btn-outline-secondary pointer
319 ${this.state.view == PersonDetailsView.Saved && "active"}
324 value={PersonDetailsView.Saved}
325 checked={this.state.view == PersonDetailsView.Saved}
326 onChange={linkEvent(this, this.handleViewChange)}
335 let profileRss = `/feeds/u/${this.state.userName}.xml?sort=${this.state.sort}`;
338 <div className="mb-2">
339 <span class="mr-3">{this.viewRadios()}</span>
341 sort={this.state.sort}
342 onChange={this.handleSortChange}
346 <a href={profileRss} rel={relTags} title="RSS">
347 <Icon icon="rss" classes="text-muted small mx-2" />
349 <link rel="alternate" type="application/atom+xml" href={profileRss} />
353 handleBlockPerson(personId: number) {
355 let blockUserForm: BlockPerson = {
360 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
363 handleUnblockPerson(recipientId: number) {
364 let blockUserForm: BlockPerson = {
365 person_id: recipientId,
369 WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
373 let pv = this.state.personRes?.person_view;
377 <BannerIconHeader banner={pv.person.banner} icon={pv.person.avatar} />
380 <div class="mb-0 d-flex flex-wrap">
382 {pv.person.display_name && (
383 <h5 class="mb-0">{pv.person.display_name}</h5>
385 <ul class="list-inline mb-2">
386 <li className="list-inline-item">
395 {isBanned(pv.person) && (
396 <li className="list-inline-item badge badge-danger">
400 {pv.person.admin && (
401 <li className="list-inline-item badge badge-light">
405 {pv.person.bot_account && (
406 <li className="list-inline-item badge badge-light">
407 {i18n.t("bot_account").toLowerCase()}
413 <div className="flex-grow-1 unselectable pointer mx-2"></div>
414 {!this.isCurrentUser && UserService.Instance.myUserInfo && (
417 className={`d-flex align-self-start btn btn-secondary mr-2 ${
418 !pv.person.matrix_user_id && "invisible"
421 href={`https://matrix.to/#/${pv.person.matrix_user_id}`}
423 {i18n.t("send_secure_message")}
426 className={"d-flex align-self-start btn btn-secondary mr-2"}
427 to={`/create_private_message/recipient/${pv.person.id}`}
429 {i18n.t("send_message")}
431 {this.state.personBlocked ? (
434 "d-flex align-self-start btn btn-secondary mr-2"
438 this.handleUnblockPerson
441 {i18n.t("unblock_user")}
446 "d-flex align-self-start btn btn-secondary mr-2"
448 onClick={linkEvent(pv.person.id, this.handleBlockPerson)}
450 {i18n.t("block_user")}
457 !this.personIsAdmin &&
458 !this.state.showBanDialog &&
459 (!isBanned(pv.person) ? (
461 className={"d-flex align-self-start btn btn-secondary mr-2"}
462 onClick={linkEvent(this, this.handleModBanShow)}
463 aria-label={i18n.t("ban")}
465 {capitalizeFirstLetter(i18n.t("ban"))}
469 className={"d-flex align-self-start btn btn-secondary mr-2"}
470 onClick={linkEvent(this, this.handleModBanSubmit)}
471 aria-label={i18n.t("unban")}
473 {capitalizeFirstLetter(i18n.t("unban"))}
478 <div className="d-flex align-items-center mb-2">
481 dangerouslySetInnerHTML={mdToHtml(pv.person.bio)}
486 <ul class="list-inline mb-2">
487 <li className="list-inline-item badge badge-light">
488 {i18n.t("number_of_posts", {
489 count: pv.counts.post_count,
490 formattedCount: numToSI(pv.counts.post_count),
493 <li className="list-inline-item badge badge-light">
494 {i18n.t("number_of_comments", {
495 count: pv.counts.comment_count,
496 formattedCount: numToSI(pv.counts.comment_count),
501 <div class="text-muted">
502 {i18n.t("joined")}{" "}
503 <MomentTime data={pv.person} showAgo ignoreUpdated />
505 <div className="d-flex align-items-center text-muted mb-2">
507 <span className="ml-2">
508 {i18n.t("cake_day_title")}{" "}
509 {moment.utc(pv.person.published).local().format("MMM DD, YYYY")}
519 let pv = this.state.personRes?.person_view;
522 {this.state.showBanDialog && (
523 <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
524 <div class="form-group row col-12">
525 <label class="col-form-label" htmlFor="profile-ban-reason">
530 id="profile-ban-reason"
531 class="form-control mr-2"
532 placeholder={i18n.t("reason")}
533 value={this.state.banReason}
534 onInput={linkEvent(this, this.handleModBanReasonChange)}
536 <label class="col-form-label" htmlFor={`mod-ban-expires`}>
541 id={`mod-ban-expires`}
542 class="form-control mr-2"
543 placeholder={i18n.t("number_of_days")}
544 value={this.state.banExpireDays}
545 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
547 <div class="form-group">
548 <div class="form-check">
550 class="form-check-input"
551 id="mod-ban-remove-data"
553 checked={this.state.removeData}
554 onChange={linkEvent(this, this.handleModRemoveDataChange)}
557 class="form-check-label"
558 htmlFor="mod-ban-remove-data"
559 title={i18n.t("remove_content_more")}
561 {i18n.t("remove_content")}
566 {/* TODO hold off on expires until later */}
567 {/* <div class="form-group row"> */}
568 {/* <label class="col-form-label">Expires</label> */}
569 {/* <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
571 <div class="form-group row">
574 class="btn btn-secondary mr-2"
575 aria-label={i18n.t("cancel")}
576 onClick={linkEvent(this, this.handleModBanSubmitCancel)}
582 class="btn btn-secondary"
583 aria-label={i18n.t("ban")}
585 {i18n.t("ban")} {pv.person.name}
597 {this.state.personRes.moderates.length > 0 && (
598 <div class="card border-secondary mb-3">
599 <div class="card-body">
600 <h5>{i18n.t("moderates")}</h5>
601 <ul class="list-unstyled mb-0">
602 {this.state.personRes.moderates.map(cmv => (
604 <CommunityLink community={cmv.community} />
616 let follows = UserService.Instance.myUserInfo.follows;
619 {follows.length > 0 && (
620 <div class="card border-secondary mb-3">
621 <div class="card-body">
622 <h5>{i18n.t("subscribed")}</h5>
623 <ul class="list-unstyled mb-0">
624 {follows.map(cfv => (
626 <CommunityLink community={cfv.community} />
637 updateUrl(paramUpdates: UrlParams) {
638 const page = paramUpdates.page || this.state.page;
639 const viewStr = paramUpdates.view || PersonDetailsView[this.state.view];
640 const sortStr = paramUpdates.sort || this.state.sort;
642 let typeView = `/u/${this.state.userName}`;
644 this.props.history.push(
645 `${typeView}/view/${viewStr}/sort/${sortStr}/page/${page}`
647 this.state.loading = true;
648 this.setState(this.state);
649 this.fetchUserData();
652 get canAdmin(): boolean {
654 this.state.siteRes?.admins &&
656 UserService.Instance.myUserInfo,
657 this.state.siteRes.admins.map(a => a.person.id),
658 this.state.personRes?.person_view.person.id
663 get personIsAdmin(): boolean {
665 this.state.siteRes?.admins &&
667 this.state.siteRes.admins.map(a => a.person.id),
668 this.state.personRes?.person_view.person.id
673 handlePageChange(page: number) {
674 this.updateUrl({ page });
677 handleSortChange(val: SortType) {
678 this.updateUrl({ sort: val, page: 1 });
681 handleViewChange(i: Profile, event: any) {
683 view: PersonDetailsView[Number(event.target.value)],
688 handleModBanShow(i: Profile) {
689 i.state.showBanDialog = true;
693 handleModBanReasonChange(i: Profile, event: any) {
694 i.state.banReason = event.target.value;
698 handleModBanExpireDaysChange(i: Profile, event: any) {
699 i.state.banExpireDays = event.target.value;
703 handleModRemoveDataChange(i: Profile, event: any) {
704 i.state.removeData = event.target.checked;
708 handleModBanSubmitCancel(i: Profile, event?: any) {
709 event.preventDefault();
710 i.state.showBanDialog = false;
714 handleModBanSubmit(i: Profile, event?: any) {
715 if (event) event.preventDefault();
717 let pv = i.state.personRes.person_view;
718 // If its an unban, restore all their data
719 let ban = !pv.person.banned;
721 i.state.removeData = false;
723 let form: BanPerson = {
724 person_id: pv.person.id,
726 remove_data: i.state.removeData,
727 reason: i.state.banReason,
728 expires: futureDaysToUnixTime(i.state.banExpireDays),
731 WebSocketService.Instance.send(wsClient.banPerson(form));
733 i.state.showBanDialog = false;
737 parseMessage(msg: any) {
738 let op = wsUserOp(msg);
741 toast(i18n.t(msg.error), "danger");
742 if (msg.error == "couldnt_find_that_username_or_email") {
743 this.context.router.history.push("/");
746 } else if (msg.reconnect) {
747 this.fetchUserData();
748 } else if (op == UserOperation.GetPersonDetails) {
749 // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
750 // and set the parent state if it is not set or differs
751 // TODO this might need to get abstracted
752 let data = wsJsonToRes<GetPersonDetailsResponse>(msg).data;
753 this.state.personRes = data;
755 this.state.loading = false;
756 this.setPersonBlock();
757 this.setState(this.state);
758 restoreScrollPosition(this.context);
759 } else if (op == UserOperation.AddAdmin) {
760 let data = wsJsonToRes<AddAdminResponse>(msg).data;
761 this.state.siteRes.admins = data.admins;
762 this.setState(this.state);
763 } else if (op == UserOperation.CreateCommentLike) {
764 let data = wsJsonToRes<CommentResponse>(msg).data;
765 createCommentLikeRes(data.comment_view, this.state.personRes.comments);
766 this.setState(this.state);
768 op == UserOperation.EditComment ||
769 op == UserOperation.DeleteComment ||
770 op == UserOperation.RemoveComment
772 let data = wsJsonToRes<CommentResponse>(msg).data;
773 editCommentRes(data.comment_view, this.state.personRes.comments);
774 this.setState(this.state);
775 } else if (op == UserOperation.CreateComment) {
776 let data = wsJsonToRes<CommentResponse>(msg).data;
778 UserService.Instance.myUserInfo &&
779 data.comment_view.creator.id ==
780 UserService.Instance.myUserInfo?.local_user_view.person.id
782 toast(i18n.t("reply_sent"));
784 } else if (op == UserOperation.SaveComment) {
785 let data = wsJsonToRes<CommentResponse>(msg).data;
786 saveCommentRes(data.comment_view, this.state.personRes.comments);
787 this.setState(this.state);
789 op == UserOperation.EditPost ||
790 op == UserOperation.DeletePost ||
791 op == UserOperation.RemovePost ||
792 op == UserOperation.LockPost ||
793 op == UserOperation.StickyPost ||
794 op == UserOperation.SavePost
796 let data = wsJsonToRes<PostResponse>(msg).data;
797 editPostFindRes(data.post_view, this.state.personRes.posts);
798 this.setState(this.state);
799 } else if (op == UserOperation.CreatePostLike) {
800 let data = wsJsonToRes<PostResponse>(msg).data;
801 createPostLikeFindRes(data.post_view, this.state.personRes.posts);
802 this.setState(this.state);
803 } else if (op == UserOperation.BanPerson) {
804 let data = wsJsonToRes<BanPersonResponse>(msg).data;
805 this.state.personRes.comments
806 .filter(c => c.creator.id == data.person_view.person.id)
807 .forEach(c => (c.creator.banned = data.banned));
808 this.state.personRes.posts
809 .filter(c => c.creator.id == data.person_view.person.id)
810 .forEach(c => (c.creator.banned = data.banned));
811 let pv = this.state.personRes.person_view;
813 if (pv.person.id == data.person_view.person.id) {
814 pv.person.banned = data.banned;
816 this.setState(this.state);
817 } else if (op == UserOperation.BlockPerson) {
818 let data = wsJsonToRes<BlockPersonResponse>(msg).data;
819 updatePersonBlock(data);
820 this.setPersonBlock();
821 this.setState(this.state);